diff --git a/.github/workflows/docs-lint.yml b/.github/workflows/docs-lint.yml
new file mode 100644
index 00000000..a0e017da
--- /dev/null
+++ b/.github/workflows/docs-lint.yml
@@ -0,0 +1,22 @@
+name: Docs lint
+
+on:
+ pull_request:
+ paths:
+ - '**/*.mdx'
+ - 'assets/**'
+ - 'scripts/check-docs.py'
+ - '.github/workflows/docs-lint.yml'
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+
+ - name: Check docs assets, frames, and nav formatting
+ run: python3 scripts/check-docs.py
diff --git a/scripts/check-docs.py b/scripts/check-docs.py
new file mode 100644
index 00000000..c8e6a885
--- /dev/null
+++ b/scripts/check-docs.py
@@ -0,0 +1,133 @@
+#!/usr/bin/env python3
+"""Docs lint: deterministic structural checks for Trunk docs (.mdx).
+
+Three rules, all whole-repo blocking gates:
+
+ 1. Broken asset reference — an image referenced from an .mdx file
+ (`src="/assets/..."` or ``) that does not exist on disk.
+ 2. Unbalanced tags — an .mdx file with a different number of
+ `` tags.
+ 3. Nav separator format — between two **bold** UI labels the separator must
+ be exactly " -> " written as the arrow U+2192 with one space each side
+ (e.g. `**Settings** -> **Repositories**`). This enforces only the
+ deterministic *format* half of the CONTRIBUTING.md navigation standard.
+ It never judges whether a click-path is *correct* against the live UI.
+
+Run from the repo root: python3 scripts/check-docs.py
+Exits 0 when clean, 1 when any violation is found.
+"""
+
+import os
+import re
+import sys
+from glob import glob
+
+# Skip generated / vendored / working trees. In CI these don't exist, but this
+# keeps local runs from scanning other worktrees under .claude/.
+SKIP_DIRS = (".claude/", "node_modules/")
+
+IMG_EXT = r"(?:png|jpe?g|gif|svg|webp|avif)"
+# Root-absolute image references only (the docs2 convention). External http(s)
+# URLs and bare relative paths are left alone.
+SRC_REF = re.compile(rf'src="(/[^"]+\.{IMG_EXT})"')
+MD_REF = re.compile(rf'!\[[^\]]*\]\((/[^)\s]+\.{IMG_EXT})\)')
+
+# A nav separator sitting between two bold labels. The bold-on-both-sides
+# requirement is what keeps this off prose ">", "CI/CD", file paths, version
+# ranges, and "read more ->" link affordances.
+NAV_SEP = re.compile(r"\*\*[^*\n]+?\*\*( *(?:->|»|‹|›|→|>) *)\*\*[^*\n]+?\*\*")
+CANONICAL = " → " # space, U+2192 arrow, space
+
+
+def mdx_files():
+ for path in sorted(glob("**/*.mdx", recursive=True)):
+ if any(seg in path for seg in SKIP_DIRS):
+ continue
+ yield path
+
+
+def lines_with_code_flag(text):
+ """Yield (lineno, line, in_fenced_code) for each line of an .mdx file."""
+ in_code = False
+ for n, line in enumerate(text.split("\n"), 1):
+ if line.lstrip().startswith("```"):
+ in_code = not in_code
+ yield n, line, True # the fence line itself counts as code
+ continue
+ yield n, line, in_code
+
+
+def strip_inline_code(line):
+ return re.sub(r"`[^`]*`", "", line)
+
+
+def check_assets(path, text):
+ out = []
+ for n, line, in_code in lines_with_code_flag(text):
+ if in_code:
+ continue
+ for m in list(SRC_REF.finditer(line)) + list(MD_REF.finditer(line)):
+ ref = m.group(1)
+ if not os.path.exists(ref.lstrip("/")):
+ out.append((n, f"broken asset reference: {ref}"))
+ return out
+
+
+def check_frames(path, text):
+ opens = closes = 0
+ for _, line, in_code in lines_with_code_flag(text):
+ if in_code:
+ continue
+ opens += line.count("")
+ if opens != closes:
+ return [(0, f"unbalanced tags: {opens} opening, {closes} closing")]
+ return []
+
+
+def check_nav(path, text):
+ out = []
+ for n, line, in_code in lines_with_code_flag(text):
+ if in_code or line.lstrip().startswith(">"):
+ continue
+ for m in NAV_SEP.finditer(strip_inline_code(line)):
+ sep = m.group(1)
+ if sep != CANONICAL:
+ shown = sep.replace("→", "U+2192")
+ out.append(
+ (n, f"nav separator must be ' -> ' (U+2192, one space each side); found '{shown}'")
+ )
+ return out
+
+
+RULES = (check_assets, check_frames, check_nav)
+
+
+def main():
+ findings = {}
+ for path in mdx_files():
+ try:
+ text = open(path, encoding="utf-8").read()
+ except OSError as e:
+ findings.setdefault(path, []).append((0, f"could not read file: {e}"))
+ continue
+ for rule in RULES:
+ for lineno, msg in rule(path, text):
+ findings.setdefault(path, []).append((lineno, msg))
+
+ if not findings:
+ print("Docs lint: clean (0 issues).")
+ return 0
+
+ total = sum(len(v) for v in findings.values())
+ print(f"Docs lint: {total} issue(s) in {len(findings)} file(s)\n")
+ for path in sorted(findings):
+ for lineno, msg in sorted(findings[path]):
+ loc = f"{path}:{lineno}" if lineno else path
+ print(f" {loc} {msg}")
+ print("\nSee CONTRIBUTING.md for the navigation format standard.")
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())