diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 123891b..8c91a63 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,7 +7,7 @@ { "name": "adhd", "source": "./plugins/adhd", - "description": "Push, pull, and lint design tokens between Tailwind v4 and Figma; push and pull React components with preflight validation." + "description": "Push, pull, and lint design tokens between Tailwind v4 and Figma; push and pull React components with preflight validation; install a live design-system docs route into your Next.js consumer app." } ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f641179..1fd5a07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,8 @@ jobs: run: node --test plugins/adhd/lib/push-component/__tests__/ - name: Run pull-component tests run: node --test plugins/adhd/lib/pull-component/__tests__/ + - name: Run sync-docs tests + run: node --test plugins/adhd/lib/sync-docs/__tests__/ hygiene: name: project hygiene diff --git a/README.md b/README.md index 3ac95e5..b870bbd 100644 --- a/README.md +++ b/README.md @@ -24,16 +24,19 @@ Then install ADHD itself: All three commands are persistent — Claude Code remembers the marketplaces and the enabled plugins across sessions. Run them once per machine. -After install, six slash commands are available: +After install, nine slash commands are available: | Command | Args | Direction | What it does | |---|---|---|---| | `/adhd:config` | — | — | Interactive wizard that produces `adhd.config.ts`. Verifies the official Figma plugin is installed + authenticated before anything else. | -| `/adhd:lint` | `[]` | read-only | Validates the Figma file (whole file or scoped) against the local design system + structure best-practices | -| `/adhd:push-design-system` | — | code → Figma | Pushes globals.css variables + named styles into Figma directly via the remote MCP | -| `/adhd:pull-design-system` | — | Figma → code | Pulls Figma variables + named styles into globals.css | -| `/adhd:push-component` | ` [--max-variants ]` | code → Figma | Pushes a React component to Figma as a structured Component Set with variant properties + variable bindings, plus a preflight lint check | -| `/adhd:pull-component` | ` [--allow-unbound]` | Figma → code | Pulls a Figma Component Set into a React source file; updates lookup tables and union types only (function body untouched) | +| `/adhd:lint` | `[]` | always interactive | Validates the Figma file (whole file or scoped) against the local design system + structure best-practices, then walks every violation through a per-rule resolution wizard. Per violation, designers pick from auto-fix in Figma (rebind to canonical / consolidate duplicates), add in code, take Figma's value, take code's value, annotate only, or skip. No flags — annotation and fix behavior are per-violation choices. | +| `/adhd:push-tokens` | `[--dry-run]` | code → Figma | Pushes globals.css variables + named styles into Figma directly via the remote MCP. Runs an interactive 7-question wizard on every invocation to set per-domain push policy: push the full Tailwind palette or only your semantic colors? Push the full spacing scale or only your authored tokens? Skip opacity entirely? Route shadows through effect styles? `--dry-run` previews exactly what would be added or skipped (reflecting your wizard answers) without writing. | +| `/adhd:pull-tokens` | `[--dry-run]` | Figma → code | Pulls Figma variables + named styles into globals.css. `--dry-run` previews without writing. | +| `/adhd:push-component` | ` [--max-variants ]` | code → Figma | Pushes a React component to Figma as a structured Component Set with variant properties + variable bindings, plus a preflight lint check. Per-variable STRUCT015/STRUCT016 resolution prompts let you sync mismatches in either direction; annotations land automatically on any abort. | +| `/adhd:push-all-components` | `[--continue-on-error] [--max-variants ]` | code → Figma | Bulk version of `push-component` — iterates over every entry in `adhd.config.ts`'s components map. Sequential, halt-on-first-failure by default. | +| `/adhd:pull-component` | ` [--allow-unbound]` | Figma → code | Pulls a Figma Component Set into a React source file. Scaffold mode generates real JSX for vector-driven (inline SVG) and simple-layout-driven components; complex layouts get a stub the developer fills in. INSTANCE children that reference tracked components in `adhd.config.ts` are emitted as `` imports rather than inlined markup — a Phase 2.8 pre-flight aborts with a "pull this first" message when an instance's source isn't tracked. Per-variable STRUCT015/STRUCT016 resolution prompts let you sync mismatches in either direction; annotations land automatically on any abort. Records an 8-char fingerprint + ISO `pulledAt` in `adhd.config.ts`; subsequent pulls short-circuit (no lint, no diff, no commit) when the Figma extract + pull-relevant config still hash to the same value. | +| `/adhd:pull-all-components` | `[--continue-on-error] [--allow-unbound]` | Figma → code | Bulk version of `pull-component` — iterates over every entry in `adhd.config.ts`'s components map. Sequential, halt-on-first-failure by default. Components whose stored fingerprint matches the fresh Figma extract are reported as `unchanged` and skipped (no lint, no diff, no commit). | +| `/adhd:sync-docs` | — | install | Generates a design-system docs route in your Next.js consumer app. Tokens read live from globals.css; components are statically imported from adhd.config.ts at setup time — re-run after editing the components map. Excluded from production builds by default. | Every command above drives Figma exclusively through the `figma@claude-plugins-official` plugin. `/adhd:config` checks it's installed + authenticated up front so setup errors surface where you can fix them, not mid-pipeline. @@ -66,8 +69,10 @@ Then: ``` /adhd:lint # validate the whole Figma file /adhd:lint https://figma.com/design/?node-id=12-2 # validate a single page/frame/component -/adhd:push-design-system # apply (code → Figma; will prompt before writing) -/adhd:pull-design-system # apply (Figma → code; will prompt before writing) +/adhd:push-tokens # apply (code → Figma; will prompt before writing) +/adhd:push-tokens --dry-run # preview what would change without writing +/adhd:pull-tokens # apply (Figma → code; will prompt before writing) +/adhd:pull-tokens --dry-run # preview what would change without writing /adhd:push-component app/components/avatar/index.tsx # push a React component to Figma ``` @@ -83,7 +88,20 @@ Pass any Figma URL that includes a `node-id` query parameter — `/adhd:lint` wi /adhd:lint https://www.figma.com/design/PBCAkpPnvGXWrz6H7qfH3V/ADHD-Reference?node-id=91-18 ``` -The scoped report covers the same rules (STRUCT001–010 + variable mismatches), just narrowed to the selected subtree. The URL must point at the file configured in `adhd.config.ts`; mismatched file keys abort with a fix-up message. +The scoped report covers the same rules (STRUCT001–016 + variable mismatches), just narrowed to the selected subtree. The URL must point at the file configured in `adhd.config.ts`; mismatched file keys abort with a fix-up message. + +### Annotate violations in Figma + +`/adhd:lint` is always interactive — every violation goes through a per-rule wizard with "Annotate only" as one of the options. Picking it pushes the violation to Figma as a node-bound annotation in a dedicated **"lint"** category (orange); picking anything else either resolves the issue directly (auto-fix in Figma, add in code, take Figma's value, take code's value) or skips it (no annotation, no fix). Stale annotations from prior runs get cleaned up automatically when their nodes drop out of the picks list — designer-authored annotations and any non-"lint" category are never touched. + +For `/adhd:push-component` and `/adhd:pull-component`, annotation is **automatic on any abort** — when the SKILL aborts due to preflight violations (including a "Don't sync" pick on the per-variable STRUCT015/STRUCT016 prompts), every blocking violation is pushed to Figma before exit. On successful runs the same annotation script clears prior-run annotations from the scope so Figma stays clean. No flag needed. + +``` +/adhd:lint # whole file — walks every violation +/adhd:lint https://www.figma.com/design/?node-id=91-18 # scoped — walks every violation +/adhd:push-component app/components/avatar/index.tsx # annotates on abort +/adhd:pull-component https://www.figma.com/design/?node-id=91-18 # annotates on abort +``` ### Push a component @@ -110,6 +128,65 @@ The skill parses the component's TypeScript prop unions, generates a temp previe The skill reads the Figma Component Set, diffs it against the React file's `Record` lookup tables, prompts on each divergence, and rewrites only those tables (plus union type members). Function body, JSX, hooks, handlers, and imports are never modified. +### Design system docs route + +Run once in your consumer repo: + +``` +/adhd:sync-docs +``` + +This generates a documentation page that reads your `globals.css` live at +request time and statically imports the components listed in +`adhd.config.ts`. The default URL is `/-docs` (the hyphen prefix telegraphs +"internal"), and files live under a Next.js route group at +`app/(design-system)/-docs/`. The page is a sidebar-and-viewer layout: + +- Sidebar: lists every Tailwind v4 token domain (colors, spacing, typography, + font families, font weights, tracking, leading, radius, shadows, + breakpoints, easing, animation), plus every component tracked in + `adhd.config.ts`. Click a row to load that route in the main pane. +- Token pages: render whatever your `@theme` (or `@theme inline`) block + declares for that domain. Empty domains link to Tailwind v4's docs for the + defaults you're inheriting. +- Component pages: each component gets its own route with URL-driven prop + toggles, derived from the component's TypeScript prop interface. + +The setup command asks **where the docs route should render** with three +options: + +- **Dev only** (default) — files use `.design-system.tsx`; `pageExtensions` + in `next.config.ts` gates on `process.env.NODE_ENV === 'production'`. The + production build literally doesn't see the files. +- **Dev + Vercel preview** — same file extension, but `pageExtensions` + gates on `process.env.VERCEL_ENV === 'production' || (!VERCEL && NODE_ENV === 'production')`. + The route renders on local dev *and* Vercel preview deploys, but stays out + of Vercel production (and out of any non-Vercel production deploy too, so + CI builds don't accidentally ship it). +- **Everywhere** — no `pageExtensions` patch; route files use plain `.tsx` + and ship in production. The layout's metadata still emits `` so it won't be indexed. + +#### Re-running after `adhd.config.ts` changes + +The setup command generates a `componentMap.tsx` with explicit static +imports per component. After **adding, renaming, or removing entries** in +`adhd.config.ts`'s `components` map, re-run +`/adhd:sync-docs` to regenerate the static imports. +Tokens don't need this — they're read from `globals.css` at request time. + +Files where you've removed the `// design-system-docs-route` marker comment +are preserved across re-runs. + +The static-import architecture is deliberate: it keeps the docs route's +bundle scoped to exactly your tracked components, sidestepping the +`Cannot apply unknown utility class …` failure mode that broad dynamic +imports trigger under Tailwind v4 (legacy shadcn classes in unrelated parts +of your codebase get bundled and explode during PostCSS). + +You can also trigger the install at the end of `/adhd:config` if you're +setting up ADHD for the first time. + ### Figma file structure The Figma file must follow the structure mandated in the spec — a `Primitives` collection (no modes) and a `Semantic` collection (Light + Dark modes). The skill validates this and surfaces fix-up guidance on failure. @@ -137,7 +214,7 @@ cd example ``` . ├── plugins/adhd/ # The plugin source -│ ├── skills/ # config, lint, push-design-system, pull-design-system +│ ├── skills/ # config, lint, push-tokens, pull-tokens │ ├── lib/ # zero-deps Node libraries (lint-engine, design-system) │ └── .claude-plugin/ # plugin manifest ├── docs/superpowers/ diff --git a/docs/superpowers/plans/2026-05-11-adhd-install-design-system-docs-route.md b/docs/superpowers/plans/2026-05-11-adhd-install-design-system-docs-route.md new file mode 100644 index 0000000..c1d08e8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-11-adhd-install-design-system-docs-route.md @@ -0,0 +1,2349 @@ +# /adhd:install-design-system-docs-route Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement `/adhd:install-design-system-docs-route` — a one-shot installer that drops a live, self-generating design-system documentation route into a Next.js consumer app. + +**Architecture:** Zero-deps Node library at `plugins/adhd/lib/install-design-system-docs-route/`, mirroring the shape of `lib/pull-component/`. Single skill at `plugins/adhd/skills/install-design-system-docs-route/SKILL.md` orchestrating a 9-phase install flow. The installed files are Next.js App Router server components that read `adhd.config.ts` and `globals.css` at request time — no regen needed. Re-running the installer is first-class: marker-comment detection drives wholesale `Write`-replacement of marker-bearing files, leaving marker-removed files alone (the user's opt-out). + +**Tech Stack:** Node 20 (lib runs zero-deps), regex-based parsers (matching the established `lib/push-component/parse-component.js` style), `node --test` runner, Next.js App Router file conventions in the consumer app. + +--- + +## File structure (lock-in) + +**New library — `plugins/adhd/lib/install-design-system-docs-route/`:** + +| File | Responsibility | +|---|---| +| `token-parser.js` | Parse `globals.css` `@theme` block → `{ colors, spacing, typography, radius, shadows, unknown }` | +| `prop-parser.js` | Parse a component source's `Props` interface → `{ propName: { type, values?, optional } }` | +| `slug.js` | Component path → URL-safe slug; collision detection across the components map | +| `next-config-patcher.js` | Idempotent patch of `next.config.{ts,mjs,js}` to add conditional `pageExtensions` | +| `robots-patcher.js` | Idempotent patch of `public/robots.txt` (Disallow entry; creates file if missing) | +| `route-installer.js` | Orchestrator: writes the 4 generated files at the right paths with the right extensions | +| `templates.js` | Template strings for `layout`, `page` (index), `[component]/page`, `PropToggle`. Exports plain-string content + the marker-comment constant. | +| `cli.js` | Subcommand surface: `parse-tokens`, `parse-props`, `slug`, `patch-next-config`, `patch-robots`, `detect-install`, `install` | +| `README.md` | One-paragraph module readme | +| `__tests__/token-parser.test.js` | Unit tests | +| `__tests__/prop-parser.test.js` | Unit tests | +| `__tests__/slug.test.js` | Unit tests | +| `__tests__/next-config-patcher.test.js` | Unit tests | +| `__tests__/robots-patcher.test.js` | Unit tests | +| `__tests__/route-installer.test.js` | Unit + golden-file tests | +| `__tests__/cli.test.js` | CLI surface tests | +| `__fixtures__/globals.css` | Sample Tailwind v4 globals for token-parser tests | +| `__fixtures__/avatar.tsx` | Sample component source for prop-parser tests | + +**New skill — `plugins/adhd/skills/install-design-system-docs-route/SKILL.md`:** the 9-phase orchestrator. + +**Modified files:** +- `plugins/adhd/skills/config/SKILL.md` — append optional final phase that invokes the install flow inline +- `.claude-plugin/marketplace.json` — bump description +- `README.md` — add command table row + "Install design system docs route" subsection +- `.github/workflows/ci.yml` — add test step + +--- + +## Task 1: Scaffold lib, CI step, module README, cli stub + +**Files:** +- Create: `plugins/adhd/lib/install-design-system-docs-route/cli.js` +- Create: `plugins/adhd/lib/install-design-system-docs-route/README.md` +- Create: `plugins/adhd/lib/install-design-system-docs-route/__tests__/cli.test.js` +- Modify: `.github/workflows/ci.yml` + +- [ ] **Step 1: Write failing test for cli `--help`** + +`plugins/adhd/lib/install-design-system-docs-route/__tests__/cli.test.js`: + +```javascript +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const path = require('node:path'); + +const CLI = path.resolve(__dirname, '..', 'cli.js'); + +test('cli with --help prints subcommand usage and exits 0', () => { + const r = spawnSync('node', [CLI, '--help'], { encoding: 'utf8' }); + assert.equal(r.status, 0); + assert.match(r.stdout, /Usage:/); + assert.match(r.stdout, /parse-tokens/); + assert.match(r.stdout, /parse-props/); + assert.match(r.stdout, /slug/); + assert.match(r.stdout, /patch-next-config/); + assert.match(r.stdout, /patch-robots/); + assert.match(r.stdout, /detect-install/); + assert.match(r.stdout, /install/); +}); + +test('cli with no args exits 2', () => { + assert.equal(spawnSync('node', [CLI], { encoding: 'utf8' }).status, 2); +}); + +test('cli with unknown subcommand exits 2', () => { + assert.equal(spawnSync('node', [CLI, 'unknown'], { encoding: 'utf8' }).status, 2); +}); +``` + +- [ ] **Step 2: Verify tests fail** + +Run: `node --test plugins/adhd/lib/install-design-system-docs-route/__tests__/cli.test.js` +Expected: FAIL — `cli.js` does not exist. + +- [ ] **Step 3: Implement cli stub** + +`plugins/adhd/lib/install-design-system-docs-route/cli.js`: + +```javascript +#!/usr/bin/env node +'use strict'; + +function parseArgs(argv) { + const args = { _: [] }; + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + if (a === '--help' || a === '-h') { args.help = true; continue; } + if (a.startsWith('--')) { args[a.slice(2)] = argv[++i]; } + else { args._.push(a); } + } + return args; +} + +function printUsage() { + console.log(`Usage: + cli.js parse-tokens --css --output + cli.js parse-props --source --output + cli.js slug --paths --output + cli.js patch-next-config --config --route-url + cli.js patch-robots --robots --route-url + cli.js detect-install --app-dir + cli.js install --config `); +} + +function main() { + const args = parseArgs(process.argv); + if (args.help) { printUsage(); process.exit(0); } + if (args._.length === 0) { printUsage(); process.exit(2); } + const cmd = args._[0]; + // Subcommands wired in later tasks. Reject unknown to keep behavior strict. + console.error('Unknown subcommand: ' + cmd); + process.exit(2); +} + +main(); +``` + +- [ ] **Step 4: Verify cli tests pass** + +Run: `node --test plugins/adhd/lib/install-design-system-docs-route/__tests__/cli.test.js` +Expected: 3 tests PASS. + +- [ ] **Step 5: Add module README** + +`plugins/adhd/lib/install-design-system-docs-route/README.md`: + +```markdown +# lib/install-design-system-docs-route + +Deterministic helpers for `/adhd:install-design-system-docs-route`. The +skill (at `plugins/adhd/skills/install-design-system-docs-route/SKILL.md`) +is the orchestrator; this library is the testable engine. + +Modules: +- `token-parser.js` — extract design-system tokens from a globals.css `@theme` block +- `prop-parser.js` — extract a component's prop interface +- `slug.js` — component path → URL slug +- `next-config-patcher.js` — idempotent patch of next.config.{ts,mjs,js} +- `robots-patcher.js` — idempotent patch of public/robots.txt +- `route-installer.js` — write the 4 generated files at the target path +- `templates.js` — page template strings +- `cli.js` — orchestrator surface invoked by SKILL.md + +See `docs/superpowers/specs/2026-05-11-adhd-install-design-system-docs-route.md` +for the authoritative spec. +``` + +- [ ] **Step 6: Add CI step** + +Modify `.github/workflows/ci.yml`. In the `lib-tests` job, after the existing `pull-component` test step: + +```yaml + - name: Run install-design-system-docs-route tests + run: node --test plugins/adhd/lib/install-design-system-docs-route/__tests__/ +``` + +- [ ] **Step 7: Run all tests, verify green** + +Run: `node --test plugins/adhd/lib/install-design-system-docs-route/__tests__/` +Expected: 3 cli tests PASS. + +- [ ] **Step 8: Commit** + +```bash +git add plugins/adhd/lib/install-design-system-docs-route .github/workflows/ci.yml +git commit -m "Scaffold lib/install-design-system-docs-route with cli stub" +``` + +--- + +## Task 2: token-parser.js — extract design tokens from globals.css + +**Files:** +- Create: `plugins/adhd/lib/install-design-system-docs-route/token-parser.js` +- Create: `plugins/adhd/lib/install-design-system-docs-route/__tests__/token-parser.test.js` +- Create: `plugins/adhd/lib/install-design-system-docs-route/__fixtures__/globals.css` + +- [ ] **Step 1: Add the fixture file** + +`plugins/adhd/lib/install-design-system-docs-route/__fixtures__/globals.css`: + +```css +@import "tailwindcss"; + +@theme { + --color-zinc-50: oklch(0.985 0 0); + --color-zinc-900: oklch(0.21 0.034 264.665); + --color-brand-500: #5e3aee; + + --spacing: 0.25rem; + + --text-xs: 0.75rem; + --text-xs--line-height: 1rem; + --text-base: 1rem; + --text-base--line-height: 1.5rem; + + --radius-sm: 0.25rem; + --radius-lg: 0.5rem; + + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1); + + --font-sans: "Inter", system-ui, sans-serif; +} +``` + +- [ ] **Step 2: Write failing tests** + +`plugins/adhd/lib/install-design-system-docs-route/__tests__/token-parser.test.js`: + +```javascript +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { parseTokens } = require('../token-parser'); + +const CSS = fs.readFileSync( + path.resolve(__dirname, '..', '__fixtures__', 'globals.css'), + 'utf8', +); + +test('extracts color tokens', () => { + const t = parseTokens(CSS); + assert.deepEqual( + t.colors.find(c => c.name === 'zinc-50'), + { name: 'zinc-50', value: 'oklch(0.985 0 0)' }, + ); + assert.deepEqual( + t.colors.find(c => c.name === 'brand-500'), + { name: 'brand-500', value: '#5e3aee' }, + ); +}); + +test('extracts the spacing multiplier', () => { + const t = parseTokens(CSS); + assert.equal(t.spacing.multiplier, '0.25rem'); +}); + +test('extracts typography sizes with optional line-heights', () => { + const t = parseTokens(CSS); + assert.deepEqual( + t.typography.find(x => x.name === 'xs'), + { name: 'xs', size: '0.75rem', lineHeight: '1rem' }, + ); + assert.deepEqual( + t.typography.find(x => x.name === 'base'), + { name: 'base', size: '1rem', lineHeight: '1.5rem' }, + ); +}); + +test('extracts radius tokens', () => { + const t = parseTokens(CSS); + assert.deepEqual( + t.radius.find(r => r.name === 'sm'), + { name: 'sm', value: '0.25rem' }, + ); +}); + +test('extracts shadow tokens', () => { + const t = parseTokens(CSS); + assert.deepEqual( + t.shadows.find(s => s.name === 'sm'), + { name: 'sm', value: '0 1px 2px 0 rgb(0 0 0 / 0.05)' }, + ); +}); + +test('puts unrecognized @theme vars in `unknown`', () => { + const t = parseTokens(CSS); + assert.ok(t.unknown.find(u => u.name === '--font-sans')); +}); + +test('returns empty domains when no @theme block exists', () => { + const t = parseTokens('body { color: red; }'); + assert.deepEqual(t.colors, []); + assert.deepEqual(t.typography, []); + assert.deepEqual(t.radius, []); + assert.deepEqual(t.shadows, []); + assert.equal(t.spacing.multiplier, null); +}); + +test('handles multiple @theme blocks (merge)', () => { + const css = ` +@theme { --color-a-100: #fff; } +@theme { --color-b-200: #000; } +`; + const t = parseTokens(css); + assert.equal(t.colors.length, 2); +}); +``` + +- [ ] **Step 3: Verify tests fail** + +Run: `node --test plugins/adhd/lib/install-design-system-docs-route/__tests__/token-parser.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 4: Implement token-parser.js** + +`plugins/adhd/lib/install-design-system-docs-route/token-parser.js`: + +```javascript +'use strict'; + +// Extracts a single @theme block's body, or null. Brace-balanced across nested objects. +function extractAllThemeBodies(css) { + const bodies = []; + let i = 0; + while (i < css.length) { + const idx = css.indexOf('@theme', i); + if (idx === -1) break; + // Skip whitespace + optional modifiers like @theme inline + let j = idx + '@theme'.length; + while (j < css.length && css[j] !== '{' && css[j] !== ';') j++; + if (css[j] !== '{') { i = j + 1; continue; } + // Brace-balanced scan + let depth = 1; + let k = j + 1; + while (k < css.length && depth > 0) { + if (css[k] === '{') depth++; + else if (css[k] === '}') depth--; + if (depth > 0) k++; + } + bodies.push(css.slice(j + 1, k)); + i = k + 1; + } + return bodies; +} + +const DECL_RE = /--([a-zA-Z0-9_-]+)\s*:\s*([^;]+);/g; + +function classify(name) { + if (name.startsWith('color-')) return { domain: 'colors', leaf: name.slice('color-'.length) }; + if (name === 'spacing') return { domain: 'spacing', leaf: null }; + if (name.startsWith('text-')) { + // text-xs or text-xs--line-height + const rest = name.slice('text-'.length); + const lhIdx = rest.indexOf('--line-height'); + if (lhIdx >= 0) return { domain: 'typography', leaf: rest.slice(0, lhIdx), kind: 'lineHeight' }; + return { domain: 'typography', leaf: rest, kind: 'size' }; + } + if (name.startsWith('radius-')) return { domain: 'radius', leaf: name.slice('radius-'.length) }; + if (name.startsWith('shadow-')) return { domain: 'shadows', leaf: name.slice('shadow-'.length) }; + return { domain: 'unknown' }; +} + +function parseTokens(globalsCss) { + const out = { + colors: [], + spacing: { multiplier: null }, + typography: [], // [{ name, size, lineHeight }] + radius: [], + shadows: [], + unknown: [], + }; + const typographyByName = new Map(); + + for (const body of extractAllThemeBodies(globalsCss)) { + DECL_RE.lastIndex = 0; + let m; + while ((m = DECL_RE.exec(body)) !== null) { + const name = m[1]; + const value = m[2].trim(); + const cls = classify(name); + if (cls.domain === 'colors') { + out.colors.push({ name: cls.leaf, value }); + } else if (cls.domain === 'spacing') { + out.spacing.multiplier = value; + } else if (cls.domain === 'typography') { + let row = typographyByName.get(cls.leaf); + if (!row) { + row = { name: cls.leaf, size: null, lineHeight: null }; + typographyByName.set(cls.leaf, row); + out.typography.push(row); + } + if (cls.kind === 'lineHeight') row.lineHeight = value; + else row.size = value; + } else if (cls.domain === 'radius') { + out.radius.push({ name: cls.leaf, value }); + } else if (cls.domain === 'shadows') { + out.shadows.push({ name: cls.leaf, value }); + } else { + out.unknown.push({ name: '--' + name, value }); + } + } + } + + return out; +} + +module.exports = { parseTokens }; +``` + +- [ ] **Step 5: Verify tests pass** + +Run: `node --test plugins/adhd/lib/install-design-system-docs-route/__tests__/token-parser.test.js` +Expected: 8 tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add plugins/adhd/lib/install-design-system-docs-route/token-parser.js \ + plugins/adhd/lib/install-design-system-docs-route/__tests__/token-parser.test.js \ + plugins/adhd/lib/install-design-system-docs-route/__fixtures__/globals.css +git commit -m "token-parser: extract colors/spacing/typography/radius/shadows from globals.css @theme" +``` + +--- + +## Task 3: prop-parser.js — extract component prop interfaces + +**Files:** +- Create: `plugins/adhd/lib/install-design-system-docs-route/prop-parser.js` +- Create: `plugins/adhd/lib/install-design-system-docs-route/__tests__/prop-parser.test.js` +- Create: `plugins/adhd/lib/install-design-system-docs-route/__fixtures__/avatar.tsx` + +- [ ] **Step 1: Add the fixture file** + +`plugins/adhd/lib/install-design-system-docs-route/__fixtures__/avatar.tsx`: + +```tsx +export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl"; +export type AvatarShape = "circle" | "square"; + +export interface AvatarProps { + name: string; + src?: string; + size?: AvatarSize; + shape?: AvatarShape; + status?: "online" | "away" | "offline"; + count?: number; + hidden?: boolean; + onClick?: (e: React.MouseEvent) => void; + children?: React.ReactNode; +} + +export function Avatar({ name, size = "md" }: AvatarProps) { + return {name}; +} +``` + +- [ ] **Step 2: Write failing tests** + +`plugins/adhd/lib/install-design-system-docs-route/__tests__/prop-parser.test.js`: + +```javascript +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { parseProps } = require('../prop-parser'); + +const SOURCE = fs.readFileSync( + path.resolve(__dirname, '..', '__fixtures__', 'avatar.tsx'), + 'utf8', +); + +test('returns the component name', () => { + const r = parseProps(SOURCE); + assert.equal(r.componentName, 'Avatar'); +}); + +test('captures string props', () => { + const r = parseProps(SOURCE); + assert.deepEqual(r.props.name, { type: 'string', optional: false }); + assert.deepEqual(r.props.src, { type: 'string', optional: true }); +}); + +test('captures number and boolean props', () => { + const r = parseProps(SOURCE); + assert.deepEqual(r.props.count, { type: 'number', optional: true }); + assert.deepEqual(r.props.hidden, { type: 'boolean', optional: true }); +}); + +test('captures named-union references with their values', () => { + const r = parseProps(SOURCE); + assert.deepEqual(r.props.size, { + type: 'union', unionName: 'AvatarSize', values: ['xs', 'sm', 'md', 'lg', 'xl'], optional: true, + }); + assert.deepEqual(r.props.shape, { + type: 'union', unionName: 'AvatarShape', values: ['circle', 'square'], optional: true, + }); +}); + +test('captures inline literal unions', () => { + const r = parseProps(SOURCE); + assert.deepEqual(r.props.status, { + type: 'union', values: ['online', 'away', 'offline'], optional: true, + }); +}); + +test('marks function props as `function` (toggle-skipped)', () => { + const r = parseProps(SOURCE); + assert.equal(r.props.onClick.type, 'function'); +}); + +test('marks ReactNode props as `reactnode` (toggle-skipped)', () => { + const r = parseProps(SOURCE); + assert.equal(r.props.children.type, 'reactnode'); +}); + +test('returns componentName=null when no exported function found', () => { + const r = parseProps('export const x = 42;'); + assert.equal(r.componentName, null); + assert.deepEqual(r.props, {}); +}); +``` + +- [ ] **Step 3: Verify tests fail** + +Run: `node --test plugins/adhd/lib/install-design-system-docs-route/__tests__/prop-parser.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 4: Implement prop-parser.js** + +`plugins/adhd/lib/install-design-system-docs-route/prop-parser.js`: + +```javascript +'use strict'; + +const TYPE_ALIAS_RE = /export\s+type\s+([A-Z][A-Za-z0-9]*)\s*=\s*([^;]+);/g; +const INTERFACE_RE = /(?:export\s+)?interface\s+([A-Z][A-Za-z0-9]*Props)\s*\{([\s\S]*?)\}/; +const TYPE_PROPS_RE = /(?:export\s+)?type\s+([A-Z][A-Za-z0-9]*Props)\s*=\s*\{([\s\S]*?)\}/; +const EXPORT_FN_RE = /export\s+(?:default\s+)?function\s+([A-Z][A-Za-z0-9]*)\s*\(/; +const PROP_LINE_RE = /^\s*([a-zA-Z_$][a-zA-Z0-9_$]*)(\?)?\s*:\s*([^;,]+)[;,]?\s*$/; + +function parseLiteralUnion(typeText) { + const trimmed = typeText.trim(); + if (!/^"[^"]*"(\s*\|\s*"[^"]*")*$/.test(trimmed)) return null; + return trimmed.split('|').map(s => { + const m = /"([^"]*)"/.exec(s.trim()); + return m ? m[1] : null; + }).filter(Boolean); +} + +function classify(typeText, knownUnions) { + const t = typeText.trim(); + const inline = parseLiteralUnion(t); + if (inline) return { type: 'union', values: inline }; + if (knownUnions[t]) return { type: 'union', unionName: t, values: knownUnions[t] }; + if (/^\([^)]*\)\s*=>/.test(t)) return { type: 'function' }; + if (/^(?:React\.)?Ref(?:Object|Callback|MutableRefObject)?$/.test(t)) return { type: 'reactnode' }; + if (t === 'string') return { type: 'string' }; + if (t === 'number') return { type: 'number' }; + if (t === 'boolean') return { type: 'boolean' }; + if (/\[\]$/.test(t) || /^Array { + assert.equal(slugFor('app/components/avatar/index.tsx'), 'avatar'); +}); + +test('preserves hyphens', () => { + assert.equal(slugFor('app/components/avatar-group/index.tsx'), 'avatar-group'); +}); + +test('handles files without /index.tsx', () => { + assert.equal(slugFor('app/components/Logo.tsx'), 'logo'); +}); + +test('lowercases', () => { + assert.equal(slugFor('app/components/AvatarGroup/index.tsx'), 'avatargroup'); +}); + +test('slugMap returns { path: slug } for unique paths', () => { + const paths = [ + 'app/components/avatar/index.tsx', + 'app/components/avatar-group/index.tsx', + ]; + assert.deepEqual(slugMap(paths), { + 'app/components/avatar/index.tsx': 'avatar', + 'app/components/avatar-group/index.tsx': 'avatar-group', + }); +}); + +test('slugMap disambiguates collisions by prepending parent dir', () => { + const paths = [ + 'app/components/avatar/index.tsx', + 'app/design-system/avatar/index.tsx', + ]; + const m = slugMap(paths); + assert.equal(new Set(Object.values(m)).size, 2, 'slugs must be unique'); + // Both contain "avatar"; we expect e.g. "components-avatar" and "design-system-avatar" + assert.ok(Object.values(m).every(s => s.includes('avatar'))); +}); +``` + +- [ ] **Step 2: Verify tests fail** + +Run: `node --test plugins/adhd/lib/install-design-system-docs-route/__tests__/slug.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement slug.js** + +`plugins/adhd/lib/install-design-system-docs-route/slug.js`: + +```javascript +'use strict'; + +function baseSlug(componentPath) { + // Strip /index.tsx or .tsx; take the last meaningful segment. + let p = componentPath.replace(/\\/g, '/').replace(/\.tsx?$/, '').replace(/\/index$/, ''); + const segs = p.split('/').filter(Boolean); + return (segs[segs.length - 1] || '').toLowerCase(); +} + +function slugFor(componentPath) { + return baseSlug(componentPath); +} + +function slugMap(paths) { + // Pass 1: tentative slugs + const tentative = paths.map(p => ({ path: p, slug: baseSlug(p) })); + // Pass 2: find collisions + const counts = {}; + for (const t of tentative) counts[t.slug] = (counts[t.slug] || 0) + 1; + // Pass 3: resolve collisions by prepending the parent dir + for (const t of tentative) { + if (counts[t.slug] === 1) continue; + const segs = t.path.replace(/\\/g, '/').replace(/\.tsx?$/, '').replace(/\/index$/, '').split('/').filter(Boolean); + // Prepend one level of parent until unique + let depth = 2; + while (depth <= segs.length) { + const candidate = segs.slice(segs.length - depth).join('-').toLowerCase(); + const colliders = tentative.filter(x => x !== t && x.slug === candidate).length; + if (colliders === 0) { + t.slug = candidate; + break; + } + depth++; + } + } + const out = {}; + for (const t of tentative) out[t.path] = t.slug; + return out; +} + +module.exports = { slugFor, slugMap }; +``` + +- [ ] **Step 4: Verify tests pass** + +Run: `node --test plugins/adhd/lib/install-design-system-docs-route/__tests__/slug.test.js` +Expected: 6 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add plugins/adhd/lib/install-design-system-docs-route/slug.js \ + plugins/adhd/lib/install-design-system-docs-route/__tests__/slug.test.js +git commit -m "slug: component path → URL slug + collision disambiguation" +``` + +--- + +## Task 5: next-config-patcher.js — idempotent pageExtensions patch + +**Files:** +- Create: `plugins/adhd/lib/install-design-system-docs-route/next-config-patcher.js` +- Create: `plugins/adhd/lib/install-design-system-docs-route/__tests__/next-config-patcher.test.js` + +- [ ] **Step 1: Write failing tests** + +`plugins/adhd/lib/install-design-system-docs-route/__tests__/next-config-patcher.test.js`: + +```javascript +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { patchNextConfig, isPatched } = require('../next-config-patcher'); + +const TS_MINIMAL = `import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + images: { + remotePatterns: [{ protocol: "https", hostname: "i.pravatar.cc" }], + }, +}; + +export default nextConfig; +`; + +const TS_ALREADY_PATCHED = `import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + pageExtensions: process.env.NODE_ENV === 'production' + ? ['ts', 'tsx'] + : ['ts', 'tsx', 'design-system.ts', 'design-system.tsx'], + images: { + remotePatterns: [{ protocol: "https", hostname: "i.pravatar.cc" }], + }, +}; + +export default nextConfig; +`; + +const TS_WITH_DIFFERENT_PAGE_EXTENSIONS = `import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + pageExtensions: ['mdx', 'ts', 'tsx'], +}; + +export default nextConfig; +`; + +test('patches a minimal next.config.ts with the conditional pageExtensions block', () => { + const out = patchNextConfig(TS_MINIMAL); + assert.match(out, /pageExtensions:\s*process\.env\.NODE_ENV/); + assert.match(out, /'design-system\.tsx'/); + // Existing config preserved + assert.match(out, /images:/); + assert.match(out, /remotePatterns:/); +}); + +test('isPatched returns true after patching', () => { + const out = patchNextConfig(TS_MINIMAL); + assert.equal(isPatched(out), true); +}); + +test('patchNextConfig is idempotent when already patched', () => { + const out = patchNextConfig(TS_ALREADY_PATCHED); + assert.equal(out, TS_ALREADY_PATCHED); +}); + +test('isPatched returns false on an unpatched file', () => { + assert.equal(isPatched(TS_MINIMAL), false); +}); + +test('patchNextConfig refuses to silently overwrite an existing different pageExtensions; returns { conflict: true }', () => { + const r = patchNextConfig(TS_WITH_DIFFERENT_PAGE_EXTENSIONS, { detectOnly: true }); + assert.equal(r.conflict, true); + assert.match(r.existing, /pageExtensions:\s*\['mdx'/); +}); +``` + +- [ ] **Step 2: Verify tests fail** + +Run: `node --test plugins/adhd/lib/install-design-system-docs-route/__tests__/next-config-patcher.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement next-config-patcher.js** + +`plugins/adhd/lib/install-design-system-docs-route/next-config-patcher.js`: + +```javascript +'use strict'; + +// Detection: look for the sentinel "design-system.tsx" pageExtension entry +// inside the conditional. This is the unique fingerprint of OUR patch. +const PATCHED_SENTINEL = /pageExtensions:\s*process\.env\.NODE_ENV\s*===\s*['"]production['"][\s\S]*?'design-system\.tsx'/; + +// Detection: any other pageExtensions definition. +const EXISTING_PAGE_EXTENSIONS_RE = /pageExtensions:\s*\[/; + +const PATCH_BLOCK = ` pageExtensions: process.env.NODE_ENV === 'production' + ? ['ts', 'tsx'] + : ['ts', 'tsx', 'design-system.ts', 'design-system.tsx'],`; + +function isPatched(source) { + return PATCHED_SENTINEL.test(source); +} + +function findConfigObjectStart(source) { + // Look for either: + // const nextConfig: NextConfig = { + // const nextConfig = { + // export default { + // module.exports = { + const patterns = [ + /const\s+nextConfig(?:\s*:\s*[^=]+)?\s*=\s*\{/, + /export\s+default\s*\{/, + /module\.exports\s*=\s*\{/, + ]; + for (const re of patterns) { + const m = re.exec(source); + if (m) return m.index + m[0].length; // position after the opening `{` + } + return -1; +} + +function patchNextConfig(source, opts = {}) { + if (isPatched(source)) return source; + + // Detect existing different pageExtensions + if (EXISTING_PAGE_EXTENSIONS_RE.test(source)) { + if (opts.detectOnly) { + const existing = /pageExtensions:[^,\n]+,?/.exec(source)[0]; + return { conflict: true, existing }; + } + // Caller hasn't checked; we still refuse to silently merge. + throw new Error('next.config already sets pageExtensions to a different value. Run with detectOnly: true to inspect and prompt the user.'); + } + + const insertAt = findConfigObjectStart(source); + if (insertAt === -1) { + throw new Error('Could not locate the config object in next.config. Manual edit required.'); + } + + // Insert the patch block immediately inside the object literal, before existing + // properties. This puts it at the top of the config for visibility. + const before = source.slice(0, insertAt); + const after = source.slice(insertAt); + // Add a newline if needed for clean formatting + const sep = after.startsWith('\n') ? '' : '\n'; + return before + sep + PATCH_BLOCK + '\n' + after.replace(/^\n/, ''); +} + +module.exports = { patchNextConfig, isPatched }; +``` + +- [ ] **Step 4: Verify tests pass** + +Run: `node --test plugins/adhd/lib/install-design-system-docs-route/__tests__/next-config-patcher.test.js` +Expected: 5 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add plugins/adhd/lib/install-design-system-docs-route/next-config-patcher.js \ + plugins/adhd/lib/install-design-system-docs-route/__tests__/next-config-patcher.test.js +git commit -m "next-config-patcher: idempotent conditional pageExtensions patch" +``` + +--- + +## Task 6: robots-patcher.js — idempotent robots.txt patch + +**Files:** +- Create: `plugins/adhd/lib/install-design-system-docs-route/robots-patcher.js` +- Create: `plugins/adhd/lib/install-design-system-docs-route/__tests__/robots-patcher.test.js` + +- [ ] **Step 1: Write failing tests** + +`plugins/adhd/lib/install-design-system-docs-route/__tests__/robots-patcher.test.js`: + +```javascript +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { patchRobots } = require('../robots-patcher'); + +test('creates robots.txt content if input is empty', () => { + const out = patchRobots('', '/-docs'); + assert.match(out, /User-agent: \*/); + assert.match(out, /Disallow: \/-docs/); +}); + +test('creates robots.txt content if input is null/undefined', () => { + const out = patchRobots(null, '/-docs'); + assert.match(out, /User-agent: \*/); + assert.match(out, /Disallow: \/-docs/); +}); + +test('appends a Disallow line to an existing robots.txt', () => { + const existing = `User-agent: * +Disallow: /admin +`; + const out = patchRobots(existing, '/-docs'); + assert.match(out, /Disallow: \/admin/); + assert.match(out, /Disallow: \/-docs/); +}); + +test('idempotent: re-patching an already-patched robots.txt returns unchanged', () => { + const existing = `User-agent: * +Disallow: /-docs +`; + const out = patchRobots(existing, '/-docs'); + assert.equal(out, existing); +}); + +test('idempotent: matching is exact (does not match /-docs-other)', () => { + const existing = `User-agent: * +Disallow: /-docs-other +`; + const out = patchRobots(existing, '/-docs'); + assert.match(out, /Disallow: \/-docs-other/); + assert.match(out, /Disallow: \/-docs$/m); +}); +``` + +- [ ] **Step 2: Verify tests fail** + +Run: `node --test plugins/adhd/lib/install-design-system-docs-route/__tests__/robots-patcher.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement robots-patcher.js** + +`plugins/adhd/lib/install-design-system-docs-route/robots-patcher.js`: + +```javascript +'use strict'; + +function patchRobots(source, routeUrl) { + const disallowLine = `Disallow: ${routeUrl}`; + if (!source) { + return `User-agent: *\n${disallowLine}\n`; + } + // Idempotent: line-anchored exact match + const exactRe = new RegExp(`^${disallowLine.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\s*$`, 'm'); + if (exactRe.test(source)) return source; + // Append (ensure newline before, single newline after) + const trimmed = source.replace(/\n+$/, ''); + return trimmed + '\n' + disallowLine + '\n'; +} + +module.exports = { patchRobots }; +``` + +- [ ] **Step 4: Verify tests pass** + +Run: `node --test plugins/adhd/lib/install-design-system-docs-route/__tests__/robots-patcher.test.js` +Expected: 5 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add plugins/adhd/lib/install-design-system-docs-route/robots-patcher.js \ + plugins/adhd/lib/install-design-system-docs-route/__tests__/robots-patcher.test.js +git commit -m "robots-patcher: idempotent Disallow entry for the docs route" +``` + +--- + +## Task 7: templates.js — page template content as string constants + +**Files:** +- Create: `plugins/adhd/lib/install-design-system-docs-route/templates.js` +- Create: `plugins/adhd/lib/install-design-system-docs-route/__tests__/templates.test.js` + +- [ ] **Step 1: Write failing tests** + +`plugins/adhd/lib/install-design-system-docs-route/__tests__/templates.test.js`: + +```javascript +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { MARKER_COMMENT, LAYOUT_TSX, INDEX_PAGE_TSX, COMPONENT_PAGE_TSX, PROP_TOGGLE_TSX } = require('../templates'); + +test('MARKER_COMMENT is a stable, non-ADHD-referencing string', () => { + assert.match(MARKER_COMMENT, /design-system-docs-route/); + assert.match(MARKER_COMMENT, /auto-generated installer artifact; safe to edit/); + assert.equal(/adhd/i.test(MARKER_COMMENT), false, 'must not reference ADHD'); +}); + +test('LAYOUT_TSX starts with the marker comment', () => { + assert.ok(LAYOUT_TSX.startsWith(MARKER_COMMENT)); +}); + +test('LAYOUT_TSX sets robots: noindex / nofollow', () => { + assert.match(LAYOUT_TSX, /robots:\s*\{[^}]*index:\s*false[^}]*follow:\s*false/); +}); + +test('LAYOUT_TSX has no ADHD references outside marker', () => { + // marker excluded + const body = LAYOUT_TSX.replace(MARKER_COMMENT, ''); + assert.equal(/adhd/i.test(body), false); +}); + +test('INDEX_PAGE_TSX renders sections for each token domain', () => { + for (const section of ['Colors', 'Spacing', 'Typography', 'Radius', 'Shadows', 'Components']) { + assert.match(INDEX_PAGE_TSX, new RegExp(section)); + } +}); + +test('INDEX_PAGE_TSX reads adhd.config.ts and globals.css via fs', () => { + assert.match(INDEX_PAGE_TSX, /adhd\.config\.ts/); + assert.match(INDEX_PAGE_TSX, /globals\.css|cssEntry/); +}); + +test('COMPONENT_PAGE_TSX uses parametric template-string dynamic import', () => { + assert.match(COMPONENT_PAGE_TSX, /await\s+import\(`/); +}); + +test('COMPONENT_PAGE_TSX reads searchParams for prop toggles', () => { + assert.match(COMPONENT_PAGE_TSX, /searchParams/); +}); + +test('PROP_TOGGLE_TSX is a client component', () => { + assert.match(PROP_TOGGLE_TSX, /^["']use client["']/); +}); + +test('PROP_TOGGLE_TSX uses router.replace for snappy URL updates', () => { + assert.match(PROP_TOGGLE_TSX, /router\.replace/); +}); + +test('none of the templates contain "ADHD" outside the marker', () => { + for (const [name, content] of Object.entries({ LAYOUT_TSX, INDEX_PAGE_TSX, COMPONENT_PAGE_TSX, PROP_TOGGLE_TSX })) { + const body = content.replace(MARKER_COMMENT, ''); + assert.equal(/adhd/i.test(body), false, `${name} must not reference ADHD outside marker`); + } +}); +``` + +- [ ] **Step 2: Verify tests fail** + +Run: `node --test plugins/adhd/lib/install-design-system-docs-route/__tests__/templates.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement templates.js** + +`plugins/adhd/lib/install-design-system-docs-route/templates.js`: + +```javascript +'use strict'; + +const MARKER_COMMENT = `// design-system-docs-route — auto-generated installer artifact; safe to edit. +// Remove this comment to disable future overwrites from re-running the installer. +`; + +const LAYOUT_TSX = `${MARKER_COMMENT}import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Design System Docs", + robots: { index: false, follow: false }, +}; + +export default function DesignSystemDocsLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+
+

Design System Docs

+ Internal — not indexed +
+
+
{children}
+
+ ); +} +`; + +const INDEX_PAGE_TSX = `${MARKER_COMMENT}import fs from "node:fs/promises"; +import path from "node:path"; +import Link from "next/link"; + +async function readConfig() { + try { + const src = await fs.readFile(path.resolve(process.cwd(), "adhd.config.ts"), "utf8"); + const components: Record = {}; + const compMatch = /components:\\s*\\{([\\s\\S]*?)\\}\\s*[,;]?/.exec(src); + if (compMatch) { + const inner = compMatch[1]; + const re = /"([^"]+)"\\s*:\\s*\\{/g; + let m; + while ((m = re.exec(inner)) !== null) { + components[m[1]] = true; + } + } + const cssEntryMatch = /cssEntry\\s*:\\s*"([^"]+)"/.exec(src); + const cssEntry = cssEntryMatch ? cssEntryMatch[1] : "app/globals.css"; + return { components: Object.keys(components), cssEntry }; + } catch { + return { components: [], cssEntry: "app/globals.css" }; + } +} + +async function readCss(cssEntry: string) { + try { + return await fs.readFile(path.resolve(process.cwd(), cssEntry), "utf8"); + } catch { + return null; + } +} + +function extractTokens(css: string | null) { + const empty = { colors: [], spacing: { multiplier: null }, typography: [], radius: [], shadows: [] }; + if (!css) return empty; + const out = { colors: [] as Array<{ name: string; value: string }>, + spacing: { multiplier: null as string | null }, + typography: [] as Array<{ name: string; size: string | null; lineHeight: string | null }>, + radius: [] as Array<{ name: string; value: string }>, + shadows: [] as Array<{ name: string; value: string }> }; + const themeRe = /@theme\\s*\\{([\\s\\S]*?)\\}/g; + let body; + while ((body = themeRe.exec(css)) !== null) { + const declRe = /--([a-zA-Z0-9_-]+)\\s*:\\s*([^;]+);/g; + let d; + while ((d = declRe.exec(body[1])) !== null) { + const name = d[1]; + const value = d[2].trim(); + if (name.startsWith("color-")) out.colors.push({ name: name.slice(6), value }); + else if (name === "spacing") out.spacing.multiplier = value; + else if (name.startsWith("text-")) { + const rest = name.slice(5); + const lhIdx = rest.indexOf("--line-height"); + const leaf = lhIdx >= 0 ? rest.slice(0, lhIdx) : rest; + let row = out.typography.find(t => t.name === leaf); + if (!row) { row = { name: leaf, size: null, lineHeight: null }; out.typography.push(row); } + if (lhIdx >= 0) row.lineHeight = value; else row.size = value; + } else if (name.startsWith("radius-")) out.radius.push({ name: name.slice(7), value }); + else if (name.startsWith("shadow-")) out.shadows.push({ name: name.slice(7), value }); + } + } + return out; +} + +export default async function DesignSystemIndex() { + const cfg = await readConfig(); + const css = await readCss(cfg.cssEntry); + const tokens = extractTokens(css); + + return ( +
+
+

Colors

+ {tokens.colors.length === 0 ?

No colors detected.

: ( +
+ {tokens.colors.map(c => ( +
+
+ {c.name} + {c.value} +
+ ))} +
+ )} +
+ +
+

Spacing

+ {tokens.spacing.multiplier ?

Multiplier: {tokens.spacing.multiplier}

:

No spacing variable detected.

} +
+ +
+

Typography

+ {tokens.typography.length === 0 ?

No typography tokens detected.

: ( +
+ {tokens.typography.map(t => ( +
+ text-{t.name} + + The quick brown fox jumps over the lazy dog + + {t.size}{t.lineHeight ? ` / ${t.lineHeight}` : ""} +
+ ))} +
+ )} +
+ +
+

Radius

+ {tokens.radius.length === 0 ?

No radius tokens detected.

: ( +
+ {tokens.radius.map(r => ( +
+
+ rounded-{r.name} +
+ ))} +
+ )} +
+ +
+

Shadows

+ {tokens.shadows.length === 0 ?

No shadow tokens detected.

: ( +
+ {tokens.shadows.map(s => ( +
+
+ shadow-{s.name} +
+ ))} +
+ )} +
+ +
+

Components

+ {cfg.components.length === 0 ?

No components tracked. Push one with /adhd:push-component <path>.

: ( +
+ {cfg.components.map(p => { + const slug = p.replace(/\\.tsx?$/, "").replace(/\\/index$/, "").split("/").pop()?.toLowerCase() ?? p; + return ( + +
{slug}
+
{p}
+ + ); + })} +
+ )} +
+
+ ); +} +`; + +const COMPONENT_PAGE_TSX = `${MARKER_COMMENT}import fs from "node:fs/promises"; +import path from "node:path"; +import { notFound } from "next/navigation"; +import { PropToggle } from "../PropToggle"; + +async function readConfig() { + try { + const src = await fs.readFile(path.resolve(process.cwd(), "adhd.config.ts"), "utf8"); + const components: string[] = []; + const compMatch = /components:\\s*\\{([\\s\\S]*?)\\}\\s*[,;]?/.exec(src); + if (compMatch) { + const inner = compMatch[1]; + const re = /"([^"]+)"\\s*:\\s*\\{/g; + let m; + while ((m = re.exec(inner)) !== null) components.push(m[1]); + } + return components; + } catch { + return []; + } +} + +function slugFor(p: string) { + return p.replace(/\\.tsx?$/, "").replace(/\\/index$/, "").split("/").pop()?.toLowerCase() ?? p; +} + +async function parseProps(componentPath: string) { + try { + const src = await fs.readFile(path.resolve(process.cwd(), componentPath), "utf8"); + const TYPE_ALIAS_RE = /export\\s+type\\s+([A-Z][A-Za-z0-9]*)\\s*=\\s*([^;]+);/g; + const INTERFACE_RE = /(?:export\\s+)?interface\\s+([A-Z][A-Za-z0-9]*Props)\\s*\\{([\\s\\S]*?)\\}/; + const PROP_LINE_RE = /^\\s*([a-zA-Z_$][a-zA-Z0-9_$]*)(\\??)\\s*:\\s*([^;,]+)[;,]?\\s*$/; + + const knownUnions: Record = {}; + TYPE_ALIAS_RE.lastIndex = 0; + let m; + while ((m = TYPE_ALIAS_RE.exec(src)) !== null) { + const body = m[2].trim(); + if (/^"[^"]*"(\\s*\\|\\s*"[^"]*")*$/.test(body)) { + knownUnions[m[1]] = body.split("|").map(s => s.trim().replace(/"/g, "")); + } + } + const iface = INTERFACE_RE.exec(src); + if (!iface) return { props: {} as Record, knownUnions }; + const props: Record = {}; + for (const rawLine of iface[2].split("\\n")) { + const line = rawLine.replace(/\\/\\/.*$/, ""); + const pm = PROP_LINE_RE.exec(line); + if (!pm) continue; + const [, name, opt, type] = pm; + const t = type.trim(); + if (knownUnions[t]) props[name] = { type: "union", values: knownUnions[t], optional: !!opt }; + else if (/^"[^"]*"(\\s*\\|\\s*"[^"]*")*$/.test(t)) { + props[name] = { type: "union", values: t.split("|").map(s => s.trim().replace(/"/g, "")), optional: !!opt }; + } else if (t === "string") props[name] = { type: "string", optional: !!opt }; + else if (t === "number") props[name] = { type: "number", optional: !!opt }; + else if (t === "boolean") props[name] = { type: "boolean", optional: !!opt }; + else props[name] = { type: "unknown", optional: !!opt }; + } + return { props, knownUnions }; + } catch { + return { props: {} as Record, knownUnions: {} }; + } +} + +export default async function ComponentPage({ + params, + searchParams, +}: { + params: Promise<{ component: string }>; + searchParams: Promise>; +}) { + const { component: slug } = await params; + const sp = await searchParams; + const paths = await readConfig(); + const componentPath = paths.find(p => slugFor(p) === slug); + if (!componentPath) notFound(); + + const { props } = await parseProps(componentPath); + + // Resolve current prop values from searchParams + const current: Record = {}; + for (const [name, def] of Object.entries(props)) { + const v = sp[name]; + if (typeof v !== "string") continue; + if (def.type === "union" && def.values.includes(v)) current[name] = v; + else if (def.type === "boolean") current[name] = v === "true"; + else if (def.type === "string") current[name] = v; + else if (def.type === "number") current[name] = Number(v); + } + + // Dynamic import the component + let Component: any = null; + let importError: string | null = null; + try { + const mod = await import(\`@/\${componentPath.replace(/\\.tsx?$/, "")}\`); + const name = Object.keys(mod).find(k => typeof mod[k] === "function") ?? "default"; + Component = mod.default ?? mod[name]; + } catch (e: any) { + importError = e?.message ?? String(e); + } + + const importPath = "@/" + componentPath.replace(/\\.tsx?$/, "").replace(/\\/index$/, ""); + const importStmt = Component ? \`import { \${Component.name ?? slug} } from "\${importPath}";\` : null; + const jsxSnippet = Component + ? \`<\${Component.name ?? slug}\${Object.entries(current).map(([k,v]) => \` \${k}={\${JSON.stringify(v)}}\`).join("")} />\` + : null; + + return ( +
+

{slug}

+ +
+

Props

+ {Object.keys(props).length === 0 ?

No prop introspection available.

: ( +
+ {Object.entries(props).map(([name, def]: [string, any]) => { + if (def.type === "union") { + return ( + + ); + } + if (def.type === "boolean") { + return ( + + ); + } + if (def.type === "string" || def.type === "number") { + return ( + + ); + } + return ( +
+ {name}: {def.type} — toggle unavailable +
+ ); + })} +
+ )} +
+ +
+ {importError ? ( +
{importError}
+ ) : Component ? ( + + ) : null} +
+ + {importStmt && jsxSnippet && ( +
+
{importStmt}
+
{jsxSnippet}
+
+ )} +
+ ); +} +`; + +const PROP_TOGGLE_TSX = `${MARKER_COMMENT}"use client"; +import { useRouter, useSearchParams, usePathname } from "next/navigation"; + +type Props = + | { name: string; kind: "union"; values: string[]; value: string } + | { name: string; kind: "boolean"; value: string } + | { name: string; kind: "string"; value: string } + | { name: string; kind: "number"; value: string }; + +export function PropToggle(p: Props) { + const router = useRouter(); + const path = usePathname(); + const sp = useSearchParams(); + + function setParam(v: string) { + const next = new URLSearchParams(sp.toString()); + if (v === "") next.delete(p.name); + else next.set(p.name, v); + router.replace(\`\${path}?\${next}\`); + } + + return ( + + ); +} +`; + +module.exports = { MARKER_COMMENT, LAYOUT_TSX, INDEX_PAGE_TSX, COMPONENT_PAGE_TSX, PROP_TOGGLE_TSX }; +``` + +- [ ] **Step 4: Verify tests pass** + +Run: `node --test plugins/adhd/lib/install-design-system-docs-route/__tests__/templates.test.js` +Expected: 10 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add plugins/adhd/lib/install-design-system-docs-route/templates.js \ + plugins/adhd/lib/install-design-system-docs-route/__tests__/templates.test.js +git commit -m "templates: layout, index, component page, PropToggle (marker-prefixed, no ADHD refs)" +``` + +--- + +## Task 8: route-installer.js — write files at the target path with marker detection + +**Files:** +- Create: `plugins/adhd/lib/install-design-system-docs-route/route-installer.js` +- Create: `plugins/adhd/lib/install-design-system-docs-route/__tests__/route-installer.test.js` + +- [ ] **Step 1: Write failing tests** + +`plugins/adhd/lib/install-design-system-docs-route/__tests__/route-installer.test.js`: + +```javascript +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { installRoute, detectExistingInstall } = require('../route-installer'); + +function makeTempProject() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'adhd-install-')); + fs.mkdirSync(path.join(root, 'app'), { recursive: true }); + return root; +} + +test('installRoute writes 4 files with the .design-system.tsx extension when prodExcluded', () => { + const root = makeTempProject(); + installRoute(root, { + groupName: '(design-system)', + routeSegment: '-docs', + prodExcluded: true, + }); + const docsDir = path.join(root, 'app', '(design-system)', '-docs'); + assert.ok(fs.existsSync(path.join(docsDir, 'layout.design-system.tsx'))); + assert.ok(fs.existsSync(path.join(docsDir, 'page.design-system.tsx'))); + assert.ok(fs.existsSync(path.join(docsDir, '[component]', 'page.design-system.tsx'))); + assert.ok(fs.existsSync(path.join(docsDir, 'PropToggle.design-system.tsx'))); +}); + +test('installRoute writes plain .tsx files when not prodExcluded', () => { + const root = makeTempProject(); + installRoute(root, { + groupName: '(design-system)', + routeSegment: '-docs', + prodExcluded: false, + }); + const docsDir = path.join(root, 'app', '(design-system)', '-docs'); + assert.ok(fs.existsSync(path.join(docsDir, 'layout.tsx'))); + assert.ok(fs.existsSync(path.join(docsDir, 'page.tsx'))); + assert.ok(fs.existsSync(path.join(docsDir, '[component]', 'page.tsx'))); + assert.ok(fs.existsSync(path.join(docsDir, 'PropToggle.tsx'))); +}); + +test('all written files start with the marker comment', () => { + const root = makeTempProject(); + installRoute(root, { groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true }); + const docsDir = path.join(root, 'app', '(design-system)', '-docs'); + for (const f of [ + 'layout.design-system.tsx', + 'page.design-system.tsx', + '[component]/page.design-system.tsx', + 'PropToggle.design-system.tsx', + ]) { + const content = fs.readFileSync(path.join(docsDir, f), 'utf8'); + assert.match(content, /design-system-docs-route/); + } +}); + +test('detectExistingInstall scans for the marker and returns matching files', () => { + const root = makeTempProject(); + installRoute(root, { groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true }); + const found = detectExistingInstall(root); + assert.ok(found.length >= 4); + assert.ok(found.every(p => p.includes('-docs'))); +}); + +test('detectExistingInstall returns [] when no marker is present', () => { + const root = makeTempProject(); + const found = detectExistingInstall(root); + assert.deepEqual(found, []); +}); + +test('detectExistingInstall does not match unrelated files', () => { + const root = makeTempProject(); + fs.writeFileSync(path.join(root, 'app', 'page.tsx'), 'export default function P() { return null; }\n'); + assert.deepEqual(detectExistingInstall(root), []); +}); + +test('re-running installRoute is safe (overwrites files cleanly)', () => { + const root = makeTempProject(); + installRoute(root, { groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true }); + // Modify a file + const layoutPath = path.join(root, 'app', '(design-system)', '-docs', 'layout.design-system.tsx'); + fs.writeFileSync(layoutPath, 'corrupted'); + // Re-install + installRoute(root, { groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true }); + const after = fs.readFileSync(layoutPath, 'utf8'); + assert.match(after, /design-system-docs-route/); + assert.match(after, /DesignSystemDocsLayout/); +}); + +test('installRoute supports an empty groupName (no route group)', () => { + const root = makeTempProject(); + installRoute(root, { groupName: '', routeSegment: '-docs', prodExcluded: true }); + const docsDir = path.join(root, 'app', '-docs'); + assert.ok(fs.existsSync(path.join(docsDir, 'layout.design-system.tsx'))); +}); +``` + +- [ ] **Step 2: Verify tests fail** + +Run: `node --test plugins/adhd/lib/install-design-system-docs-route/__tests__/route-installer.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement route-installer.js** + +`plugins/adhd/lib/install-design-system-docs-route/route-installer.js`: + +```javascript +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { MARKER_COMMENT, LAYOUT_TSX, INDEX_PAGE_TSX, COMPONENT_PAGE_TSX, PROP_TOGGLE_TSX } = require('./templates'); + +function mkdirpSync(p) { + fs.mkdirSync(p, { recursive: true }); +} + +function installRoute(projectRoot, opts) { + const { groupName = '', routeSegment, prodExcluded } = opts; + if (!routeSegment) throw new Error('routeSegment is required'); + + const ext = prodExcluded ? '.design-system.tsx' : '.tsx'; + const segments = ['app']; + if (groupName) segments.push(groupName); + segments.push(routeSegment); + const docsDir = path.join(projectRoot, ...segments); + const componentDir = path.join(docsDir, '[component]'); + + mkdirpSync(docsDir); + mkdirpSync(componentDir); + + fs.writeFileSync(path.join(docsDir, `layout${ext}`), LAYOUT_TSX); + fs.writeFileSync(path.join(docsDir, `page${ext}`), INDEX_PAGE_TSX); + fs.writeFileSync(path.join(componentDir, `page${ext}`), COMPONENT_PAGE_TSX); + fs.writeFileSync(path.join(docsDir, `PropToggle${ext}`), PROP_TOGGLE_TSX); + + return { + files: [ + path.join(docsDir, `layout${ext}`), + path.join(docsDir, `page${ext}`), + path.join(componentDir, `page${ext}`), + path.join(docsDir, `PropToggle${ext}`), + ], + }; +} + +function detectExistingInstall(projectRoot) { + const found = []; + function walk(dir) { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return; } + for (const ent of entries) { + if (ent.name === 'node_modules' || ent.name === '.next' || ent.name.startsWith('.git')) continue; + const full = path.join(dir, ent.name); + if (ent.isDirectory()) walk(full); + else if (/\.tsx?$/.test(ent.name)) { + try { + const content = fs.readFileSync(full, 'utf8'); + if (content.includes('design-system-docs-route')) { + found.push(full); + } + } catch {} + } + } + } + walk(path.join(projectRoot, 'app')); + return found; +} + +module.exports = { installRoute, detectExistingInstall }; +``` + +- [ ] **Step 4: Verify tests pass** + +Run: `node --test plugins/adhd/lib/install-design-system-docs-route/__tests__/route-installer.test.js` +Expected: 8 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add plugins/adhd/lib/install-design-system-docs-route/route-installer.js \ + plugins/adhd/lib/install-design-system-docs-route/__tests__/route-installer.test.js +git commit -m "route-installer: write the 4 page files; detect existing installs via marker" +``` + +--- + +## Task 9: cli.js — wire all subcommands + +**Files:** +- Modify: `plugins/adhd/lib/install-design-system-docs-route/cli.js` +- Modify: `plugins/adhd/lib/install-design-system-docs-route/__tests__/cli.test.js` + +- [ ] **Step 1: Extend cli tests for each subcommand** + +Append to `plugins/adhd/lib/install-design-system-docs-route/__tests__/cli.test.js`: + +```javascript +const fs = require('node:fs'); +const os = require('node:os'); + +function tmp(filename, content) { + const p = path.join(os.tmpdir(), 'adhd-ids-' + Date.now() + '-' + Math.random().toString(16).slice(2, 8) + '-' + filename); + fs.writeFileSync(p, content); + return p; +} + +const FX_CSS = path.resolve(__dirname, '..', '__fixtures__', 'globals.css'); +const FX_AVATAR = path.resolve(__dirname, '..', '__fixtures__', 'avatar.tsx'); + +test('parse-tokens subcommand outputs token JSON', () => { + const out = tmp('tokens.json', ''); + const r = spawnSync('node', [CLI, 'parse-tokens', '--css', FX_CSS, '--output', out], { encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + const t = JSON.parse(fs.readFileSync(out, 'utf8')); + assert.ok(t.colors.length > 0); +}); + +test('parse-props subcommand outputs props JSON', () => { + const out = tmp('props.json', ''); + const r = spawnSync('node', [CLI, 'parse-props', '--source', FX_AVATAR, '--output', out], { encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + const p = JSON.parse(fs.readFileSync(out, 'utf8')); + assert.equal(p.componentName, 'Avatar'); + assert.ok(p.props.size.values.length === 5); +}); + +test('slug subcommand outputs slug map JSON', () => { + const out = tmp('slugs.json', ''); + const r = spawnSync('node', [CLI, 'slug', '--paths', 'app/components/avatar/index.tsx,app/components/avatar-group/index.tsx', '--output', out], { encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + const m = JSON.parse(fs.readFileSync(out, 'utf8')); + assert.equal(m['app/components/avatar/index.tsx'], 'avatar'); +}); + +test('patch-next-config subcommand mutates the file in place', () => { + const cfg = tmp('next.config.ts', `import type { NextConfig } from "next";\nconst nextConfig: NextConfig = {};\nexport default nextConfig;\n`); + const r = spawnSync('node', [CLI, 'patch-next-config', '--config', cfg, '--route-url', '/-docs'], { encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + const after = fs.readFileSync(cfg, 'utf8'); + assert.match(after, /pageExtensions:\s*process\.env\.NODE_ENV/); +}); + +test('patch-robots subcommand mutates the file in place; creates if missing', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'adhd-ids-robots-')); + const robots = path.join(root, 'robots.txt'); + const r = spawnSync('node', [CLI, 'patch-robots', '--robots', robots, '--route-url', '/-docs'], { encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + const after = fs.readFileSync(robots, 'utf8'); + assert.match(after, /Disallow: \/-docs/); +}); + +test('detect-install subcommand prints existing install paths to stdout', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'adhd-ids-detect-')); + fs.mkdirSync(path.join(root, 'app', '(design-system)', '-docs'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'app', '(design-system)', '-docs', 'layout.tsx'), + '// design-system-docs-route — auto-generated installer artifact; safe to edit.\nexport default function L({ children }) { return children; }\n', + ); + const r = spawnSync('node', [CLI, 'detect-install', '--app-dir', root], { encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /-docs\/layout\.tsx/); +}); + +test('install subcommand writes files based on choices JSON', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'adhd-ids-install-')); + fs.mkdirSync(path.join(root, 'app'), { recursive: true }); + const choices = tmp('choices.json', JSON.stringify({ + projectRoot: root, groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + })); + const r = spawnSync('node', [CLI, 'install', '--config', choices], { encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + assert.ok(fs.existsSync(path.join(root, 'app', '(design-system)', '-docs', 'page.design-system.tsx'))); +}); +``` + +- [ ] **Step 2: Verify the new tests fail** + +Run: `node --test plugins/adhd/lib/install-design-system-docs-route/__tests__/cli.test.js` +Expected: 7 new tests FAIL; original 3 still pass. + +- [ ] **Step 3: Implement cli.js full surface** + +Replace `plugins/adhd/lib/install-design-system-docs-route/cli.js`: + +```javascript +#!/usr/bin/env node +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { parseTokens } = require('./token-parser'); +const { parseProps } = require('./prop-parser'); +const { slugMap } = require('./slug'); +const { patchNextConfig } = require('./next-config-patcher'); +const { patchRobots } = require('./robots-patcher'); +const { installRoute, detectExistingInstall } = require('./route-installer'); + +function parseArgs(argv) { + const args = { _: [] }; + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + if (a === '--help' || a === '-h') { args.help = true; continue; } + if (a.startsWith('--')) { args[a.slice(2)] = argv[++i]; } + else { args._.push(a); } + } + return args; +} + +function printUsage() { + console.log(`Usage: + cli.js parse-tokens --css --output + cli.js parse-props --source --output + cli.js slug --paths --output + cli.js patch-next-config --config --route-url + cli.js patch-robots --robots --route-url + cli.js detect-install --app-dir + cli.js install --config `); +} + +function main() { + const args = parseArgs(process.argv); + if (args.help) { printUsage(); process.exit(0); } + if (args._.length === 0) { printUsage(); process.exit(2); } + const cmd = args._[0]; + + if (cmd === 'parse-tokens') { + if (!args.css || !args.output) { console.error('Usage: parse-tokens --css --output '); process.exit(2); } + const css = fs.readFileSync(args.css, 'utf8'); + fs.writeFileSync(args.output, JSON.stringify(parseTokens(css), null, 2)); + process.exit(0); + } + + if (cmd === 'parse-props') { + if (!args.source || !args.output) { console.error('Usage: parse-props --source --output '); process.exit(2); } + const src = fs.readFileSync(args.source, 'utf8'); + fs.writeFileSync(args.output, JSON.stringify(parseProps(src), null, 2)); + process.exit(0); + } + + if (cmd === 'slug') { + if (!args.paths || !args.output) { console.error('Usage: slug --paths --output '); process.exit(2); } + const paths = args.paths.split(',').map(s => s.trim()).filter(Boolean); + fs.writeFileSync(args.output, JSON.stringify(slugMap(paths), null, 2)); + process.exit(0); + } + + if (cmd === 'patch-next-config') { + if (!args.config || !args['route-url']) { console.error('Usage: patch-next-config --config --route-url '); process.exit(2); } + const src = fs.readFileSync(args.config, 'utf8'); + const r = patchNextConfig(src, { detectOnly: true }); + if (r && r.conflict) { + console.error('next.config already sets pageExtensions: ' + r.existing); + process.exit(3); + } + const out = patchNextConfig(src); + fs.writeFileSync(args.config, out); + process.exit(0); + } + + if (cmd === 'patch-robots') { + if (!args.robots || !args['route-url']) { console.error('Usage: patch-robots --robots --route-url '); process.exit(2); } + let src = ''; + try { src = fs.readFileSync(args.robots, 'utf8'); } catch {} + fs.writeFileSync(args.robots, patchRobots(src, args['route-url'])); + process.exit(0); + } + + if (cmd === 'detect-install') { + if (!args['app-dir']) { console.error('Usage: detect-install --app-dir '); process.exit(2); } + const found = detectExistingInstall(args['app-dir']); + for (const f of found) process.stdout.write(f + '\n'); + process.exit(0); + } + + if (cmd === 'install') { + if (!args.config) { console.error('Usage: install --config '); process.exit(2); } + const choices = JSON.parse(fs.readFileSync(args.config, 'utf8')); + if (!choices.projectRoot) { console.error('install: choices.projectRoot is required'); process.exit(2); } + const r = installRoute(choices.projectRoot, choices); + process.stdout.write(JSON.stringify({ files: r.files }, null, 2) + '\n'); + process.exit(0); + } + + console.error('Unknown subcommand: ' + cmd); + process.exit(2); +} + +main(); +``` + +- [ ] **Step 4: Verify all cli tests pass** + +Run: `node --test plugins/adhd/lib/install-design-system-docs-route/__tests__/cli.test.js` +Expected: 10 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add plugins/adhd/lib/install-design-system-docs-route/cli.js \ + plugins/adhd/lib/install-design-system-docs-route/__tests__/cli.test.js +git commit -m "cli: wire all subcommands (parse-tokens, parse-props, slug, patch-*, detect-install, install)" +``` + +--- + +## Task 10: SKILL.md — the 9-phase orchestrator + +**Files:** +- Create: `plugins/adhd/skills/install-design-system-docs-route/SKILL.md` + +- [ ] **Step 1: Write SKILL.md** + +`plugins/adhd/skills/install-design-system-docs-route/SKILL.md`: + +````markdown +--- +description: "Install a self-generating design-system documentation route into a Next.js consumer app. The route reads adhd.config.ts and globals.css at request time, renders a token catalog (colors / spacing / typography / radius / shadows) plus per-component pages with URL-driven prop toggles. Optionally excluded from production builds via Next.js pageExtensions trick. Re-runnable: marker-comment detection drives updates." +disable-model-invocation: true +argument-hint: "" +allowed-tools: Read Write Edit Bash AskUserQuestion +--- + +# ADHD Install Design System Docs Route + +One-shot installer that drops a live design-system docs page into a Next.js App Router project. The page reads `adhd.config.ts` and `globals.css` at request time — no regen needed when components or tokens change. Re-running this skill picks up template improvements over time. + +**Authoritative spec:** `docs/superpowers/specs/2026-05-11-adhd-install-design-system-docs-route.md` + +## Invariants + +1. **No ADHD references in generated files** outside of import paths pointing at `adhd.config.ts`. The marker comment is generic. +2. **adhd.config.ts is NOT modified** by this skill. Install choices live in the filesystem. +3. **All file writes are idempotent on re-run.** Marker-bearing files are replaced wholesale with the latest templates. Files where the user deleted the marker are left alone. + +## Phase 1: Validate consumer environment + +```bash +test -f adhd.config.ts || { echo "Missing adhd.config.ts. Run /adhd:config first."; exit 1; } +test -d app || { echo "Missing app/ directory. This installer requires the Next.js App Router."; exit 1; } +test -f package.json || { echo "No package.json at the working directory."; exit 1; } +``` + +Read `package.json` and confirm `next` is in `dependencies` or `devDependencies`. Warn if missing or version < 16; continue anyway. + +## Phase 2: Detect existing install + +```bash +node plugins/adhd/lib/install-design-system-docs-route/cli.js detect-install --app-dir . +``` + +Output is newline-separated paths of files containing the marker comment. + +- **No matches:** fresh install. Proceed to Phase 3 with defaults. +- **One or more matches:** use `AskUserQuestion`: + - "Update in place" — re-write the listed marker-bearing files with the latest templates. + - "Move to new location" — Phase 3 reasks the install questions; files at the old location are NOT deleted (the user manages them). + - "Abort" — exit with no changes. + +If user chose "Update in place," skip ahead to Phase 6 (patch + write) using the existing folder's group/segment as the choice; ask only "Exclude from production builds?" to confirm current state. + +## Phase 3: Ask installation choices + +Use `AskUserQuestion` three times: + +1. **Route URL** — default `/-docs`. Validate: starts with `/`, only `a-z0-9-/` characters, no leading `_`. +2. **Route group** — default `(design-system)`. Validate: parens-wrapped, alphanumerics + hyphens inside, OR empty string for "no group." +3. **Exclude from production builds?** — default `Yes`. + +Derive `groupName` and `routeSegment` from these answers. Example: routeUrl `/-docs` → routeSegment `-docs`. The group is independent of the URL. + +## Phase 4: Detect Next.js config file + +```bash +for f in next.config.ts next.config.mjs next.config.js; do + test -f "$f" && echo "$f" && break +done +``` + +If none found: abort with "No next.config.* at the project root. Create one before running this installer." + +## Phase 5: Detect filesystem collisions + +```bash +TARGET="app/${GROUP}/${SEGMENT}" +test -e "$TARGET" && echo "EXISTS" || echo "FREE" +``` + +If `EXISTS` and Phase 2 didn't already mark this as an existing install: prompt "Path `` already exists but is not an installer artifact. Pick a different route or abort." + +## Phase 6: Patch next.config.ts (only if prod-exclusion: yes) + +```bash +node plugins/adhd/lib/install-design-system-docs-route/cli.js patch-next-config \ + --config "" \ + --route-url "" +``` + +Exit code 3 means an existing different `pageExtensions` was detected. The CLI prints the existing value. Use `AskUserQuestion`: "Your next.config.ts sets pageExtensions to ``. Merge with the design-system extension conditional? [Yes / Show me the manual patch / Abort]." + +On "Yes": re-run the CLI without `detectOnly` (currently errors; for v1, print "Manual merge required. Patch the file to combine the existing pageExtensions with the conditional. Example:" and abort). On "Show me the manual patch": print the patch block and continue with file installs. + +## Phase 7: Write the page files + +```bash +node plugins/adhd/lib/install-design-system-docs-route/cli.js install \ + --config +``` + +Where `` is a temp file with shape: +```json +{ + "projectRoot": ".", + "groupName": "(design-system)", + "routeSegment": "-docs", + "prodExcluded": true +} +``` + +The CLI prints the list of files it wrote. + +## Phase 8: Patch robots.txt + +```bash +node plugins/adhd/lib/install-design-system-docs-route/cli.js patch-robots \ + --robots public/robots.txt \ + --route-url "" +``` + +If `public/` doesn't exist, create it first: +```bash +mkdir -p public +``` + +## Phase 9: Final report + +Print: +``` +✓ Design system docs route installed. + + URL: http://localhost:3000 + Filesystem: app/// + Prod exclusion: + noindex meta: ON + robots.txt: Disallow added + +Run `npm run dev` and visit the URL to preview. The page reads adhd.config.ts +and globals.css at request time — no regen needed when you add components or +tokens. + +Re-run /adhd:install-design-system-docs-route to pick up improved templates +over time. Files where you've removed the marker comment will be left alone. +``` + +## Common errors + +| Error | Fix-up | +|---|---| +| `Missing adhd.config.ts` | Run `/adhd:config` first. | +| `Missing app/ directory` | This installer requires the Next.js App Router (not Pages Router). | +| `No next.config.* at the project root` | Create one with a default export of `{}`. | +| `Path already exists but is not an installer artifact` | Pick a different route URL or move/delete the existing folder. | +| `next.config.ts sets pageExtensions to ` | Manually merge with the design-system conditional, or skip prod-exclusion. | +```` + +- [ ] **Step 2: Validate SKILL frontmatter** + +Run: `node scripts/validate-skill-frontmatter.js` +Expected: PASS — frontmatter valid; all SKILLs accounted for. + +- [ ] **Step 3: Commit** + +```bash +git add plugins/adhd/skills/install-design-system-docs-route/ +git commit -m "Add /adhd:install-design-system-docs-route skill" +``` + +--- + +## Task 11: /adhd:config integration — optional final phase + +**Files:** +- Modify: `plugins/adhd/skills/config/SKILL.md` + +- [ ] **Step 1: Read the current SKILL.md to find the insertion point** + +The config skill ends with Phase 5 (Report). The new phase goes after Phase 5, before any "Common errors" or "Reference" sections. + +- [ ] **Step 2: Insert the new optional phase** + +Add to `plugins/adhd/skills/config/SKILL.md` after the existing Phase 5: + +```markdown +## Phase 6 (optional): Set up the design-system docs route + +Use `AskUserQuestion`: + +``` +Question: "Set up the design-system docs route now? It's a live, self-generating +documentation page that reads your adhd.config.ts and globals.css. Mini-Storybook +for designers; not indexed by search engines." +Header: "Docs route" +Options: + - "Yes, install it now" + - "No, maybe later" +``` + +On "Yes": execute the phases of `/adhd:install-design-system-docs-route` inline. +See `plugins/adhd/skills/install-design-system-docs-route/SKILL.md` for the +detailed phase list (validate environment → detect existing install → ask install +choices → detect Next.js config → detect collisions → patch next.config.ts → +write files → patch robots.txt → final report). + +On "No": print `Run /adhd:install-design-system-docs-route later to set it up.` +Exit normally. +``` + +- [ ] **Step 3: Validate frontmatter still passes** + +Run: `node scripts/validate-skill-frontmatter.js` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add plugins/adhd/skills/config/SKILL.md +git commit -m "config: offer to install the design-system docs route as an optional final phase" +``` + +--- + +## Task 12: README + marketplace updates + +**Files:** +- Modify: `README.md` +- Modify: `.claude-plugin/marketplace.json` + +- [ ] **Step 1: Update the command table** + +In `README.md`, change `After install, six slash commands are available:` → `After install, seven slash commands are available:`. + +Add a row to the command table after `/adhd:pull-component`: + +``` +| `/adhd:install-design-system-docs-route` | — | install | One-shot installer for a live, self-generating design-system docs route in your Next.js consumer app. Reads adhd.config.ts + globals.css at request time. Excluded from production builds by default. | +``` + +- [ ] **Step 2: Add a "Design system docs route" subsection** + +After the existing "Pull a component" subsection, add: + +```markdown +### Design system docs route + +Run once in your consumer repo: + +``` +/adhd:install-design-system-docs-route +``` + +This installs a live, self-generating documentation page that reads your +`adhd.config.ts` and `globals.css` at request time. The default URL is +`/-docs` (the hyphen prefix telegraphs "internal"), and files live under a +Next.js route group at `app/(design-system)/-docs/`. The page shows: + +- Token catalog: every color / spacing / typography / radius / shadow in your + Tailwind v4 `@theme` block, rendered as visual samples. +- Component pages: each component from `adhd.config.ts`'s `components.*` map + gets its own route with URL-driven prop toggles. + +By default the route is excluded from production builds via Next.js's +`pageExtensions` trick — files use the `.design-system.tsx` extension and +the production build literally doesn't see them. You can opt out at install +time if you'd rather ship the route (it still has `` either way). + +Re-run the installer over time to pick up improved templates. Files you've +customized — by removing the `// design-system-docs-route` marker comment — +are left alone. + +You can also trigger the install at the end of `/adhd:config` if you're +setting up ADHD for the first time. +``` + +- [ ] **Step 3: Update marketplace.json description** + +Read the file. Update the `adhd` plugin's description to mention the new install command. Preserve existing phrasing style. + +- [ ] **Step 4: Commit** + +```bash +git add README.md .claude-plugin/marketplace.json +git commit -m "README + marketplace: document /adhd:install-design-system-docs-route" +``` + +--- + +## Task 13: Final verification + PR + +- [ ] **Step 1: Run all lib tests** + +```bash +node --test plugins/adhd/lib/lint-engine/__tests__/ \ + plugins/adhd/lib/design-system/__tests__/ \ + plugins/adhd/lib/push-component/__tests__/ \ + plugins/adhd/lib/pull-component/__tests__/ \ + plugins/adhd/lib/install-design-system-docs-route/__tests__/ +``` + +Expected: all pass. New tests added: ~47 (3 cli stub + 8 token + 8 prop + 6 slug + 5 next-config + 5 robots + 10 templates + 8 route-installer + 7 cli wiring = ~60 total in the new lib). + +- [ ] **Step 2: Run the SKILL frontmatter validator** + +```bash +node scripts/validate-skill-frontmatter.js +``` + +Expected: PASS, 7/7 skills valid (config, lint, pull-component, pull-design-system, push-component, push-design-system, install-design-system-docs-route). + +- [ ] **Step 3: Build the example app to sanity-check** + +```bash +cd example && npm run build && cd .. +``` + +Expected: compile clean. + +- [ ] **Step 4: Push the branch** + +```bash +git push -u origin adhd/install-design-system-docs-route +``` + +- [ ] **Step 5: Open the PR** + +```bash +gh pr create --title "Add /adhd:install-design-system-docs-route skill" --body "$(cat <<'EOF' +## Summary + +Adds /adhd:install-design-system-docs-route — a one-shot installer that drops a live, self-generating design-system documentation route into a Next.js consumer app. The route reads adhd.config.ts and globals.css at request time, renders a token catalog (colors / spacing / typography / radius / shadows) plus per-component pages with URL-driven prop toggles. + +## Key design choices + +- **Pure one-shot install.** No adhd.config.ts schema additions — install choices (route URL, route group, prod-exclusion) live in the filesystem. +- **Route group `(design-system)` + hyphen-prefix URL `/-docs` by default.** Group organizes future internal routes filesystem-side; hyphen prefix telegraphs "internal." +- **Production exclusion via Next.js pageExtensions conditional.** Files use `.design-system.tsx` extension; next.config.ts patched to include the extension only when NODE_ENV !== 'production'. Files literally invisible to the production build. +- **Ejection-friendly.** Generated files contain zero references to "ADHD." Marker comment is generic. +- **Re-runnable.** Marker-bearing files get replaced with the latest templates on re-run; user can opt OUT of overwrites by deleting the marker. +- **Triggered as optional final phase of /adhd:config** for first-time setup. Available standalone for retroactive install. + +## Test plan + +- [x] ~60 new unit tests across token-parser, prop-parser, slug, next-config-patcher, robots-patcher, templates, route-installer, cli +- [x] Full lib suite green +- [x] 7/7 SKILL frontmatters valid +- [x] Example app builds clean +- [ ] Manual smoke test in example/: install → npm run dev → visit /-docs → click into a component → toggle props → npm run build → npm start → confirm /-docs returns 404 + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Expected: PR URL printed. + +- [ ] **Step 6: Verify CI is green** + +```bash +sleep 30 && gh pr checks $(gh pr view --json number -q .number) +``` + +Expected: all checks pass. + +--- + +## Self-review + +**Spec coverage:** + +| Spec section / criterion | Task | +|---|---| +| Skill command surface | Task 10 (SKILL), Task 12 (README) | +| File layout in consumer app | Task 7 (templates), Task 8 (route-installer) | +| Route group + hyphen URL defaults | Task 8 (installRoute), Task 10 (SKILL Phase 3) | +| File extensions (.design-system.tsx vs .tsx) | Task 8 (installRoute logic) | +| Marker comment | Task 7 (templates MARKER_COMMENT), Task 8 (detectExistingInstall) | +| Pipeline Phase 1 (Validate environment) | Task 10 | +| Pipeline Phase 2 (Detect existing install) | Task 8 (detectExistingInstall), Task 10 (SKILL Phase 2) | +| Pipeline Phase 3 (Ask choices) | Task 10 | +| Pipeline Phase 4 (Detect next.config) | Task 10 | +| Pipeline Phase 5 (Detect collisions) | Task 10 | +| Pipeline Phase 6 (Patch next.config) | Task 5 (next-config-patcher), Task 10 | +| Pipeline Phase 7 (Write files) | Task 8 (route-installer), Task 10 | +| Pipeline Phase 8 (Patch robots.txt) | Task 6 (robots-patcher), Task 10 | +| Pipeline Phase 9 (Report) | Task 10 | +| Update semantics (re-run replaces marker-bearing) | Task 8 + Task 10 | +| Token-parser behavior | Task 2 | +| Prop-parser behavior | Task 3 | +| Slug + collision | Task 4 | +| /adhd:config integration | Task 11 | +| README updates | Task 12 | +| Marketplace description | Task 12 | +| CI step | Task 1 | +| Acceptance criteria 1-21 | Covered across Tasks 1-13 | + +No gaps. + +**Type / signature consistency:** + +- `parseTokens(css: string)` → `{ colors, spacing, typography, radius, shadows, unknown }` — Tasks 2, 7 (used in INDEX_PAGE_TSX template), 9 +- `parseProps(source: string)` → `{ componentName, props, unions }` — Tasks 3, 7 (used in COMPONENT_PAGE_TSX), 9 +- `slugFor(path: string)` → string; `slugMap(paths: string[])` → `{ [path]: slug }` — Tasks 4, 9 +- `patchNextConfig(source: string, opts?: { detectOnly: boolean })` → string OR `{ conflict, existing }` — Tasks 5, 9 +- `patchRobots(source: string | null, routeUrl: string)` → string — Tasks 6, 9 +- `installRoute(projectRoot: string, opts: { groupName, routeSegment, prodExcluded })` → `{ files: string[] }` — Tasks 8, 9 +- `detectExistingInstall(projectRoot: string)` → string[] — Tasks 8, 9 +- Marker comment string `design-system-docs-route — auto-generated installer artifact; safe to edit.` — consistent across templates, route-installer, SKILL.md + +**Placeholder scan:** + +Searched the plan for TODO/TBD/FIXME — only legitimate hits (e.g. inside code comments showing intentional behavior). No real placeholders. diff --git a/docs/superpowers/specs/2026-05-11-adhd-install-design-system-docs-route.md b/docs/superpowers/specs/2026-05-11-adhd-install-design-system-docs-route.md new file mode 100644 index 0000000..0495a88 --- /dev/null +++ b/docs/superpowers/specs/2026-05-11-adhd-install-design-system-docs-route.md @@ -0,0 +1,503 @@ +# /adhd:install-design-system-docs-route — Install a Self-Generating Design-System Docs Route + +**Goal:** One-shot scaffolding command that installs a live, self-generating documentation route into a Next.js consumer app. The route reads `adhd.config.ts` and `globals.css` at request time, renders a token catalog (colors / spacing / typography / radius / shadows) plus a list of every tracked component, and offers per-component pages with URL-driven prop toggles. Behaves like a mini-Storybook tailored to the ADHD design-system model. + +**Architectural premise:** The route is **dynamic at runtime**. No regen step when components or tokens change — the page reads filesystem state on each request. The skill is a pure one-shot installer that drops a small set of files into the consumer app and optionally patches `next.config.ts`. Nothing is stored in `adhd.config.ts` about the docs route — the install choices are encoded in the filesystem. + +**Ejection-friendly:** generated files contain zero references to the word "ADHD." The only place ADHD appears in the consumer app is `adhd.config.ts` itself. If the user later ejects from ADHD, the docs route still works as long as `adhd.config.ts` (or whatever they rename it to) remains present and parseable. + +**Precondition:** The consumer app is a Next.js 16+ App Router project with an `adhd.config.ts` at the repo root. The skill aborts otherwise. + +--- + +## Final command surface + +``` +/adhd:install-design-system-docs-route — install or update the docs route (NEW) +``` + +Also triggered as an optional final phase of `/adhd:config` (the wizard asks "Set up the design-system docs route?" and on `yes` walks through the same install flow inline). + +**Re-running the skill** is a first-class flow. As the templates evolve over time (better layouts, new token sections, bug fixes), the user re-runs `/adhd:install-design-system-docs-route` to pick up the improvements. On detect-existing-install: +- "Update in place" → the skill `Write`s the latest templates over the existing generated files. Idempotent patches (`next.config.ts`, `robots.txt`) are re-applied as no-ops. User customizations to the generated files are replaced. +- "Abort" → no changes made. +- The user can opt OUT of future overwrites by deleting the marker comment from a file they want to preserve. The skill refuses to touch any file lacking the marker, except when the user explicitly says "force overwrite" at the prompt. + +**Out of scope for v1:** +- Multi-route documentation (e.g. one URL per token domain). v1 is a single route with index + per-component pages. +- Image-based component previews (rendering a server-side screenshot). v1 renders the component as live HTML. +- Live Figma comparison side-by-side. v1 is purely code-side documentation. +- Three-way merging of user customizations with new template versions. v1's update flow is "wholesale replace, with the marker-removal escape hatch for files the user wants to preserve." + +--- + +## Architecture + +**File layout in the consumer app** (defaults shown; both `(design-system)` and `-docs` are configurable at install time): + +``` +example/ +├── adhd.config.ts # untouched by this skill +├── next.config.ts # patched only if prod-exclusion: yes +├── public/ +│ └── robots.txt # patched (Disallow line added; file created if missing) +└── app/ + └── (design-system)/ # Next.js route group — invisible in URL + └── -docs/ + ├── layout.design-system.tsx # or layout.tsx — see "File extensions" below + ├── page.design-system.tsx # index — URL: /-docs + └── [component]/ + └── page.design-system.tsx # per-component — URL: /-docs/ +``` + +**Route group `(design-system)`:** organizes the route filesystem-side without affecting URLs. Future internal design-system routes (token playground, fixture viewer, etc.) cohabit cleanly under the same group. The user can pick a different group name at install or omit the group entirely. + +**Route URL `/-docs`:** the hyphen prefix telegraphs "internal" in the URL itself. The user can pick a different URL at install (e.g. `/design-system`, `/docs`, `/-internal/design-system`). + +**File extensions when prod-excluded:** files use `.design-system.tsx`. The skill patches `next.config.ts` to include this extension in `pageExtensions` only when `NODE_ENV !== 'production'`: + +```ts +const nextConfig: NextConfig = { + pageExtensions: process.env.NODE_ENV === 'production' + ? ['ts', 'tsx'] + : ['ts', 'tsx', 'design-system.ts', 'design-system.tsx'], + // ...existing config +}; +``` + +Production builds literally do not see these files. Zero bundle pollution. + +**File extensions when NOT prod-excluded:** plain `.tsx`. `next.config.ts` is not patched. The route ships normally in production with `` and a `robots.txt` Disallow entry. Used by teams that want internal docs reachable in deployed environments behind their own auth. + +**The marker comment:** every generated file starts with: +```ts +// design-system-docs-route — auto-generated installer artifact; safe to edit. +// Remove this comment to disable future overwrites from re-running the installer. +``` +The skill scans for this comment to detect existing installs. The user can opt out of future overwrites by deleting the comment. + +--- + +## Pipeline + +``` +Phase 1 Validate consumer environment — adhd.config.ts present, Next.js 16+ App Router +Phase 2 Detect existing install — scan app/ for marker comment +Phase 3 Ask installation choices — route URL, route group, prod-exclusion +Phase 4 Detect Next.js config file — .ts / .mjs / .js +Phase 5 Detect filesystem collisions — target folder, route group name +Phase 6 Patch next.config.ts — only if prod-exclusion: yes +Phase 7 Write the page files — layout, index, [component] +Phase 8 Patch robots.txt — Disallow entry +Phase 9 Final report +``` + +### Phase 1 — Validate consumer environment + +Required: +- `adhd.config.ts` at the project root. If missing: abort with "Run /adhd:config first." +- `package.json` declares `next` as a dependency. Parse the version; if < 16, warn but continue (App Router has been stable since 13.4; the install is likely to work). +- `app/` directory present (App Router convention). If only `pages/` is present, abort with "This installer requires the Next.js App Router. App Router is in `app/`; you appear to be using Pages Router." + +### Phase 2 — Detect existing install + +Scan `app/**/page.*tsx` and `app/**/layout.*tsx` for the marker comment. If found, capture the folder path of the install. Behaviors: + +| Found | Skill behavior | +|---|---| +| No marker comment anywhere | Fresh install — proceed to Phase 3 with defaults. | +| One marker found | Prompt: "An existing install at ``. [Update in place / Move to new location / Abort]." | +| Multiple markers found | Unusual. Print all locations, prompt: "Pick which to update or move; the others stay as-is." | + +**Update-in-place semantics (re-running to pick up improved templates):** + +- All files with the marker comment get `Write`-replaced with the latest template content. No attempt to preserve user customizations in those files. +- Files WITHOUT the marker (user removed it deliberately, or new files in the install dir the user added) are NOT touched. +- The user is shown a list of files that will be replaced before confirming. Sample prompt: + ``` + Update in place at app/(design-system)/-docs/? + These files will be replaced with the latest templates: + • layout.design-system.tsx + • page.design-system.tsx + • [component]/page.design-system.tsx + • PropToggle.design-system.tsx + These files have the marker comment removed and will be left alone: + (none) + Continue? [Y/n] + ``` +- Idempotent patches (`next.config.ts`'s `pageExtensions`, `robots.txt` Disallow) are re-applied; if already at the expected state, they're no-ops. + +**Force-overwrite escape:** if the user wants to push a template update onto a file they've previously opted out of (marker removed), they can re-add the marker comment to the top of the file before re-running the skill. The skill will then treat it as an update target. + +### Phase 3 — Ask installation choices + +Use `AskUserQuestion` for each (with defaults filled in from the existing install if updating): + +1. **Route URL** — default `/-docs`. Validate: starts with `/`, only `a-z0-9-/` characters. Reject `_` prefixes (Next.js private folders won't route). +2. **Route group** — default `(design-system)`. Validate: parens-wrapped, alphanumerics + hyphens inside. Empty/`""` is also valid (no group). +3. **Exclude from production builds?** — default `yes`. Determines file extension + `next.config.ts` patch. + +Save the choices in working memory. The choices are NOT written to `adhd.config.ts` — they're encoded in the filesystem. + +### Phase 4 — Detect Next.js config file + +Look for `next.config.ts`, `next.config.mjs`, `next.config.js` in priority order. If multiple, prefer `.ts`. Capture the file path. If none, abort: "No `next.config.*` found at the project root." + +### Phase 5 — Detect filesystem collisions + +Construct the install path: `app///`. Check: +- Target folder exists but has no marker comment → existing user content. Prompt: "Path `` already exists. Pick a different route or abort." +- Group folder exists but for unrelated purpose (e.g. user has their own `(design-system)` group) → prompt: "Group `(design-system)` already in use. Pick a different group or abort." + +### Phase 6 — Patch `next.config.ts` (conditional) + +Only runs if prod-exclusion: yes. + +Read the existing `next.config.ts`. Use `Edit` to add or update the `pageExtensions` field within the `NextConfig` object. The patch shape: + +```ts +const nextConfig: NextConfig = { + pageExtensions: process.env.NODE_ENV === 'production' + ? ['ts', 'tsx'] + : ['ts', 'tsx', 'design-system.ts', 'design-system.tsx'], + // ...existing config preserved verbatim +}; +``` + +**Idempotent:** if `pageExtensions` already has this exact conditional shape, no-op. If it has a different `pageExtensions` value entirely, prompt: "Your `next.config.ts` already sets `pageExtensions`. Show me the current value and the patch I'd apply; do you want to merge?" Print both, ask for confirmation, merge. + +**Edit failure:** if the config file's shape isn't a clean `export default { ... }` object the regex can patch, print the exact lines to add manually, continue with file installs. + +### Phase 7 — Write the page files + +Three files written via `Write`. All start with the marker comment. + +**`layout[.design-system].tsx`:** +```tsx +// design-system-docs-route — auto-generated installer artifact; safe to edit. +// Remove this comment to disable future overwrites from re-running the installer. +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Design System Docs", + robots: { index: false, follow: false }, +}; + +export default function DesignSystemDocsLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+
+

Design System Docs

+ Internal — not indexed +
+
+
{children}
+
+ ); +} +``` + +**`page[.design-system].tsx` (index):** + +Server component. Reads: +1. `adhd.config.ts` source — extracts `cssEntry` (default `app/globals.css`) and `components.*` map keys. +2. The resolved `globals.css` source — parses `@theme` block via the inlined `token-parser` helper. + +Renders sections: +- **Colors:** swatch grid (color div + name + resolved value). +- **Spacing:** horizontal bars sized to each spacing increment. +- **Typography:** each `--text-*` rendered as `"The quick brown fox"` at its size with its line-height applied. +- **Radius:** small squares with each `--radius-*` applied. +- **Shadows:** small boxes with each shadow effect applied. +- **Components:** list of components from the config, each linking to `/-docs/`. + +Empty-state behavior: +- No `@theme` block in `globals.css` → token sections show "No tokens detected. Configure `@theme` in your CSS entry." +- No `components` map in `adhd.config.ts` → components section shows "No components tracked. Push one with `/adhd:push-component `." + +**`[component]/page[.design-system].tsx` (per-component dynamic route):** + +Server component. Receives `params.component` and `searchParams`. Steps: + +1. Resolve the component path: scan `adhd.config.ts`'s `components.*` keys, slug each, match against `params.component`. +2. Read the component source file via `fs.readFile`. Parse the props interface inline (regex parser, ~40 LOC; handles named-union references and inline literal unions). +3. Compute current prop values from `searchParams` — each prop's value is `searchParams.get(propName) ?? `. Booleans parse `'true'/'false'`. Unknown prop values for unions fall back to the default. +4. Dynamic-import the component via parametric template-string: + ```ts + const mod = await import(`@/${componentPath.replace(/^app\//, 'app/').replace(/\.tsx?$/, '')}`); + const Component = mod.default ?? mod[componentName]; + ``` +5. Render: + - **Top:** prop toggle UI (a small client component for snappy URL updates; falls back to a plain `
` for no-JS). + - **Middle:** `` inside an error boundary. + - **Bottom:** import statement + JSX invocation snippet, both as `
` blocks reflecting current state.
+
+**Client island for snappy toggles** (a tiny separate file, also `.design-system.tsx`):
+```tsx
+"use client";
+import { useRouter, useSearchParams, usePathname } from "next/navigation";
+
+export function PropToggle({ name, options, value }: { name: string; options: string[]; value: string }) {
+  const router = useRouter();
+  const path = usePathname();
+  const params = useSearchParams();
+  return (
+    
+  );
+}
+```
+
+### Phase 8 — Patch `robots.txt`
+
+Look for `public/robots.txt`. If absent, create with:
+```
+User-agent: *
+Disallow: /-docs
+```
+
+If present, check for an existing `Disallow: /-docs` entry; add if missing. Idempotent.
+
+### Phase 9 — Final report
+
+Print:
+```
+✓ Design system docs route installed.
+
+  URL:           http://localhost:3000/-docs
+  Filesystem:    app/(design-system)/-docs/
+  Prod exclusion: ON (next.config.ts patched)
+  noindex meta:  ON
+  robots.txt:    Disallow added
+
+Run `npm run dev` and visit the URL to preview. The page reads adhd.config.ts
+and globals.css at request time — no regen needed when you add components or
+tokens.
+```
+
+---
+
+## Data flow (runtime, in the consumer app)
+
+```
+┌────────────────────────────────────────────────────────────────┐
+│ HTTP GET /-docs                                                 │
+└────────────────────────────────────────────────────────────────┘
+                  │
+                  ▼
+┌────────────────────────────────────────────────────────────────┐
+│ app/(design-system)/-docs/page[.design-system].tsx              │
+│ (server component)                                              │
+└────────────────────────────────────────────────────────────────┘
+        │                                       │
+        │ fs.readFile                           │ fs.readFile
+        ▼                                       ▼
+┌──────────────────────┐                ┌──────────────────────┐
+│ adhd.config.ts       │                │ globals.css          │
+│  - figma.url         │                │  @theme              │
+│  - cssEntry          │                │   --color-*          │
+│  - components.*      │                │   --spacing          │
+│                      │                │   --text-*           │
+│                      │                │   --radius-*         │
+│                      │                │   --shadow-*         │
+└──────────────────────┘                └──────────────────────┘
+        │                                       │
+        ▼                                       ▼
+┌────────────────────────────────────────────────────────────────┐
+│ HTML response                                                   │
+│  - color swatches, spacing bars, type demos, radius/shadow      │
+│  - components list (linked to /-docs/)                    │
+└────────────────────────────────────────────────────────────────┘
+
+┌────────────────────────────────────────────────────────────────┐
+│ HTTP GET /-docs/avatar?size=lg&shape=circle                     │
+└────────────────────────────────────────────────────────────────┘
+                  │
+                  ▼
+┌────────────────────────────────────────────────────────────────┐
+│ app/(design-system)/-docs/[component]/page[.design-system].tsx  │
+│ (server component)                                              │
+└────────────────────────────────────────────────────────────────┘
+        │                       │                       │
+        │ fs.readFile           │ fs.readFile           │ dynamic import
+        ▼                       ▼                       ▼
+┌──────────────────┐  ┌──────────────────┐  ┌──────────────────┐
+│ adhd.config.ts   │  │ components/      │  │ Component module │
+│ resolve slug →   │  │  avatar/         │  │ via parametric   │
+│ component path   │  │  index.tsx       │  │ template-string  │
+│                  │  │ (parse props)    │  │ import           │
+└──────────────────┘  └──────────────────┘  └──────────────────┘
+        │                       │                       │
+        └───────────────────────┴───────────────────────┘
+                  │
+                  ▼
+┌────────────────────────────────────────────────────────────────┐
+│ HTML response                                                   │
+│  - PropToggle (client island) per prop, hydrated for snappy     │
+│    URL updates                                                   │
+│  -  inside error boundary        │
+│  - 
import { Avatar } from "@/app/components/avatar";
│ +│ -
│ +└────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Module layout + +New library at `plugins/adhd/lib/install-design-system-docs-route/`: + +| File | Responsibility | +|---|---| +| `token-parser.js` | Extract colors / spacing / typography / radius / shadows from a `globals.css` string. Slimmer variant of `lib/design-system/code-parser.js`; returns shape suited for the docs page's rendering. | +| `prop-parser.js` | Regex-based parser for a component's `Props` interface. Returns `{ [propName]: { type, values?, optional } }`. Reuses logic from `lib/push-component/parse-component.js` but exported as a standalone helper. | +| `slug.js` | Path → URL slug + collision detection. | +| `next-config-patcher.js` | Idempotent patch of `next.config.{ts,mjs,js}` to set conditional `pageExtensions`. Preserves existing config. Detects current state; no-op on re-apply. | +| `robots-patcher.js` | Idempotent patch of `public/robots.txt`. Creates if missing. | +| `route-installer.js` | Orchestrates: writes the 3 page files (or 4 with the client island), with the correct extension based on prod-exclusion choice. | +| `cli.js` | Subcommand surface for the SKILL: `detect-install`, `parse-tokens`, `parse-props`, `slug`, `patch-next-config`, `patch-robots`, `install`. | + +New skill at `plugins/adhd/skills/install-design-system-docs-route/SKILL.md`. + +Modified files: +- `plugins/adhd/skills/config/SKILL.md` — add optional final phase that invokes the install flow. +- `.claude-plugin/marketplace.json` — bump description. +- `README.md` — add the new command row. +- `.github/workflows/ci.yml` — add the new test step. + +--- + +## Marker comment + +Generated files start with: + +```ts +// design-system-docs-route — auto-generated installer artifact; safe to edit. +// Remove this comment to disable future overwrites from re-running the installer. +``` + +The string `design-system-docs-route` is unique enough to detect via grep. The user can opt out of future overwrites by removing the comment — the skill will then refuse to touch the file unless the user explicitly confirms re-install. + +**No reference to "ADHD" or "/adhd:..."** in the comment. The marker is generic; ejection-friendly. + +--- + +## Edge cases & errors + +| Case | Behavior | +|---|---| +| `adhd.config.ts` missing | Abort: "Run /adhd:config first to set up ADHD." | +| `package.json` missing or doesn't declare `next` | Abort: "This installer expects a Next.js project at the working directory." | +| `app/` directory missing (Pages Router project) | Abort: "This installer requires the Next.js App Router." | +| `next.config.ts/.mjs/.js` missing | Abort: "No next.config.* at the project root. Create one before running this installer." | +| Existing install at target path (marker present) | Prompt: update / move / abort | +| Existing user folder at target path (no marker) | Prompt: pick a different route or abort | +| Existing route group with the chosen name (no marker) | Prompt: pick a different group or abort | +| `next.config.ts` already sets `pageExtensions` to a different value | Prompt: show the existing value, show the proposed merge, ask to confirm | +| `next.config.ts` shape unrecognizable | Print the exact lines to add, continue with file installs | +| `public/` directory missing | Create `public/robots.txt`; the directory comes along | +| `robots.txt` already has the Disallow line | No-op | +| User chose route URL `/foo` but folder `app/foo/` already exists with user content | Phase 5 catches this; prompts before proceeding | +| Component referenced in `adhd.config.ts` no longer exists | Index page shows it with a "missing" badge; per-component route returns a clean 404 with the missing path | +| Component's Props interface can't be parsed | Per-component page renders the component with declared defaults; banner "Prop introspection failed — toggles unavailable." | +| Component throws at render | Error boundary catches; shows the error message inline; "reset to defaults" link | +| Dynamic-import path fails to resolve (component file moved/deleted) | Surface the error inline on that component's page; other routes keep working | +| Search-param value invalid for a union prop | Fall back to default; small inline warning | +| User runs `next build` with prod-exclusion ON | Files invisible to the build; route returns 404 in production | +| User runs `next build` with prod-exclusion OFF | Route ships; noindex meta + robots.txt entry still apply | +| User has CRLF line endings | `Edit` preserves them; new files written with the platform's default ending | + +--- + +## Symmetric-pipeline assertions + +| Assertion | Mechanism | +|---|---| +| `prop-parser.js` shares its behavior contract with `lib/push-component/parse-component.js` | The two regex parsers handle the same prop-type categories (union, primitive, optional flag, ReactNode/function/ref skips). Unit-tested in parallel; a smoke test asserts both produce equivalent output for the Avatar source. | +| `token-parser.js` produces tokens consistent with `lib/design-system/code-parser.js` | Same `@theme` extraction logic, narrowed to the subset the docs page needs. Unit-tested against the same `globals.css` fixtures. | + +--- + +## Testing strategy + +**Unit tests** (`plugins/adhd/lib/install-design-system-docs-route/__tests__/`): + +| Module | Coverage | +|---|---| +| `token-parser.js` | Extracts all 5 domains from a Tailwind v4 `globals.css`; handles missing `@theme` block; handles unknown vars (returns "unknown" category). | +| `prop-parser.js` | Parses the Avatar interface; handles inline unions, named-union references, primitives, ReactNode/function/ref (skipped). | +| `slug.js` | Path → slug; collision detection. | +| `next-config-patcher.js` | Patches `.ts` / `.mjs` / `.js`; idempotent on re-apply; preserves existing config; detects already-customized `pageExtensions` and merges with prompt. | +| `robots-patcher.js` | Creates / appends; idempotent. | +| `route-installer.js` | Writes correct files for each prod-exclusion choice; refuses overwrite without confirmation; detects existing install via marker. | +| `cli.js` | Each subcommand exits 0 on success, 2 on usage error. | + +**Integration test** (one): +- Run end-to-end against a copy of `example/` in a temp dir. Assert all files exist with the marker; `next.config.ts` has the conditional `pageExtensions`; re-running detects the install. + +**Manual smoke test** (acceptance criterion #20): +1. In `example/`: run `/adhd:install-design-system-docs-route`. Pick defaults. +2. `npm run dev`; visit `/-docs`. Verify token catalog + components list. +3. Click into a component; verify toggles, URL updates, rendered output. +4. `npm run build`; verify the `/-docs` chunks don't appear in `.next/server/app/`. +5. `npm start`; visit `/-docs`; verify 404. + +--- + +## Integration with `/adhd:config` + +`plugins/adhd/skills/config/SKILL.md` gets a new optional final phase (Phase 6 or after the existing "Report"): + +```markdown +## Phase 6 (optional): Set up the design-system docs route + +Use AskUserQuestion: + + "Set up the design-system docs route? It's a live, self-generating + documentation page that reads your adhd.config.ts and globals.css. + Mini-Storybook for designers; not indexed by search engines." + + Options: + - "Yes, install it now" → walk through the install phases inline + (see plugins/adhd/skills/install-design-system-docs-route/SKILL.md + for the full phase list) + - "No, maybe later" → print "Run /adhd:install-design-system-docs-route + to set it up later." Exit. +``` + +The install phases are documented in the standalone skill; `/adhd:config` references that skill and instructs Claude to follow its phases inline. + +--- + +## Acceptance criteria + +1. `/adhd:install-design-system-docs-route` runs against a Next.js 16+ App Router project with an existing `adhd.config.ts`. Writes layout + index page + dynamic `[component]/page` (+ a small client-island file for prop toggles). +2. Default route URL is `/-docs`; route group default is `(design-system)`; both configurable at install time; neither stored in `adhd.config.ts`. +3. Default behavior: prod-excluded. `next.config.ts` gets the conditional `pageExtensions` patch; generated files use `.design-system.tsx` extension. User can opt out at install time. +4. Skill detects existing installs via the marker comment (`design-system-docs-route — auto-generated installer artifact; safe to edit.`) and prompts before overwriting. +5. Skill detects `next.config.ts` / `.mjs` / `.js` and patches whichever exists; if the file's shape can't be safely patched via `Edit`, prints the exact patch and continues with file installs. +6. Index page renders sections for colors, spacing, typography, radius, shadows (parsed from `globals.css`'s `@theme`); empty-state strings when a section is missing. +7. Index page lists components from `adhd.config.ts`'s `components.*` map; each links to `/-docs/`. +8. Per-component page dynamically imports the component via parametric template-string import; renders inside an error boundary. +9. Prop toggles: `` for booleans; text/number inputs for primitives; ReactNode / function / ref / array / object props skipped with an inline note. +10. Prop toggles update URL search params via a small client island; the server component re-renders with new params. No-JS fallback via `` works. +11. Per-component page shows the import statement + current JSX invocation as `
` blocks reflecting the current prop state.
+12. Layout has ``; `robots.txt` Disallow entry added/created.
+13. Generated files contain zero references to "ADHD." Only `adhd.config.ts` does.
+14. Marker comment is generic: `// design-system-docs-route — auto-generated installer artifact; safe to edit.`
+15. `/adhd:config` gets a new optional final phase: "Set up the design-system docs route?" On yes, walks through the install flow inline. On no, prints the run-it-yourself instruction.
+16. Re-running the skill is idempotent — no duplicate writes, no duplicate `next.config.ts` patches, no duplicate `robots.txt` entries, prompts on existing install.
+17. With prod-exclusion enabled: `next build` produces no chunks for the route; `npm start` returns 404 at the route URL.
+18. With prod-exclusion disabled: route ships with noindex meta still applied.
+19. README's command table includes the new `/adhd:install-design-system-docs-route` row.
+20. Manual smoke test against `example/` passes end-to-end: install → dev server → click through → build → 404 in production.
+21. Re-running the skill against an existing install reliably updates files that still bear the marker comment to the latest template version, while leaving files where the marker was deliberately removed untouched. Verified by: install at template v1 → ship a template v2 (different layout content) → re-run skill → confirm marker-bearing files now contain v2 content and marker-removed files contain their preserved pre-v2 state.
diff --git a/plugins/adhd/lib/design-system/README.md b/plugins/adhd/lib/design-system/README.md
index 6113e16..acac5fe 100644
--- a/plugins/adhd/lib/design-system/README.md
+++ b/plugins/adhd/lib/design-system/README.md
@@ -1,7 +1,7 @@
 # design-system
 
-Pure-JS engine that powers `/adhd:push-design-system` and
-`/adhd:pull-design-system`. Parses both sides (globals.css and Figma
+Pure-JS engine that powers `/adhd:push-tokens` and
+`/adhd:pull-tokens`. Parses both sides (globals.css and Figma
 variables) into a canonical `DesignSystem` shape, compares them, and
 emits conflict reports / write actions.
 
diff --git a/plugins/adhd/lib/design-system/__tests__/cli.test.js b/plugins/adhd/lib/design-system/__tests__/cli.test.js
index a96ea78..819d951 100644
--- a/plugins/adhd/lib/design-system/__tests__/cli.test.js
+++ b/plugins/adhd/lib/design-system/__tests__/cli.test.js
@@ -69,3 +69,121 @@ test('apply mode produces actions list', () => {
   assert.equal(actions.length, 1);
   assert.equal(actions[0].kind, 'create-variable');
 });
+
+test('preview (push): lists adds + conflicts + figma-only count without writing', () => {
+  // Verifies the dry-run formatter for /adhd:push-tokens --dry-run:
+  // every code-only token shows as an ADD line per mode, every conflict
+  // shows BOTH values (we don't pre-resolve in dry-run), figma-only
+  // tokens are surfaced as a count only.
+  const diff = tmp('diff.json', {
+    same: [],
+    conflict: [
+      { path: 'color/brand-500', mode: 'default', domain: 'color', code: '#aaa', figma: '#bbb' },
+      { path: 'spacing/4',       mode: 'default', domain: 'spacing', code: '1rem',  figma: '0.875rem' },
+    ],
+    codeOnly: [
+      { domain: 'color', path: 'gold/100', values: { default: '#faf0c5' } },
+      { domain: 'color', path: 'surface',  values: { light: '#fff', dark: '#0a0a0a' } },
+    ],
+    figmaOnly: [
+      { domain: 'color', path: 'legacy/old', values: { default: '#123456' } },
+    ],
+  });
+
+  const result = spawnSync('node', [CLI, 'preview', '--diff', diff, '--direction', 'push'], { encoding: 'utf8' });
+  assert.equal(result.status, 0, result.stderr);
+
+  assert.match(result.stdout, /DRY RUN — code → Figma/);
+  // Each codeOnly token expanded per mode → 1 + 2 = 3 ADD rows
+  assert.match(result.stdout, /Would add to Figma \(3 entries\)/);
+  assert.match(result.stdout, /\+ gold\/100/);
+  assert.match(result.stdout, /\+ surface[^\n]+light[^\n]+#fff/);
+  assert.match(result.stdout, /\+ surface[^\n]+dark[^\n]+#0a0a0a/);
+  // Conflict rows show both sides
+  assert.match(result.stdout, /Would prompt for 2 conflicts/);
+  assert.match(result.stdout, /! color\/brand-500[^\n]+code=#aaa[^\n]+figma=#bbb/);
+  // Figma-only count
+  assert.match(result.stdout, /Figma-only \(left untouched per additive policy\): 1 entry/);
+  // Footer
+  assert.match(result.stdout, /To apply: re-run without --dry-run/);
+});
+
+test('preview (pull): flips the direction labels', () => {
+  // Symmetric for pull — figmaOnly becomes ADD, codeOnly becomes the
+  // untouched count.
+  const diff = tmp('diff.json', {
+    same: [], conflict: [],
+    codeOnly: [
+      { domain: 'color', path: 'kept-in-code', values: { default: '#aaa' } },
+    ],
+    figmaOnly: [
+      { domain: 'color', path: 'new-from-figma', values: { default: '#bbb' } },
+    ],
+  });
+
+  const result = spawnSync('node', [CLI, 'preview', '--diff', diff, '--direction', 'pull'], { encoding: 'utf8' });
+  assert.equal(result.status, 0, result.stderr);
+
+  assert.match(result.stdout, /DRY RUN — figma → code/);
+  assert.match(result.stdout, /Would add to code \(1 entry\)/);
+  assert.match(result.stdout, /\+ new-from-figma/);
+  assert.match(result.stdout, /code-only \(left untouched per additive policy\): 1 entry/);
+});
+
+test('compare: surfaces Tailwind defaults in codeOnly with fromTailwindDefault marker', () => {
+  // Comparator is policy-free now — every token from the parser lands in
+  // codeOnly. Filtering (Tailwind palette vs only my semantics) happens
+  // at the action-builder layer per the user's dispositions. This test
+  // confirms the marker travels through compare so dispositions can use
+  // it.
+  const css = tmp('globals.css', `@theme { --color-brand: #5e3aee; }`);
+  const figma = tmp('figma.json', { collections: [], effectStyles: [], textStyles: [] });
+  const out = path.join(os.tmpdir(), 'diff-' + Date.now() + '.json');
+
+  const result = spawnSync('node', [CLI, 'compare', '--code', css, '--figma', figma, '--output', out], { encoding: 'utf8' });
+  assert.equal(result.status, 0);
+  const diff = JSON.parse(fs.readFileSync(out, 'utf8'));
+  // Tailwind defaults present (hundreds of them).
+  assert.ok(diff.codeOnly.length > 100, `expected Tailwind defaults to surface, got ${diff.codeOnly.length}`);
+  // User-authored brand color also present, marker cleared.
+  const brand = diff.codeOnly.find(t => t.path === 'brand');
+  assert.ok(brand);
+  assert.equal(brand.fromTailwindDefault, false);
+  // Tailwind-default markers present on the palette tokens.
+  assert.ok(diff.codeOnly.some(t => t.fromTailwindDefault === true));
+});
+
+test('preview: buckets additions by domain when there are many entries', () => {
+  // Above the flat-list threshold (25), the preview groups by domain
+  // with a sample-of-each rather than a hundreds-line dump.
+  const codeOnly = [];
+  for (let i = 0; i < 40; i++) {
+    codeOnly.push({ domain: 'color', path: `zinc/${i}`, values: { default: `#000${i}` } });
+  }
+  for (let i = 0; i < 30; i++) {
+    codeOnly.push({ domain: 'spacing', path: `${i}`, values: { default: `${i}px` } });
+  }
+  const diff = tmp('diff.json', { same: [], conflict: [], codeOnly, figmaOnly: [] });
+
+  const result = spawnSync('node', [CLI, 'preview', '--diff', diff, '--direction', 'push'], { encoding: 'utf8' });
+  assert.equal(result.status, 0, result.stderr);
+  assert.match(result.stdout, /Would add to Figma \(70 entries across 2 domains\)/);
+  assert.match(result.stdout, /\bCOLOR \(40\)/);
+  assert.match(result.stdout, /\bSPACING \(30\)/);
+  // Each bucket is truncated; the trailer shows the remaining count.
+  assert.match(result.stdout, /\[\+34 more\]/);
+  assert.match(result.stdout, /\[\+24 more\]/);
+});
+
+test('preview: errors on missing --diff or invalid --direction', () => {
+  // Sanity: the subcommand must validate its inputs.
+  let r = spawnSync('node', [CLI, 'preview', '--direction', 'push'], { encoding: 'utf8' });
+  assert.equal(r.status, 2);
+
+  const diff = tmp('diff.json', { same: [], conflict: [], codeOnly: [], figmaOnly: [] });
+  r = spawnSync('node', [CLI, 'preview', '--diff', diff], { encoding: 'utf8' });
+  assert.equal(r.status, 2);
+
+  r = spawnSync('node', [CLI, 'preview', '--diff', diff, '--direction', 'bogus'], { encoding: 'utf8' });
+  assert.notEqual(r.status, 0);
+});
diff --git a/plugins/adhd/lib/design-system/__tests__/code-parser.test.js b/plugins/adhd/lib/design-system/__tests__/code-parser.test.js
index 3d73362..3331d3b 100644
--- a/plugins/adhd/lib/design-system/__tests__/code-parser.test.js
+++ b/plugins/adhd/lib/design-system/__tests__/code-parser.test.js
@@ -66,6 +66,62 @@ test('var(--x) references become aliases', () => {
   assert.deepEqual(t.values.dark,  { type: 'alias', target: 'gold/900' });
 });
 
+test('@theme inline with calc(var(--X) ± Npx) resolves to a literal override against the Tailwind default', () => {
+  // The real-world shadcn/Tailwind v4 pattern: `--radius` lives in :root,
+  // and the four canonical radius variants derive from it via calc().
+  // Without the resolver, the parser silently fell back to Tailwind's
+  // `--radius-sm: 0.25rem` defaults, which mismatch Figma's resolved
+  // 6/8/10/14px values and produced spurious conflicts on every push.
+  const ds = parseCodeDesignSystem(`
+    :root { --radius: 0.625rem; }
+    @theme inline {
+      --radius-sm: calc(var(--radius) - 4px);
+      --radius-md: calc(var(--radius) - 2px);
+      --radius-lg: var(--radius);
+      --radius-xl: calc(var(--radius) + 4px);
+    }
+  `, { includeTailwindDefaults: true });
+  const byPath = (p) => ds.tokens.find(t => t.domain === 'radius' && t.path === p);
+  // 0.625rem = 10px; the four overrides resolve to 6 / 8 / 10 / 14px.
+  assert.equal(byPath('sm').values.default.value, '6px');
+  assert.equal(byPath('md').values.default.value, '8px');
+  assert.equal(byPath('lg').values.default.value, '10px');
+  assert.equal(byPath('xl').values.default.value, '14px');
+  // After a user override the Tailwind-default marker is cleared, so the
+  // override pushes into Figma (it's authored intent, not implicit default).
+  assert.equal(byPath('sm').fromTailwindDefault, false);
+});
+
+test('@theme inline: calc with multiplier resolves (var * N)', () => {
+  // Tailwind's own pattern for the spacing scale: `--spacing-N: calc(var(--spacing) * N)`.
+  const ds = parseCodeDesignSystem(`
+    :root { --gap: 0.5rem; }
+    @theme inline {
+      --spacing-double: calc(var(--gap) * 2);
+    }
+  `);
+  const t = ds.tokens.find(x => x.domain === 'spacing' && x.path === 'double');
+  // 0.5rem * 2 = 16px
+  assert.equal(t.values.default.value, '16px');
+});
+
+test('@theme inline: unresolvable expression leaves Tailwind default in place', () => {
+  // The resolver only handles patterns it can confidently reduce to a
+  // literal length. A var that's not defined anywhere can't be resolved
+  // → leave the override out and keep the Tailwind default. The diff
+  // surfaces the conflict only if the runtime value diverges (which we
+  // can't know from text alone).
+  const ds = parseCodeDesignSystem(`
+    @theme inline {
+      --radius-sm: calc(var(--never-defined) + 4px);
+    }
+  `, { includeTailwindDefaults: true });
+  const t = ds.tokens.find(x => x.domain === 'radius' && x.path === 'sm');
+  // Falls back to Tailwind's `--radius-sm: 0.25rem` default literal.
+  assert.equal(t.values.default.value, '0.25rem');
+  assert.equal(t.fromTailwindDefault, true);
+});
+
 test('@theme inline entries land in ds.exposure, not ds.tokens', () => {
   const ds = parseCodeDesignSystem(`
     :root { --brand-surface: var(--color-gold-100); }
diff --git a/plugins/adhd/lib/design-system/__tests__/comparator.test.js b/plugins/adhd/lib/design-system/__tests__/comparator.test.js
index f395170..7f96365 100644
--- a/plugins/adhd/lib/design-system/__tests__/comparator.test.js
+++ b/plugins/adhd/lib/design-system/__tests__/comparator.test.js
@@ -205,4 +205,106 @@ test('valuesEqual: still flags real value differences after normalization', () =
   assert.equal(r.same.length, 0);
 });
 
+test('canonicalization: code path "gold/25" and figma path "gold-25" reconcile to `same` (round-trip dup fix)', () => {
+  // The lossy-CSS-var bug: pull-tokens wrote `--color-gold-25` for a
+  // Figma variable named `Color/gold-25` (single leaf with internal
+  // hyphen). On push, code-parser tokenizes the CSS var as path `gold/25`
+  // (split-on-first-hyphen interpretation). Without canonicalization, the
+  // comparator sees `gold/25` in code and `gold-25` in Figma as distinct
+  // tokens — push would create a duplicate Figma variable. Canonicalization
+  // pairs them up by CSS-var equivalence.
+  const code = {
+    tokens: [{ domain: 'color', path: 'gold/25', values: { default: { type: 'literal', value: '#c5a572' } } }],
+    styles: { effects: [] },
+  };
+  const figma = {
+    tokens: [{ domain: 'color', path: 'gold-25', values: { default: { type: 'literal', value: '#c5a572' } } }],
+    styles: { effects: [] },
+  };
+  const diff = compareDesignSystems(code, figma);
+  assert.equal(diff.same.length, 1);
+  assert.equal(diff.codeOnly.length, 0);
+  assert.equal(diff.figmaOnly.length, 0);
+});
+
+test('canonicalization: alias targets match across "neutral/0" (code) and "neutral-0" (figma)', () => {
+  // Same root cause, alias edition. A Figma variable that aliases
+  // `neutral-0` pulls into code as `var(--color-neutral-0)`, which the
+  // code-parser interprets as alias-target `neutral/0`. Both should
+  // compare equal so push doesn't see a phantom conflict.
+  const code = {
+    tokens: [{ domain: 'color', path: 'background', values: { light: { type: 'alias', target: 'neutral/0' } } }],
+    styles: { effects: [] },
+  };
+  const figma = {
+    tokens: [{ domain: 'color', path: 'background', values: { light: { type: 'alias', target: 'neutral-0' } } }],
+    styles: { effects: [] },
+  };
+  const diff = compareDesignSystems(code, figma);
+  assert.equal(diff.same.length, 1);
+  assert.equal(diff.conflict.length, 0);
+});
+
+test('canonicalization: distinct domains don\'t collide even when paths canonicalize the same', () => {
+  // `color/gold/25` and `shadow/gold/25` would both reduce to similar
+  // CSS-var-equivalent forms — but their domain prefix differs
+  // (--color-gold-25 vs --shadow-gold-25). They must stay distinct.
+  const code = {
+    tokens: [{ domain: 'color', path: 'gold/25', values: { default: { type: 'literal', value: '#c5a572' } } }],
+    styles: { effects: [] },
+  };
+  const figma = {
+    tokens: [{ domain: 'shadow', path: 'gold/25', values: { default: { type: 'literal', value: '0 1px 2px black' } } }],
+    styles: { effects: [] },
+  };
+  const diff = compareDesignSystems(code, figma);
+  // Neither moves to `same` — different domains, different CSS vars.
+  assert.equal(diff.same.length, 0);
+  assert.equal(diff.codeOnly.length, 1);
+  assert.equal(diff.figmaOnly.length, 1);
+});
+
+test('codeOnly: surfaces all tokens including Tailwind defaults (filtering is the dispositions layer\'s job)', () => {
+  // Comparator is policy-free: every code-side token appears in codeOnly.
+  // Filtering ("push the Tailwind palette or only my semantics?") lives
+  // in the dispositions wizard, applied at the action-builder layer.
+  // The `fromTailwindDefault` marker travels through so dispositions can
+  // apply per-token rules.
+  const code = {
+    tokens: [
+      { domain: 'color', path: 'zinc/500', values: { default: { type: 'literal', value: '#71717a' } }, fromTailwindDefault: true },
+      { domain: 'color', path: 'brand',    values: { default: { type: 'literal', value: '#5e3aee' } }, fromTailwindDefault: false },
+    ],
+    styles: { effects: [] },
+  };
+  const figma = { tokens: [], styles: { effects: [] } };
+  const diff = compareDesignSystems(code, figma);
+  assert.equal(diff.codeOnly.length, 2);
+  const byPath = Object.fromEntries(diff.codeOnly.map(t => [t.path, t]));
+  assert.equal(byPath['zinc/500'].fromTailwindDefault, true);
+  assert.equal(byPath['brand'].fromTailwindDefault, false);
+});
+
+test('Tailwind-default-origin token with a Figma value mismatch still surfaces as conflict', () => {
+  // The filter is codeOnly-specific. If Figma has a different value for a
+  // Tailwind default (designer overrode `--color-zinc-500`), that's real
+  // state and stays in `conflict`.
+  const code = {
+    tokens: [
+      { domain: 'color', path: 'zinc/500', values: { default: { type: 'literal', value: '#71717a' } }, fromTailwindDefault: true },
+    ],
+    styles: { effects: [] },
+  };
+  const figma = {
+    tokens: [
+      { domain: 'color', path: 'zinc/500', values: { default: { type: 'literal', value: '#888888' } } },
+    ],
+    styles: { effects: [] },
+  };
+  const diff = compareDesignSystems(code, figma);
+  assert.equal(diff.codeOnly.length, 0);
+  assert.equal(diff.conflict.length, 1);
+  assert.equal(diff.conflict[0].path, 'zinc/500');
+});
+
 
diff --git a/plugins/adhd/lib/design-system/__tests__/dispositions.test.js b/plugins/adhd/lib/design-system/__tests__/dispositions.test.js
new file mode 100644
index 0000000..8f5db42
--- /dev/null
+++ b/plugins/adhd/lib/design-system/__tests__/dispositions.test.js
@@ -0,0 +1,118 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { classifyToken, parsePushTokensFromConfig, formatPushTokensForConfig } = require('../dispositions');
+
+const tok = (domain, path, extras = {}) => ({ domain, path, ...extras });
+
+test('font families always skip (hardcoded, regardless of dispositions)', () => {
+  const v = classifyToken(tok('typography', 'font/aeonik'), { typography: 'all' });
+  assert.equal(v.action, 'skip');
+  assert.match(v.reason, /font-family/);
+});
+
+test('color "all": pushes both palette + semantic', () => {
+  const dispositions = { color: 'all' };
+  assert.equal(classifyToken(tok('color', 'zinc/500', { fromTailwindDefault: true }), dispositions).action, 'push');
+  assert.equal(classifyToken(tok('color', 'brand'), dispositions).action, 'push');
+});
+
+test('color "semantic-only": skips Tailwind palette, keeps authored', () => {
+  const dispositions = { color: 'semantic-only' };
+  const palette = classifyToken(tok('color', 'zinc/500', { fromTailwindDefault: true }), dispositions);
+  assert.equal(palette.action, 'skip');
+  assert.match(palette.reason, /semantic-only/);
+  assert.equal(classifyToken(tok('color', 'brand'), dispositions).action, 'push');
+});
+
+test('color "skip": nothing pushes', () => {
+  assert.equal(classifyToken(tok('color', 'brand'), { color: 'skip' }).action, 'skip');
+  assert.equal(classifyToken(tok('color', 'zinc/500', { fromTailwindDefault: true }), { color: 'skip' }).action, 'skip');
+});
+
+test('typography "sizes-and-weights": text-* and font-weight-* push, leading/tracking skip', () => {
+  const dispositions = { typography: 'sizes-and-weights' };
+  assert.equal(classifyToken(tok('typography', 'text/sm'), dispositions).action, 'push');
+  assert.equal(classifyToken(tok('typography', 'font-weight/bold'), dispositions).action, 'push');
+  assert.equal(classifyToken(tok('typography', 'leading/relaxed'), dispositions).action, 'skip');
+  assert.equal(classifyToken(tok('typography', 'tracking/tight'), dispositions).action, 'skip');
+});
+
+test('spacing "authored-only": Tailwind defaults skip, authored push', () => {
+  const dispositions = { spacing: 'authored-only' };
+  const tw = classifyToken(tok('spacing', '4', { fromTailwindDefault: true }), dispositions);
+  assert.equal(tw.action, 'skip');
+  assert.equal(classifyToken(tok('spacing', 'card-gap'), dispositions).action, 'push');
+});
+
+test('radius / border-width: push by default, skip when set', () => {
+  assert.equal(classifyToken(tok('radius', 'sm'), {}).action, 'push');
+  assert.equal(classifyToken(tok('border-width', 'thin'), {}).action, 'push');
+  assert.equal(classifyToken(tok('radius', 'sm'), { radiusAndBorder: 'skip' }).action, 'skip');
+});
+
+test('shadow: routes to effect-style when push enabled, skip otherwise', () => {
+  assert.equal(classifyToken(tok('shadow', 'md'), {}).action, 'effect-style');
+  assert.equal(classifyToken(tok('shadow', 'md'), { shadow: 'skip' }).action, 'skip');
+});
+
+test('opacity defaults to skip (Tailwind uses / class modifiers)', () => {
+  const v = classifyToken(tok('opacity', '50'), {});
+  assert.equal(v.action, 'skip');
+  assert.match(v.reason, /class modifier/);
+  assert.equal(classifyToken(tok('opacity', '50'), { opacity: 'push' }).action, 'push');
+});
+
+test('utility domains (animate/ease/aspect/perspective/container/breakpoint/z-index/blur) default to skip', () => {
+  const utils = ['animate', 'ease', 'aspect', 'perspective', 'container', 'breakpoint', 'z-index', 'blur'];
+  for (const dom of utils) {
+    const v = classifyToken(tok(dom, 'x'), {});
+    assert.equal(v.action, 'skip', `${dom} should skip by default`);
+    assert.match(v.reason, /utilityDomains/);
+  }
+});
+
+test('utility domains push when explicitly enabled', () => {
+  for (const dom of ['animate', 'breakpoint', 'z-index']) {
+    assert.equal(classifyToken(tok(dom, 'x'), { utilityDomains: 'push' }).action, 'push');
+  }
+});
+
+test('parsePushTokensFromConfig: returns null when no block present', () => {
+  assert.equal(parsePushTokensFromConfig(`export default { figma: { url: 'x' } };`), null);
+  assert.equal(parsePushTokensFromConfig(''), null);
+  assert.equal(parsePushTokensFromConfig(null), null);
+});
+
+test('parsePushTokensFromConfig: extracts a well-formed block', () => {
+  const src = `
+    export default {
+      figma: { url: "https://figma.com/design/abc" },
+      pushTokens: {
+        color: "all",
+        typography: "sizes-and-weights",
+        opacity: "skip",
+      },
+      naming: "kebab-case",
+    };
+  `;
+  const out = parsePushTokensFromConfig(src);
+  assert.deepEqual(out, { color: 'all', typography: 'sizes-and-weights', opacity: 'skip' });
+});
+
+test('formatPushTokensForConfig: produces a stable, insertable block', () => {
+  const out = formatPushTokensForConfig({
+    color: 'all',
+    typography: 'all',
+    spacing: 'all',
+    radiusAndBorder: 'push',
+    shadow: 'effect-styles',
+    opacity: 'skip',
+    utilityDomains: 'skip',
+  });
+  assert.match(out, /^  pushTokens: \{\n/);
+  // Stable order — color first.
+  assert.match(out, /color: "all"/);
+  assert.match(out, /utilityDomains: "skip"/);
+});
diff --git a/plugins/adhd/lib/design-system/__tests__/e2e-pipeline.test.js b/plugins/adhd/lib/design-system/__tests__/e2e-pipeline.test.js
index e8560ae..27c5d13 100644
--- a/plugins/adhd/lib/design-system/__tests__/e2e-pipeline.test.js
+++ b/plugins/adhd/lib/design-system/__tests__/e2e-pipeline.test.js
@@ -23,12 +23,12 @@ function loadFixture(name) {
   return JSON.parse(fs.readFileSync(path.join(FIXTURES, name), 'utf8'));
 }
 
-function pipeline(figmaFixtureName, { resolutions = [], direction = 'push' } = {}) {
+function pipeline(figmaFixtureName, { resolutions = [], direction = 'push', dispositions = null } = {}) {
   const css = fs.readFileSync(GLOBALS, 'utf8');
   const codeDS = parseCodeDesignSystem(css, { includeTailwindDefaults: true });
   const figmaDS = parseFigmaDesignSystem(loadFixture(figmaFixtureName));
   const diff = compareDesignSystems(codeDS, figmaDS);
-  const actions = buildFigmaActions(diff, resolutions, direction);
+  const actions = buildFigmaActions(diff, resolutions, direction, { dispositions });
   return { codeDS, figmaDS, diff, actions };
 }
 
@@ -36,15 +36,17 @@ function countKind(actions, kind) {
   return actions.filter(a => a.kind === kind).length;
 }
 
-test('e2e: clean sync — diff has no codeOnly/conflict/figmaOnly variables', () => {
+test('e2e: clean sync — diff has zero conflict/figmaOnly variables', () => {
+  // With the comparator surfacing all code-side tokens (including Tailwind
+  // defaults), codeOnly is no longer a tight "diff of what's missing" — it
+  // becomes "everything in code minus what Figma already has." When both
+  // sides hold the full palette the diff still has no conflict/figmaOnly,
+  // but codeOnly may contain shadow-domain tokens (shadows live on the
+  // effect-style channel, never the variable channel). The action layer
+  // is where filtering happens — see the empty-figma test below.
   const { diff } = pipeline('figma-full-tailwind.json');
   assert.equal(diff.conflict.length, 0, 'expected zero conflicts');
   assert.equal(diff.figmaOnly.length, 0, 'expected zero figmaOnly');
-  // Shadows are emitted as effect styles, not variables, so they always look
-  // "codeOnly" on the variable side. Confirm every codeOnly is a shadow.
-  for (const t of diff.codeOnly) {
-    assert.equal(t.domain, 'shadow', `unexpected non-shadow codeOnly: ${t.domain}:${t.path}`);
-  }
   assert.equal(diff.styles.effects.same.length > 0, true, 'expected effect styles to match');
   assert.equal(diff.styles.effects.codeOnly.length, 0, 'effect-style codeOnly should be empty');
 });
@@ -57,17 +59,44 @@ test('e2e: push against synced figma produces zero create-variable actions', ()
   assert.equal(countKind(actions, 'create-effect-style'), 0);
 });
 
-test('e2e: empty figma — push produces create-variable actions for every variable token', () => {
-  const { diff, actions } = pipeline('figma-empty.json', { direction: 'push' });
+test('e2e: empty figma — dispositions filter the push (semantic-only color + authored-only spacing)', () => {
+  // The interesting case: comparator surfaces every code-side token
+  // (palette + authored) but the dispositions wizard's "semantic-only"
+  // and "authored-only" picks filter Tailwind defaults out at the
+  // action-builder layer. This test exercises the same workflow the user
+  // performs by clicking through the wizard.
+  const { diff, actions } = pipeline('figma-empty.json', {
+    direction: 'push',
+    dispositions: {
+      color: 'semantic-only',
+      typography: 'all',
+      spacing: 'authored-only',
+      radiusAndBorder: 'push',
+      shadow: 'effect-styles',
+      opacity: 'skip',
+      utilityDomains: 'skip',
+    },
+  });
   assert.equal(diff.same.length, 0);
   assert.equal(diff.conflict.length, 0);
   assert.equal(diff.figmaOnly.length, 0);
-  assert.ok(diff.codeOnly.length > 400, `expected hundreds of codeOnly, got ${diff.codeOnly.length}`);
-  // codeOnly with non-shadow domains → create-variable; shadows → create-effect-style.
-  const nonShadowCodeOnly = diff.codeOnly.filter(t => t.domain !== 'shadow').length;
-  assert.equal(countKind(actions, 'create-variable'), nonShadowCodeOnly,
-    'create-variable count should equal non-shadow codeOnly count');
-  assert.ok(countKind(actions, 'create-effect-style') > 0, 'expected some effect-style creates for shadows');
+  // Comparator surfaces hundreds of tokens (the full Tailwind palette + authored).
+  assert.ok(diff.codeOnly.length > 100, `expected full palette to surface, got ${diff.codeOnly.length}`);
+  // Action layer applies dispositions: Tailwind palette colors filtered
+  // out, Tailwind spacing scale filtered out, opacity + utilities skipped.
+  // What remains: authored color tokens, full typography (sizes + weights
+  // + leading + tracking — all from Tailwind), authored spacing, radii,
+  // border widths, shadow as effect-styles.
+  const createCount = countKind(actions, 'create-variable');
+  const skipCount = countKind(actions, 'skip-by-disposition');
+  const effectCount = countKind(actions, 'create-effect-style');
+  assert.ok(createCount > 0, `expected some pushes, got ${createCount}`);
+  assert.ok(skipCount > 100, `expected many skip-by-disposition (Tailwind palette + opacity + utilities), got ${skipCount}`);
+  assert.ok(effectCount > 0, `expected shadow effect-styles, got ${effectCount}`);
+  // Every skip carries a disposition-derived reason.
+  for (const a of actions.filter(a => a.kind === 'skip-by-disposition')) {
+    assert.ok(a.reason && a.reason.length > 0, `skip action missing reason: ${JSON.stringify(a)}`);
+  }
 });
 
 test('e2e: empty figma — pull is a no-op (nothing in figma to pull)', () => {
diff --git a/plugins/adhd/lib/design-system/__tests__/figma-write-actions.test.js b/plugins/adhd/lib/design-system/__tests__/figma-write-actions.test.js
index 700856a..ec3bf9a 100644
--- a/plugins/adhd/lib/design-system/__tests__/figma-write-actions.test.js
+++ b/plugins/adhd/lib/design-system/__tests__/figma-write-actions.test.js
@@ -80,25 +80,99 @@ test('spacing token with 0.25rem produces FLOAT create-variable with resolved va
   assert.equal(actions[0].resolvedByMode.default, 4);
 });
 
-test('font-family typography token produces STRING create-variable', () => {
+test('font-family typography tokens are skipped (text-styles channel, not variables)', () => {
+  // Font families belong in Figma's text-style system, not its variable
+  // system. The disposition classifier hardcodes this skip so it applies
+  // even when the user picks "push all typography" — text styles are the
+  // right channel period.
   const diff = {
     same: [], conflict: [], figmaOnly: [],
     codeOnly: [{
       domain: 'typography',
       path: 'font/sans',
-      values: {
-        default: { type: 'literal', value: 'ui-sans-serif, system-ui, sans-serif' },
-      },
+      values: { default: { type: 'literal', value: 'ui-sans-serif, system-ui, sans-serif' } },
     }],
   };
-  const actions = buildFigmaActions(diff, [], 'push');
+  const actions = buildFigmaActions(diff, [], 'push', { dispositions: { typography: 'all' } });
+  assert.equal(actions.length, 1);
+  assert.equal(actions[0].kind, 'skip-by-disposition');
+  assert.equal(actions[0].path, 'font/sans');
+  assert.match(actions[0].reason, /font-family/);
+});
+
+test('text//line-height with calc(num / num) value → FLOAT create-variable (was STRING + FONT_SIZE → rejected)', () => {
+  // Tailwind v4 ships every text-size with a paired line-height that's
+  // defined as a calc ratio: `--text-xs--line-height: calc(1 / 0.75)`.
+  // Pre-fix: dimensionToPx rejected calc(), so the action got typed as
+  // STRING; the write script then narrowed text/* to FONT_SIZE; Figma
+  // refused with "Invalid scope for this variable type." The two fixes
+  // landing together make these tokens push as FLOAT with LINE_HEIGHT
+  // scope (the scope half is enforced inside WRITE_SCRIPT — covered in
+  // figma-write-script.test.js).
+  const diff = {
+    same: [], conflict: [], figmaOnly: [],
+    codeOnly: [{
+      domain: 'typography',
+      path: 'text/xs/line-height',
+      values: { default: { type: 'literal', value: 'calc(1 / 0.75)' } },
+    }],
+  };
+  const actions = buildFigmaActions(diff, [], 'push', { dispositions: { typography: 'all' } });
   assert.equal(actions.length, 1);
   assert.equal(actions[0].kind, 'create-variable');
+  assert.equal(actions[0].type, 'FLOAT');
+  // 1 / 0.75 ≈ 1.333…
+  assert.ok(Math.abs(actions[0].resolvedByMode.default - (1 / 0.75)) < 1e-9);
+});
+
+test('dimensionToPx via action builder: calc(1rem * 2) resolves to 32 (rem→px through both operands)', () => {
+  // Another shape Tailwind uses internally — e.g. `calc(var(--spacing) * 4)`
+  // surface analogues. With var() stripped (the @theme-inline resolver
+  // handles those), the remaining calc(num * num) should evaluate.
+  const diff = {
+    same: [], conflict: [], figmaOnly: [],
+    codeOnly: [{
+      domain: 'spacing', path: 'double',
+      values: { default: { type: 'literal', value: 'calc(1rem * 2)' } },
+    }],
+  };
+  const actions = buildFigmaActions(diff, [], 'push', { dispositions: { spacing: 'all' } });
+  assert.equal(actions[0].type, 'FLOAT');
+  assert.equal(actions[0].resolvedByMode.default, 32);
+});
+
+test('dimensionToPx via action builder: unresolvable calc (var ref or nested) falls through to STRING', () => {
+  // calc(var(--gap) * 2) can't be evaluated at this layer — var() refs
+  // are resolved earlier in code-parser's @theme-inline pass. Whatever
+  // leaks through here stays STRING (and the scope-narrowing branch
+  // catches whether that's a valid combination per-domain).
+  const diff = {
+    same: [], conflict: [], figmaOnly: [],
+    codeOnly: [{
+      domain: 'spacing', path: 'tricky',
+      values: { default: { type: 'literal', value: 'calc(var(--gap) * 2)' } },
+    }],
+  };
+  const actions = buildFigmaActions(diff, [], 'push', { dispositions: { spacing: 'all' } });
+  // FLOAT_DOMAINS includes spacing, but the value isn't resolvable → STRING.
   assert.equal(actions[0].type, 'STRING');
-  assert.equal(
-    actions[0].resolvedByMode.default,
-    'ui-sans-serif, system-ui, sans-serif',
-  );
+});
+
+test('font-weight typography tokens still push as variables (only font families skip)', () => {
+  // `--font-weight-bold` lives under the typography domain but it's a
+  // scalar value designers consume as a Tailwind utility — variables are
+  // the right home. Only the `font/` path triggers the skip.
+  const diff = {
+    same: [], conflict: [], figmaOnly: [],
+    codeOnly: [{
+      domain: 'typography',
+      path: 'font-weight/bold',
+      values: { default: { type: 'literal', value: '700' } },
+    }],
+  };
+  const actions = buildFigmaActions(diff, [], 'push');
+  assert.equal(actions.length, 1);
+  assert.equal(actions[0].kind, 'create-variable');
 });
 
 test('shadow token produces a create-effect-style action', () => {
@@ -199,6 +273,8 @@ test('color token still emits COLOR-typed create-variable (regression guard)', (
 });
 
 test('opacity token (0.05) → FLOAT in `opacity` collection', () => {
+  // Opacity defaults to skip (Tailwind's class-modifier pattern), so this
+  // test passes an opt-in disposition to exercise the create-variable shape.
   const diff = {
     same: [], conflict: [], figmaOnly: [],
     codeOnly: [{
@@ -206,7 +282,7 @@ test('opacity token (0.05) → FLOAT in `opacity` collection', () => {
       values: { default: { type: 'literal', value: '0.05' } },
     }],
   };
-  const actions = buildFigmaActions(diff, [], 'push');
+  const actions = buildFigmaActions(diff, [], 'push', { dispositions: { opacity: 'push' } });
   assert.equal(actions.length, 1);
   assert.equal(actions[0].kind, 'create-variable');
   assert.equal(actions[0].collection, 'opacity');
@@ -236,7 +312,7 @@ test('z-index token (50, unitless) → FLOAT in `z-index` collection', () => {
       values: { default: { type: 'literal', value: '50' } },
     }],
   };
-  const actions = buildFigmaActions(diff, [], 'push');
+  const actions = buildFigmaActions(diff, [], 'push', { dispositions: { utilityDomains: 'push' } });
   assert.equal(actions[0].collection, 'z-index');
   assert.equal(actions[0].type, 'FLOAT');
   assert.equal(actions[0].resolvedByMode.default, 50);
@@ -250,7 +326,7 @@ test('breakpoint token (40rem) → FLOAT converted to 640 px', () => {
       values: { default: { type: 'literal', value: '40rem' } },
     }],
   };
-  const actions = buildFigmaActions(diff, [], 'push');
+  const actions = buildFigmaActions(diff, [], 'push', { dispositions: { utilityDomains: 'push' } });
   assert.equal(actions[0].collection, 'breakpoint');
   assert.equal(actions[0].type, 'FLOAT');
   assert.equal(actions[0].resolvedByMode.default, 640);
@@ -264,7 +340,7 @@ test('aspect token (16 / 9) → STRING in `aspect` collection', () => {
       values: { default: { type: 'literal', value: '16 / 9' } },
     }],
   };
-  const actions = buildFigmaActions(diff, [], 'push');
+  const actions = buildFigmaActions(diff, [], 'push', { dispositions: { utilityDomains: 'push' } });
   assert.equal(actions[0].collection, 'aspect');
   assert.equal(actions[0].type, 'STRING');
   assert.equal(actions[0].resolvedByMode.default, '16 / 9');
@@ -278,7 +354,7 @@ test('ease cubic-bezier → STRING in `ease` collection', () => {
       values: { default: { type: 'literal', value: 'cubic-bezier(0.4, 0, 0.2, 1)' } },
     }],
   };
-  const actions = buildFigmaActions(diff, [], 'push');
+  const actions = buildFigmaActions(diff, [], 'push', { dispositions: { utilityDomains: 'push' } });
   assert.equal(actions[0].collection, 'ease');
   assert.equal(actions[0].type, 'STRING');
   assert.equal(actions[0].resolvedByMode.default, 'cubic-bezier(0.4, 0, 0.2, 1)');
@@ -292,7 +368,7 @@ test('animate shorthand → STRING in `animate` collection', () => {
       values: { default: { type: 'literal', value: 'spin 1s linear infinite' } },
     }],
   };
-  const actions = buildFigmaActions(diff, [], 'push');
+  const actions = buildFigmaActions(diff, [], 'push', { dispositions: { utilityDomains: 'push' } });
   assert.equal(actions[0].collection, 'animate');
   assert.equal(actions[0].type, 'STRING');
 });
diff --git a/plugins/adhd/lib/design-system/__tests__/figma-write-script.test.js b/plugins/adhd/lib/design-system/__tests__/figma-write-script.test.js
new file mode 100644
index 0000000..b202299
--- /dev/null
+++ b/plugins/adhd/lib/design-system/__tests__/figma-write-script.test.js
@@ -0,0 +1,97 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { WRITE_SCRIPT, tokenScopesFor, findCollectionAlias, COLLECTION_ALIASES } = require('../figma-write-script');
+
+test('tokenScopesFor: text/ → FONT_SIZE', () => {
+  assert.deepEqual(tokenScopesFor('typography', 'text/xs'), ['FONT_SIZE']);
+  assert.deepEqual(tokenScopesFor('typography', 'text/base'), ['FONT_SIZE']);
+});
+
+test('tokenScopesFor: text//line-height → LINE_HEIGHT (regression — companion line-heights must not get FONT_SIZE scope)', () => {
+  // The bug: Tailwind v4 ships paired line-height values with every text
+  // size (--text-xs--line-height: calc(1 / 0.75), etc.). Earlier the
+  // startsWith('text/') branch matched first and assigned FONT_SIZE,
+  // and Figma rejected the push with "Invalid scope for this variable
+  // type" on every text-size's paired line-height.
+  assert.deepEqual(tokenScopesFor('typography', 'text/xs/line-height'), ['LINE_HEIGHT']);
+  assert.deepEqual(tokenScopesFor('typography', 'text/base/line-height'), ['LINE_HEIGHT']);
+  assert.deepEqual(tokenScopesFor('typography', 'text/4xl/line-height'), ['LINE_HEIGHT']);
+});
+
+test('tokenScopesFor: leading/, tracking/, font/, font-weight/ keep their dedicated scopes', () => {
+  assert.deepEqual(tokenScopesFor('typography', 'leading/relaxed'), ['LINE_HEIGHT']);
+  assert.deepEqual(tokenScopesFor('typography', 'tracking/tight'), ['LETTER_SPACING']);
+  assert.deepEqual(tokenScopesFor('typography', 'font/sans'), ['FONT_FAMILY']);
+  assert.deepEqual(tokenScopesFor('typography', 'font-weight/bold'), ['FONT_WEIGHT']);
+});
+
+test('tokenScopesFor: non-typography domains pull from the domain table', () => {
+  assert.deepEqual(tokenScopesFor('color', 'gold/100'), ['FRAME_FILL', 'SHAPE_FILL', 'TEXT_FILL', 'STROKE_COLOR']);
+  assert.deepEqual(tokenScopesFor('radius', 'sm'), ['CORNER_RADIUS']);
+  assert.deepEqual(tokenScopesFor('opacity', '50'), ['OPACITY']);
+});
+
+test('findCollectionAlias: matches case-insensitively to the canonical (Color → color)', () => {
+  // The user's "duped collections" bug. Designer's Figma file had a
+  // "Color" collection (capital C) but push was looking up "color"
+  // case-sensitively, missed it, and created a parallel "color"
+  // collection alongside. Same for "Radius" / "radius".
+  assert.equal(findCollectionAlias('color', ['Color', 'Other']), 'Color');
+  assert.equal(findCollectionAlias('radius', ['Other', 'Radius']), 'Radius');
+});
+
+test('findCollectionAlias: matches synonyms (Space → spacing, Borders → border-width, Type + Effects → typography)', () => {
+  // Real collection names from the user's screenshot. Each maps to a
+  // different canonical without case alone — these are semantic synonyms.
+  assert.equal(findCollectionAlias('spacing', ['Space']), 'Space');
+  assert.equal(findCollectionAlias('border-width', ['Borders']), 'Borders');
+  assert.equal(findCollectionAlias('typography', ['Type + Effects']), 'Type + Effects');
+  assert.equal(findCollectionAlias('shadow', ['Effects']), 'Effects');
+});
+
+test('findCollectionAlias: returns null when nothing matches (caller creates new collection)', () => {
+  assert.equal(findCollectionAlias('color', ['Spacing', 'Radii']), null);
+  assert.equal(findCollectionAlias('color', []), null);
+});
+
+test('findCollectionAlias: unknown canonical returns null (safe default)', () => {
+  assert.equal(findCollectionAlias('not-a-domain', ['Color', 'Radius']), null);
+});
+
+test('COLLECTION_ALIASES covers every domain the action builder might emit', () => {
+  // If a new domain gets added to figma-write-actions's DOMAIN_COLLECTION
+  // but not here, the alias lookup silently returns null for it and
+  // push starts creating differently-cased duplicate collections.
+  const { DOMAIN_COLLECTION } = require('../figma-write-actions');
+  for (const canonical of Object.values(DOMAIN_COLLECTION)) {
+    assert.ok(COLLECTION_ALIASES[canonical],
+      `missing alias list for canonical "${canonical}" — push will create a fresh collection instead of reusing existing case-variants`);
+  }
+});
+
+test('WRITE_SCRIPT inlines the same alias logic (drift guard)', () => {
+  // The script template carries its own copy of COLLECTION_ALIASES.
+  // This guard catches drift: if the JS-side table evolves without the
+  // inline copy keeping up, real pushes go back to creating parallel
+  // case-variant collections.
+  for (const aliases of Object.values(COLLECTION_ALIASES)) {
+    for (const a of aliases) {
+      assert.ok(WRITE_SCRIPT.includes(`'${a}'`),
+        `alias "${a}" missing from inlined COLLECTION_ALIASES in WRITE_SCRIPT`);
+    }
+  }
+});
+
+test('WRITE_SCRIPT inlines the same line-height pattern (drift guard)', () => {
+  // The script template can't `require`, so it carries its own copy of
+  // tokenScopesFor. The exported JS-side function above is its mirror.
+  // This guard catches drift: if someone edits the inline version without
+  // touching the JS-side mirror (or vice versa), the line-height case
+  // could silently regress.
+  assert.match(WRITE_SCRIPT,
+    /path\.startsWith\(['"`]text\/['"`]\)\s*&&\s*path\.endsWith\(['"`]\/line-height['"`]\)/,
+    'WRITE_SCRIPT should include the text/*/line-height branch BEFORE the broader text/ check',
+  );
+});
diff --git a/plugins/adhd/lib/design-system/cli.js b/plugins/adhd/lib/design-system/cli.js
index b806f36..77a8ada 100644
--- a/plugins/adhd/lib/design-system/cli.js
+++ b/plugins/adhd/lib/design-system/cli.js
@@ -27,7 +27,8 @@ function parseArgs(argv) {
 function printUsage() {
   console.log(`Usage:
   cli.js compare           --code  --figma  --output 
-  cli.js apply             --diff  --resolutions  --direction  --output 
+  cli.js apply             --diff  --resolutions  --direction  --output  [--dispositions ]
+  cli.js preview           --diff  --direction  [--actions ]
   cli.js assemble-extract  --chunks-dir  --output 
 
 compare:
@@ -39,6 +40,12 @@ apply:
   conflict). Produces an actions list. For push, actions are Figma
   variable mutations. For pull, actions are CSS edits.
 
+preview:
+  Reads a diff JSON and prints a human-readable dry-run preview to stdout
+  — which variables would be added, which would conflict with existing
+  values on the destination side. No prompts, no writes. Used by
+  /adhd:push-tokens --dry-run and /adhd:pull-tokens --dry-run.
+
 assemble-extract:
   Reads every *.json file in --chunks-dir (responses from
   EXTRACT_CHUNK_SCRIPT — one manifest + one-or-more slices) and merges them
@@ -46,6 +53,170 @@ assemble-extract:
   full design system is too large to fetch in a single use_figma call.`);
 }
 
+// Renders a value (hex string, px string, shadow descriptor, etc.) in a
+// single short token. TokenValue objects from the parsers come in two
+// shapes — `{type: 'literal', value}` and `{type: 'alias', target}` —
+// unwrap them so designers see `#fff` instead of `{"type":"literal",...}`.
+function fmtValue(v) {
+  if (v == null) return '—';
+  if (typeof v === 'string') return v;
+  if (typeof v === 'number') return String(v);
+  if (v && typeof v === 'object') {
+    if (v.type === 'literal' && 'value' in v) return String(v.value);
+    if (v.type === 'alias' && 'target' in v) return `→ ${v.target}`;
+  }
+  try { return JSON.stringify(v); } catch { return String(v); }
+}
+
+// One line per (path, mode). Code-only and figma-only token entries store
+// their values per mode under `values`; conflict entries already arrive
+// flattened by mode. When `actions` is provided (push-direction dry-run
+// with dispositions), entries are split into "would push" vs "would
+// skip" buckets so designers see the actual outcome of their disposition
+// choices, not the unfiltered diff.
+function formatPreview(diff, direction, actions = null) {
+  if (direction !== 'push' && direction !== 'pull') {
+    throw new Error(`preview: --direction must be 'push' or 'pull', got '${direction}'`);
+  }
+  const fromSide = direction === 'push' ? 'code'  : 'figma';
+  const toSide   = direction === 'push' ? 'Figma' : 'code';
+  const fromOnly = direction === 'push' ? diff.codeOnly  : diff.figmaOnly;
+  const toOnly   = direction === 'push' ? diff.figmaOnly : diff.codeOnly;
+
+  const lines = [];
+  lines.push(`DRY RUN — ${fromSide} → ${toSide}. No changes will be applied.`);
+  lines.push('');
+
+  // Additions: tokens that exist only on the source side. Each token may
+  // span multiple modes; emit one row per mode.
+  // Below this threshold, show every row inline — short lists are easier
+  // to read flat than bucketed. Above it, group by domain and truncate
+  // each bucket so a 493-entry Tailwind-palette seed remains scannable
+  // instead of unfurling 493 lines into the terminal.
+  const FLAT_THRESHOLD = 25;
+  const BUCKET_SAMPLE_SIZE = 6;
+
+  // When actions are available, build a (domain, path) → skipReason map
+  // so we can label each row's disposition outcome. Push-action tokens
+  // produce no map entry — they push as normal.
+  const skipReasonByKey = new Map();
+  if (actions && direction === 'push') {
+    for (const a of actions) {
+      if (a.kind === 'skip-by-disposition') {
+        skipReasonByKey.set((a.domain || '') + ':' + a.path, a.reason);
+      }
+    }
+  }
+
+  const addRows = [];
+  for (const tok of fromOnly) {
+    const skipReason = skipReasonByKey.get((tok.domain || '') + ':' + tok.path) || null;
+    for (const [mode, value] of Object.entries(tok.values || {})) {
+      addRows.push({ path: tok.path, mode, value, domain: tok.domain || 'other', skipReason });
+    }
+  }
+  addRows.sort((a, b) =>
+    a.domain.localeCompare(b.domain) ||
+    a.path.localeCompare(b.path) ||
+    a.mode.localeCompare(b.mode),
+  );
+
+  // Split into push and skip lanes when dispositions are available; the
+  // skip lane shows reasons so designers see exactly why their tokens
+  // are filtered out.
+  const pushRows = addRows.filter(r => !r.skipReason);
+  const skipRows = addRows.filter(r => r.skipReason);
+
+  if (pushRows.length === 0) {
+    lines.push(`Would add to ${toSide}: none.`);
+  } else if (pushRows.length <= FLAT_THRESHOLD) {
+    lines.push(`Would add to ${toSide} (${pushRows.length} entr${pushRows.length === 1 ? 'y' : 'ies'}):`);
+    const pathW = Math.max(...pushRows.map(r => r.path.length));
+    const modeW = Math.max(...pushRows.map(r => r.mode.length));
+    for (const r of pushRows) {
+      lines.push(`  + ${r.path.padEnd(pathW)}  (${r.mode.padEnd(modeW)})  = ${fmtValue(r.value)}`);
+    }
+  } else {
+    // Bucket by domain. Show counts up front, then a sample of each
+    // domain so the user can sanity-check the shape without scrolling
+    // past hundreds of rows.
+    const byDomain = new Map();
+    for (const r of pushRows) {
+      if (!byDomain.has(r.domain)) byDomain.set(r.domain, []);
+      byDomain.get(r.domain).push(r);
+    }
+    lines.push(`Would add to ${toSide} (${pushRows.length} entries across ${byDomain.size} domain${byDomain.size === 1 ? '' : 's'}):`);
+    for (const domain of [...byDomain.keys()].sort()) {
+      const rows = byDomain.get(domain);
+      lines.push(``);
+      lines.push(`  ${domain.toUpperCase()} (${rows.length})`);
+      const sample = rows.slice(0, BUCKET_SAMPLE_SIZE);
+      const pathW = Math.max(...sample.map(r => r.path.length));
+      const modeW = Math.max(...sample.map(r => r.mode.length));
+      for (const r of sample) {
+        lines.push(`    + ${r.path.padEnd(pathW)}  (${r.mode.padEnd(modeW)})  = ${fmtValue(r.value)}`);
+      }
+      if (rows.length > BUCKET_SAMPLE_SIZE) {
+        lines.push(`    [+${rows.length - BUCKET_SAMPLE_SIZE} more]`);
+      }
+    }
+    lines.push('');
+    lines.push(`  Full list written to the diff JSON (codeOnly array). For a one-time review, sort by domain in the diff file.`);
+  }
+  lines.push('');
+
+  // Skipped-by-disposition lane (only renders when actions were provided).
+  if (skipRows.length > 0) {
+    const byReason = new Map();
+    for (const r of skipRows) {
+      if (!byReason.has(r.skipReason)) byReason.set(r.skipReason, []);
+      byReason.get(r.skipReason).push(r);
+    }
+    lines.push(`Would NOT add to ${toSide} (${skipRows.length} entr${skipRows.length === 1 ? 'y' : 'ies'} filtered by your dispositions):`);
+    for (const [reason, rows] of byReason.entries()) {
+      lines.push(``);
+      lines.push(`  ${rows.length} × ${reason}`);
+      const sample = rows.slice(0, BUCKET_SAMPLE_SIZE);
+      for (const r of sample) {
+        lines.push(`    - ${r.domain}/${r.path}  (${r.mode})  = ${fmtValue(r.value)}`);
+      }
+      if (rows.length > BUCKET_SAMPLE_SIZE) {
+        lines.push(`    [+${rows.length - BUCKET_SAMPLE_SIZE} more]`);
+      }
+    }
+    lines.push('');
+  }
+
+  // Conflicts: same path on both sides, different value per mode. Show
+  // BOTH sides so the user can judge — the dry run intentionally doesn't
+  // pre-resolve in favor of either side, since the prompt loop in Phase 4
+  // is where resolution happens.
+  const conflicts = diff.conflict || [];
+  if (conflicts.length === 0) {
+    lines.push(`Would overwrite in ${toSide}: none.`);
+  } else {
+    lines.push(`Would prompt for ${conflicts.length} conflict${conflicts.length === 1 ? '' : 's'} (existing on both sides, values differ):`);
+    const pathW = Math.max(...conflicts.map(c => c.path.length));
+    const modeW = Math.max(...conflicts.map(c => c.mode.length));
+    for (const c of conflicts) {
+      lines.push(`  ! ${c.path.padEnd(pathW)}  (${c.mode.padEnd(modeW)})  code=${fmtValue(c.code)}  figma=${fmtValue(c.figma)}`);
+    }
+    lines.push('');
+    lines.push(`  Per-conflict prompts let you keep ${fromSide}'s value or ${toSide === 'Figma' ? toSide.toLowerCase() : toSide}'s. Either way, no removal — only the chosen value is written.`);
+  }
+  lines.push('');
+
+  // Other-side-only: tokens that exist only on the destination. The
+  // additive policy means we never delete these; they're shown only so
+  // the user understands the full surface.
+  const toOnlyCount = (toOnly || []).reduce((n, t) => n + Object.keys(t.values || {}).length, 0);
+  lines.push(`${toSide}-only (left untouched per additive policy): ${toOnlyCount} entr${toOnlyCount === 1 ? 'y' : 'ies'}.`);
+  lines.push('');
+  lines.push(`To apply: re-run without --dry-run.`);
+
+  return lines.join('\n');
+}
+
 function main() {
   const args = parseArgs(process.argv);
   if (args.help) { printUsage(); process.exit(0); }
@@ -54,9 +225,11 @@ function main() {
   if (cmd === 'compare') {
     const css = fs.readFileSync(args.code, 'utf8');
     const figmaExtract = JSON.parse(fs.readFileSync(args.figma, 'utf8'));
-    // Default: include Tailwind v4's full default theme so push/pull see
-    // the complete design system, not just what's redeclared in globals.css.
-    // Disable with --no-tailwind-defaults if you only want explicit overrides.
+    // Parser always learns about Tailwind v4's default theme so the
+    // `fromTailwindDefault` marker travels with each token — the
+    // disposition wizard in /adhd:push-tokens uses it to decide which
+    // tokens push (color: semantic-only excludes Tailwind palette, etc.).
+    // Disable via --no-tailwind-defaults for explicit-overrides-only mode.
     const includeTailwindDefaults = !('no-tailwind-defaults' in args);
     const codeDS = parseCodeDesignSystem(css, { includeTailwindDefaults });
     const figmaDS = parseFigmaDesignSystem(figmaExtract);
@@ -68,11 +241,36 @@ function main() {
   if (cmd === 'apply') {
     const diff = JSON.parse(fs.readFileSync(args.diff, 'utf8'));
     const resolutions = JSON.parse(fs.readFileSync(args.resolutions, 'utf8'));
-    const actions = buildFigmaActions(diff, resolutions, args.direction);
+    // Optional dispositions (push only) — the per-domain policy collected
+    // by /adhd:push-tokens's wizard. When absent, defaults from
+    // dispositions.js apply.
+    let dispositions = null;
+    if (args.dispositions) {
+      try { dispositions = JSON.parse(fs.readFileSync(args.dispositions, 'utf8')); }
+      catch { dispositions = null; }
+    }
+    const actions = buildFigmaActions(diff, resolutions, args.direction, { dispositions });
     fs.writeFileSync(args.output, JSON.stringify(actions, null, 2));
     process.exit(0);
   }
 
+  if (cmd === 'preview') {
+    if (!args.diff) { console.error('Missing --diff'); process.exit(2); }
+    if (!args.direction) { console.error('Missing --direction'); process.exit(2); }
+    const diff = JSON.parse(fs.readFileSync(args.diff, 'utf8'));
+    // Optional: when actions.json is provided, the preview reflects what
+    // the action builder would actually do — grouping additions into
+    // "would push" vs "would skip — reason". Without it, the preview is
+    // diff-only (legacy behavior).
+    let actions = null;
+    if (args.actions) {
+      try { actions = JSON.parse(fs.readFileSync(args.actions, 'utf8')); }
+      catch { actions = null; }
+    }
+    process.stdout.write(formatPreview(diff, args.direction, actions) + '\n');
+    process.exit(0);
+  }
+
   if (cmd === 'assemble-extract') {
     const dir = args['chunks-dir'];
     if (!dir) { console.error('Missing --chunks-dir'); process.exit(2); }
@@ -89,3 +287,5 @@ function main() {
 }
 
 main();
+
+module.exports = { formatPreview };
diff --git a/plugins/adhd/lib/design-system/code-parser.js b/plugins/adhd/lib/design-system/code-parser.js
index 1b0338c..d96bffb 100644
--- a/plugins/adhd/lib/design-system/code-parser.js
+++ b/plugins/adhd/lib/design-system/code-parser.js
@@ -191,6 +191,81 @@ function valueFromString(raw) {
   return { type: 'literal', value: trimmed };
 }
 
+// Convert a CSS length string ("0.625rem", "16px", "1.5") to pixels.
+// rem/em use a 16px base — Tailwind v4's default and the figma-write-
+// actions's convention. Returns null if the input isn't a simple length.
+function lengthToPx(raw) {
+  if (raw == null) return null;
+  const s = String(raw).trim();
+  const m = /^(-?\d*\.?\d+)(px|rem|em)?$/.exec(s);
+  if (!m) return null;
+  const n = parseFloat(m[1]);
+  const unit = m[2] || '';
+  if (unit === 'rem' || unit === 'em') return n * 16;
+  return n;
+}
+
+// Try to resolve a CSS value expression to a literal `px` string by
+// looking up referenced variables in `rawVars`. Handles the patterns
+// designers actually write in `@theme inline {}`:
+//
+//   var(--name)                          → resolve --name (recursive)
+//   calc(var(--name) ± Npx | ± Nrem)     → arithmetic
+//   calc(var(--name) * N | / N)          → arithmetic
+//   plain literals ("0.5rem", "8px")     → returned as `px`
+//
+// Returns null when the expression contains anything more elaborate (two
+// vars, nested calcs, percentage math, etc.) — caller falls back to the
+// existing behavior (skipping the override). Recursion is bounded by
+// `depth` to prevent cycles in pathological aliases.
+function resolveToPxLiteral(raw, rawVars, depth = 0) {
+  if (depth > 8) return null;
+  const s = String(raw || '').trim();
+  if (!s) return null;
+
+  // Plain literal — already a length.
+  const direct = lengthToPx(s);
+  if (direct != null) return direct + 'px';
+
+  // var(--name) alone.
+  const varOnly = /^var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,[^)]*)?\)$/.exec(s);
+  if (varOnly) {
+    const target = rawVars.get(varOnly[1]);
+    if (target == null) return null;
+    return resolveToPxLiteral(target, rawVars, depth + 1);
+  }
+
+  // calc(  ) — restricted form.
+  const calc = /^calc\(\s*(.+?)\s*\)$/.exec(s);
+  if (calc) {
+    const inner = calc[1];
+    // Two operands separated by + - * /. Operators must have surrounding
+    // whitespace per the CSS spec (calc(8px+4px) is invalid).
+    const opSplit = /^(.+?)\s+([+\-*/])\s+(.+)$/.exec(inner);
+    if (!opSplit) return null;
+    const [, lhsRaw, op, rhsRaw] = opSplit;
+    const lhs = resolveToPxLiteral(lhsRaw, rawVars, depth + 1);
+    const rhs = resolveToPxLiteral(rhsRaw, rawVars, depth + 1);
+    // For * and /, one side is a plain number (no unit). For +/-, both
+    // must resolve to lengths.
+    const lhsNum = lhs ? lengthToPx(lhs) : lengthToPx(lhsRaw);
+    const rhsNum = rhs ? lengthToPx(rhs) : lengthToPx(rhsRaw);
+    if (lhsNum == null || rhsNum == null) return null;
+    let val;
+    switch (op) {
+      case '+': val = lhsNum + rhsNum; break;
+      case '-': val = lhsNum - rhsNum; break;
+      case '*': val = lhsNum * rhsNum; break;
+      case '/': val = rhsNum === 0 ? null : lhsNum / rhsNum; break;
+      default:  return null;
+    }
+    if (val == null || !Number.isFinite(val)) return null;
+    return val + 'px';
+  }
+
+  return null;
+}
+
 function findExtractedDarkBlocks(css) {
   // Extract @media (prefers-color-scheme: dark) { :root { ... } } bodies
   const out = [];
@@ -340,8 +415,15 @@ function parseCodeDesignSystem(css, opts = {}) {
   }
 
   const tokens = new Map(); // domain+path → token
-
-  const upsert = (cssVar, mode, valueRaw) => {
+  // Every `--name: rawValue` declaration we see, regardless of domain.
+  // Powers `@theme inline {}` expression resolution: when a designer writes
+  // `--radius-sm: calc(var(--radius) - 4px)`, we need to look up --radius's
+  // literal to compute the resolved 6px and override the Tailwind default.
+  // Storing raw strings (not parsed values) keeps the resolver simple.
+  const rawVars = new Map();
+
+  const upsert = (cssVar, mode, valueRaw, meta) => {
+    rawVars.set(cssVar, valueRaw);
     const path = pathFromCssVar(cssVar);
     const domain = inferDomain(cssVar);
     if (domain === 'unknown') return;
@@ -349,7 +431,21 @@ function parseCodeDesignSystem(css, opts = {}) {
     if (!tokens.has(key)) {
       tokens.set(key, { domain, path, values: {}, cssVar });
     }
-    tokens.get(key).values[mode] = valueFromString(valueRaw);
+    const tok = tokens.get(key);
+    tok.values[mode] = valueFromString(valueRaw);
+    // Origin marker — set ONLY when the token first lands (so a later
+    // user-globals upsert clears it back to "authored"). The comparator
+    // uses this to exclude pure-default tokens from codeOnly: pushing the
+    // entire Tailwind palette into Figma is rarely what the user wants,
+    // and the additive policy lets implicit defaults stay implicit on
+    // both sides. Tokens in `same` or `conflict` still surface — those
+    // are real state.
+    if (meta && meta.fromTailwindDefault) {
+      if (!('fromTailwindDefault' in tok)) tok.fromTailwindDefault = true;
+    } else {
+      // Any user-authored upsert overrides the default flag.
+      tok.fromTailwindDefault = false;
+    }
   };
 
   // 0. Tailwind defaults (optional). Merged FIRST so that user's globals.css
@@ -358,7 +454,7 @@ function parseCodeDesignSystem(css, opts = {}) {
     const body = loadTailwindDefaultsBody();
     const entries = parseEntries(body);
     for (const [cssVar, raw] of Object.entries(entries)) {
-      upsert(cssVar, 'default', raw);
+      upsert(cssVar, 'default', raw, { fromTailwindDefault: true });
     }
     // Tailwind v4 doesn't ship explicit `--spacing-N` or `--radius-{none,full}`
     // variables — most utility classes derive from --spacing at build time
@@ -368,6 +464,7 @@ function parseCodeDesignSystem(css, opts = {}) {
     for (const t of synthetic) {
       const key = t.domain + ':' + t.path;
       if (!tokens.has(key)) {
+        t.fromTailwindDefault = true;
         tokens.set(key, t);
       }
     }
@@ -455,7 +552,18 @@ function parseCodeDesignSystem(css, opts = {}) {
           target: refMatch[1].replace(/^--/, ''),
         });
       }
-      // else ignore (raw values in @theme inline are unusual)
+      // @theme inline can ALSO carry value overrides — most often as
+      // calc()/var() expressions that resolve to a concrete length at
+      // runtime (e.g. `--radius-sm: calc(var(--radius) - 4px)`). Without
+      // this resolution step, the parser would silently fall back to the
+      // Tailwind default for --radius-sm, producing false conflicts
+      // against Figma's resolved value. We try to resolve every inline
+      // entry to a literal `px` string and override the default; if
+      // the expression is too complex, we leave the default alone.
+      const resolved = resolveToPxLiteral(raw, rawVars);
+      if (resolved != null) {
+        upsert(cssVar, 'default', resolved);
+      }
     }
     themeOpenRe2.lastIndex = i + 1;
   }
@@ -475,4 +583,4 @@ function parseCodeDesignSystem(css, opts = {}) {
   return { tokens: tokenList, exposure, styles };
 }
 
-module.exports = { parseCodeDesignSystem, pathFromCssVar, inferDomain };
+module.exports = { parseCodeDesignSystem, pathFromCssVar, inferDomain, synthesizeTailwindUtilityScale };
diff --git a/plugins/adhd/lib/design-system/comparator.js b/plugins/adhd/lib/design-system/comparator.js
index 5569d65..6959385 100644
--- a/plugins/adhd/lib/design-system/comparator.js
+++ b/plugins/adhd/lib/design-system/comparator.js
@@ -1,5 +1,28 @@
 'use strict';
 
+const { pathToCssVar } = require('./figma-write-actions');
+
+// CSS variable names are lossy: a Figma variable named `Color/gold-25`
+// (single leaf with an internal hyphen) and one named `Color/gold/25`
+// (two-segment path) both pull to `--color-gold-25` in globals.css.
+// The path reconstruction on push always picks the slash interpretation
+// (`gold/25`), so the same underlying variable looks like a fresh
+// codeOnly token to the comparator and push would duplicate it in Figma.
+// Canonicalize to the CSS-var form so both interpretations match.
+function tokenCanonicalKey(t) {
+  try { return pathToCssVar(t.domain, t.path); }
+  catch { return null; }
+}
+
+// Same canonicalization for alias targets — Figma stores `target.name`
+// verbatim ("neutral-0"), code stores the parsed path ("neutral/0"). They
+// describe the same variable; collapse to a hyphenated form so a
+// round-tripped alias compares equal regardless of which side authored it.
+function canonicalizeAliasTarget(target) {
+  if (target == null) return '';
+  return String(target).replace(/\//g, '-').toLowerCase();
+}
+
 // Convert "0.25rem" / "16px" / "1.5" → number (px). Returns null if not a
 // simple dimension/unitless number. Mirrors dimensionToPx in figma-write-actions.
 function dimensionToPx(raw) {
@@ -49,7 +72,7 @@ function valuesEqual(a, b) {
   if (!a || !b) return false;
   if (a.type !== b.type) return false;
   if (a.type === 'alias') {
-    return a.target === b.target;
+    return canonicalizeAliasTarget(a.target) === canonicalizeAliasTarget(b.target);
   }
   // literal
   return canonicalLiteral(a.value) === canonicalLiteral(b.value);
@@ -62,6 +85,11 @@ function tokenKey(t) {
 }
 
 function compareDesignSystems(code, figma) {
+  // `codeOnly` always surfaces every token from the code side, including
+  // Tailwind defaults. Filtering by intent (push the palette vs only my
+  // semantics) lives one layer up in the dispositions wizard — that's
+  // where designer policy belongs. The `fromTailwindDefault` marker
+  // travels through so the action builder can apply per-domain rules.
   const same = [];
   const conflict = [];
   const codeOnly = [];
@@ -130,6 +158,37 @@ function compareDesignSystems(code, figma) {
     }
   }
 
+  // Reclaim cross-tokenization duplicates. The per-domain matching above
+  // keys on `domain:path` with the literal path strings — `gold/25` and
+  // `gold-25` are distinct keys even though they describe the same Figma
+  // variable. Canonicalize both sides to the CSS-var form and pair them
+  // up: any (codeOnly, figmaOnly) pair that resolves to the same CSS var
+  // moves into `same`. Without this step, push would create a duplicate
+  // variable in Figma every time pull pulled a single-leaf hyphenated name.
+  const figmaByCanon = new Map();
+  for (const t of figmaOnly) {
+    const key = tokenCanonicalKey(t);
+    if (key) figmaByCanon.set(key, t);
+  }
+  const survivedCodeOnly = [];
+  const matchedFigma = new Set();
+  for (const t of codeOnly) {
+    const key = tokenCanonicalKey(t);
+    if (key && figmaByCanon.has(key)) {
+      same.push(t);
+      matchedFigma.add(figmaByCanon.get(key));
+    } else {
+      survivedCodeOnly.push(t);
+    }
+  }
+  const survivedFigmaOnly = figmaOnly.filter(t => !matchedFigma.has(t));
+
+  // codeOnly carries everything — the dispositions wizard filters at the
+  // action-builder layer. The `fromTailwindDefault` marker is preserved
+  // on each token so domain-aware dispositions (color: semantic-only,
+  // spacing: authored-only) can apply the right per-token rule.
+  const filteredCodeOnly = survivedCodeOnly;
+
   // ── Effect styles ──────────────────────────────────────────────────────
   // Diff by name only. Each side may not have styles at all (older callers).
   // The full effect-payload comparison is intentionally not attempted: Figma
@@ -148,7 +207,7 @@ function compareDesignSystems(code, figma) {
     },
   };
 
-  return { same, conflict, codeOnly, figmaOnly, styles };
+  return { same, conflict, codeOnly: filteredCodeOnly, figmaOnly: survivedFigmaOnly, styles };
 }
 
 module.exports = { compareDesignSystems, valuesEqual };
diff --git a/plugins/adhd/lib/design-system/dispositions.js b/plugins/adhd/lib/design-system/dispositions.js
new file mode 100644
index 0000000..76a5542
--- /dev/null
+++ b/plugins/adhd/lib/design-system/dispositions.js
@@ -0,0 +1,149 @@
+'use strict';
+
+// Push-token dispositions — user-controlled per-domain policy for what
+// gets pushed to Figma, and how. The /adhd:push-tokens wizard collects
+// the seven choices interactively on EVERY push (no persistence —
+// conscious decision each time, dispositions live in /tmp for the run).
+//
+// The categorical "Figma can't consume this anyway" filter (z-index,
+// container queries, animate, ease, etc.) lives here. So does the
+// design-decision filter ("push the full Tailwind color palette, or only
+// my semantic colors?"). Tokens classified as 'skip' surface in the
+// dry-run with their reason, but never become create-variable actions.
+
+const UTILITY_DOMAINS = new Set([
+  'z-index', 'animate', 'ease', 'aspect', 'perspective',
+  'container', 'breakpoint', 'blur',
+]);
+
+// Defaults applied when a key is missing from `dispositions`. Mirrors
+// the "recommended" answer in the wizard so a partial config still
+// behaves predictably.
+const DEFAULT_DISPOSITIONS = {
+  color: 'all',
+  typography: 'all',
+  spacing: 'all',
+  radiusAndBorder: 'push',
+  shadow: 'effect-styles',
+  opacity: 'skip',
+  utilityDomains: 'skip',
+};
+
+// Return { action: 'push' | 'effect-style' | 'skip', reason? } for one
+// token. `dispositions` is the parsed pushTokens object from
+// adhd.config.ts; missing keys fall back to DEFAULT_DISPOSITIONS.
+function classifyToken(token, dispositions) {
+  const d = { ...DEFAULT_DISPOSITIONS, ...(dispositions || {}) };
+  const dom = token.domain;
+  const path = token.path || '';
+
+  // Font families: always skip. Hardcoded — text styles are Figma's
+  // native channel for typography choices, and pushing `--font-aeonik`
+  // as a STRING variable competes with that workflow.
+  if (dom === 'typography' && path.startsWith('font/')) {
+    return { action: 'skip', reason: 'font-family — manage in Figma text styles, not variables' };
+  }
+
+  if (dom === 'color') {
+    if (d.color === 'skip')          return { action: 'skip', reason: 'pushTokens.color = skip' };
+    if (d.color === 'semantic-only' && token.fromTailwindDefault === true) {
+      return { action: 'skip', reason: 'pushTokens.color = semantic-only — Tailwind palette stays in code' };
+    }
+    return { action: 'push' };
+  }
+
+  if (dom === 'typography') {
+    if (d.typography === 'skip') return { action: 'skip', reason: 'pushTokens.typography = skip' };
+    if (d.typography === 'sizes-and-weights') {
+      if (path.startsWith('leading/') || path.startsWith('tracking/')) {
+        return { action: 'skip', reason: 'pushTokens.typography = sizes-and-weights — leading/tracking skipped' };
+      }
+    }
+    return { action: 'push' };
+  }
+
+  if (dom === 'spacing') {
+    if (d.spacing === 'skip') return { action: 'skip', reason: 'pushTokens.spacing = skip' };
+    if (d.spacing === 'authored-only' && token.fromTailwindDefault === true) {
+      return { action: 'skip', reason: 'pushTokens.spacing = authored-only — Tailwind scale stays in code' };
+    }
+    return { action: 'push' };
+  }
+
+  if (dom === 'radius' || dom === 'border-width') {
+    if (d.radiusAndBorder === 'skip') return { action: 'skip', reason: 'pushTokens.radiusAndBorder = skip' };
+    return { action: 'push' };
+  }
+
+  if (dom === 'shadow') {
+    if (d.shadow === 'skip') return { action: 'skip', reason: 'pushTokens.shadow = skip' };
+    return { action: 'effect-style' };
+  }
+
+  if (dom === 'opacity') {
+    if (d.opacity === 'skip') {
+      return { action: 'skip', reason: 'pushTokens.opacity = skip — Tailwind applies opacity via / class modifiers' };
+    }
+    return { action: 'push' };
+  }
+
+  if (UTILITY_DOMAINS.has(dom)) {
+    if (d.utilityDomains === 'skip') {
+      return { action: 'skip', reason: `pushTokens.utilityDomains = skip — Figma doesn't consume ${dom} tokens` };
+    }
+    return { action: 'push' };
+  }
+
+  // Unknown domain: default to push so it surfaces. Better to let an
+  // unrecognized domain through than silently drop user data.
+  return { action: 'push' };
+}
+
+// Parse the `pushTokens: { ... }` block out of adhd.config.ts. Returns
+// null when no block is present (which signals "run the wizard"). The
+// schema is intentionally simple — one string value per key — so a
+// permissive regex + key-quoting + JSON.parse handles it without
+// requiring a TS evaluator at runtime.
+function parsePushTokensFromConfig(configSrc) {
+  if (!configSrc) return null;
+  const open = /pushTokens\s*:\s*\{/.exec(configSrc);
+  if (!open) return null;
+  let depth = 1;
+  let i = open.index + open[0].length;
+  while (i < configSrc.length && depth > 0) {
+    const ch = configSrc[i];
+    if (ch === '{') depth++;
+    else if (ch === '}') depth--;
+    if (depth === 0) break;
+    i++;
+  }
+  const body = configSrc.slice(open.index + open[0].length, i);
+  // Wrap THEN transform so the leading key has a `{` anchor for the
+  // key-quoting regex. Doing the regex on the bare body would leave the
+  // first key unquoted because there's no `[{,]` directly before it.
+  const jsonish = ('{' + body + '}')
+    .replace(/\/\/[^\n]*/g, '')          // strip line comments
+    .replace(/'/g, '"')                  // single → double quotes
+    .replace(/([{,]\s*)([a-zA-Z_][\w-]*)\s*:/g, '$1"$2":')  // quote keys
+    .replace(/,\s*}/g, '}');             // trailing comma
+  try { return JSON.parse(jsonish); }
+  catch { return null; }
+}
+
+// Serialize a dispositions object into a TS object literal suitable for
+// inserting into adhd.config.ts. Stable key order so diffs stay clean.
+function formatPushTokensForConfig(dispositions) {
+  const order = ['color', 'typography', 'spacing', 'radiusAndBorder', 'shadow', 'opacity', 'utilityDomains'];
+  const lines = order
+    .filter(k => k in dispositions)
+    .map(k => `    ${k}: "${dispositions[k]}",`);
+  return '  pushTokens: {\n' + lines.join('\n') + '\n  },';
+}
+
+module.exports = {
+  classifyToken,
+  parsePushTokensFromConfig,
+  formatPushTokensForConfig,
+  DEFAULT_DISPOSITIONS,
+  UTILITY_DOMAINS,
+};
diff --git a/plugins/adhd/lib/design-system/figma-write-actions.js b/plugins/adhd/lib/design-system/figma-write-actions.js
index 3fccc5b..f7dc5e4 100644
--- a/plugins/adhd/lib/design-system/figma-write-actions.js
+++ b/plugins/adhd/lib/design-system/figma-write-actions.js
@@ -1,6 +1,7 @@
 'use strict';
 
 const { parseShadow } = require('./shadow-parser');
+const { classifyToken } = require('./dispositions');
 
 const DOMAIN_COLLECTION = {
   color: 'color',
@@ -51,14 +52,35 @@ const STRING_DOMAINS = new Set(['aspect', 'ease', 'animate']);
 function dimensionToPx(raw) {
   if (raw == null) return null;
   const s = String(raw).trim();
-  // Reject expressions: calc(...), var(...), comma-separated lists, etc.
-  if (/[(),]/.test(s)) return null;
-  const m = /^(-?\d*\.?\d+)(px|rem|em)?$/.exec(s);
-  if (!m) return null;
-  const n = parseFloat(m[1]);
-  const unit = m[2] || '';
-  if (unit === 'rem' || unit === 'em') return n * 16;
-  return n; // px or unitless
+  // Simple length / unitless number.
+  const simple = /^(-?\d*\.?\d+)(px|rem|em)?$/.exec(s);
+  if (simple) {
+    const n = parseFloat(simple[1]);
+    const unit = simple[2] || '';
+    if (unit === 'rem' || unit === 'em') return n * 16;
+    return n;
+  }
+  // Two-operand calc() — common for Tailwind v4's text-size line-height
+  // companions like `calc(1 / 0.75)` (ratio) and `calc(1rem * 2)` (px).
+  // Each operand re-enters dimensionToPx so we get rem→px conversion for
+  // free. We don't support nested calls or more than two operands — a
+  // narrow window that covers Tailwind's shipped patterns without
+  // turning into a full CSS expression evaluator.
+  const calc = /^calc\(\s*([^()]+?)\s+([+\-*/])\s+([^()]+?)\s*\)$/.exec(s);
+  if (calc) {
+    const a = dimensionToPx(calc[1]);
+    const b = dimensionToPx(calc[3]);
+    if (a == null || b == null) return null;
+    switch (calc[2]) {
+      case '+': return a + b;
+      case '-': return a - b;
+      case '*': return a * b;
+      case '/': return b === 0 ? null : a / b;
+    }
+  }
+  // var(...) or any other expression we can't evaluate — let the caller
+  // fall through to STRING typing.
+  return null;
 }
 
 // Decide the Figma variable type for a (domain, value) pair. Returns one of
@@ -107,7 +129,8 @@ function pathToCssVar(domain, path) {
   return DOMAIN_PREFIX[domain] + dashed;
 }
 
-function buildFigmaActions(diff, resolutions, direction) {
+function buildFigmaActions(diff, resolutions, direction, opts = {}) {
+  const dispositions = opts.dispositions || null;
   const resolutionMap = new Map();
   for (const r of resolutions) {
     resolutionMap.set(r.path + ':' + (r.mode ?? 'default'), r.winner);
@@ -126,8 +149,24 @@ function buildFigmaActions(diff, resolutions, direction) {
       // not extract.collections), but include here for forward-compat.
       ...(((diff.styles && diff.styles.effects && diff.styles.effects.same) || []).map(s => s.name)),
     ]);
-    // Code-only: create in Figma
+    // Code-only: create in Figma. Each token is classified through the
+    // user's push-token dispositions (collected via the wizard in
+    // /adhd:push-tokens — see dispositions.js). Three outcomes:
+    //   - 'push'         → create-variable
+    //   - 'effect-style' → create-effect-style (shadow)
+    //   - 'skip'         → skip-by-disposition with reason (visible in
+    //                       the dry-run + final report, no Figma write)
     for (const t of diff.codeOnly) {
+      const verdict = classifyToken(t, dispositions);
+      if (verdict.action === 'skip') {
+        actions.push({
+          kind: 'skip-by-disposition',
+          domain: t.domain,
+          path: t.path,
+          reason: verdict.reason,
+        });
+        continue;
+      }
       // Shadow tokens map to Figma effect styles, not variables. Parse the
       // CSS shadow string and emit a create-effect-style action.
       if (t.domain === 'shadow') {
@@ -314,4 +353,5 @@ module.exports = {
   figmaTypeForToken,
   dimensionToPx,
   resolveFigmaValue,
+  DOMAIN_COLLECTION,
 };
diff --git a/plugins/adhd/lib/design-system/figma-write-script.js b/plugins/adhd/lib/design-system/figma-write-script.js
index 795f53c..139aee6 100644
--- a/plugins/adhd/lib/design-system/figma-write-script.js
+++ b/plugins/adhd/lib/design-system/figma-write-script.js
@@ -1,5 +1,83 @@
 'use strict';
 
+// Collection-name aliases. Figma users name their collections however
+// they like — "Color", "Colors", "Colour", "Type + Effects", "Borders",
+// "Space" — but our canonical names are the lowercase Tailwind domain
+// keys ("color", "typography", "border-width", "spacing"). Without
+// alias-aware lookup, pushing into a file where the designer has
+// "Color" (capital C) creates a parallel "color" collection — Figma
+// treats names case-sensitively. The aliases below mirror common
+// real-world variations + Tailwind's own multi-word names.
+//
+// Match is case-insensitive on lowercase+trimmed Figma names. First
+// canonical that finds an existing collection wins; subsequent pushes
+// targeting the same canonical reuse that collection.
+const COLLECTION_ALIASES = {
+  color:          ['color', 'colors', 'colour', 'colours'],
+  spacing:        ['spacing', 'space', 'spaces'],
+  radius:         ['radius', 'radii', 'rounded', 'corner radius'],
+  shadow:         ['shadow', 'shadows', 'effects'],
+  opacity:        ['opacity', 'alpha'],
+  'border-width': ['border-width', 'border width', 'borders', 'border', 'strokes', 'stroke'],
+  typography:     ['typography', 'type', 'text', 'text styles', 'type + effects', 'text + effects', 'fonts'],
+  'z-index':      ['z-index', 'z'],
+  breakpoint:     ['breakpoint', 'breakpoints', 'screens', 'media'],
+  container:      ['container', 'containers'],
+  blur:           ['blur'],
+  perspective:    ['perspective'],
+  aspect:         ['aspect', 'aspects', 'aspect ratio', 'ratios'],
+  ease:           ['ease', 'easing', 'easings', 'transitions', 'transition'],
+  animate:        ['animate', 'animation', 'animations'],
+};
+
+// Given the canonical domain name and the list of existing Figma
+// collection names, return the existing name that aliases to the
+// canonical (if any). Returns null when nothing matches — caller should
+// create a new collection with the canonical name.
+function findCollectionAlias(canonical, existingNames) {
+  const aliases = COLLECTION_ALIASES[canonical];
+  if (!aliases) return null;
+  for (const existing of existingNames) {
+    const norm = String(existing).toLowerCase().trim();
+    if (aliases.includes(norm)) return existing;
+  }
+  return null;
+}
+
+// Exported mirror of the inline `tokenScopesFor` inside WRITE_SCRIPT.
+// The Figma plugin sandbox can't `require`, so the script needs an
+// inline copy. We keep this JS-side function as the testable mirror —
+// the WRITE_SCRIPT template is grep-asserted to contain the same key
+// patterns so the two can't silently drift.
+function tokenScopesFor(domain, path) {
+  if (domain !== 'typography') {
+    const NON_TYPO_SCOPES = {
+      color: ['FRAME_FILL', 'SHAPE_FILL', 'TEXT_FILL', 'STROKE_COLOR'],
+      spacing: ['GAP', 'WIDTH_HEIGHT'],
+      radius: ['CORNER_RADIUS'],
+      shadow: ['EFFECT_FLOAT'],
+      opacity: ['OPACITY'],
+      'border-width': ['STROKE_FLOAT'],
+      'z-index': ['ALL_SCOPES'],
+      breakpoint: ['ALL_SCOPES'],
+      container: ['WIDTH_HEIGHT'],
+      blur: ['EFFECT_FLOAT'],
+      perspective: ['ALL_SCOPES'],
+      aspect: ['ALL_SCOPES'],
+      ease: ['ALL_SCOPES'],
+      animate: ['ALL_SCOPES'],
+    };
+    return NON_TYPO_SCOPES[domain] || ['ALL_SCOPES'];
+  }
+  if (path.startsWith('text/') && path.endsWith('/line-height')) return ['LINE_HEIGHT'];
+  if (path.startsWith('text/')) return ['FONT_SIZE'];
+  if (path.startsWith('font-weight/')) return ['FONT_WEIGHT'];
+  if (path.startsWith('font/')) return ['FONT_FAMILY'];
+  if (path.startsWith('leading/')) return ['LINE_HEIGHT'];
+  if (path.startsWith('tracking/')) return ['LETTER_SPACING'];
+  return ['ALL_SCOPES'];
+}
+
 /**
  * JS string injected into use_figma. Reads `__ACTIONS__` (a JSON array
  * of { kind, ... }) and applies each one to the Figma file. Returns
@@ -39,9 +117,15 @@ const SCOPES = {
 };
 
 // Narrow typography scopes from the Figma path (e.g. 'text/xs' → FONT_SIZE,
-// 'font/sans' → FONT_FAMILY). Keep ALL_SCOPES as fallback.
+// 'font/sans' → FONT_FAMILY). Keep ALL_SCOPES as fallback. The
+// 'text//line-height' companion paths (Tailwind v4 ships paired
+// line-height values with every text size) must check BEFORE the
+// 'text/' branch — otherwise the LINE_HEIGHT companions get the
+// FONT_SIZE scope and Figma rejects them with "Invalid scope for this
+// variable type."
 function tokenScopesFor(domain, path) {
   if (domain !== 'typography') return SCOPES[domain] || ['ALL_SCOPES'];
+  if (path.startsWith('text/') && path.endsWith('/line-height')) return ['LINE_HEIGHT'];
   if (path.startsWith('text/')) return ['FONT_SIZE'];
   if (path.startsWith('font-weight/')) return ['FONT_WEIGHT'];
   if (path.startsWith('font/')) return ['FONT_FAMILY'];
@@ -104,8 +188,46 @@ const collections = await figma.variables.getLocalVariableCollectionsAsync();
 const collectionByName = {};
 for (const c of collections) collectionByName[c.name] = c;
 
+// Alias table — see the JS-side mirror at the top of this file.
+// Inline here because the Figma plugin sandbox can't require().
+// Drift-guarded by a test in __tests__/figma-write-script.test.js.
+const COLLECTION_ALIASES = {
+  color:          ['color', 'colors', 'colour', 'colours'],
+  spacing:        ['spacing', 'space', 'spaces'],
+  radius:         ['radius', 'radii', 'rounded', 'corner radius'],
+  shadow:         ['shadow', 'shadows', 'effects'],
+  opacity:        ['opacity', 'alpha'],
+  'border-width': ['border-width', 'border width', 'borders', 'border', 'strokes', 'stroke'],
+  typography:     ['typography', 'type', 'text', 'text styles', 'type + effects', 'text + effects', 'fonts'],
+  'z-index':      ['z-index', 'z'],
+  breakpoint:     ['breakpoint', 'breakpoints', 'screens', 'media'],
+  container:      ['container', 'containers'],
+  blur:           ['blur'],
+  perspective:    ['perspective'],
+  aspect:         ['aspect', 'aspects', 'aspect ratio', 'ratios'],
+  ease:           ['ease', 'easing', 'easings', 'transitions', 'transition'],
+  animate:        ['animate', 'animation', 'animations'],
+};
+
+function resolveExistingCollection(canonical) {
+  if (collectionByName[canonical]) return collectionByName[canonical];
+  const aliases = COLLECTION_ALIASES[canonical];
+  if (!aliases) return null;
+  for (const c of collections) {
+    const norm = String(c.name).toLowerCase().trim();
+    if (aliases.includes(norm)) return c;
+  }
+  return null;
+}
+
 async function ensureCollection(name, withModes) {
-  if (collectionByName[name]) return collectionByName[name];
+  const existing = resolveExistingCollection(name);
+  if (existing) {
+    // Cache under the canonical key too so repeat calls in this run
+    // hit the fast path.
+    collectionByName[name] = existing;
+    return existing;
+  }
   const col = figma.variables.createVariableCollection(name);
   if (withModes && withModes.length > 1) {
     col.renameMode(col.modes[0].modeId, withModes[0]);
@@ -116,6 +238,7 @@ async function ensureCollection(name, withModes) {
     col.renameMode(col.modes[0].modeId, withModes[0]);
   }
   collectionByName[name] = col;
+  collections.push(col);
   return col;
 }
 
@@ -251,4 +374,4 @@ for (const a of actions) {
 return { applied, skipped, errors };
 `;
 
-module.exports = { WRITE_SCRIPT };
+module.exports = { WRITE_SCRIPT, tokenScopesFor, findCollectionAlias, COLLECTION_ALIASES };
diff --git a/plugins/adhd/lib/lint-engine/__tests__/binding-checker.test.js b/plugins/adhd/lib/lint-engine/__tests__/binding-checker.test.js
new file mode 100644
index 0000000..68f9466
--- /dev/null
+++ b/plugins/adhd/lib/lint-engine/__tests__/binding-checker.test.js
@@ -0,0 +1,299 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { checkBindings, inferDomain, PROPERTY_TO_DOMAIN } = require('../binding-checker');
+
+const OPTS = (extra) => ({ fileKey: 'abc', varIdMap: {}, badSuggestionsByName: {}, ...extra });
+
+test('inferDomain: collection-as-domain', () => {
+  assert.equal(inferDomain('Color/brand'), 'color');
+  assert.equal(inferDomain('Spacing/4'), 'spacing');
+  assert.equal(inferDomain('Radius/sm'), 'radius');
+  assert.equal(inferDomain('Tracking/normal'), 'tracking');
+  assert.equal(inferDomain('Leading/relaxed'), 'leading');
+});
+
+test('inferDomain: synonym collection', () => {
+  // "Colors" → "Color" (synonym), so inferred domain is color
+  assert.equal(inferDomain('Colors/brand'), 'color');
+  assert.equal(inferDomain('Space/4'), 'spacing');
+});
+
+test('inferDomain: tier collection looks inside', () => {
+  assert.equal(inferDomain('Primitives/color/brand'), 'color');
+  assert.equal(inferDomain('Semantic/spacing/md'), 'spacing');
+});
+
+test('inferDomain: returns null for ambiguous/unmapped', () => {
+  // ambiguous (path says leading, leaf says tracking)
+  assert.equal(inferDomain('Type + Effects/Line-Height/Letter Space 0'), null);
+  // no-mapping (no domain signal anywhere)
+  assert.equal(inferDomain('Foo/bar/baz'), null);
+});
+
+test('checkBindings: no violations when varIdMap is empty', () => {
+  const root = {
+    id: '1:1', name: 'A', type: 'FRAME',
+    boundVariables: { letterSpacing: { id: 'VAR:1', type: 'VARIABLE_ALIAS' } },
+  };
+  const result = checkBindings(root, OPTS());
+  assert.deepEqual(result, []);
+});
+
+test('STRUCT012: spacing variable bound to letterSpacing fires per-layer', () => {
+  const root = {
+    id: '1:1', name: 'Card', type: 'TEXT',
+    boundVariables: { letterSpacing: { id: 'VAR:spacing4', type: 'VARIABLE_ALIAS' } },
+  };
+  const result = checkBindings(root, OPTS({
+    varIdMap: { 'VAR:spacing4': 'Spacing/4' },
+  }));
+  assert.equal(result.length, 1);
+  const v = result[0];
+  assert.equal(v.rule, 'STRUCT012');
+  assert.equal(v.severity, 'error');
+  assert.equal(v.nodeId, '1:1');
+  assert.equal(v.nodePath, 'Card');
+  assert.match(v.message, /Spacing\/4/);
+  assert.match(v.message, /a spacing variable/);
+  assert.match(v.message, /letterSpacing/);
+  assert.match(v.message, /expects a tracking variable/);
+});
+
+test('STRUCT012: same-domain binding is not flagged', () => {
+  const root = {
+    id: '1:1', name: 'Card', type: 'TEXT',
+    boundVariables: { letterSpacing: { id: 'VAR:trackingNormal' } },
+  };
+  const result = checkBindings(root, OPTS({
+    varIdMap: { 'VAR:trackingNormal': 'Tracking/normal' },
+  }));
+  assert.deepEqual(result, []);
+});
+
+test('STRUCT012: padding bindings flagged when wrong domain', () => {
+  const root = {
+    id: '1:1', name: 'Card', type: 'FRAME',
+    boundVariables: { paddingTop: { id: 'VAR:radiusSm' } },
+  };
+  const result = checkBindings(root, OPTS({
+    varIdMap: { 'VAR:radiusSm': 'Radius/sm' },
+  }));
+  assert.equal(result.length, 1);
+  assert.equal(result[0].rule, 'STRUCT012');
+  assert.match(result[0].message, /Radius\/sm/);
+  assert.match(result[0].message, /spacing/);
+});
+
+test('STRUCT012: corner radius bindings flagged when wrong domain', () => {
+  const root = {
+    id: '1:1', name: 'Card', type: 'FRAME',
+    boundVariables: { topLeftRadius: { id: 'VAR:colorBrand' } },
+  };
+  const result = checkBindings(root, OPTS({
+    varIdMap: { 'VAR:colorBrand': 'Color/brand' },
+  }));
+  assert.equal(result.length, 1);
+  assert.equal(result[0].rule, 'STRUCT012');
+  assert.match(result[0].message, /color/);
+  assert.match(result[0].message, /radius/);
+});
+
+test('STRUCT012: fill color binding crossed with non-color variable', () => {
+  const root = {
+    id: '1:1', name: 'Card', type: 'FRAME',
+    fills: [{ type: 'SOLID', boundVariables: { color: { id: 'VAR:spacingMd' } } }],
+  };
+  const result = checkBindings(root, OPTS({
+    varIdMap: { 'VAR:spacingMd': 'Spacing/md' },
+  }));
+  assert.equal(result.length, 1);
+  assert.equal(result[0].rule, 'STRUCT012');
+  assert.match(result[0].message, /Spacing\/md/);
+});
+
+test('STRUCT012: ambiguous variable name (Line-Height path + Letter Space leaf) does NOT fire — not enough confidence', () => {
+  const root = {
+    id: '1:1', name: 'Title', type: 'TEXT',
+    boundVariables: { letterSpacing: { id: 'VAR:ambig' } },
+  };
+  const result = checkBindings(root, OPTS({
+    varIdMap: { 'VAR:ambig': 'Type + Effects/Line-Height/Letter Space 0' },
+  }));
+  // inferDomain returns null for ambiguous → no STRUCT012
+  assert.deepEqual(result, []);
+});
+
+test('STRUCT011 per-layer: emits one violation per layer that uses a bad variable', () => {
+  // Two layers both bind the same bad-named variable. We want both to be
+  // annotated, not just one aggregate emission.
+  const root = {
+    id: '1:1', name: 'Page', type: 'FRAME',
+    children: [
+      { id: '1:2', name: 'A', type: 'TEXT',
+        boundVariables: { letterSpacing: { id: 'VAR:bad' } } },
+      { id: '1:3', name: 'B', type: 'TEXT',
+        boundVariables: { letterSpacing: { id: 'VAR:bad' } } },
+    ],
+  };
+  const result = checkBindings(root, OPTS({
+    varIdMap: { 'VAR:bad': 'Tracking/BadName' },
+    badSuggestionsByName: {
+      'Tracking/BadName': { name: 'Tracking/BadName', kind: 'rename', target: 'Tracking/bad-name' },
+    },
+  }));
+  const struct011 = result.filter(v => v.rule === 'STRUCT011');
+  assert.equal(struct011.length, 2);
+  const ids = struct011.map(v => v.nodeId).sort();
+  assert.deepEqual(ids, ['1:2', '1:3']);
+  assert.match(struct011[0].message, /Move to "Tracking" collection/);
+  assert.match(struct011[0].message, /final name "Tracking\/bad-name"/);
+});
+
+test('STRUCT011 per-layer: dedupes per (rule, varName) within a single layer', () => {
+  // A layer that binds the same bad variable to BOTH fills.color and
+  // strokes.color should get ONE STRUCT011 violation, not two.
+  const root = {
+    id: '1:1', name: 'A', type: 'FRAME',
+    fills:   [{ type: 'SOLID', boundVariables: { color: { id: 'VAR:bad' } } }],
+    strokes: [{ type: 'SOLID', boundVariables: { color: { id: 'VAR:bad' } } }],
+  };
+  const result = checkBindings(root, OPTS({
+    varIdMap: { 'VAR:bad': 'Color/BrandGold' },
+    badSuggestionsByName: {
+      'Color/BrandGold': { name: 'Color/BrandGold', kind: 'rename', target: 'Color/brand-gold' },
+    },
+  }));
+  const struct011 = result.filter(v => v.rule === 'STRUCT011');
+  assert.equal(struct011.length, 1);
+});
+
+test('STRUCT011 per-layer: emits ambiguous suggestion correctly', () => {
+  const root = {
+    id: '1:1', name: 'Title', type: 'TEXT',
+    boundVariables: { fontSize: { id: 'VAR:ambig' } },
+  };
+  const result = checkBindings(root, OPTS({
+    varIdMap: { 'VAR:ambig': 'Type/Letter Space 0' },
+    badSuggestionsByName: {
+      'Type/Letter Space 0': {
+        name: 'Type/Letter Space 0', kind: 'ambiguous',
+        target: 'Text/letter-space-0', alternate: 'Tracking/0',
+        primaryReason: 'collection suggests text', alternateReason: 'leaf suggests tracking',
+      },
+    },
+  }));
+  const struct011 = result.find(v => v.rule === 'STRUCT011');
+  assert.ok(struct011);
+  assert.match(struct011.message, /Ambiguous target/);
+  assert.match(struct011.message, /primary → Text\/letter-space-0/);
+  assert.match(struct011.message, /alternate → Tracking\/0/);
+});
+
+test('STRUCT011 + STRUCT012 can both fire for the same (node, binding)', () => {
+  // A spacing-domain variable with a BAD name, bound to letterSpacing:
+  //  - STRUCT011 wants to rename the variable
+  //  - STRUCT012 wants to change the binding to a tracking variable
+  // Both fire because they describe different fixes.
+  const root = {
+    id: '1:1', name: 'X', type: 'TEXT',
+    boundVariables: { letterSpacing: { id: 'VAR:bad' } },
+  };
+  const result = checkBindings(root, OPTS({
+    varIdMap: { 'VAR:bad': 'Spacing/BadName' },
+    badSuggestionsByName: {
+      'Spacing/BadName': { name: 'Spacing/BadName', kind: 'rename', target: 'Spacing/bad-name' },
+    },
+  }));
+  const rules = result.map(v => v.rule).sort();
+  assert.deepEqual(rules, ['STRUCT011', 'STRUCT012']);
+});
+
+test('STRUCT015: layer binding a variable missing from code → error', () => {
+  // The user's core complaint: a Figma component binds `Font-Size/Body`
+  // but code's globals.css has no such variable. Pulling now would
+  // generate code that references --font-size-body, which Tailwind
+  // would resolve to nothing → broken rendering. Lint must block.
+  const root = {
+    id: '1:1', name: 'Title', type: 'TEXT',
+    boundVariables: { fontSize: { id: 'VAR:fontBody' } },
+  };
+  const result = checkBindings(root, OPTS({
+    varIdMap: { 'VAR:fontBody': 'Type + Effects/Font-Size/Body' },
+    missingVarNames: new Set(['Type + Effects/Font-Size/Body']),
+  }));
+  assert.equal(result.length, 1);
+  assert.equal(result[0].rule, 'STRUCT015');
+  assert.equal(result[0].severity, 'error');
+  assert.equal(result[0].nodeId, '1:1');
+  assert.match(result[0].message, /doesn't exist in code's design system/);
+  assert.match(result[0].message, /\/adhd:pull-tokens/);
+});
+
+test('STRUCT015: variable present in code does NOT fire', () => {
+  const root = {
+    id: '1:1', name: 'Title', type: 'TEXT',
+    boundVariables: { fontSize: { id: 'VAR:textSm' } },
+  };
+  const result = checkBindings(root, OPTS({
+    varIdMap: { 'VAR:textSm': 'text/sm' },
+    missingVarNames: new Set(['text/foo']), // text/sm not in missing set
+  }));
+  assert.equal(result.filter(v => v.rule === 'STRUCT015').length, 0);
+});
+
+test('STRUCT016: layer binding a variable with value conflict → error showing both values', () => {
+  // Designer's Figma sm = 6px, code's --radius-sm = 4px. Pulling would
+  // render with 4px — visual drift from what's in Figma.
+  const root = {
+    id: '1:1', name: 'Card', type: 'FRAME',
+    boundVariables: { cornerRadius: { id: 'VAR:radiusSm' } },
+  };
+  const result = checkBindings(root, OPTS({
+    varIdMap: { 'VAR:radiusSm': 'Radius/sm' },
+    conflictsByName: {
+      'Radius/sm': { local: '0.25rem', figma: '6px', mode: undefined },
+    },
+  }));
+  const v = result.find(x => x.rule === 'STRUCT016');
+  assert.ok(v);
+  assert.equal(v.severity, 'error');
+  assert.equal(v.nodeId, '1:1');
+  assert.match(v.message, /code: +0\.25rem/);
+  assert.match(v.message, /figma: +6px/);
+  assert.match(v.message, /\/adhd:pull-tokens.*\/adhd:push-tokens/s);
+});
+
+test('STRUCT016: format-mismatch values (hex vs RGB object) format the message readably', () => {
+  // The "primary" case: Figma's raw color is `{r: 0.039, ...}`, code's
+  // is `#0a0a0a`. The category itself shouldn't fire (value-normalizer
+  // recognizes both as the same color), but if it does fire elsewhere
+  // the message shouldn't dump a JSON object on the designer.
+  const root = {
+    id: '1:1', name: 'Hero', type: 'FRAME',
+    fills: [{ type: 'SOLID', boundVariables: { color: { id: 'VAR:p' } } }],
+  };
+  const result = checkBindings(root, OPTS({
+    varIdMap: { 'VAR:p': 'Color/primary' },
+    conflictsByName: {
+      'Color/primary': { local: '#0a0a0a', figma: { r: 0.039, g: 0.039, b: 0.039, a: 1 } },
+    },
+  }));
+  const v = result.find(x => x.rule === 'STRUCT016');
+  assert.ok(v);
+  // Figma value renders as a hex color, not a `{"r":0.039,...}` blob.
+  assert.match(v.message, /figma: +#0a0a0a/);
+});
+
+test('PROPERTY_TO_DOMAIN: covers the expected property set', () => {
+  // Sanity check — if someone removes a property mapping by accident the
+  // STRUCT012 check silently goes quiet for that property. Lock the
+  // mapping down with a test.
+  assert.equal(PROPERTY_TO_DOMAIN.letterSpacing, 'tracking');
+  assert.equal(PROPERTY_TO_DOMAIN.lineHeight,    'leading');
+  assert.equal(PROPERTY_TO_DOMAIN.fontSize,      'text');
+  assert.equal(PROPERTY_TO_DOMAIN.paddingTop,    'spacing');
+  assert.equal(PROPERTY_TO_DOMAIN.itemSpacing,   'spacing');
+  assert.equal(PROPERTY_TO_DOMAIN.cornerRadius,  'radius');
+});
diff --git a/plugins/adhd/lib/lint-engine/__tests__/canonical-matcher.test.js b/plugins/adhd/lib/lint-engine/__tests__/canonical-matcher.test.js
new file mode 100644
index 0000000..0668749
--- /dev/null
+++ b/plugins/adhd/lib/lint-engine/__tests__/canonical-matcher.test.js
@@ -0,0 +1,154 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { findCanonicalForValue, looksSemantic, typographyFamily } = require('../canonical-matcher');
+const { synthesizeTailwindUtilityScale } = require('../../design-system/code-parser');
+
+// Build a realistic primitives map — Tailwind defaults + the synthesized
+// utility scale, mirroring what loadTailwindDefaultPrimitives produces.
+function buildPrimitives() {
+  const out = {
+    '--color-red-500':   '#ef4444',
+    '--color-zinc-500':  '#71717a',
+    '--color-white':     '#fff',
+    '--text-xs':         '0.75rem',
+    '--text-sm':         '0.875rem',
+    '--text-base':       '1rem',
+    '--text-lg':         '1.125rem',
+    '--text-2xl':        '1.5rem',
+    '--leading-5':       '1.25rem',
+    '--leading-6':       '1.5rem',
+    '--leading-7':       '1.75rem',
+    '--tracking-tight':  '-0.025em',
+    '--tracking-normal': '0em',
+    '--radius':          '0.25rem',
+  };
+  for (const t of synthesizeTailwindUtilityScale()) {
+    if (!(t.cssVar in out)) out[t.cssVar] = t.values.default.value;
+  }
+  return out;
+}
+
+// ─── Strict value matching ──────────────────────────────────────────
+
+test('font-size 14 (number) matches Tailwind --text-sm (0.875rem = 14px)', () => {
+  // The user\'s "Font-Size/Body = 14" case from the screenshot. After
+  // normalizeDimension, both reduce to 14px. Auto-fix candidate.
+  const p = buildPrimitives();
+  const out = findCanonicalForValue('typography/Font-Size/Body', 14, p);
+  assert.equal(out, '--text-sm');
+});
+
+test('line-height 28 matches --leading-7 (1.75rem = 28px)', () => {
+  // The user\'s "Line-Height/Line Height 28" case. Family disambiguator
+  // picks --leading-7 because the figma path mentions "Line-Height,"
+  // which maps to the leading family.
+  const p = buildPrimitives();
+  const out = findCanonicalForValue('typography/Line-Height/Line Height 28', 28, p);
+  assert.equal(out, '--leading-7');
+});
+
+test('color hex matches identical Tailwind palette entry', () => {
+  const p = buildPrimitives();
+  const out = findCanonicalForValue('color/red', '#ef4444', p);
+  assert.equal(out, '--color-red-500');
+});
+
+test('color rgb-object (Figma\'s raw form) matches palette entry via normalization', () => {
+  const p = buildPrimitives();
+  // #ef4444 = rgb(239, 68, 68) = (0.937..., 0.267..., 0.267..., 1)
+  const out = findCanonicalForValue('color/red', { r: 239 / 255, g: 68 / 255, b: 68 / 255, a: 1 }, p);
+  assert.equal(out, '--color-red-500');
+});
+
+test('spacing 16 (number, 16px) matches --spacing-4 (synthesized 1rem = 16px)', () => {
+  const p = buildPrimitives();
+  const out = findCanonicalForValue('spacing/4', 16, p);
+  assert.equal(out, '--spacing-4');
+});
+
+// ─── No match cases ──────────────────────────────────────────────────
+
+test('value with no Tailwind match returns null', () => {
+  const p = buildPrimitives();
+  // Reactor's gold color isn\'t in any Tailwind default.
+  const out = findCanonicalForValue('color/gold', '#c5a572', p);
+  assert.equal(out, null);
+});
+
+test('semantic name with coincidental match still returns the canonical (matcher is name-agnostic; SKILL decides what to surface)', () => {
+  // The matcher returns the match — the SKILL\'s prompt-builder uses
+  // `looksSemantic` separately to label the "Add as semantic" option
+  // prominently. Designer decides; matcher just reports.
+  const p = buildPrimitives();
+  const out = findCanonicalForValue('color/brand', '#ef4444', p);
+  assert.equal(out, '--color-red-500');
+});
+
+test('null / undefined / unparseable values return null (no crash)', () => {
+  const p = buildPrimitives();
+  assert.equal(findCanonicalForValue('x/y', null, p), null);
+  assert.equal(findCanonicalForValue('x/y', undefined, p), null);
+  assert.equal(findCanonicalForValue('x/y', { wrong: 'shape' }, p), null);
+});
+
+test('empty primitives map returns null safely', () => {
+  assert.equal(findCanonicalForValue('color/red', '#ef4444', {}), null);
+  assert.equal(findCanonicalForValue('color/red', '#ef4444', null), null);
+});
+
+// ─── Family disambiguation in typography ────────────────────────────
+
+test('typography family disambiguator: font-size path skips leading candidates with same value', () => {
+  // 16px is BOTH text-base AND leading-6 in Tailwind. Without family
+  // disambiguation, the matcher would return whichever came first in
+  // iteration order. Family hint pins it to the right family.
+  const p = buildPrimitives();
+  const fontSizeOut = findCanonicalForValue('typography/Font-Size/Body', 16, p);
+  assert.equal(fontSizeOut, '--text-base');
+  const leadingOut = findCanonicalForValue('typography/Line-Height/Normal', 24, p);
+  assert.equal(leadingOut, '--leading-6');
+});
+
+test('typographyFamily picks up "Font-Size", "Line-Height", "Tracking", etc.', () => {
+  assert.equal(typographyFamily('typography/Font-Size/Body'), 'text');
+  assert.equal(typographyFamily('typography/Line-Height/Line Height 28'), 'leading');
+  assert.equal(typographyFamily('Type + Effects/Letter Space 0'), 'tracking');
+  assert.equal(typographyFamily('Font-Weight/Bold'), 'font-weight');
+  assert.equal(typographyFamily('font-family/Inter'), 'font');
+  assert.equal(typographyFamily('text/sm'), 'text');
+});
+
+// ─── Semantic-name detector ──────────────────────────────────────────
+
+test('looksSemantic: recognizes brand / accent / surface / etc.', () => {
+  assert.equal(looksSemantic('color/brand'), true);
+  assert.equal(looksSemantic('color/accent'), true);
+  assert.equal(looksSemantic('color/surface'), true);
+  assert.equal(looksSemantic('color/background'), true);
+  assert.equal(looksSemantic('color/foreground'), true);
+  assert.equal(looksSemantic('color/primary'), true);
+  assert.equal(looksSemantic('color/success'), true);
+  assert.equal(looksSemantic('color/destructive'), true);
+  // Tier-collection variants.
+  assert.equal(looksSemantic('Semantic/brand/surface'), true);
+});
+
+test('looksSemantic: Tailwind canonical names are NOT marked semantic', () => {
+  assert.equal(looksSemantic('color/red-500'), false);
+  assert.equal(looksSemantic('color/zinc-500'), false);
+  assert.equal(looksSemantic('text/sm'), false);
+  assert.equal(looksSemantic('spacing/4'), false);
+  assert.equal(looksSemantic('radius/full'), false);
+  // The user\'s non-canonical-but-not-semantic case.
+  assert.equal(looksSemantic('typography/Font-Size/Body'), false);
+});
+
+test('looksSemantic: edge cases (null, empty, single-segment) handled safely', () => {
+  assert.equal(looksSemantic(null), false);
+  assert.equal(looksSemantic(''), false);
+  // Single-segment semantic name (no collection prefix at all).
+  assert.equal(looksSemantic('brand'), true);
+  assert.equal(looksSemantic('red-500'), false);
+});
diff --git a/plugins/adhd/lib/lint-engine/__tests__/cli.test.js b/plugins/adhd/lib/lint-engine/__tests__/cli.test.js
index a40395d..0794edc 100644
--- a/plugins/adhd/lib/lint-engine/__tests__/cli.test.js
+++ b/plugins/adhd/lib/lint-engine/__tests__/cli.test.js
@@ -165,3 +165,465 @@ test('cli with structure errors but no variable issues exits 1', () => {
   assert.equal(summary.variable.length, 0);
   assert.ok(summary.errors >= 1);
 });
+
+test('STRUCT011: groups renames by target collection + calls the action "Move to"', () => {
+  // Real-world scenario from the user's reactor file: a "Type + Effects"
+  // collection bundling typography sizes, line-heights, and opacity. The
+  // message groups by target collection so the designer sees the pattern
+  // ("create Text + Leading, move things into them") instead of N
+  // disconnected lines.
+  const varDefs = tmp('vars.json', {
+    'Type + Effects/Font-Size/Body':             '16px',
+    'Type + Effects/Font-Size/Body LG':          '20px',
+    'Type + Effects/Line-Height/Line Height 28': '28px',
+    'Type + Effects/Line-Height/Letter Space 0': '0',     // ambiguous (leaf hints tracking)
+    'Type + Effects/Effects/Opacity 100%':       '1',     // opacity has no v4 domain
+    'Primitives/color/BrandPrimary':             '#000',  // tier-mode case fix
+    'Color/gold':                                '#c5a572', // already correct
+  });
+  const ctx = tmp('ctx.json', { id: '5:42', name: 'Logo', type: 'FRAME', layoutMode: 'VERTICAL' });
+  const cssPath = tmp('globals.css', `@theme {} :root {} :root[data-theme="dark"] {}`);
+  const configPath = tmp('adhd.config.ts', `export default { naming: 'kebab-case' };`);
+  const reportPath = path.join(os.tmpdir(), 'adhd-report-' + Date.now() + '.md');
+
+  const result = spawnSync('node', [
+    CLI, '--variable-defs', varDefs, '--design-context', ctx, '--globals-css', cssPath,
+    '--config', configPath, '--target', 'Logo',
+    '--target-url', 'https://figma.com/design/abc?node-id=5-42', '--output', reportPath,
+  ], { encoding: 'utf8' });
+
+  const summary = JSON.parse(result.stdout);
+  const struct011 = summary.structure.find(v => v.rule === 'STRUCT011');
+  assert.ok(struct011);
+  assert.equal(struct011.severity, 'warning');
+  assert.equal(struct011.nodeId, '5:42');
+  // Header tone shifted from "renaming" (misleading) to "restructure" — these
+  // are moves, not renames, and the body explains how Figma's Move-To works.
+  assert.match(struct011.message, /variable-naming issue\(s\)\. Suggested restructure/);
+  // Target-collection groups present:
+  assert.match(struct011.message, /Move to "Text" collection \(2 vars\):/);
+  assert.match(struct011.message, /Text\/body/);
+  assert.match(struct011.message, /Text\/body-lg/);
+  assert.match(struct011.message, /Move to "Leading" collection \(1 var\):/);
+  assert.match(struct011.message, /Leading\/line-height-28/);
+  assert.match(struct011.message, /Move to "Primitives" collection \(1 var\):/);
+  // Ambiguity section surfaces both options
+  assert.match(struct011.message, /Ambiguous[\s\S]*Letter Space 0/);
+  assert.match(struct011.message, /Primary: +→ Leading/);
+  assert.match(struct011.message, /Alternate: +→ Tracking/);
+  // No-mapping section has opacity-specific guidance, not the generic list
+  assert.match(struct011.message, /No Tailwind v4 mapping[\s\S]*Opacity 100%[\s\S]*class modifiers/);
+  // Footer explains the Figma mechanic and calls out the rename-vs-move distinction
+  assert.match(struct011.message, /How to apply each move in Figma/);
+  assert.match(struct011.message, /Right-click the source variable → "Move to/);
+  assert.match(struct011.message, /Use Figma's "Move to\.\.\." \(not "Rename"\)/);
+  // Already-correct variable doesn't appear
+  assert.doesNotMatch(struct011.message, /Color\/gold/);
+});
+
+test('STRUCT011: variable case is always kebab-case, regardless of project naming config', () => {
+  // PascalCase project config (for components) doesn't bleed into variable
+  // naming. `Color/BrandGold` still gets renamed to `Color/brand-gold`.
+  const varDefs = tmp('vars.json', {
+    'Color/gold':      '#c5a572',
+    'Color/BrandGold': '#c5a572',
+  });
+  const ctx = tmp('ctx.json', { id: '5:1', name: 'X', type: 'FRAME', layoutMode: 'VERTICAL' });
+  const cssPath = tmp('globals.css', `@theme {} :root {} :root[data-theme="dark"] {}`);
+  const configPath = tmp('adhd.config.ts', `export default { naming: 'PascalCase' };`);
+  const reportPath = path.join(os.tmpdir(), 'adhd-report-' + Date.now() + '.md');
+
+  const result = spawnSync('node', [
+    CLI, '--variable-defs', varDefs, '--design-context', ctx, '--globals-css', cssPath,
+    '--config', configPath, '--target', 'X',
+    '--target-url', 'https://figma.com/design/abc?node-id=5-1', '--output', reportPath,
+  ], { encoding: 'utf8' });
+
+  const summary = JSON.parse(result.stdout);
+  const struct011 = summary.structure.find(v => v.rule === 'STRUCT011');
+  assert.ok(struct011);
+  assert.match(struct011.message, /Color\/BrandGold[\s\S]*→ Color\/brand-gold/);
+  // Color/gold is compliant; doesn't appear
+  assert.doesNotMatch(struct011.message, /Color\/gold[\s\S]*→/);
+});
+
+test('STRUCT011: collection-name-is-domain (Color/, Radius/, Spacing/) — no domain suggestion', () => {
+  // The user's "Radius/sm flagged as unknown domain" report. Fixed: when the
+  // collection name itself is a Tailwind domain, the variable name doesn't
+  // need another domain prefix.
+  const varDefs = tmp('vars.json', {
+    'Radius/sm':   '4px',
+    'Color/gold':  '#c5a572',
+    'Spacing/md':  '0.75rem',
+  });
+  const ctx = tmp('ctx.json', { id: '5:1', name: 'X', type: 'FRAME', layoutMode: 'VERTICAL' });
+  const cssPath = tmp('globals.css', `@theme {} :root {} :root[data-theme="dark"] {}`);
+  const configPath = tmp('adhd.config.ts', `export default { naming: 'kebab-case' };`);
+  const reportPath = path.join(os.tmpdir(), 'adhd-report-' + Date.now() + '.md');
+
+  const result = spawnSync('node', [
+    CLI,
+    '--variable-defs', varDefs,
+    '--design-context', ctx,
+    '--globals-css', cssPath,
+    '--config', configPath,
+    '--target', 'X',
+    '--target-url', 'https://figma.com/design/abc?node-id=5-1',
+    '--output', reportPath,
+  ], { encoding: 'utf8' });
+
+  const summary = JSON.parse(result.stdout);
+  // No STRUCT011 at all — all three vars are valid (collection IS the domain).
+  assert.equal(summary.structure.filter(v => v.rule === 'STRUCT011').length, 0);
+});
+
+test('Tailwind-default variables are NEVER reported as missing in code', () => {
+  // Tailwind v4 ships `--color-white`, `--color-black`, the spacing
+  // multiplier, the --text-* scale, etc. by default. If a Figma file has
+  // a variable that maps to one of those (e.g. `Color/white` = #ffffff),
+  // the comparator must NOT surface it as `status: 'missing'` — the user
+  // would then see a "add this to globals.css" prompt for a token Tailwind
+  // already provides. Pure clutter.
+  const varDefs = tmp('vars.json', {
+    'Primitives/color/white':  '#ffffff',    // Tailwind default → must NOT be missing
+    'Primitives/color/black':  '#000000',    // Tailwind default → must NOT be missing
+    'Primitives/color/custom': '#5e3aee',    // genuinely missing
+  });
+  const ctx = tmp('ctx.json', { id: '5:1', name: 'X', type: 'FRAME', layoutMode: 'VERTICAL' });
+  // globals.css has nothing — relying entirely on Tailwind defaults.
+  const cssPath = tmp('globals.css', `@theme {} :root {} :root[data-theme="dark"] {}`);
+  const configPath = tmp('adhd.config.ts', `export default { naming: 'kebab-case' };`);
+  const reportPath = path.join(os.tmpdir(), 'adhd-report-' + Date.now() + '.md');
+
+  const result = spawnSync('node', [
+    CLI, '--variable-defs', varDefs, '--design-context', ctx, '--globals-css', cssPath,
+    '--config', configPath, '--target', 'X',
+    '--target-url', 'https://figma.com/design/abc?node-id=5-1', '--output', reportPath,
+  ], { encoding: 'utf8' });
+
+  const summary = JSON.parse(result.stdout);
+  const missing = summary.variable.filter(v => v.status === 'missing');
+  // Only the custom brand color should be "missing" — white/black are covered by Tailwind.
+  assert.equal(missing.length, 1, 'only the non-default variable should be flagged as missing');
+  assert.equal(missing[0].token, 'color/custom');
+  // The defaults are absent from the missing list.
+  assert.equal(missing.filter(v => v.token === 'color/white').length, 0);
+  assert.equal(missing.filter(v => v.token === 'color/black').length, 0);
+});
+
+test('STRUCT011 per-layer: with --var-id-map, fires once per layer using the bad var (drops aggregate)', () => {
+  // The user-visible upgrade — annotations land on the layer that actually
+  // uses each bad variable, not lumped onto the scope root. Designers walk
+  // the annotated layers one-by-one instead of cross-referencing a single
+  // multiline message against the layer tree.
+  const varDefs = tmp('vars.json', { 'Tracking/BadName': '0.05em' });
+  const ctx = tmp('ctx.json', {
+    id: '5:1', name: 'Page', type: 'FRAME',
+    children: [
+      { id: '5:2', name: 'A', type: 'TEXT',
+        boundVariables: { letterSpacing: { id: 'VAR:bad' } } },
+      { id: '5:3', name: 'B', type: 'TEXT',
+        boundVariables: { letterSpacing: { id: 'VAR:bad' } } },
+    ],
+  });
+  const idMap = tmp('varidmap.json', { 'VAR:bad': 'Tracking/BadName' });
+  const cssPath = tmp('globals.css', `@theme {} :root {} :root[data-theme="dark"] {}`);
+  const configPath = tmp('adhd.config.ts', `export default { naming: 'kebab-case' };`);
+  const reportPath = path.join(os.tmpdir(), 'adhd-report-' + Date.now() + '.md');
+
+  const result = spawnSync('node', [
+    CLI, '--variable-defs', varDefs, '--var-id-map', idMap, '--design-context', ctx,
+    '--globals-css', cssPath, '--config', configPath, '--target', 'Page',
+    '--target-url', 'https://figma.com/design/abc?node-id=5-1', '--output', reportPath,
+  ], { encoding: 'utf8' });
+
+  const summary = JSON.parse(result.stdout);
+  const struct011 = summary.structure.filter(v => v.rule === 'STRUCT011');
+  // Two layers each get their own violation — no aggregate.
+  assert.equal(struct011.length, 2);
+  const ids = struct011.map(v => v.nodeId).sort();
+  assert.deepEqual(ids, ['5:2', '5:3']);
+  // Per-layer message format
+  assert.match(struct011[0].message, /Layer uses "Tracking\/BadName"/);
+  assert.match(struct011[0].message, /Move to "Tracking" collection/);
+});
+
+test('STRUCT012: cross-domain binding (Spacing var → letterSpacing) fires per-layer', () => {
+  // Designer's `Spacing/4` variable (correctly named for its domain) is
+  // bound to letter-spacing — semantically wrong, but STRUCT011 stays quiet
+  // because the name itself is fine. STRUCT012 catches it.
+  const varDefs = tmp('vars.json', { 'Spacing/4': '1rem' });
+  const ctx = tmp('ctx.json', {
+    id: '5:1', name: 'Title', type: 'TEXT',
+    boundVariables: { letterSpacing: { id: 'VAR:spacing4' } },
+  });
+  const idMap = tmp('varidmap.json', { 'VAR:spacing4': 'Spacing/4' });
+  const cssPath = tmp('globals.css', `@theme {} :root {} :root[data-theme="dark"] {}`);
+  const configPath = tmp('adhd.config.ts', `export default { naming: 'kebab-case' };`);
+  const reportPath = path.join(os.tmpdir(), 'adhd-report-' + Date.now() + '.md');
+
+  const result = spawnSync('node', [
+    CLI, '--variable-defs', varDefs, '--var-id-map', idMap, '--design-context', ctx,
+    '--globals-css', cssPath, '--config', configPath, '--target', 'Title',
+    '--target-url', 'https://figma.com/design/abc?node-id=5-1', '--output', reportPath,
+  ], { encoding: 'utf8' });
+
+  assert.equal(result.status, 1, 'STRUCT012 is severity=error; cli exits 1');
+  const summary = JSON.parse(result.stdout);
+  const struct012 = summary.structure.filter(v => v.rule === 'STRUCT012');
+  assert.equal(struct012.length, 1);
+  assert.equal(struct012[0].nodeId, '5:1');
+  assert.equal(struct012[0].severity, 'error');
+  assert.match(struct012[0].message, /Spacing\/4/);
+  assert.match(struct012[0].message, /spacing variable/);
+  assert.match(struct012[0].message, /tracking variable/);
+  // No STRUCT011 — the name itself is fine.
+  assert.equal(summary.structure.filter(v => v.rule === 'STRUCT011').length, 0);
+});
+
+test('STRUCT012: same-domain binding is silent', () => {
+  // Tracking variable bound to letterSpacing is fine — no violations.
+  const varDefs = tmp('vars.json', { 'Tracking/normal': '0' });
+  const ctx = tmp('ctx.json', {
+    id: '5:1', name: 'Title', type: 'TEXT',
+    boundVariables: { letterSpacing: { id: 'VAR:trackingNormal' } },
+  });
+  const idMap = tmp('varidmap.json', { 'VAR:trackingNormal': 'Tracking/normal' });
+  const cssPath = tmp('globals.css', `@theme {} :root {} :root[data-theme="dark"] {}`);
+  const configPath = tmp('adhd.config.ts', `export default { naming: 'kebab-case' };`);
+  const reportPath = path.join(os.tmpdir(), 'adhd-report-' + Date.now() + '.md');
+
+  const result = spawnSync('node', [
+    CLI, '--variable-defs', varDefs, '--var-id-map', idMap, '--design-context', ctx,
+    '--globals-css', cssPath, '--config', configPath, '--target', 'Title',
+    '--target-url', 'https://figma.com/design/abc?node-id=5-1', '--output', reportPath,
+  ], { encoding: 'utf8' });
+
+  const summary = JSON.parse(result.stdout);
+  assert.equal(summary.structure.filter(v => v.rule === 'STRUCT012').length, 0);
+});
+
+test('STRUCT013: surfaces Figma variables that duplicate Tailwind defaults (strict match)', () => {
+  // Designer pushed the full Tailwind palette to Figma, then left their
+  // legacy `Color/white` sitting alongside the canonical `--color-white`.
+  // Same name, same value → flag for consolidation. A coincidental value
+  // match on a differently-named variable (`Color/Background`) is NOT
+  // flagged — semantic intent is respected.
+  //
+  // We use the white/black defaults here rather than a zinc shade because
+  // Tailwind's color palette ships as oklch() in tailwind-defaults.css —
+  // a hex fixture wouldn't string-match. The user's real Figma files
+  // typically resolve all variables to a consistent format (Figma
+  // converts oklch to hex for the resolved value); a fully realistic
+  // fixture would mock that resolution.
+  const varDefs = tmp('vars.json', {
+    'Color/white':      '#fff',      // dupe — same name + value as Tailwind default
+    'Color/Background': '#fff',      // semantic — value matches but name doesn't, no fire
+    'Color/brand':      '#5e3aee',   // not a Tailwind default at all
+    'Spacing/default':  '0.25rem',   // dupe of --spacing
+  });
+  const idMap = tmp('varidmap.json', {
+    'VAR:white':   'Color/white',
+    'VAR:spacing': 'Spacing/default',
+  });
+  const ctx = tmp('ctx.json', { id: '5:1', name: 'X', type: 'FRAME', layoutMode: 'VERTICAL' });
+  const cssPath = tmp('globals.css', `@theme {} :root {} :root[data-theme="dark"] {}`);
+  const configPath = tmp('adhd.config.ts', `export default { naming: 'kebab-case' };`);
+  const reportPath = path.join(os.tmpdir(), 'adhd-report-' + Date.now() + '.md');
+
+  const result = spawnSync('node', [
+    CLI, '--variable-defs', varDefs, '--var-id-map', idMap, '--design-context', ctx,
+    '--globals-css', cssPath, '--config', configPath, '--target', 'X',
+    '--target-url', 'https://figma.com/design/abc?node-id=5-1', '--output', reportPath,
+  ], { encoding: 'utf8' });
+
+  const summary = JSON.parse(result.stdout);
+  const struct013 = summary.structure.filter(v => v.rule === 'STRUCT013');
+  // Only `Color/white` fires — `Color/Background` has the same value but
+  // a different name (semantic intent respected), `Color/brand` isn't a
+  // Tailwind default at all, and `Spacing/default` normalizes to
+  // "spacing-default" which doesn't align with `--spacing`'s canonical
+  // form (strict name match).
+  assert.equal(struct013.length, 1);
+  const v = struct013[0];
+  assert.equal(v.severity, 'warning');
+  assert.equal(v.figmaVarName, 'Color/white');
+  assert.equal(v.figmaVarId, 'VAR:white');
+  assert.equal(v.tailwindCssVar, '--color-white');
+  assert.match(v.message, /duplicates Tailwind default `--color-white`/);
+  assert.match(v.message, /\/adhd:lint/);
+});
+
+test('STRUCT015: violation carries canonicalCandidate + looksSemantic when a Tailwind canonical matches the value', () => {
+  // The user-driven "auto-fix" pathway: when a missing variable\'s
+  // value matches an existing Tailwind canonical, surface that
+  // canonical on the violation so the SKILL can offer "Rebind to
+  // " as a fourth option. Semantic-named variables (brand,
+  // accent, etc.) get a looksSemantic=true flag so the SKILL emphasizes
+  // the "Add as semantic" option for those, even when a coincidental
+  // value match exists.
+  const varDefs = tmp('vars.json', {
+    // Non-canonical name, value matches Tailwind --text-sm (0.875rem = 14px).
+    'typography/Font-Size/Body': 14,
+    // Semantic name, value matches Tailwind --color-white (literal #fff).
+    // Picked --color-white because it ships as a plain hex in the defaults;
+    // most Tailwind v4 colors ship as oklch() which converts to v4\'s
+    // redesigned palette, not the legacy hex values designers might type.
+    'color/brand': '#ffffff',
+  });
+  const idMap = tmp('varidmap.json', {
+    'VAR:fontBody': 'typography/Font-Size/Body',
+    'VAR:brand':    'color/brand',
+  });
+  const ctx = tmp('ctx.json', {
+    id: '5:1', name: 'Card', type: 'FRAME', layoutMode: 'VERTICAL',
+    fills: [{ type: 'SOLID', boundVariables: { color: { id: 'VAR:brand' } } }],
+    children: [
+      { id: '5:2', name: 'T', type: 'TEXT', boundVariables: { fontSize: { id: 'VAR:fontBody' } } },
+    ],
+  });
+  const cssPath = tmp('globals.css', `@theme {} :root {} :root[data-theme="dark"] {}`);
+  const configPath = tmp('adhd.config.ts', `export default { naming: 'kebab-case' };`);
+  const reportPath = path.join(os.tmpdir(), 'adhd-report-' + Date.now() + '.md');
+
+  const result = spawnSync('node', [
+    CLI, '--variable-defs', varDefs, '--var-id-map', idMap, '--design-context', ctx,
+    '--globals-css', cssPath, '--config', configPath, '--target', 'Card',
+    '--target-url', 'https://figma.com/design/abc?node-id=5-1', '--output', reportPath,
+  ], { encoding: 'utf8' });
+
+  const summary = JSON.parse(result.stdout);
+  const struct015 = summary.structure.filter(v => v.rule === 'STRUCT015');
+  // One per layer × one variable each = 2 STRUCT015.
+  assert.equal(struct015.length, 2);
+
+  const byVar = Object.fromEntries(struct015.map(v => [v.message.match(/binds "([^"]+)"/)[1], v]));
+  // Font-Size/Body has a canonical match (--text-sm), NOT semantic.
+  const body = byVar['typography/Font-Size/Body'];
+  assert.equal(body.canonicalCandidate, '--text-sm');
+  assert.notEqual(body.looksSemantic, true);
+  // color/brand has a value-match (--color-white) AND looks semantic.
+  // Matcher returns the match; SKILL uses looksSemantic to emphasize the
+  // "Add as semantic" option in the prompt — so a brand color that
+  // happens to be white doesn't get accidentally rebound to `--color-white`.
+  const brand = byVar['color/brand'];
+  assert.equal(brand.canonicalCandidate, '--color-white');
+  assert.equal(brand.looksSemantic, true);
+});
+
+test('STRUCT015 + STRUCT016: per-layer errors when a layer binds a missing/conflicting Figma variable', () => {
+  // The user's main complaint: a component binds Figma variables like
+  // "Font-Size/Body" that don't exist in code, but lint reported
+  // "ready for code translation" because the variable issues weren't
+  // tied to specific layers. Now they ARE tied to layers AND surface
+  // as blocking errors.
+  const varDefs = tmp('vars.json', {
+    'Type + Effects/Font-Size/Body': '16px',  // not in code → STRUCT015
+    'Radius/sm': '6px',                        // value differs → STRUCT016
+  });
+  const idMap = tmp('varidmap.json', {
+    'VAR:fontBody': 'Type + Effects/Font-Size/Body',
+    'VAR:radiusSm': 'Radius/sm',
+  });
+  const ctx = tmp('ctx.json', {
+    id: '5:1', name: 'Card', type: 'FRAME', layoutMode: 'VERTICAL',
+    cornerRadius: 6,
+    boundVariables: { cornerRadius: { id: 'VAR:radiusSm' } },
+    children: [
+      {
+        id: '5:2', name: 'Title', type: 'TEXT',
+        boundVariables: { fontSize: { id: 'VAR:fontBody' } },
+      },
+    ],
+  });
+  const cssPath = tmp('globals.css', `@theme { --radius-sm: 0.25rem; } :root {} :root[data-theme="dark"] {}`);
+  const configPath = tmp('adhd.config.ts', `export default { naming: 'kebab-case' };`);
+  const reportPath = path.join(os.tmpdir(), 'adhd-report-' + Date.now() + '.md');
+
+  const result = spawnSync('node', [
+    CLI, '--variable-defs', varDefs, '--var-id-map', idMap, '--design-context', ctx,
+    '--globals-css', cssPath, '--config', configPath, '--target', 'Card',
+    '--target-url', 'https://figma.com/design/abc?node-id=5-1', '--output', reportPath,
+  ], { encoding: 'utf8' });
+
+  assert.equal(result.status, 1, 'errors should exit 1: stderr=' + result.stderr);
+  const summary = JSON.parse(result.stdout);
+  const struct015 = summary.structure.filter(v => v.rule === 'STRUCT015');
+  const struct016 = summary.structure.filter(v => v.rule === 'STRUCT016');
+  // STRUCT015 fires on the Title layer (binds fontSize → Font-Size/Body, not in code).
+  assert.equal(struct015.length, 1);
+  assert.equal(struct015[0].severity, 'error');
+  assert.equal(struct015[0].nodeId, '5:2');
+  assert.match(struct015[0].message, /doesn't exist in code's design system/);
+  // STRUCT016 fires on the Card layer (binds cornerRadius → Radius/sm; code 0.25rem vs figma 6px).
+  assert.equal(struct016.length, 1);
+  assert.equal(struct016[0].severity, 'error');
+  assert.equal(struct016[0].nodeId, '5:1');
+  assert.match(struct016[0].message, /value differs between code and Figma/);
+});
+
+test('STRUCT014: surfaces alias-equivalent collections side by side (Color vs color, Space vs spacing)', () => {
+  // Real scenario from the user's reactor file: prior pushes created
+  // lowercase `color`/`spacing` collections alongside designer's existing
+  // `Color`/`Space`. The new rule surfaces these so /adhd:lint's wizard
+  // to consolidate; alias-aware push prevents future duplicates.
+  const varDefs = tmp('vars.json', {
+    'Color/zinc-500': '#71717a',
+    'Color/red-500': '#ef4444',
+    'color/extra-1': '#aaa',
+    'Space/4': '1rem',
+    'spacing/4': '1rem',
+    'spacing/8': '2rem',
+  });
+  const ctx = tmp('ctx.json', { id: '5:1', name: 'X', type: 'FRAME', layoutMode: 'VERTICAL' });
+  const cssPath = tmp('globals.css', `@theme {} :root {} :root[data-theme="dark"] {}`);
+  const configPath = tmp('adhd.config.ts', `export default { naming: 'kebab-case' };`);
+  const reportPath = path.join(os.tmpdir(), 'adhd-report-' + Date.now() + '.md');
+
+  const result = spawnSync('node', [
+    CLI, '--variable-defs', varDefs, '--design-context', ctx,
+    '--globals-css', cssPath, '--config', configPath, '--target', 'X',
+    '--target-url', 'https://figma.com/design/abc?node-id=5-1', '--output', reportPath,
+  ], { encoding: 'utf8' });
+
+  const summary = JSON.parse(result.stdout);
+  const struct014 = summary.structure.filter(v => v.rule === 'STRUCT014');
+  assert.equal(struct014.length, 2);
+  const byCanonical = Object.fromEntries(struct014.map(v => [v.canonical, v]));
+  assert.ok(byCanonical['color']);
+  assert.ok(byCanonical['spacing']);
+  // Most-populated collection first in each group.
+  assert.equal(byCanonical['color'].collections[0].name, 'Color');
+  assert.equal(byCanonical['color'].collections[0].varCount, 2);
+  assert.equal(byCanonical['spacing'].collections[0].name, 'spacing');
+  assert.equal(byCanonical['spacing'].collections[0].varCount, 2);
+  // Message names the canonical + lists the colliding collections.
+  assert.match(byCanonical['color'].message, /alias to "color"/);
+  assert.match(byCanonical['color'].message, /\/adhd:lint/);
+});
+
+test('STRUCT011: omits nodeId in whole-file mode (no scope root to annotate)', () => {
+  const varDefs = tmp('vars.json', { 'Primitives/color/BrandPrimary': '#000' });
+  const ctx = tmp('ctx.json', { mode: 'whole-file', pages: [] });
+  const cssPath = tmp('globals.css', `@theme {} :root {} :root[data-theme="dark"] {}`);
+  const configPath = tmp('adhd.config.ts', `export default { naming: 'kebab-case' };`);
+  const reportPath = path.join(os.tmpdir(), 'adhd-report-' + Date.now() + '.md');
+
+  const result = spawnSync('node', [
+    CLI,
+    '--variable-defs', varDefs,
+    '--design-context', ctx,
+    '--globals-css', cssPath,
+    '--config', configPath,
+    '--target', 'Whole file',
+    '--target-url', 'https://figma.com/design/abc/Test',
+    '--output', reportPath,
+  ], { encoding: 'utf8' });
+
+  const summary = JSON.parse(result.stdout);
+  const struct011 = summary.structure.find(v => v.rule === 'STRUCT011');
+  assert.ok(struct011);
+  // No nodeId — the violation appears in the report but doesn't annotate
+  // anywhere (the annotation flow filters out items without nodeIds).
+  assert.equal(struct011.nodeId, undefined);
+});
diff --git a/plugins/adhd/lib/lint-engine/__tests__/collection-duplicate-detector.test.js b/plugins/adhd/lib/lint-engine/__tests__/collection-duplicate-detector.test.js
new file mode 100644
index 0000000..0fe3893
--- /dev/null
+++ b/plugins/adhd/lib/lint-engine/__tests__/collection-duplicate-detector.test.js
@@ -0,0 +1,79 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { detectDuplicateCollections } = require('../collection-duplicate-detector');
+
+test('detects case-variant collections (Color vs color)', () => {
+  const groups = detectDuplicateCollections([
+    'Color/zinc-500', 'Color/red-500',
+    'color/zinc-500', 'color/red-500', 'color/blue-500',
+  ]);
+  assert.equal(groups.length, 1);
+  assert.equal(groups[0].canonical, 'color');
+  // Most-populated first.
+  assert.equal(groups[0].collections[0].name, 'color');
+  assert.equal(groups[0].collections[0].varCount, 3);
+  assert.equal(groups[0].collections[1].name, 'Color');
+  assert.equal(groups[0].collections[1].varCount, 2);
+});
+
+test('detects synonym collections (Space vs spacing, Borders vs border-width, Type + Effects vs typography)', () => {
+  const groups = detectDuplicateCollections([
+    'Space/4', 'Space/8',
+    'spacing/4', 'spacing/8', 'spacing/16',
+    'Borders/sm', 'Borders/md',
+    'border-width/sm',
+    'Type + Effects/text-lg',
+    'typography/text-lg', 'typography/leading-relaxed',
+  ]);
+  const byCanonical = Object.fromEntries(groups.map(g => [g.canonical, g]));
+  assert.deepEqual(Object.keys(byCanonical).sort(), ['border-width', 'spacing', 'typography']);
+  assert.equal(byCanonical['spacing'].collections.length, 2);
+  assert.equal(byCanonical['border-width'].collections.length, 2);
+  assert.equal(byCanonical['typography'].collections.length, 2);
+});
+
+test('does not fire when only one collection exists per canonical', () => {
+  // No duplicates — alias resolution finds exactly one Figma collection
+  // per canonical, nothing to consolidate.
+  const groups = detectDuplicateCollections([
+    'Color/zinc-500', 'Color/red-500',
+    'Spacing/4',
+    'Radius/sm',
+  ]);
+  assert.deepEqual(groups, []);
+});
+
+test('ignores collections whose names don\'t alias to a known canonical', () => {
+  // "Foo" isn't in the alias table. Doesn't surface.
+  const groups = detectDuplicateCollections([
+    'Foo/x', 'Foo/y',
+    'Bar/x',
+  ]);
+  assert.deepEqual(groups, []);
+});
+
+test('handles three-way duplicates (Color + colors + Colour)', () => {
+  const groups = detectDuplicateCollections([
+    'Color/a', 'Color/b', 'Color/c',
+    'colors/a',
+    'Colour/a', 'Colour/b',
+  ]);
+  assert.equal(groups.length, 1);
+  // Three collections, sorted by varCount desc, then by name asc.
+  assert.deepEqual(groups[0].collections.map(c => c.name), ['Color', 'Colour', 'colors']);
+});
+
+test('empty / invalid input returns empty array (safe default)', () => {
+  assert.deepEqual(detectDuplicateCollections([]), []);
+  assert.deepEqual(detectDuplicateCollections(null), []);
+  assert.deepEqual(detectDuplicateCollections(undefined), []);
+});
+
+test('skips entries without a slash (single-segment names)', () => {
+  // Bare names like "loose" don't have a collection prefix; ignore them.
+  const groups = detectDuplicateCollections(['Color/a', 'color/a', 'loose']);
+  assert.equal(groups.length, 1);
+  assert.equal(groups[0].canonical, 'color');
+});
diff --git a/plugins/adhd/lib/lint-engine/__tests__/structure-checker.test.js b/plugins/adhd/lib/lint-engine/__tests__/structure-checker.test.js
index ffc5f63..e3c3495 100644
--- a/plugins/adhd/lib/lint-engine/__tests__/structure-checker.test.js
+++ b/plugins/adhd/lib/lint-engine/__tests__/structure-checker.test.js
@@ -51,6 +51,72 @@ test('STRUCT001: does NOT flag a frame holding a single shape primitive (icon/lo
   }
 });
 
+test('STRUCT001: does NOT flag a frame whose subtree is shape-only through nested containers', () => {
+  // The user's real-world case: a Logo Component Set whose outer frame holds
+  // "light" and "dark" sub-frames, each containing only vector paths. The whole
+  // outer frame rasterizes to a single SVG — flexbox doesn't apply, even though
+  // the immediate children are FRAMEs (not shapes) themselves.
+  const node = makeFrame({
+    layoutMode: 'NONE',
+    children: [
+      {
+        id: '1:2', name: 'light', type: 'FRAME', layoutMode: 'NONE',
+        children: [
+          { id: '1:3', name: 'path-1', type: 'VECTOR' },
+          { id: '1:4', name: 'path-2', type: 'VECTOR' },
+        ],
+      },
+      {
+        id: '1:5', name: 'dark', type: 'COMPONENT', layoutMode: 'NONE',
+        children: [
+          { id: '1:6', name: 'path-1', type: 'VECTOR' },
+          { id: '1:7', name: 'mask', type: 'BOOLEAN_OPERATION' },
+        ],
+      },
+    ],
+  });
+  const violations = checkStructure(node, { fileKey: FIGMA_FILE_KEY, namingConvention: 'kebab-case' });
+  // STRUCT001 must not fire on the OUTER frame (it's a shape-only composition).
+  const outerStruct001 = violations.find(v => v.rule === 'STRUCT001' && v.nodeId === node.id);
+  assert.equal(outerStruct001, undefined, 'outer frame should be exempt — entire subtree is shape-only');
+});
+
+test('STRUCT001: still flags a deeply-nested frame if a leaf is non-shape (TEXT)', () => {
+  // One TEXT leaf anywhere in the subtree breaks the shape-only predicate.
+  const node = makeFrame({
+    layoutMode: 'NONE',
+    children: [
+      {
+        id: '1:2', name: 'badge', type: 'FRAME', layoutMode: 'NONE',
+        children: [
+          { id: '1:3', name: 'path', type: 'VECTOR' },
+          { id: '1:4', name: 'label', type: 'TEXT' },
+        ],
+      },
+    ],
+  });
+  const violations = checkStructure(node, { fileKey: FIGMA_FILE_KEY, namingConvention: 'kebab-case' });
+  assert.ok(violations.find(v => v.rule === 'STRUCT001'));
+});
+
+test('STRUCT001: does NOT flag a frame whose children are all shape primitives (multi-path SVG)', () => {
+  // Real-world case: a Logo Component Set variant that's a composite of multiple
+  // vector paths (e.g. a wordmark with separate paths per letter, or a mark with
+  // multiple boolean-op layers). The whole frame rasterizes to a single SVG;
+  // flexbox doesn't apply. Auto-layout would be incorrect here.
+  const node = makeFrame({
+    layoutMode: 'NONE',
+    children: [
+      { id: '1:2', name: 'path-1', type: 'VECTOR' },
+      { id: '1:3', name: 'path-2', type: 'VECTOR' },
+      { id: '1:4', name: 'mask',   type: 'BOOLEAN_OPERATION' },
+      { id: '1:5', name: 'dot',    type: 'ELLIPSE' },
+    ],
+  });
+  const violations = checkStructure(node, { fileKey: FIGMA_FILE_KEY, namingConvention: 'kebab-case' });
+  assert.equal(violations.filter(v => v.rule === 'STRUCT001').length, 0);
+});
+
 test('STRUCT001: still flags a frame with a single TEXT child (needs padding/alignment control)', () => {
   const node = makeFrame({
     layoutMode: 'NONE',
@@ -69,21 +135,65 @@ test('STRUCT001: still flags a frame with a single FRAME child', () => {
   assert.ok(violations.find(v => v.rule === 'STRUCT001'));
 });
 
-test('STRUCT001: still flags a frame with 2+ children regardless of types', () => {
+test('STRUCT001: still flags a frame with mixed shapes + non-shape children (needs auto-layout)', () => {
+  // If even one child isn't a shape primitive, the exemption doesn't apply —
+  // the non-shape needs auto-layout for padding/alignment.
   const node = makeFrame({
     layoutMode: 'NONE',
     children: [
-      { id: '1:2', name: 'a', type: 'VECTOR' },
-      { id: '1:3', name: 'b', type: 'VECTOR' },
+      { id: '1:2', name: 'icon-path', type: 'VECTOR' },
+      { id: '1:3', name: 'label',     type: 'TEXT' },
     ],
   });
   const violations = checkStructure(node, { fileKey: FIGMA_FILE_KEY, namingConvention: 'kebab-case' });
   assert.ok(violations.find(v => v.rule === 'STRUCT001'));
 });
 
-test('STRUCT003: flags a fill with raw hex (no boundVariables)', () => {
+test('STRUCT003: flags a fill with raw hex (no boundVariables) and names the color', () => {
+  // The user's case: a layer with #FFFFFF in the Figma fills panel, no variable
+  // bound. The previous generic copy ("Fill is a raw color") was easy to skim
+  // past when many violations fired at once; now the message includes the hex.
+  const node = makeFrame({
+    fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }],
+  });
+  const violations = checkStructure(node, { fileKey: FIGMA_FILE_KEY, namingConvention: 'kebab-case' });
+  const v = violations.find(x => x.rule === 'STRUCT003');
+  assert.ok(v);
+  assert.match(v.message, /#FFFFFF/);
+  assert.match(v.message, /bind it to a color variable or apply a paint style/);
+});
+
+test('STRUCT003: does NOT fire on a layer bound to a paint style (legacy design-token mechanism)', () => {
+  // Paint styles pre-date variables but are still valid design tokens. A layer
+  // with a non-empty fillStyleId is bound — STRUCT003 shouldn't ask the
+  // designer to migrate to a variable just for the lint to pass.
+  const node = makeFrame({
+    fillStyleId: 'S:abc123,1:0',
+    fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }],
+  });
+  const violations = checkStructure(node, { fileKey: FIGMA_FILE_KEY, namingConvention: 'kebab-case' });
+  assert.equal(violations.filter(v => v.rule === 'STRUCT003').length, 0);
+});
+
+test('STRUCT003: flags __MIXED__ fills (per-range mixed paints — could hide raw values)', () => {
+  // Figma returns `figma.mixed` for `node.fills` on TEXT with multiple paint
+  // segments. The serializer coerces that Symbol to "__MIXED__" so it survives
+  // JSON.stringify (Symbols don't). Without this rule firing, the violation
+  // would silently disappear from the report — which is exactly what the user
+  // hit on their Logo Component Set.
+  const node = makeFrame({ fills: '__MIXED__' });
+  const violations = checkStructure(node, { fileKey: FIGMA_FILE_KEY, namingConvention: 'kebab-case' });
+  const v = violations.find(x => x.rule === 'STRUCT003');
+  assert.ok(v);
+  assert.match(v.message, /mixed across ranges/);
+});
+
+test('STRUCT003: a MIXED fillStyleId still falls through to the fills check', () => {
+  // Some ranges styled, others not. We can't trust the style binding covers
+  // every range, so the fills check still runs and catches any raw paint.
   const node = makeFrame({
-    fills: [{ type: 'SOLID', color: { r: 0.37, g: 0.23, b: 0.93 } }],
+    fillStyleId: '__MIXED__',
+    fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 } }],
   });
   const violations = checkStructure(node, { fileKey: FIGMA_FILE_KEY, namingConvention: 'kebab-case' });
   assert.ok(violations.find(v => v.rule === 'STRUCT003'));
@@ -215,7 +325,7 @@ test('STRUCT008: flags auto-named layers like "Frame 47"', () => {
   assert.ok(violations.find(v => v.rule === 'STRUCT008'));
 });
 
-test('STRUCT010: flags a Component Set with children that have empty variantProperties', () => {
+test('STRUCT010: flags a Component Set with children that have empty variantProperties + diagnostic message', () => {
   const node = {
     id: '1:1',
     name: 'Button',
@@ -227,7 +337,13 @@ test('STRUCT010: flags a Component Set with children that have empty variantProp
     ],
   };
   const violations = checkStructure(node, { fileKey: FIGMA_FILE_KEY, namingConvention: 'kebab-case' });
-  assert.ok(violations.find(v => v.rule === 'STRUCT010'));
+  const struct010 = violations.find(v => v.rule === 'STRUCT010');
+  assert.ok(struct010);
+  // Message names the count, suggests a property example, and calls out the
+  // codegen consequence so the designer understands the fix is non-optional.
+  assert.match(struct010.message, /2 variant\(s\) but no variant property/);
+  assert.match(struct010.message, /Properties panel/);
+  assert.match(struct010.message, /2 separate components/);
 });
 
 test('STRUCT010: does not flag a Component Set with declared variant properties', () => {
@@ -387,19 +503,41 @@ test('STRUCT006: flags a FRAME with wasInstance: true (warning, not error)', ()
   assert.equal(struct006.severity, 'warning');
 });
 
-test('STRUCT007: flags sibling components sharing a name prefix outside a Component Set', () => {
+test('STRUCT007: flags sibling components sharing a name prefix outside a Component Set with diagnostic message', () => {
   const node = makeFrame({
     type: 'FRAME',
     children: [
-      { id: '1:2', name: 'Button/primary', type: 'COMPONENT' },
-      { id: '1:3', name: 'Button/secondary', type: 'COMPONENT' },
+      { id: '1:2', name: 'Logo/light', type: 'COMPONENT' },
+      { id: '1:3', name: 'Logo/dark', type: 'COMPONENT' },
     ],
   });
   const violations = checkStructure(node, { fileKey: FIGMA_FILE_KEY, namingConvention: 'kebab-case' });
   const struct007 = violations.find(v => v.rule === 'STRUCT007');
   assert.ok(struct007, 'expected STRUCT007 violation');
   assert.equal(struct007.severity, 'warning');
-  assert.match(struct007.message, /Button\/\.\.\./);
+  // The message names the prefix, the specific siblings, and the codegen
+  // consequence — so a designer reading the annotation in Figma understands
+  // what to fix AND why it matters.
+  assert.match(struct007.message, /"Logo\/"/);
+  assert.match(struct007.message, /"light"/);
+  assert.match(struct007.message, /"dark"/);
+  assert.match(struct007.message, /look like variants/i);
+  assert.match(struct007.message, /Combine as Variants/);
+  assert.match(struct007.message, /code generation/i);
+});
+
+test('STRUCT007: truncates the suffix list to four with a "+N more" hint when there are many', () => {
+  const node = makeFrame({
+    type: 'FRAME',
+    children: Array.from({ length: 7 }, (_, i) => ({
+      id: `1:${10 + i}`, name: `Logo/v${i + 1}`, type: 'COMPONENT',
+    })),
+  });
+  const violations = checkStructure(node, { fileKey: FIGMA_FILE_KEY, namingConvention: 'kebab-case' });
+  const struct007 = violations.find(v => v.rule === 'STRUCT007');
+  assert.ok(struct007);
+  // First four suffixes shown, then "+3 more"
+  assert.match(struct007.message, /"v1", "v2", "v3", "v4", \+3 more/);
 });
 
 test('STRUCT007: does not flag a single child component (no siblings to group)', () => {
diff --git a/plugins/adhd/lib/lint-engine/__tests__/tailwind-duplicate-detector.test.js b/plugins/adhd/lib/lint-engine/__tests__/tailwind-duplicate-detector.test.js
new file mode 100644
index 0000000..286b842
--- /dev/null
+++ b/plugins/adhd/lib/lint-engine/__tests__/tailwind-duplicate-detector.test.js
@@ -0,0 +1,84 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const {
+  detectTailwindDuplicates,
+  normalizeFigmaVarName,
+} = require('../tailwind-duplicate-detector');
+
+test('normalizeFigmaVarName: domain-as-collection', () => {
+  assert.equal(normalizeFigmaVarName('Color/zinc-500'), 'color-zinc-500');
+  assert.equal(normalizeFigmaVarName('Spacing/4'), 'spacing-4');
+  assert.equal(normalizeFigmaVarName('Radius/sm'), 'radius-sm');
+});
+
+test('normalizeFigmaVarName: tier collection stripped', () => {
+  // Primitives is a tier — its prefix is invisible.
+  assert.equal(normalizeFigmaVarName('Primitives/color/zinc/500'), 'color-zinc-500');
+  assert.equal(normalizeFigmaVarName('Semantic/spacing/md'), 'spacing-md');
+});
+
+test('normalizeFigmaVarName: PascalCase / camelCase leaf is kebabed', () => {
+  assert.equal(normalizeFigmaVarName('Color/BrandGold'), 'color-brand-gold');
+  assert.equal(normalizeFigmaVarName('Spacing/spacing0_5'), 'spacing-spacing-0-5');
+});
+
+test('detectTailwindDuplicates: strict name + value match wins', () => {
+  const varDefs = {
+    'Color/zinc-500': '#71717a',
+    'Color/zinc-600': '#52525b',
+  };
+  const tailwindDefaults = {
+    '--color-zinc-500': '#71717a',
+    '--color-zinc-600': '#52525b',
+    '--color-zinc-700': '#3f3f46',
+  };
+  const out = detectTailwindDuplicates(varDefs, tailwindDefaults);
+  const names = out.map(d => d.figmaName).sort();
+  assert.deepEqual(names, ['Color/zinc-500', 'Color/zinc-600']);
+  assert.equal(out[0].tailwindCssVar, '--color-zinc-500');
+});
+
+test('detectTailwindDuplicates: name matches but value differs → no fire', () => {
+  // The user's `Color/MyZinc` case, but with the canonical name. If the
+  // value diverges from the Tailwind default, it's not a duplicate — the
+  // designer has intentionally overridden it.
+  const varDefs = { 'Color/zinc-500': '#888888' };
+  const tailwindDefaults = { '--color-zinc-500': '#71717a' };
+  const out = detectTailwindDuplicates(varDefs, tailwindDefaults);
+  assert.deepEqual(out, []);
+});
+
+test('detectTailwindDuplicates: value matches but name differs → no fire (semantic-intent guard)', () => {
+  // `Color/MyZinc = #71717a` happens to equal `--color-zinc-500`, but
+  // the name signals a semantic intent ("my brand's zinc") — strict mode
+  // refuses to flag this. The designer's naming choice is respected.
+  const varDefs = { 'Color/MyZinc': '#71717a' };
+  const tailwindDefaults = { '--color-zinc-500': '#71717a' };
+  const out = detectTailwindDuplicates(varDefs, tailwindDefaults);
+  assert.deepEqual(out, []);
+});
+
+test('detectTailwindDuplicates: tier-collection variants still match canonical', () => {
+  const varDefs = { 'Primitives/color/zinc/500': '#71717a' };
+  const tailwindDefaults = { '--color-zinc-500': '#71717a' };
+  const out = detectTailwindDuplicates(varDefs, tailwindDefaults);
+  assert.equal(out.length, 1);
+  assert.equal(out[0].figmaName, 'Primitives/color/zinc/500');
+  assert.equal(out[0].tailwindCssVar, '--color-zinc-500');
+});
+
+test('detectTailwindDuplicates: case-insensitive value comparison', () => {
+  // Designer entered `#71717A`; Tailwind ships `#71717a`. Still a dupe.
+  const varDefs = { 'Color/zinc-500': '#71717A' };
+  const tailwindDefaults = { '--color-zinc-500': '#71717a' };
+  const out = detectTailwindDuplicates(varDefs, tailwindDefaults);
+  assert.equal(out.length, 1);
+});
+
+test('detectTailwindDuplicates: handles empty inputs gracefully', () => {
+  assert.deepEqual(detectTailwindDuplicates(null, {}), []);
+  assert.deepEqual(detectTailwindDuplicates({}, null), []);
+  assert.deepEqual(detectTailwindDuplicates({}, {}), []);
+});
diff --git a/plugins/adhd/lib/lint-engine/__tests__/value-normalizer.test.js b/plugins/adhd/lib/lint-engine/__tests__/value-normalizer.test.js
index 7693ec0..b469fd6 100644
--- a/plugins/adhd/lib/lint-engine/__tests__/value-normalizer.test.js
+++ b/plugins/adhd/lib/lint-engine/__tests__/value-normalizer.test.js
@@ -41,6 +41,25 @@ test('valuesMatch dispatches on domain', () => {
   assert.equal(valuesMatch('1.5', '1.5', 'typography'), true);
 });
 
+test('normalizeColor accepts Figma\'s raw {r,g,b,a} object form (channels 0..1)', () => {
+  // Real scenario from the user's reactor file: the SKILL\'s serializer
+  // emits color variable values straight from variable.valuesByMode, so
+  // figma side arrives as `{r:0.039, g:0.039, b:0.039, a:1}` while code
+  // is `#0a0a0a`. Without this branch the comparator falsely flagged
+  // these as conflicts.
+  assert.equal(normalizeColor({ r: 0.039, g: 0.039, b: 0.039, a: 1 }), '#0a0a0a');
+  assert.equal(normalizeColor({ r: 0, g: 0, b: 0 }), '#000000');
+  assert.equal(normalizeColor({ r: 1, g: 1, b: 1 }), '#ffffff');
+  // Alpha < 1 surfaces in the hex.
+  assert.equal(normalizeColor({ r: 1, g: 0, b: 0, a: 0.5 }), '#ff000080');
+});
+
+test('valuesMatch resolves hex-vs-RGB-object as equal (the "primary" false-conflict fix)', () => {
+  assert.equal(valuesMatch({ r: 0.039, g: 0.039, b: 0.039, a: 1 }, '#0a0a0a', 'color'), true);
+  // Truly different colors still conflict.
+  assert.equal(valuesMatch({ r: 1, g: 0, b: 0, a: 1 }, '#000000', 'color'), false);
+});
+
 test('valuesMatch deep-equals shadow objects', () => {
   const a = { offsetX: '0px', offsetY: '4px', blur: '8px', spread: '0px', color: '#000000' };
   const b = { offsetX: '0px', offsetY: '4px', blur: '8px', spread: '0px', color: '#000000' };
diff --git a/plugins/adhd/lib/lint-engine/__tests__/variable-categorizer.test.js b/plugins/adhd/lib/lint-engine/__tests__/variable-categorizer.test.js
index 45afd2b..04dccfb 100644
--- a/plugins/adhd/lib/lint-engine/__tests__/variable-categorizer.test.js
+++ b/plugins/adhd/lib/lint-engine/__tests__/variable-categorizer.test.js
@@ -49,6 +49,102 @@ test('does not emit violations for variables that match', () => {
   assert.equal(matches.length, 0);
 });
 
+test('Figma\'s raw {r,g,b,a} object compares equal to code\'s #hex (regression for the "primary" false-conflict)', () => {
+  // The user's reactor file kept reporting STRUCT016 on color/primary:
+  // "code #0a0a0a vs figma #0a0a0a — same value." Root cause was two
+  // bugs stacked: (1) the figma side arrives as {r:0.039,g:0.039,b:0.039,a:1}
+  // (Figma's raw color form, channels 0..1), and (2) inferDomain was
+  // receiving the COLLECTION-STRIPPED token ("primary"), so it returned
+  // "unknown" instead of "color" — meaning valuesMatch dispatched to
+  // the strict-equality default branch and never normalized the rgb
+  // object to hex.
+  const violations = categorizeVariables(
+    { 'color/primary': { r: 0.039, g: 0.039, b: 0.039, a: 1 } },
+    { primitives: { '--color-primary': '#0a0a0a' }, exposure: {}, light: {}, dark: {} },
+  );
+  assert.equal(violations.length, 0);
+});
+
+test('synthesized Tailwind scale (spacing/0, radius/full, etc.) is recognized via the cli\'s default-loader, not just the @theme parser', () => {
+  // Regression for the user-reported false STRUCT015 on spacing/0:
+  // Tailwind v4 doesn\'t ship explicit `--spacing-N` variables in its
+  // @theme block — it has `--spacing: 0.25rem` and synthesizes the rest
+  // at build time. The lint engine's loadTailwindDefaultPrimitives must
+  // call synthesizeTailwindUtilityScale to mirror that, or else `spacing/0`
+  // (a perfectly canonical Tailwind path) shows up as missing-in-code.
+  //
+  // This test runs against the categorizer with a manually-built theme
+  // that mirrors what the cli's loader produces — guards the join point
+  // between the two modules.
+  const { synthesizeTailwindUtilityScale } = require('../../design-system/code-parser');
+  const primitives = {};
+  for (const t of synthesizeTailwindUtilityScale()) {
+    primitives[t.cssVar] = t.values.default.value;
+  }
+  const theme = { primitives, exposure: {}, light: {}, dark: {} };
+
+  // Canonical Tailwind paths match the synthesized scale → no violations.
+  assert.equal(categorizeVariables({ 'spacing/0': 0 }, theme).length, 0);
+  assert.equal(categorizeVariables({ 'spacing/4': 16 }, theme).length, 0);
+  assert.equal(categorizeVariables({ 'radius/full': '9999px' }, theme).length, 0);
+
+  // Non-canonical paths still flag as missing (correct — `spacing/space/0`
+  // isn\'t a Tailwind path; the designer needs to rename).
+  const v = categorizeVariables({ 'spacing/space/0': 0 }, theme);
+  assert.equal(v.length, 1);
+  assert.equal(v[0].status, 'missing');
+});
+
+test('shadcn pattern: --color-primary aliases --primary in :root, figma rgb-object resolves through the alias chain', () => {
+  // Pre-fix, the categorizer found `--color-primary: var(--primary)` in
+  // the @theme inline exposure layer, compared its raw `var(--primary)`
+  // string against Figma\'s `{r,g,b,a}` object, normalizeColor threw on
+  // the alias string, valuesMatch caught and returned false, and STRUCT016
+  // fired as a "false-positive" conflict on every shadcn setup. Fix:
+  // resolve the local alias through the :root layer and compare against
+  // the literal `--primary` value.
+  const css = `
+    :root { --primary: #0a0a0a; }
+    @theme inline { --color-primary: var(--primary); }
+  `;
+  const { parseTheme } = require('../theme-parser');
+  const theme = parseTheme(css);
+  const v = categorizeVariables(
+    { 'color/primary': { r: 0.039, g: 0.039, b: 0.039, a: 1 } },
+    theme,
+  );
+  assert.equal(v.length, 0);
+});
+
+test('shadcn pattern: real value drift through an alias still surfaces as conflict', () => {
+  // Sanity check: the alias-resolution fix doesn\'t mask actual drift.
+  // If figma\'s value resolves to a different color than the alias chain
+  // points to, STRUCT016 must still fire.
+  const css = `
+    :root { --primary: #0a0a0a; }
+    @theme inline { --color-primary: var(--primary); }
+  `;
+  const { parseTheme } = require('../theme-parser');
+  const theme = parseTheme(css);
+  const v = categorizeVariables(
+    { 'color/primary': { r: 1, g: 0, b: 0, a: 1 } },  // real red, not the black alias target
+    theme,
+  );
+  assert.equal(v.length, 1);
+  assert.equal(v[0].status, 'conflict');
+});
+
+test('Capitalized Figma collection names still resolve their domain ("Color/primary" → color)', () => {
+  // Designer's collection is "Color" (capital C). Pre-fix, inferDomain
+  // was case-sensitive and missed this — domain came back "unknown" and
+  // the conflict surfaced even when values matched.
+  const violations = categorizeVariables(
+    { 'Color/primary': { r: 0.039, g: 0.039, b: 0.039, a: 1 } },
+    { primitives: { '--color-primary': '#0a0a0a' }, exposure: {}, light: {}, dark: {} },
+  );
+  assert.equal(violations.length, 0);
+});
+
 test('treats hex case as semantically identical', () => {
   const violations = categorizeVariables(
     { 'Primitives/color/x': '#5E3AEE' },
@@ -72,7 +168,7 @@ test('missing variables include a suggested-fix hint', () => {
   );
   const m = violations.find(v => v.status === 'missing');
   assert.ok(m);
-  assert.equal(m.hint, 'Run /adhd:pull-design-system to import this token.');
+  assert.equal(m.hint, 'Run /adhd:pull-tokens to import this token.');
 });
 
 test('does NOT flag a conflict when both sides are aliases (semantic→primitive)', () => {
diff --git a/plugins/adhd/lib/lint-engine/__tests__/variable-namer.test.js b/plugins/adhd/lib/lint-engine/__tests__/variable-namer.test.js
new file mode 100644
index 0000000..8f21197
--- /dev/null
+++ b/plugins/adhd/lib/lint-engine/__tests__/variable-namer.test.js
@@ -0,0 +1,305 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const {
+  checkVariableNames, caseMatchesSegment, suggestName, toCase,
+  checkVariableDomains, classifyDomain, TAILWIND_DOMAINS,
+} = require('../variable-namer');
+
+// Real Figma var keys arrive as `/`. The first segment is
+// the collection name (Primitives, Semantic) and is left alone — that's the
+// same treatment variable-categorizer applies. All assertions below use the
+// realistic shape.
+
+test('returns [] when convention is false (check disabled)', () => {
+  assert.deepEqual(checkVariableNames(['Primitives/color/BrandPrimary', 'Primitives/radius/MD'], false), []);
+});
+
+test('returns [] when every variable name is compliant in kebab-case (with path segments)', () => {
+  const names = [
+    'Primitives/color/brand-primary',
+    'Semantic/color/text/default',
+    'Primitives/radius/sm',
+    'Primitives/shadow/md',
+  ];
+  assert.deepEqual(checkVariableNames(names, 'kebab-case'), []);
+});
+
+test('does NOT flag the collection prefix even when it is PascalCase (real Figma convention)', () => {
+  // `Primitives` is PascalCase but it's a collection name, not a variable
+  // name. The rule mirrors variable-categorizer.strippedToken behavior.
+  const names = ['Primitives/color/brand-primary'];
+  assert.deepEqual(checkVariableNames(names, 'kebab-case'), []);
+});
+
+test('flags PascalCase-shaped variable segments in a kebab-case project', () => {
+  const result = checkVariableNames(['Primitives/color/BrandPrimary', 'Primitives/radius/MD'], 'kebab-case');
+  assert.equal(result.length, 2);
+  assert.deepEqual(result[0], { name: 'Primitives/color/BrandPrimary', suggestion: 'Primitives/color/brand-primary' });
+  assert.deepEqual(result[1], { name: 'Primitives/radius/MD', suggestion: 'Primitives/radius/md' });
+});
+
+test('only the BAD segment fails; good segments are preserved in the suggestion', () => {
+  // `color` is fine in kebab; `Brand_Primary` is the bad part.
+  const result = checkVariableNames(['Primitives/color/Brand_Primary'], 'kebab-case');
+  assert.equal(result.length, 1);
+  assert.equal(result[0].suggestion, 'Primitives/color/brand-primary');
+});
+
+test('handles numerics in segments without inserting stray separators in kebab', () => {
+  // `color/blue/500` is valid kebab; `color/Blue500` should become `color/blue-500`.
+  const valid = checkVariableNames(['Primitives/color/blue/500'], 'kebab-case');
+  assert.deepEqual(valid, []);
+  const result = checkVariableNames(['Primitives/color/Blue500'], 'kebab-case');
+  assert.equal(result[0].suggestion, 'Primitives/color/blue-500');
+});
+
+test('PascalCase project: flags kebab-cased variable segments (collection prefix kept)', () => {
+  const result = checkVariableNames(['Primitives/brand-primary', 'Primitives/sm'], 'PascalCase');
+  assert.equal(result.length, 2);
+  assert.equal(result[0].suggestion, 'Primitives/BrandPrimary');
+  assert.equal(result[1].suggestion, 'Primitives/Sm');
+});
+
+test('camelCase project: flags Pascal- and kebab-cased segments and suggests camel', () => {
+  const result = checkVariableNames(['Primitives/color/BrandPrimary', 'Primitives/color/text-default'], 'camelCase');
+  assert.equal(result.length, 2);
+  assert.equal(result[0].suggestion, 'Primitives/color/brandPrimary');
+  assert.equal(result[1].suggestion, 'Primitives/color/textDefault');
+});
+
+test('top-level vars without a collection prefix are skipped (no name to check)', () => {
+  // An unprefixed var like "spacing" can't be split — nothing to enforce.
+  assert.deepEqual(checkVariableNames(['spacing'], 'kebab-case'), []);
+});
+
+// ---------------------------------------------------------------------------
+// Domain "did you mean?" half of STRUCT011
+
+test('classifyDomain: recognized Tailwind v4 prefixes return known', () => {
+  for (const d of TAILWIND_DOMAINS) {
+    assert.deepEqual(classifyDomain(d), { kind: 'known' }, `expected "${d}" to be known`);
+  }
+});
+
+test('classifyDomain: synonyms suggest the canonical name', () => {
+  assert.deepEqual(classifyDomain('colors'), { kind: 'synonym', suggestion: 'color' });
+  assert.deepEqual(classifyDomain('space'), { kind: 'synonym', suggestion: 'spacing' });
+  assert.deepEqual(classifyDomain('shadows'), { kind: 'synonym', suggestion: 'shadow' });
+  assert.deepEqual(classifyDomain('screens'), { kind: 'synonym', suggestion: 'breakpoint' });
+  assert.deepEqual(classifyDomain('font-size'), { kind: 'synonym', suggestion: 'text' });
+  assert.deepEqual(classifyDomain('line-height'), { kind: 'synonym', suggestion: 'leading' });
+});
+
+test('classifyDomain: small typos (distance ≤ 2) are classified as typo with suggestion', () => {
+  // "colur" → "color" (distance 1, missing 'o' + extra letter)
+  const r1 = classifyDomain('colur');
+  assert.equal(r1.kind, 'typo');
+  assert.equal(r1.suggestion, 'color');
+  // "radiu" → "radius" (distance 1, missing 's')
+  const r2 = classifyDomain('radiu');
+  assert.equal(r2.kind, 'typo');
+  assert.equal(r2.suggestion, 'radius');
+});
+
+test('classifyDomain: genuinely unknown prefixes return kind:unknown', () => {
+  // "widget" is too distant from anything in the list to be a typo.
+  assert.deepEqual(classifyDomain('widget'), { kind: 'unknown' });
+  assert.deepEqual(classifyDomain('miscellaneous'), { kind: 'unknown' });
+});
+
+test('classifyDomain is case-insensitive on input', () => {
+  // The case-convention check is a separate concern; domain classification
+  // shouldn't depend on whether the designer wrote "Color" or "color".
+  assert.deepEqual(classifyDomain('Color'), { kind: 'known' });
+  assert.deepEqual(classifyDomain('SHADOWS'), { kind: 'synonym', suggestion: 'shadow' });
+});
+
+test('checkVariableDomains: real Figma keys with collection prefix', () => {
+  const names = [
+    'Primitives/color/brand-500',           // ok
+    'Primitives/colur/brand-500',           // typo
+    'Primitives/space/sm',                  // synonym
+    'Primitives/widget/foo',                // unknown
+    'Semantic/color/text/default',          // ok
+  ];
+  const out = checkVariableDomains(names);
+  assert.equal(out.length, 3);
+  assert.equal(out[0].name, 'Primitives/colur/brand-500');
+  assert.equal(out[0].classification.kind, 'typo');
+  assert.equal(out[0].classification.suggestion, 'color');
+  assert.equal(out[1].name, 'Primitives/space/sm');
+  assert.equal(out[1].classification.kind, 'synonym');
+  assert.equal(out[1].classification.suggestion, 'spacing');
+  assert.equal(out[2].name, 'Primitives/widget/foo');
+  assert.equal(out[2].classification.kind, 'unknown');
+});
+
+test('checkVariableDomains: collection-only names (no slash) are skipped', () => {
+  assert.deepEqual(checkVariableDomains(['Primitives']), []);
+});
+
+test('checkVariableDomains: collection name IS the domain — skip domain check on the var', () => {
+  // Some teams organize Figma collections by domain ("Color", "Radius", "Spacing")
+  // instead of by tier ("Primitives", "Semantic"). When the collection name
+  // itself matches a Tailwind domain, the variable name doesn't need another
+  // domain prefix. `Color/gold` and `Radius/sm` are valid.
+  const names = ['Color/gold', 'Radius/sm', 'Spacing/sm', 'Shadow/lg'];
+  assert.deepEqual(checkVariableDomains(names), []);
+});
+
+test('checkVariableDomains: collection synonym counts too (Colors/, Shadows/, Screens/)', () => {
+  // If the collection is named with a synonym (plural, alternate), accept it.
+  // Otherwise the rule would tell the designer to add ANOTHER "color" segment
+  // inside a `Colors` collection — busywork.
+  const names = ['Colors/gold', 'Shadows/sm', 'Screens/md'];
+  assert.deepEqual(checkVariableDomains(names), []);
+});
+
+test('checkVariableDomains: case- and whitespace-normalized collection match (Type + Effects is NOT a domain)', () => {
+  // "Type + Effects" → "type-effects" — doesn't match any domain. So the
+  // first segment after the collection still needs to be checked.
+  const names = ['Type + Effects/Font-Size/Body'];
+  const out = checkVariableDomains(names);
+  assert.equal(out.length, 1);
+  // "Font-Size" is a known synonym for "text"
+  assert.equal(out[0].classification.kind, 'synonym');
+  assert.equal(out[0].classification.suggestion, 'text');
+});
+
+test('normalizeCollectionName collapses separators and lowercases', () => {
+  const { normalizeCollectionName } = require('../variable-namer');
+  assert.equal(normalizeCollectionName('Color'), 'color');
+  assert.equal(normalizeCollectionName('Type + Effects'), 'type-effects');
+  assert.equal(normalizeCollectionName('Font Weight'), 'font-weight');
+  assert.equal(normalizeCollectionName('  Spacing  '), 'spacing');
+});
+
+// ---------------------------------------------------------------------------
+// suggestTargetName — actionable per-variable rename targets
+
+const { suggestTargetName } = require('../variable-namer');
+
+test('suggestTargetName: tier collection (Primitives/Semantic) preserves the tier', () => {
+  // The standard two-tier organization. Internal domain segments and leaves
+  // get kebab-cased; the tier itself stays.
+  assert.deepEqual(suggestTargetName('Primitives/color/BrandPrimary'), {
+    name: 'Primitives/color/BrandPrimary', kind: 'rename', target: 'Primitives/color/brand-primary',
+  });
+  assert.deepEqual(suggestTargetName('Primitives/color/brand-500'), {
+    name: 'Primitives/color/brand-500', kind: 'ok',
+  });
+});
+
+test('suggestTargetName: tier collection with unrecognized inner domain → no-mapping', () => {
+  // Tier is fine, but "widget" inside isn't a Tailwind domain — can't auto-rename safely.
+  const r = suggestTargetName('Primitives/widget/foo');
+  assert.equal(r.kind, 'no-mapping');
+  assert.match(r.reason, /Inside the "Primitives" tier, the segment "widget" doesn't match any Tailwind v4 domain/);
+});
+
+test('suggestTargetName: domain-named collection (Color/gold) preserves collection', () => {
+  // Some teams organize by domain at the collection level. No need to inject
+  // a redundant "color" segment.
+  assert.deepEqual(suggestTargetName('Color/gold'), { name: 'Color/gold', kind: 'ok' });
+  assert.deepEqual(suggestTargetName('Radius/sm'), { name: 'Radius/sm', kind: 'ok' });
+  // Case-fix the leaf in this mode too.
+  assert.deepEqual(suggestTargetName('Color/BrandGold'), {
+    name: 'Color/BrandGold', kind: 'rename', target: 'Color/brand-gold',
+  });
+});
+
+test('suggestTargetName: synonym-collection rewrites to canonical Tailwind name', () => {
+  // A collection named "Colors" or "Shadows" gets renormalized to the
+  // canonical domain (Color, Shadow).
+  assert.deepEqual(suggestTargetName('Colors/gold'), {
+    name: 'Colors/gold', kind: 'rename', target: 'Color/gold',
+  });
+  assert.deepEqual(suggestTargetName('Shadows/sm'), {
+    name: 'Shadows/sm', kind: 'rename', target: 'Shadow/sm',
+  });
+});
+
+test('suggestTargetName: bundled collection with domain hint in rest → MOVE to domain collection', () => {
+  // The user's "Type + Effects" case. The engine detects that one of the
+  // inner segments hints at a domain ("Font-Size" → text, "Line-Height" →
+  // leading) and suggests moving the variable to a dedicated collection.
+  // The redundant domain-naming segment is dropped from the path.
+  assert.deepEqual(suggestTargetName('Type + Effects/Font-Size/Body'), {
+    name: 'Type + Effects/Font-Size/Body', kind: 'rename', target: 'Text/body',
+  });
+  assert.deepEqual(suggestTargetName('Type + Effects/Font-Size/Body LG'), {
+    name: 'Type + Effects/Font-Size/Body LG', kind: 'rename', target: 'Text/body-lg',
+  });
+  assert.deepEqual(suggestTargetName('Type + Effects/Line-Height/Line Height 28'), {
+    name: 'Type + Effects/Line-Height/Line Height 28', kind: 'rename', target: 'Leading/line-height-28',
+  });
+});
+
+test('suggestTargetName: opacity-shaped names get a specific concept-aware hint', () => {
+  // Tailwind v4 has no "opacity" domain — opacity is applied via class
+  // modifiers (`bg-white/50`). The no-mapping message reflects that
+  // rather than just listing canonical domains.
+  const r = suggestTargetName('Type + Effects/Effects/Opacity 100%');
+  assert.equal(r.kind, 'no-mapping');
+  assert.match(r.reason, /Tailwind v4 has no "opacity" domain/);
+  assert.match(r.reason, /class modifiers/);
+  // Doesn't repeat the generic "Expected one of: ..." list.
+  assert.doesNotMatch(r.reason, /Expected one of: color/);
+});
+
+test('suggestTargetName: leaf hint conflicts with path → ambiguous result', () => {
+  // The user's real case: "Type + Effects/Line-Height/Letter Space 0".
+  // Path says line-height (→ leading), leaf says "Letter Space" (→ tracking).
+  // The variable could be either; surface both options for the designer.
+  const r = suggestTargetName('Type + Effects/Line-Height/Letter Space 0');
+  assert.equal(r.kind, 'ambiguous');
+  assert.equal(r.target, 'Leading/letter-space-0');
+  assert.equal(r.alternate, 'Tracking/0');
+  assert.match(r.primaryReason, /path suggests leading/);
+  assert.match(r.alternateReason, /Letter Space.*suggests tracking/);
+});
+
+test('suggestTargetName: when no domain hint exists AND no opacity → generic no-mapping', () => {
+  // Fallback case for truly unmappable variables. Surfaces the canonical
+  // domain list as a menu.
+  const r = suggestTargetName('Foo/widget/thing');
+  assert.equal(r.kind, 'no-mapping');
+  assert.match(r.reason, /No Tailwind v4 domain found in path/);
+  assert.match(r.reason, /Expected one of: color, spacing, text/);
+});
+
+test('suggestTargetName: top-level vars without collection are ok by default', () => {
+  // Can't classify without a path; leave alone.
+  assert.deepEqual(suggestTargetName('spacing'), { name: 'spacing', kind: 'ok' });
+});
+
+test('caseMatchesSegment: kebab accepts lowercase+digits+hyphens, rejects uppercase', () => {
+  assert.equal(caseMatchesSegment('brand-primary', 'kebab-case'), true);
+  assert.equal(caseMatchesSegment('blue500', 'kebab-case'), true);
+  assert.equal(caseMatchesSegment('Brand', 'kebab-case'), false);
+  assert.equal(caseMatchesSegment('brand_primary', 'kebab-case'), false);
+});
+
+test('caseMatchesSegment: PascalCase requires leading uppercase', () => {
+  assert.equal(caseMatchesSegment('BrandPrimary', 'PascalCase'), true);
+  assert.equal(caseMatchesSegment('brand', 'PascalCase'), false);
+  assert.equal(caseMatchesSegment('Brand-Primary', 'PascalCase'), false);
+});
+
+test('caseMatchesSegment: camelCase requires leading lowercase, no separators', () => {
+  assert.equal(caseMatchesSegment('brandPrimary', 'camelCase'), true);
+  assert.equal(caseMatchesSegment('Brand', 'camelCase'), false);
+  assert.equal(caseMatchesSegment('brand_primary', 'camelCase'), false);
+});
+
+test('toCase handles HTMLParser-style acronyms by splitting before the lowercase run', () => {
+  // "HTMLParser" → words ["html","parser"] → kebab "html-parser", Pascal "HtmlParser"
+  assert.equal(toCase('HTMLParser', 'kebab-case'), 'html-parser');
+  assert.equal(toCase('HTMLParser', 'PascalCase'), 'HtmlParser');
+});
+
+test('suggestName preserves the / path separator', () => {
+  assert.equal(suggestName('color/text/PrimaryBold', 'kebab-case'), 'color/text/primary-bold');
+});
diff --git a/plugins/adhd/lib/lint-engine/binding-checker.js b/plugins/adhd/lib/lint-engine/binding-checker.js
new file mode 100644
index 0000000..9b8e99e
--- /dev/null
+++ b/plugins/adhd/lib/lint-engine/binding-checker.js
@@ -0,0 +1,259 @@
+'use strict';
+
+// Per-layer binding rules.
+//
+// STRUCT011 (variable naming) and STRUCT012 (cross-domain bindings) both
+// need to know which Figma variable each layer binds. The structure-checker
+// can't do it on its own because the node-level boundVariables reference
+// variables by Figma ID — the lint engine needs an ID→name lookup
+// (`varIdMap`) to bridge to the same names the variable-namer reasons about.
+//
+// STRUCT011 emission was historically aggregated onto the scope root (or
+// suppressed in whole-file mode). That made annotations cluster on a
+// single layer even when 10 frames were using the bad variable —
+// designers had to read the message, then hunt for the offending layers
+// themselves. Per-layer emission flips this: each layer that uses a bad
+// variable gets its own annotation, contextualized to where it's used.
+//
+// STRUCT012 covers the cross-domain case the older rules missed — a
+// designer binding e.g. `Spacing/4` to `letterSpacing`. The variable name
+// is fine on its own (so STRUCT011 stays quiet), but the binding is
+// semantically wrong: in Tailwind's domain model, spacing and tracking are
+// distinct token sets and shouldn't share variables. We infer the
+// variable's intended domain from its name (same logic as the namer's
+// suggestion) and compare to the expected domain for the property.
+
+const { suggestTargetName, normalizeCollectionName, classifyDomain, TIER_COLLECTIONS } = require('./variable-namer');
+
+// Figma `boundVariables` property name → Tailwind v4 domain.
+//
+// Properties that don't classify cleanly are intentionally absent — we
+// can't fire STRUCT012 without an expected domain. Width / height could
+// arguably map to `spacing` (Tailwind reuses the spacing scale for
+// sizing) but in practice designers often use other scales there; we'd
+// rather under-report than false-positive. Add entries when real cases
+// surface.
+const PROPERTY_TO_DOMAIN = {
+  fontSize: 'text',
+  fontWeight: 'font-weight',
+  letterSpacing: 'tracking',
+  lineHeight: 'leading',
+  fontFamily: 'font',
+  fontStyle: 'font',
+  paddingTop: 'spacing',
+  paddingRight: 'spacing',
+  paddingBottom: 'spacing',
+  paddingLeft: 'spacing',
+  itemSpacing: 'spacing',
+  cornerRadius: 'radius',
+  topLeftRadius: 'radius',
+  topRightRadius: 'radius',
+  bottomLeftRadius: 'radius',
+  bottomRightRadius: 'radius',
+  // Synthesized for fills/strokes — Figma stores these bindings on each
+  // paint object's own boundVariables rather than at the node level, so
+  // we walk fills/strokes separately and pass these as the property name.
+  'fills[].color': 'color',
+  'strokes[].color': 'color',
+};
+
+// Variable-name → inferred Tailwind domain. Returns the domain string
+// (e.g. 'color', 'spacing') or null if uncertain. Delegates to
+// `suggestTargetName` so the resolution order stays in one place:
+//   1. Collection IS a domain (or its synonym) → that's the domain.
+//   2. Collection is a TIER (Primitives, Semantic, …) → first rest segment.
+//   3. Unknown collection → walk rest looking for a domain segment.
+//   4. Ambiguous or no-mapping → null (don't false-positive STRUCT012).
+function inferDomain(name) {
+  if (!name || typeof name !== 'string') return null;
+  const result = suggestTargetName(name);
+  if (result.kind !== 'ok' && result.kind !== 'rename') return null;
+  const target = result.kind === 'ok' ? name : result.target;
+  const [collection, ...rest] = target.split('/');
+  const collNorm = normalizeCollectionName(collection);
+  const c = classifyDomain(collNorm);
+  if (c.kind === 'known') return collNorm;
+  if (c.kind === 'synonym') return c.suggestion;
+  if (TIER_COLLECTIONS.has(collNorm) && rest.length > 0) {
+    const d = classifyDomain(rest[0]);
+    if (d.kind === 'known') return rest[0].toLowerCase();
+    if (d.kind === 'synonym') return d.suggestion;
+  }
+  return null;
+}
+
+function deepLink(fileKey, nodeId) {
+  return 'https://figma.com/design/' + fileKey + '?node-id=' + nodeId.replace(':', '-');
+}
+
+// Pretty-print a value for a STRUCT016 conflict message. Figma's raw
+// `{r,g,b,a}` and resolved hex strings both need a compact human
+// form so designers can eyeball the drift at a glance.
+function formatValue(v) {
+  if (v == null) return '(none)';
+  if (typeof v === 'string') return v;
+  if (typeof v === 'number') return String(v);
+  if (v && typeof v === 'object' && 'r' in v && 'g' in v && 'b' in v) {
+    const to2 = (n) => Math.round(Math.max(0, Math.min(1, Number(n))) * 255).toString(16).padStart(2, '0');
+    let hex = '#' + to2(v.r) + to2(v.g) + to2(v.b);
+    if (v.a !== undefined && Number(v.a) < 1) hex += to2(v.a);
+    return hex;
+  }
+  try { return JSON.stringify(v); } catch { return String(v); }
+}
+
+function formatPerLayerSuggestion(varName, suggestion, prop) {
+  if (suggestion.kind === 'rename') {
+    const targetCollection = suggestion.target.split('/')[0];
+    return `Layer uses "${varName}" (bound to ${prop}). ` +
+      `Move to "${targetCollection}" collection → final name "${suggestion.target}". ` +
+      `In Figma: right-click the variable → "Move to..." → pick "${targetCollection}". ` +
+      `Figma auto-rewires references.`;
+  }
+  if (suggestion.kind === 'ambiguous') {
+    return `Layer uses "${varName}" (bound to ${prop}). Ambiguous target — ` +
+      `${suggestion.primaryReason}, but ${suggestion.alternateReason}. ` +
+      `Pick based on actual usage: primary → ${suggestion.target}, alternate → ${suggestion.alternate}.`;
+  }
+  if (suggestion.kind === 'no-mapping') {
+    return `Layer uses "${varName}" (bound to ${prop}). ${suggestion.reason}`;
+  }
+  return `Layer uses "${varName}" (bound to ${prop}).`;
+}
+
+function walk(node, parentPath, visitor) {
+  const nodePath = parentPath ? parentPath + ' > ' + node.name : node.name;
+  visitor(node, nodePath);
+  if (Array.isArray(node.children)) {
+    for (const c of node.children) walk(c, nodePath, visitor);
+  }
+}
+
+// Walks `rootNode` and emits per-layer STRUCT011 / STRUCT012 violations.
+//
+// `opts.varIdMap`: { '': '/' }. Without it
+// (no per-binding name lookup) neither rule can fire — caller falls back
+// to the legacy aggregated STRUCT011 emission instead.
+//
+// `opts.badSuggestionsByName`: { '': suggestion } from
+// `buildVariableSuggestions`. STRUCT011 fires per-layer for each binding
+// whose variable name appears here.
+//
+// Violations are deduped per (rule, varName) within a node — a layer that
+// binds the same bad variable to both `fills.color` and `strokes.color`
+// gets ONE annotation, not two. Different variables on the same layer
+// still produce separate violations.
+function checkBindings(rootNode, opts) {
+  const out = [];
+  const varIdMap = opts.varIdMap || {};
+  const badSuggestions = opts.badSuggestionsByName || {};
+  // STRUCT015 / STRUCT016 — per-layer errors for layers binding to
+  // Figma variables that either don't exist in code's design system
+  // (missing) or have a different value in code than in Figma
+  // (conflict). Both block pulls: missing means the generated code
+  // would reference a CSS variable that doesn't exist; conflict means
+  // the rendered output drifts from what the designer saw in Figma.
+  // Callers build these from the variable-categorizer's output.
+  const missingVarNames = opts.missingVarNames instanceof Set ? opts.missingVarNames : new Set(opts.missingVarNames || []);
+  const conflicts = opts.conflictsByName || {};
+  // Per-missing-variable metadata — { canonicalCandidate, looksSemantic,
+  // figmaValue } keyed by the categorizer's collection-stripped token AND
+  // (after cli.js's pre-expansion) the full `/` form.
+  // Used to attach auto-fix info to STRUCT015 violations so the SKILL
+  // prompts can surface the right options per variable.
+  const missingMeta = opts.missingVarMeta || {};
+  const fileKey = opts.fileKey;
+
+  walk(rootNode, '', (node, nodePath) => {
+    const seen = new Set();
+    const push = (rule, severity, varName, message, meta) => {
+      const key = rule + '::' + varName;
+      if (seen.has(key)) return;
+      seen.add(key);
+      const entry = {
+        rule, severity,
+        nodeId: node.id, nodePath, message,
+        deepLink: deepLink(fileKey, node.id),
+      };
+      // Optional auto-fix metadata — surfaces in the JSON sidecar so
+      // the SKILL can build per-variable resolution prompts that
+      // include the canonical-rebind option when one exists.
+      if (meta && meta.canonicalCandidate) entry.canonicalCandidate = meta.canonicalCandidate;
+      if (meta && meta.looksSemantic) entry.looksSemantic = true;
+      if (meta && meta.figmaValue !== undefined) entry.figmaValueRaw = meta.figmaValue;
+      out.push(entry);
+    };
+
+    const handleBinding = (prop, alias) => {
+      if (!alias || !alias.id) return;
+      const varName = varIdMap[alias.id];
+      if (!varName) return;
+
+      const suggestion = badSuggestions[varName];
+      if (suggestion) {
+        push('STRUCT011', 'warning', varName,
+          formatPerLayerSuggestion(varName, suggestion, prop));
+      }
+
+      const expectedDomain = PROPERTY_TO_DOMAIN[prop];
+      if (expectedDomain) {
+        const varDomain = inferDomain(varName);
+        if (varDomain && varDomain !== expectedDomain) {
+          push('STRUCT012', 'error', varName,
+            `Layer binds "${varName}" (a ${varDomain} variable) to ${prop}, ` +
+            `which expects a ${expectedDomain} variable. ` +
+            `Bind a ${expectedDomain}-domain variable instead — or, if you want both ` +
+            `domains to share a value, create a ${expectedDomain} variable that ` +
+            `aliases the same primitive.`);
+        }
+      }
+
+      if (missingVarNames.has(varName)) {
+        // Look up the per-variable meta the cli built — find the entry
+        // whose key matches by collection-stripped form OR by full name.
+        const stripped = varName.includes('/') ? varName.split('/').slice(1).join('/') : varName;
+        const meta = missingMeta[varName] || missingMeta[stripped] || null;
+        push('STRUCT015', 'error', varName,
+          `Layer binds "${varName}" (to ${prop}), but that variable doesn't exist in code's design system. ` +
+          `Pulling this component would generate code referencing a CSS variable globals.css never declares — rendering breaks at runtime.\n\n` +
+          `Fix in Figma: rebind the layer to a variable that IS in code, OR run /adhd:pull-tokens first to add this variable to globals.css. The pull-tokens flow shows the missing variable in its summary so you can choose whether to take it on.`,
+          meta);
+      }
+
+      const conflict = conflicts[varName];
+      if (conflict) {
+        push('STRUCT016', 'error', varName,
+          `Layer binds "${varName}" (to ${prop}), and that variable's value differs between code and Figma.\n` +
+          `  code:  ${formatValue(conflict.local)}\n` +
+          `  figma: ${formatValue(conflict.figma)}\n\n` +
+          `Pulling this component now would render with code's value — visual drift from what the designer sees in Figma. Reconcile first: run /adhd:pull-tokens to take Figma's value, or /adhd:push-tokens to send code's value back to Figma. Then re-run the pull.`);
+      }
+    };
+
+    if (node.boundVariables && typeof node.boundVariables === 'object') {
+      for (const [prop, alias] of Object.entries(node.boundVariables)) {
+        // fills / strokes / effects arrive as arrays of per-paint bindings
+        // (handled below) — the top-level entry is empty or a stale alias
+        // shape Figma leaves around.
+        if (prop === 'fills' || prop === 'strokes' || prop === 'effects') continue;
+        handleBinding(prop, alias);
+      }
+    }
+    if (Array.isArray(node.fills)) {
+      for (const fill of node.fills) {
+        const alias = fill && fill.boundVariables && fill.boundVariables.color;
+        if (alias) handleBinding('fills[].color', alias);
+      }
+    }
+    if (Array.isArray(node.strokes)) {
+      for (const stroke of node.strokes) {
+        const alias = stroke && stroke.boundVariables && stroke.boundVariables.color;
+        if (alias) handleBinding('strokes[].color', alias);
+      }
+    }
+  });
+
+  return out;
+}
+
+module.exports = { checkBindings, inferDomain, PROPERTY_TO_DOMAIN };
diff --git a/plugins/adhd/lib/lint-engine/canonical-matcher.js b/plugins/adhd/lib/lint-engine/canonical-matcher.js
new file mode 100644
index 0000000..87f46c7
--- /dev/null
+++ b/plugins/adhd/lib/lint-engine/canonical-matcher.js
@@ -0,0 +1,181 @@
+'use strict';
+
+// Given a Figma variable value + its inferred domain, find the canonical
+// Tailwind CSS variable (if any) whose value matches strictly. Powers
+// the "Auto-fix" option in pull-component / push-component's STRUCT015
+// resolution flow.
+//
+// Strict equality only: same normalized hex for colors, same px-after-rem
+// conversion for dimensions, same string after trim/lowercase for
+// everything else. If multiple canonicals match the same value (e.g.
+// --leading-3 and --text-xs--line-height both = 12px), return only the
+// first one inside the same sub-domain — the caller falls back to
+// "Add as-is" when there's ambiguity worth surfacing manually.
+//
+// Names that LOOK semantic (brand/surface/accent/etc.) are surfaced to
+// the SKILL via `looksSemantic` so the prompt's "Add as semantic" label
+// stays prominent — auto-fix is only safe for variables that
+// accidentally took a non-canonical name when a canonical existed.
+
+const { normalizeColor, normalizeDimension } = require('./value-normalizer');
+
+// Heuristic: names whose first segment after the collection prefix looks
+// like a semantic role (brand, accent, surface, etc.) rather than a
+// Tailwind scale step (`zinc-500`, `text-sm`, `radius-md`). Used to
+// label the "Add as semantic" option more prominently in the prompt;
+// it does NOT block the auto-fix option from appearing — designers can
+// still pick auto-fix if they explicitly want it.
+const SEMANTIC_LEADING = /^(brand|accent|surface|background|foreground|primary|secondary|tertiary|success|warning|error|danger|info|muted|destructive|popover|card|sidebar|chart|on-|inverse|highlight|focus|disabled|hover|active|selected|placeholder|outline|ring|shadow-color)\b/i;
+
+function looksSemantic(figmaPath) {
+  if (!figmaPath || typeof figmaPath !== 'string') return false;
+  const segments = figmaPath.split('/').filter(Boolean);
+  // Look at every segment after the collection — any segment that
+  // matches the semantic leading pattern marks the whole name semantic.
+  for (let i = 1; i < segments.length; i++) {
+    if (SEMANTIC_LEADING.test(segments[i])) return true;
+  }
+  // First segment too, when there's no collection prefix (single-segment names).
+  if (segments.length === 1 && SEMANTIC_LEADING.test(segments[0])) return true;
+  return false;
+}
+
+// Normalize a value for cross-form equality. Uses the same canonical
+// forms the categorizer uses, so this matcher and that comparator
+// agree on what counts as "equal."
+function normalizeForMatch(value, domain) {
+  if (value == null) return null;
+  try {
+    if (domain === 'color') return normalizeColor(value);
+    if (domain === 'spacing' || domain === 'radius') return normalizeDimension(value);
+    // typography covers font-size, leading, tracking, font-weight, font-family.
+    // dimensions for the first three; raw string for the last two.
+    if (domain === 'typography') {
+      try { return normalizeDimension(value); } catch {
+        return String(value).trim().toLowerCase();
+      }
+    }
+  } catch { return null; }
+  return String(value).trim().toLowerCase();
+}
+
+// Given a Figma path like `typography/Font-Size/Body` and the parsed
+// theme.primitives map (Tailwind defaults + user @theme), return the
+// canonical Tailwind cssVar that shares the figma value. Returns null
+// when:
+//   - no match
+//   - multiple matches that span different "sub-domains" (e.g. a value
+//     that's both a valid text-size and a valid leading; ambiguous, ask
+//     the designer manually)
+//   - figma value can't be normalized
+function findCanonicalForValue(figmaPath, figmaValue, primitives, opts = {}) {
+  if (!primitives || typeof primitives !== 'object') return null;
+  const domain = opts.domain || inferDomainFromPath(figmaPath);
+  const fNorm = normalizeForMatch(figmaValue, domain);
+  if (fNorm == null) return null;
+
+  // Filter candidates by sub-domain when typography is split into
+  // multiple Tailwind families (--text-*, --leading-*, --tracking-*,
+  // --font-*, --font-weight-*). The figma path's leaf tells us which
+  // family the designer's variable was meant to be.
+  const family = typographyFamily(figmaPath);
+
+  const matches = [];
+  for (const [cssVar, value] of Object.entries(primitives)) {
+    const candidateDomain = domainForCssVar(cssVar);
+    if (candidateDomain !== domain) continue;
+    if (family && typographyFamilyForCssVar(cssVar) !== family) continue;
+    const cNorm = normalizeForMatch(value, candidateDomain);
+    if (cNorm == null) continue;
+    if (cNorm === fNorm) matches.push(cssVar);
+  }
+  if (matches.length === 0) return null;
+  // Single unambiguous match — return it.
+  if (matches.length === 1) return matches[0];
+  // Multiple matches — only surface auto-fix if they all reduce to the
+  // same canonical "shortest" name (e.g. `--spacing-4` over a synonym).
+  // Otherwise stay quiet and let the designer pick "Add as-is."
+  matches.sort((a, b) => a.length - b.length || a.localeCompare(b));
+  // For the common ambiguity (`--text-Xs--line-height` vs `--leading-N`),
+  // pick the family-matched one. If we already filtered by family above,
+  // any remaining ambiguity is between siblings of the same family —
+  // surface the first sorted name as the suggestion.
+  return matches[0];
+}
+
+// Mirror of variable-categorizer's inferDomain but operates on a full
+// path string. Lowercased + delimiter-anchored to handle capitalized
+// Figma collections.
+function inferDomainFromPath(figmaPath) {
+  if (!figmaPath) return 'unknown';
+  const lc = String(figmaPath).toLowerCase();
+  if (lc.startsWith('color/')   || lc.includes('/color/'))   return 'color';
+  if (lc.startsWith('spacing/') || lc.includes('/spacing/')) return 'spacing';
+  if (lc.startsWith('space/')   || lc.includes('/space/'))   return 'spacing';
+  if (lc.startsWith('radius/')  || lc.includes('/radius/'))  return 'radius';
+  if (lc.startsWith('shadow/')  || lc.includes('/shadow/'))  return 'shadow';
+  if (lc.startsWith('font/')    || lc.includes('/font/') ||
+      lc.startsWith('typography/') || lc.includes('/typography/') ||
+      lc.includes('text-') || lc.includes('line-height')) return 'typography';
+  return 'unknown';
+}
+
+function domainForCssVar(cssVar) {
+  const lc = cssVar.toLowerCase();
+  if (lc.startsWith('--color-')) return 'color';
+  if (lc === '--spacing' || lc.startsWith('--spacing-')) return 'spacing';
+  if (lc.startsWith('--radius-')) return 'radius';
+  if (lc.startsWith('--shadow-') || lc.startsWith('--drop-shadow-') ||
+      lc.startsWith('--inset-shadow-') || lc.startsWith('--text-shadow-')) return 'shadow';
+  if (lc.startsWith('--text-') || lc.startsWith('--font-') ||
+      lc.startsWith('--leading-') || lc.startsWith('--tracking-')) return 'typography';
+  return 'unknown';
+}
+
+// Determine which typography family a figma path or cssVar belongs to.
+// Returns one of 'text', 'leading', 'tracking', 'font-weight', 'font',
+// or null when ambiguous. Used to disambiguate matches when the same
+// numeric value is valid in multiple families.
+const FONT_FAMILY_HINTS = [
+  // Multi-word phrases first so they win over single-word fallbacks.
+  { re: /\bline[\s\-_]?height\b/i,   family: 'leading' },
+  { re: /\bletter[\s\-_]?spac/i,     family: 'tracking' },
+  { re: /\bfont[\s\-_]?size\b/i,     family: 'text' },
+  { re: /\bfont[\s\-_]?weight\b/i,   family: 'font-weight' },
+  { re: /\bfont[\s\-_]?family\b/i,   family: 'font' },
+  { re: /\btext[\s\-_]?size\b/i,     family: 'text' },
+  // Canonical Tailwind path prefixes (after collection-strip) — these
+  // are how a well-named figma variable maps to a typography family.
+  { re: /(^|\/)leading\//i,          family: 'leading' },
+  { re: /(^|\/)tracking\//i,         family: 'tracking' },
+  { re: /(^|\/)font-weight\//i,      family: 'font-weight' },
+  { re: /(^|\/)font\//i,             family: 'font' },
+  { re: /(^|\/)text\//i,             family: 'text' },
+];
+
+function typographyFamily(figmaPath) {
+  if (!figmaPath) return null;
+  for (const { re, family } of FONT_FAMILY_HINTS) {
+    if (re.test(figmaPath)) return family;
+  }
+  return null;
+}
+
+function typographyFamilyForCssVar(cssVar) {
+  const lc = cssVar.toLowerCase();
+  if (lc.startsWith('--leading-')) return 'leading';
+  if (lc.startsWith('--tracking-')) return 'tracking';
+  if (lc.startsWith('--font-weight-')) return 'font-weight';
+  if (lc.startsWith('--font-')) return 'font';
+  if (lc.startsWith('--text-')) return 'text';
+  return null;
+}
+
+module.exports = {
+  findCanonicalForValue,
+  looksSemantic,
+  inferDomainFromPath,
+  domainForCssVar,
+  typographyFamily,
+  normalizeForMatch,
+};
diff --git a/plugins/adhd/lib/lint-engine/cli.js b/plugins/adhd/lib/lint-engine/cli.js
index 0d83469..93bd2d9 100644
--- a/plugins/adhd/lib/lint-engine/cli.js
+++ b/plugins/adhd/lib/lint-engine/cli.js
@@ -18,9 +18,54 @@
  */
 
 const fs = require('node:fs');
+const path = require('node:path');
 const { parseTheme } = require('./theme-parser');
+const { synthesizeTailwindUtilityScale } = require('../design-system/code-parser');
+
+// Tailwind v4 ships a full default @theme: --color-white, --color-black,
+// --color-red-500, --spacing, the --text-* / --leading-* scales, etc.
+// `lib/design-system/tailwind-defaults.css` carries the canonical copy
+// (already used by push/pull-tokens via parseCodeDesignSystem).
+// We merge those defaults into the user's parsed primitives BEFORE the
+// variable comparator runs — otherwise a Figma `Color/white` variable
+// would surface as "missing in code" even though Tailwind covers it,
+// and downstream surfaces (lint reports, pull-component Phase 2.7
+// discovery prompts) would suggest writing `--color-white: #fff` to
+// globals.css — pure clutter, no value.
+const TAILWIND_DEFAULTS_PATH = path.resolve(__dirname, '..', 'design-system', 'tailwind-defaults.css');
+
+function loadTailwindDefaultPrimitives() {
+  let css;
+  try { css = fs.readFileSync(TAILWIND_DEFAULTS_PATH, 'utf8'); }
+  catch { return {}; }
+  // The defaults file uses `@theme default {` and `@theme default inline {`
+  // (Tailwind's syntax for the canonical reference theme). parseTheme only
+  // matches plain `@theme {` / `@theme inline {`, so rewrite the headers
+  // before parsing.
+  const normalized = css
+    .replace(/@theme\s+default\s+inline\s*\{/g, '@theme inline {')
+    .replace(/@theme\s+default\s*\{/g, '@theme {');
+  const parsed = parseTheme(normalized).primitives;
+  // Tailwind v4 doesn't ship explicit `--spacing-N` / `--radius-{none,full}`
+  // / `--opacity-N` variables in the @theme block — most utility classes
+  // derive from `--spacing` at build time (`p-4` → `calc(var(--spacing) * 4)`).
+  // The design-system code-parser synthesizes these for push/pull-tokens; the
+  // lint engine needs the same view or it falsely flags `spacing/0`,
+  // `radius/full`, `opacity-50`, etc. as missing from code.
+  for (const t of synthesizeTailwindUtilityScale()) {
+    if (!(t.cssVar in parsed)) {
+      parsed[t.cssVar] = t.values.default.value;
+    }
+  }
+  return parsed;
+}
 const { categorizeVariables } = require('./variable-categorizer');
 const { checkStructure } = require('./structure-checker');
+const { buildVariableSuggestions } = require('./variable-namer');
+const { checkBindings } = require('./binding-checker');
+const { detectTailwindDuplicates } = require('./tailwind-duplicate-detector');
+const { detectDuplicateCollections } = require('./collection-duplicate-detector');
+const { findCanonicalForValue, looksSemantic } = require('./canonical-matcher');
 const { formatReport } = require('./report-formatter');
 
 function parseArgs(argv) {
@@ -78,7 +123,22 @@ function main() {
   const namingConvention = readNamingConvention(args['config']);
   const fileKey = extractFileKey(args['target-url']);
 
-  const theme = parseTheme(cssText);
+  // Optional: serializer-produced { '': '/' }
+  // map. Required for per-layer STRUCT011 annotations and STRUCT012
+  // cross-domain detection; absent in older SKILL versions, in which case
+  // we fall back to the legacy aggregated STRUCT011 emission.
+  let varIdMap = {};
+  if (args['var-id-map']) {
+    try { varIdMap = readJson(args['var-id-map']); } catch { varIdMap = {}; }
+  }
+
+  const userTheme = parseTheme(cssText);
+  const tailwindDefaults = loadTailwindDefaultPrimitives();
+  // User's @theme wins on key collision (override always beats default).
+  const theme = {
+    ...userTheme,
+    primitives: { ...tailwindDefaults, ...userTheme.primitives },
+  };
   const variableViolations = categorizeVariables(varDefs, theme);
 
   let structureViolations = [];
@@ -102,6 +162,212 @@ function main() {
     structureViolations = checkStructure(designCtx, { fileKey, namingConvention });
   }
 
+  // STRUCT011 (variable naming) + STRUCT012 (cross-domain bindings).
+  //
+  // Both rules need to know which LAYER uses each variable so the annotation
+  // lands on the offender instead of the scope root — designers asked for
+  // this so they can walk a list of annotations and fix them one by one
+  // without hunting through the layer tree. The binding-checker walks the
+  // node tree and emits one violation per (layer, variable) pair.
+  //
+  // STRUCT012 catches the cross-domain case STRUCT011 misses: a `Spacing/4`
+  // variable (well-named for its domain) bound to letter-spacing. The
+  // variable's name is fine, but the binding crosses Tailwind's token-domain
+  // boundary. We infer each variable's intended domain from its name and
+  // compare to the property's expected domain.
+  //
+  // Variable names are ALWAYS checked against kebab-case for the leaves,
+  // regardless of the project's `naming` config (which is for component
+  // identifiers, not CSS custom properties).
+  //
+  // Fallback: when the SKILL didn't emit a varIdMap (older versions), we
+  // can't bridge per-node bindings to variable names, so STRUCT011 falls
+  // back to the legacy aggregated emission and STRUCT012 stays silent.
+  const varKeys = Object.keys(varDefs || {});
+  const suggestions = buildVariableSuggestions(varKeys);
+  const badSuggestionsByName = {};
+  for (const s of suggestions) badSuggestionsByName[s.name] = s;
+  const hasIdMap = Object.keys(varIdMap).length > 0;
+
+  if (hasIdMap) {
+    // Bridge the variable-categorizer's file-level missing/conflict
+    // findings into per-layer STRUCT015/STRUCT016 errors. The
+    // categorizer says "this variable is missing in code" at the file
+    // level; the binding-checker says "this specific layer binds that
+    // variable" — together, designers get an annotation right where
+    // they need to act.
+    //
+    // Figma collection names appear once in the categorizer's
+    // `token` field (collection-stripped) but the SKILL's vars.json
+    // keys include the collection. Build the set both ways so the
+    // binding-checker matches whichever form varIdMap emits.
+    const missingVarNames = new Set();
+    const conflictsByName = {};
+    // Per-missing-variable metadata that powers the per-variable
+    // resolution prompts in pull-component / push-component. The
+    // canonical candidate (when present) drives the "Auto-fix: rebind
+    // to " option; the looksSemantic flag drives the
+    // emphasis on the "Add as semantic variable" option.
+    const missingVarMeta = {};
+    for (const v of variableViolations) {
+      if (v.status === 'missing') {
+        missingVarNames.add(v.token); // collection-stripped form
+        // `token` is the collection-stripped form (e.g. `Font-Size/Body`),
+        // which retains enough structure for the typography-family
+        // disambiguator to do its job. Pass v.domain explicitly so the
+        // matcher doesn't need to re-infer from a path missing its
+        // collection prefix.
+        missingVarMeta[v.token] = {
+          canonicalCandidate: findCanonicalForValue(v.token, v.figma, theme.primitives, { domain: v.domain }),
+          looksSemantic: looksSemantic(v.token),
+          figmaValue: v.figma,
+        };
+      } else if (v.status === 'conflict') {
+        conflictsByName[v.token] = { local: v.local, figma: v.figma, mode: v.mode };
+      }
+    }
+    // Re-key with collection prefixes too: walk varIdMap values, for
+    // each, also test the collection-stripped form against the set.
+    // The binding-checker is the single consumer — instead of doing
+    // two-form lookups everywhere, pre-expand the sets here.
+    for (const fullName of Object.values(varIdMap)) {
+      const stripped = fullName.split('/').slice(1).join('/');
+      if (missingVarNames.has(stripped)) missingVarNames.add(fullName);
+      if (conflictsByName[stripped]) conflictsByName[fullName] = conflictsByName[stripped];
+    }
+
+    const bindingViolations = [];
+    const opts = { fileKey, varIdMap, badSuggestionsByName, missingVarNames, conflictsByName, missingVarMeta };
+    if (designCtx && designCtx.mode === 'whole-file' && Array.isArray(designCtx.pages)) {
+      for (const page of designCtx.pages) {
+        for (const node of page.nodes) {
+          const v = checkBindings(node, opts);
+          for (const x of v) x._page = page.name;
+          bindingViolations.push(...v);
+        }
+      }
+    } else if (designCtx) {
+      bindingViolations.push(...checkBindings(designCtx, opts));
+    }
+    structureViolations.push(...bindingViolations);
+  } else if (suggestions.length > 0) {
+    // Legacy aggregated STRUCT011 emission, kept as a graceful fallback
+    // when the SKILL hasn't been upgraded to emit varIdMap. Pre-existing
+    // behavior — single violation on the scope root listing every bad name.
+    const isScoped = designCtx && designCtx.mode !== 'whole-file' && designCtx.id;
+    const scopedNodeId = isScoped ? designCtx.id : undefined;
+    const renames = suggestions.filter(s => s.kind === 'rename');
+    const ambiguous = suggestions.filter(s => s.kind === 'ambiguous');
+    const noMapping = suggestions.filter(s => s.kind === 'no-mapping');
+    const byTarget = new Map();
+    for (const s of renames) {
+      const collection = s.target.split('/')[0];
+      if (!byTarget.has(collection)) byTarget.set(collection, []);
+      byTarget.get(collection).push(s);
+    }
+    const sortedTargets = [...byTarget.keys()].sort();
+    const sections = [];
+    for (const collection of sortedTargets) {
+      const items = byTarget.get(collection);
+      const lines = items.map(s => `  • ${s.name}\n      → ${s.target}`);
+      sections.push(`→ Move to "${collection}" collection (${items.length} var${items.length === 1 ? '' : 's'}):\n${lines.join('\n')}`);
+    }
+    if (ambiguous.length > 0) {
+      const lines = ambiguous.map(s =>
+        `  • ${s.name}\n` +
+        `      ⚠ Ambiguous — ${s.primaryReason}, but ${s.alternateReason}. Pick based on actual usage:\n` +
+        `        Primary:    → ${s.target}\n` +
+        `        Alternate:  → ${s.alternate}`,
+      );
+      sections.push(`⚠ Ambiguous (${ambiguous.length}) — path and leaf disagree on the target domain:\n${lines.join('\n\n')}`);
+    }
+    if (noMapping.length > 0) {
+      const lines = noMapping.map(s => `  • ${s.name}\n      ⚠ ${s.reason}`);
+      sections.push(`⚠ No Tailwind v4 mapping (${noMapping.length}):\n${lines.join('\n\n')}`);
+    }
+    structureViolations.push({
+      rule: 'STRUCT011',
+      severity: 'warning',
+      nodeId: scopedNodeId,
+      nodePath: 'Variables',
+      message:
+        `${suggestions.length} variable-naming issue(s). Suggested restructure:\n\n` +
+        `${sections.join('\n\n')}\n\n` +
+        `How to apply each move in Figma:\n` +
+        `  1. Open the Variables panel; create any missing target collections.\n` +
+        `  2. Right-click the source variable → "Move to..." → pick the target collection. Figma auto-rewires existing references.\n` +
+        `  3. Inside the target, rename to drop redundant path segments (the variable's path within the new collection becomes its full name).\n` +
+        `\n` +
+        `Use Figma's "Move to..." (not "Rename") — Rename only works within the same collection, but most of these are moves across collections.`,
+      deepLink: scopedNodeId
+        ? 'https://figma.com/design/' + fileKey + '?node-id=' + scopedNodeId.replace(':', '-')
+        : args['target-url'],
+    });
+  }
+
+  // STRUCT013 — Figma variable duplicates a Tailwind v4 default.
+  //
+  // Surfaces when a designer has both the canonical Tailwind variable
+  // (e.g. `Color/zinc-500`) AND a same-value duplicate sitting alongside.
+  // Strict match — name AND value must align — so semantic vars like
+  // `Color/MyZinc` (same value, different intent) are NOT flagged.
+  //
+  // The fix is "consolidate": rebind every layer using the duplicate to
+  // the canonical Tailwind variable, then delete the duplicate. The
+  // /adhd:lint wizard walks each candidate through AskUserQuestion
+  // before applying — never auto-rewires.
+  const duplicates = detectTailwindDuplicates(varDefs, tailwindDefaults);
+  for (const dup of duplicates) {
+    // varIdMap is { id: name }; invert to find the duplicate's Figma ID
+    // (used by --fix to rebind layers). Absent when the SKILL hasn't
+    // emitted varIdMap — STRUCT013 still surfaces, just without an ID
+    // hint for the migration script.
+    let figmaVarId = null;
+    for (const [id, name] of Object.entries(varIdMap || {})) {
+      if (name === dup.figmaName) { figmaVarId = id; break; }
+    }
+    structureViolations.push({
+      rule: 'STRUCT013',
+      severity: 'warning',
+      nodeId: undefined, // file-level concern; doesn't annotate a layer
+      nodePath: 'Variables',
+      message:
+        `Figma variable "${dup.figmaName}" (= ${dup.value}) duplicates Tailwind default \`${dup.tailwindCssVar}\`. ` +
+        `Same value, same canonical name — consolidating removes the duplicate without changing what gets rendered.\n\n` +
+        `To apply: run \`/adhd:lint\`. The wizard walks each candidate; pick "Auto-fix in Figma" to rebind every layer that uses "${dup.figmaName}" to \`${dup.tailwindCssVar}\` and delete the duplicate.`,
+      deepLink: args['target-url'],
+      // Extra fields consumed by --fix (ignored by the report formatter):
+      figmaVarName: dup.figmaName,
+      figmaVarId,
+      tailwindCssVar: dup.tailwindCssVar,
+    });
+  }
+
+  // STRUCT014 — duplicate collections (e.g. "Color" + "color" side-by-
+  // side, "Type + Effects" + "typography"). Surface every group so
+  // designers can see what's grown up over time. /adhd:lint's wizard
+  // consolidates per group: pick the keeper, move every variable from
+  // the loser collections into it (rebinding existing layer references),
+  // then delete the empty losers.
+  const dupGroups = detectDuplicateCollections(Object.keys(varDefs || {}));
+  for (const group of dupGroups) {
+    const collNames = group.collections.map(c => `"${c.name}" (${c.varCount})`).join(', ');
+    structureViolations.push({
+      rule: 'STRUCT014',
+      severity: 'warning',
+      nodeId: undefined,
+      nodePath: 'Variables',
+      message:
+        `${group.collections.length} Figma collections alias to "${group.canonical}": ${collNames}. ` +
+        `These describe the same token domain but Figma treats them as separate collections, so designers see duplicate-looking groups in the Variables panel and push must guess which to append into.\n\n` +
+        `To apply: run \`/adhd:lint\`. The wizard asks which collection to keep (the most-populated one is suggested); every variable in the others gets moved into the keeper, layer bindings update automatically, and the empty collections are deleted.`,
+      deepLink: args['target-url'],
+      // Extra fields consumed by --fix:
+      canonical: group.canonical,
+      collections: group.collections,
+    });
+  }
+
   const meta = {
     target: args.target,
     targetUrl: args['target-url'],
diff --git a/plugins/adhd/lib/lint-engine/collection-duplicate-detector.js b/plugins/adhd/lib/lint-engine/collection-duplicate-detector.js
new file mode 100644
index 0000000..30631f0
--- /dev/null
+++ b/plugins/adhd/lib/lint-engine/collection-duplicate-detector.js
@@ -0,0 +1,66 @@
+'use strict';
+
+// STRUCT014 — Figma file has multiple collections that alias to the
+// same canonical domain. The classic case: designer's "Color" collection
+// + an older push's "color" (lowercase) collection sitting side-by-side,
+// each holding some variables. The alias-aware push fix prevents new
+// duplicates from forming, but pre-existing ones need active consolidation.
+//
+// Detection works from the variable name list alone — every variable
+// key from the SKILL's `varDefs` is `/`, so collecting
+// distinct first segments gives us every collection in the file. We
+// reuse the alias table from figma-write-script so collection-name
+// matching stays consistent with push-tokens.
+
+const { COLLECTION_ALIASES } = require('../design-system/figma-write-script');
+
+// Canonical mapping: lowercase+trimmed Figma name → canonical domain.
+// Built once per call (the alias table is small).
+function buildCanonicalLookup() {
+  const out = new Map();
+  for (const [canonical, aliases] of Object.entries(COLLECTION_ALIASES)) {
+    for (const a of aliases) out.set(a, canonical);
+  }
+  return out;
+}
+
+// Given an array of Figma variable names like ["Color/zinc-500",
+// "color/red-500", "Radius/sm", "radius/lg"], return an array of
+// duplicate groups: { canonical, collections: [{ name, varCount }] }.
+// Only groups with 2+ collections appear. Order within a group: most
+// variables first (so the --fix wizard surfaces the natural "keep this
+// one" suggestion at the top).
+function detectDuplicateCollections(varNames) {
+  if (!Array.isArray(varNames)) return [];
+  const lookup = buildCanonicalLookup();
+
+  // Bucket varNames by collection name (first segment).
+  const collections = new Map();
+  for (const name of varNames) {
+    const slash = name.indexOf('/');
+    if (slash < 0) continue;
+    const coll = name.slice(0, slash);
+    collections.set(coll, (collections.get(coll) || 0) + 1);
+  }
+
+  // Group collections by their canonical domain.
+  const byCanonical = new Map();
+  for (const [collName, varCount] of collections) {
+    const canonical = lookup.get(collName.toLowerCase().trim());
+    if (!canonical) continue;
+    if (!byCanonical.has(canonical)) byCanonical.set(canonical, []);
+    byCanonical.get(canonical).push({ name: collName, varCount });
+  }
+
+  const groups = [];
+  for (const [canonical, members] of byCanonical) {
+    if (members.length < 2) continue;
+    members.sort((a, b) => b.varCount - a.varCount || a.name.localeCompare(b.name));
+    groups.push({ canonical, collections: members });
+  }
+  // Sort groups for deterministic output.
+  groups.sort((a, b) => a.canonical.localeCompare(b.canonical));
+  return groups;
+}
+
+module.exports = { detectDuplicateCollections };
diff --git a/plugins/adhd/lib/lint-engine/structure-checker.js b/plugins/adhd/lib/lint-engine/structure-checker.js
index 0e156d2..002d1e8 100644
--- a/plugins/adhd/lib/lint-engine/structure-checker.js
+++ b/plugins/adhd/lib/lint-engine/structure-checker.js
@@ -2,17 +2,67 @@
 
 const AUTO_NAME_RE = /^(Frame|Group|Rectangle|Ellipse|Vector|Line|Star|Polygon)\s+\d+$/;
 
-// Shape primitives that, as a frame's only child, fill the container via constraints
-// and do not benefit from auto-layout (icons, logos, decorative backgrounds).
-const SINGLE_CHILD_SHAPE_EXEMPT = new Set([
+// Shape primitives that don't benefit from auto-layout (the leaves of a
+// shape-only subtree).
+const SHAPE_PRIMITIVE_TYPES = new Set([
   'VECTOR', 'BOOLEAN_OPERATION', 'ELLIPSE', 'RECTANGLE', 'STAR', 'POLYGON', 'LINE',
 ]);
 
+// Container types that the shape-only check is willing to recurse into. A
+// frame containing nested frames/groups/components that themselves contain
+// only shape primitives is still going to rasterize to a single SVG, so
+// flexbox doesn't apply to the outer container either. Mixed content (text,
+// other layouts) anywhere in the subtree breaks the exemption.
+const SHAPE_SUBTREE_CONTAINER_TYPES = new Set([
+  'FRAME', 'GROUP', 'COMPONENT', 'INSTANCE',
+]);
+
+// True iff `node` is a shape primitive OR a container with at least one
+// child whose entire subtree is shape-only. Empty containers DON'T count —
+// an empty FRAME is a placeholder, not a shape; the outer frame still needs
+// auto-layout to handle it. Anything else (TEXT, COMPONENT_SET as a child,
+// etc.) breaks the predicate.
+function isShapeOnlySubtree(node) {
+  if (SHAPE_PRIMITIVE_TYPES.has(node.type)) return true;
+  if (!SHAPE_SUBTREE_CONTAINER_TYPES.has(node.type)) return false;
+  if (!Array.isArray(node.children) || node.children.length === 0) return false;
+  return node.children.every(isShapeOnlySubtree);
+}
+
 // Paints are "visible" by default; only treat as hidden when explicitly false.
 function isVisiblePaint(p) {
   return p && p.visible !== false;
 }
 
+// Convert a Figma SOLID paint's normalized color (r/g/b each in 0..1) to
+// a #RRGGBB hex literal — used in diagnostic messages so the designer
+// knows exactly which color is raw, not just "some fill somewhere."
+function paintToHex(paint) {
+  if (!paint || !paint.color) return '?';
+  const to255 = (c) => Math.round(Math.max(0, Math.min(1, c)) * 255);
+  const hex = [paint.color.r, paint.color.g, paint.color.b]
+    .map(to255)
+    .map(n => n.toString(16).padStart(2, '0'))
+    .join('');
+  return '#' + hex.toUpperCase();
+}
+
+// Sentinel the serializer uses for fields where Figma returned `figma.mixed`.
+// JSON.stringify drops Symbols silently, so the serializer coerces them to
+// this marker string before assignment — otherwise per-range mixed paints
+// would disappear from the lint surface entirely.
+const MIXED = '__MIXED__';
+
+// True if the node is FULLY bound to a paint STYLE (Figma's legacy design-
+// token mechanism, distinct from variable bindings). Paint styles are valid
+// design tokens, so STRUCT003 shouldn't fire on style-bound layers. A MIXED
+// style id means SOME ranges are styled and some aren't — fall through to
+// the fills check so unbound ranges get caught.
+function hasPaintStyleBinding(node, kind) {
+  const id = node[kind];
+  return typeof id === 'string' && id.length > 0 && id !== MIXED;
+}
+
 function deepLink(fileKey, nodeId) {
   return 'https://figma.com/design/' + fileKey + '?node-id=' + nodeId.replace(':', '-');
 }
@@ -40,15 +90,17 @@ function visit(node, ctx, parentPath, parent) {
   };
 
   // STRUCT001: auto-layout required.
-  // Exempt: a frame whose ONLY child is a shape primitive (icon / logo / decorative
-  // shape that fills the container via constraints). Multi-child frames and
-  // single-child wrappers around TEXT / FRAME / COMPONENT / INSTANCE still fire —
-  // those typically want auto-layout for padding and alignment.
+  // Exempt: a frame whose entire subtree is shape-only. Covers icon / logo /
+  // illustration cases including nested compositions — a frame containing
+  // "light" and "dark" sub-frames, each holding only vector paths, still
+  // rasterizes to one SVG and doesn't want flexbox at the outer level.
+  // Mixed-content subtrees (text, instances of layout components, anything
+  // that isn't a shape or shape-only container) still fire.
   if ((node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE') &&
       Array.isArray(node.children) && node.children.length > 0 &&
       node.layoutMode === 'NONE') {
-    const exempt = node.children.length === 1 && SINGLE_CHILD_SHAPE_EXEMPT.has(node.children[0].type);
-    if (!exempt) {
+    const allShapes = node.children.every(isShapeOnlySubtree);
+    if (!allShapes) {
       push('STRUCT001', 'error', 'Frame has children but auto-layout is not enabled.');
     }
   }
@@ -67,27 +119,40 @@ function visit(node, ctx, parentPath, parent) {
     }
   }
 
-  // STRUCT003: visible solid colors use variables. Paints with `visible: false`
-  // don't render and are excluded — Figma keeps invisible paint entries on a node
-  // when the user has hidden them in the UI; enforcing variable bindings on
-  // unseen paints is busywork. COMPONENT_SET wrappers are also skipped — they're
-  // organizational scaffolding that doesn't render in instances. Figma's editor
-  // chrome (the dashed-purple Component Set outline at #9747FF) shows up in the
-  // wrapper's `strokes` array as a real SOLID entry; firing STRUCT003 on it would
-  // ask the designer to bind a color they never added.
-  if (node.type !== 'COMPONENT_SET' && Array.isArray(node.fills)) {
-    for (const fill of node.fills) {
-      if (fill.type === 'SOLID' && isVisiblePaint(fill) && !fill.boundVariables?.color) {
-        push('STRUCT003', 'error', 'Fill is a raw color; use a color variable.');
-        break;
+  // STRUCT003: visible solid colors use variables OR paint styles. Paints with
+  // `visible: false` don't render and are excluded. COMPONENT_SET wrappers are
+  // also skipped — they're organizational scaffolding and Figma's editor chrome
+  // (the dashed-purple outline at #9747FF) lives in the wrapper's `strokes`.
+  // Layers bound to a paint STYLE (legacy mechanism — `fillStyleId` /
+  // `strokeStyleId`) are valid design tokens too; we don't ask the designer to
+  // migrate them.
+  if (node.type !== 'COMPONENT_SET' && !hasPaintStyleBinding(node, 'fillStyleId')) {
+    if (node.fills === MIXED) {
+      // Multi-range mixed paints — fall through from the serializer's sentinel.
+      // Often a TEXT layer with per-character coloring; could be hiding raw values.
+      push('STRUCT003', 'error',
+        'Fills are mixed across ranges — bind each range to a color variable, or apply a paint style to the layer.');
+    } else if (Array.isArray(node.fills)) {
+      for (const fill of node.fills) {
+        if (fill.type === 'SOLID' && isVisiblePaint(fill) && !fill.boundVariables?.color) {
+          push('STRUCT003', 'error',
+            `Fill is a raw color (${paintToHex(fill)}); bind it to a color variable or apply a paint style.`);
+          break;
+        }
       }
     }
   }
-  if (node.type !== 'COMPONENT_SET' && Array.isArray(node.strokes)) {
-    for (const stroke of node.strokes) {
-      if (stroke.type === 'SOLID' && isVisiblePaint(stroke) && !stroke.boundVariables?.color) {
-        push('STRUCT003', 'error', 'Stroke is a raw color; use a color variable.');
-        break;
+  if (node.type !== 'COMPONENT_SET' && !hasPaintStyleBinding(node, 'strokeStyleId')) {
+    if (node.strokes === MIXED) {
+      push('STRUCT003', 'error',
+        'Strokes are mixed across ranges — bind each range to a color variable, or apply a paint style to the layer.');
+    } else if (Array.isArray(node.strokes)) {
+      for (const stroke of node.strokes) {
+        if (stroke.type === 'SOLID' && isVisiblePaint(stroke) && !stroke.boundVariables?.color) {
+          push('STRUCT003', 'error',
+            `Stroke is a raw color (${paintToHex(stroke)}); bind it to a color variable or apply a paint style.`);
+          break;
+        }
       }
     }
   }
@@ -159,7 +224,14 @@ function visit(node, ctx, parentPath, parent) {
     }
   }
 
-  // STRUCT007: sibling components share a name prefix but aren't wrapped in a Component Set
+  // STRUCT007: sibling components share a name prefix but aren't wrapped in a
+  // Component Set. The wording calls out the suspected variant intent and the
+  // codegen consequence — a designer who organized siblings as "Logo/light"
+  // and "Logo/dark" was almost certainly trying to model a variant axis, and
+  // we want them to know that without the Component Set wrapper each sibling
+  // becomes a separately-imported component instead of one component with
+  // prop axes. Strong copy here is the difference between code gen quietly
+  // doing the wrong thing and the designer fixing the source.
   if (Array.isArray(node.children) && node.type !== 'COMPONENT_SET') {
     const components = node.children.filter(c => c.type === 'COMPONENT');
     const byPrefix = {};
@@ -170,8 +242,20 @@ function visit(node, ctx, parentPath, parent) {
     }
     for (const [prefix, group] of Object.entries(byPrefix)) {
       if (group.length >= 2) {
+        // Pull the suffix from each sibling for the message (e.g. "light", "dark").
+        // Cap the displayed list at 4 and add a count suffix if there are more.
+        const suffixes = group.map(c => {
+          const rest = c.name.slice(prefix.length + 1); // strip "prefix/"
+          return rest || c.name;
+        });
+        const shown = suffixes.slice(0, 4).map(s => `"${s}"`).join(', ');
+        const more = suffixes.length > 4 ? `, +${suffixes.length - 4} more` : '';
         push('STRUCT007', 'warning',
-          `${group.length} sibling components named "${prefix}/..." should be wrapped in a Component Set.`);
+          `${group.length} sibling components share the "${prefix}/" prefix (${shown}${more}). ` +
+          `These look like variants of "${prefix}". Wrap them in a Component Set ` +
+          `(select all → right-click → "Combine as Variants") and add a variant property — ` +
+          `otherwise code generation imports them as ${group.length} separate components instead of one ` +
+          `"${prefix}" component with a prop axis.`);
         break;
       }
     }
@@ -186,7 +270,9 @@ function visit(node, ctx, parentPath, parent) {
     );
     if (!hasDefs && allChildrenEmpty) {
       push('STRUCT010', 'error',
-        'Component Set has no variant properties declared. Define variant axes (size, state, etc.).');
+        `Component Set has ${node.children.length} variant(s) but no variant property declared. ` +
+        `Add one in the Figma Properties panel (e.g. theme = light | dark, size = sm | md | lg) — ` +
+        `without it, code generation can't tell the variants apart and will import them as ${node.children.length} separate components.`);
     }
   }
 
diff --git a/plugins/adhd/lib/lint-engine/tailwind-duplicate-detector.js b/plugins/adhd/lib/lint-engine/tailwind-duplicate-detector.js
new file mode 100644
index 0000000..c35ca18
--- /dev/null
+++ b/plugins/adhd/lib/lint-engine/tailwind-duplicate-detector.js
@@ -0,0 +1,106 @@
+'use strict';
+
+// STRUCT013 — Figma variable duplicates a Tailwind v4 default.
+//
+// After a designer has pushed the full Tailwind token system into Figma,
+// they may have legacy custom variables sitting alongside the canonical
+// Tailwind ones with the same value. STRUCT013 surfaces those duplicates
+// so designers (via `/adhd:lint`'s wizard) can rebind every layer that uses
+// the duplicate to the canonical Tailwind variable, then delete the
+// duplicate.
+//
+// **Strict match only.** Both the variable's NORMALIZED NAME and its
+// VALUE must align with a Tailwind default — value-only matches like
+// `Color/MyZinc = #71717a` happening to equal `--color-zinc-500` would
+// trample the designer's semantic intent (e.g. "this is my brand's zinc,
+// not a generic gray-500"). Strict mode trades recall for precision.
+//
+// Tier collections (Primitives / Semantic / Tokens / Base / Theme) are
+// invisible — `Primitives/color/zinc/500` matches `--color-zinc-500`
+// just like `Color/zinc-500` does. Same convention STRUCT011 uses.
+
+const { TIER_COLLECTIONS, normalizeCollectionName } = require('./variable-namer');
+
+function toKebab(seg) {
+  return seg
+    .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
+    .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
+    .replace(/([a-zA-Z])([0-9])/g, '$1-$2')
+    .replace(/([0-9])([a-zA-Z])/g, '$1-$2')
+    .replace(/[\s_]+/g, '-')
+    .toLowerCase();
+}
+
+// `/<...rest>` → kebab-flat name, with tier collections
+// stripped. `Color/zinc-500` → `color-zinc-500`. `Primitives/color/zinc/500`
+// → `color-zinc-500`. Returns null when there's no rest segment to
+// normalize.
+function normalizeFigmaVarName(name) {
+  if (!name || typeof name !== 'string') return null;
+  const segments = name.split('/');
+  if (segments.length < 2) return null;
+  const collection = segments[0];
+  const collNorm = normalizeCollectionName(collection);
+  const path = TIER_COLLECTIONS.has(collNorm) ? segments.slice(1) : segments;
+  const kebab = path.map(toKebab).filter(Boolean).join('-');
+  return kebab || null;
+}
+
+function normalizeCssVarName(cssVar) {
+  if (!cssVar) return null;
+  return cssVar.replace(/^--/, '').toLowerCase();
+}
+
+// Cheap string parity for high-confidence equality. Resolved values from
+// the SKILL's serializer arrive as strings (`'#71717a'`, `'0.25rem'`,
+// `'oklch(0.62 0.18 264)'`). Lowercase, collapse internal whitespace.
+// False negatives here are fine — strict mode favors precision; if a
+// match is missed, the variable still shows up as a normal (non-STRUCT013)
+// variable and nothing bad happens.
+function normalizeValue(v) {
+  if (v == null) return null;
+  if (typeof v !== 'string') {
+    try { return JSON.stringify(v); } catch { return String(v); }
+  }
+  return v.trim().toLowerCase().replace(/\s+/g, '');
+}
+
+// Returns an array of { figmaName, normalizedName, value, tailwindCssVar }
+// for every Figma variable whose normalized name AND value match a
+// Tailwind v4 default primitive.
+//
+// `varDefs`: { '/': '' } — from the
+//   lint SKILL's serializer (or MCP `get_variable_defs`).
+// `tailwindDefaults`: { '--': '' } — from
+//   parseTheme(tailwind-defaults.css).primitives.
+function detectTailwindDuplicates(varDefs, tailwindDefaults) {
+  if (!varDefs || !tailwindDefaults) return [];
+  const twByNorm = new Map();
+  for (const [cssVar, value] of Object.entries(tailwindDefaults)) {
+    const norm = normalizeCssVarName(cssVar);
+    if (!norm) continue;
+    twByNorm.set(norm, { cssVar: '--' + norm, value: normalizeValue(value) });
+  }
+  const out = [];
+  for (const [figmaName, value] of Object.entries(varDefs)) {
+    const norm = normalizeFigmaVarName(figmaName);
+    if (!norm) continue;
+    const tw = twByNorm.get(norm);
+    if (!tw) continue;
+    if (tw.value !== normalizeValue(value)) continue;
+    out.push({
+      figmaName,
+      normalizedName: norm,
+      value,
+      tailwindCssVar: tw.cssVar,
+    });
+  }
+  return out;
+}
+
+module.exports = {
+  detectTailwindDuplicates,
+  normalizeFigmaVarName,
+  normalizeCssVarName,
+  normalizeValue,
+};
diff --git a/plugins/adhd/lib/lint-engine/value-normalizer.js b/plugins/adhd/lib/lint-engine/value-normalizer.js
index f27e1f3..0d45889 100644
--- a/plugins/adhd/lib/lint-engine/value-normalizer.js
+++ b/plugins/adhd/lib/lint-engine/value-normalizer.js
@@ -1,16 +1,42 @@
 'use strict';
 
+const { oklchStringToHex } = require('../design-system/oklch');
+
 const HEX_3 = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i;
 const HEX_6 = /^#([0-9a-f]{6})$/i;
 const HEX_8 = /^#([0-9a-f]{8})$/i;
 const RGB_RE = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/i;
+const OKLCH_RE = /^oklch\(/i;
 
 function normalizeColor(input) {
+  // Figma's raw color form — `{r, g, b, a}` with each channel 0..1. The
+  // SKILL's serializer emits values straight from
+  // `variable.valuesByMode[mode]` without converting; without this
+  // branch, a Figma `#0a0a0a` color compared against code's `#0a0a0a`
+  // hex falsely conflicts because the figma side is `{r:0.039,...}`.
+  if (input && typeof input === 'object' && 'r' in input && 'g' in input && 'b' in input) {
+    const to2 = (n) => Math.round(Math.max(0, Math.min(1, Number(n))) * 255).toString(16).padStart(2, '0');
+    let hex = '#' + to2(input.r) + to2(input.g) + to2(input.b);
+    if (input.a !== undefined && Number(input.a) < 1) {
+      hex += to2(input.a);
+    }
+    return hex.toLowerCase();
+  }
   if (typeof input !== 'string') {
-    throw new TypeError('normalizeColor: expected string, got ' + typeof input);
+    throw new TypeError('normalizeColor: expected string or color object, got ' + typeof input);
   }
   const trimmed = input.trim();
 
+  // Tailwind v4's default theme ships every color in oklch() form
+  // (--color-red-500: oklch(63.7% 0.237 25.331), etc.). Without this
+  // branch the matcher can\'t cross-reference Figma hex / rgb values
+  // against the canonical scale — every comparison falls into the
+  // throw → caught → false-conflict path.
+  if (OKLCH_RE.test(trimmed)) {
+    try { return oklchStringToHex(trimmed).toLowerCase(); }
+    catch { /* fall through to other formats / throw below */ }
+  }
+
   const m3 = HEX_3.exec(trimmed);
   if (m3) {
     return ('#' + m3[1] + m3[1] + m3[2] + m3[2] + m3[3] + m3[3]).toLowerCase();
@@ -33,8 +59,16 @@ function normalizeColor(input) {
 }
 
 function normalizeDimension(input) {
+  // The SKILL's serializer emits Figma's raw `valuesByMode` shape
+  // unchanged — for spacing / radius / line-height variables that's
+  // typically a bare number (`6` for 6px, `0` for 0px, `1.5` for a
+  // unitless line-height ratio). Accept both forms so the comparator
+  // doesn't crash mid-run on the first numeric value it encounters.
+  if (typeof input === 'number') {
+    return Number.isInteger(input) ? input + 'px' : String(input);
+  }
   if (typeof input !== 'string') {
-    throw new TypeError('normalizeDimension: expected string, got ' + typeof input);
+    throw new TypeError('normalizeDimension: expected string or number, got ' + typeof input);
   }
   const trimmed = input.trim();
   const remMatch = /^(-?[\d.]+)rem$/i.exec(trimmed);
diff --git a/plugins/adhd/lib/lint-engine/variable-categorizer.js b/plugins/adhd/lib/lint-engine/variable-categorizer.js
index 592d6a4..034e7ec 100644
--- a/plugins/adhd/lib/lint-engine/variable-categorizer.js
+++ b/plugins/adhd/lib/lint-engine/variable-categorizer.js
@@ -3,13 +3,26 @@
 const { figmaToCssVar } = require('./name-normalizer');
 const { valuesMatch } = require('./value-normalizer');
 
-function inferDomain(token) {
-  if (token.startsWith('color/') || token.includes('/color/')) return 'color';
-  if (token.startsWith('space/') || token.includes('/space/')) return 'spacing';
-  if (token.startsWith('radius/') || token.includes('/radius/')) return 'radius';
-  if (token.startsWith('shadow/') || token.includes('/shadow/')) return 'shadow';
-  if (token.startsWith('font/') || token.includes('/font/') ||
-      token.includes('text-') || token.includes('line-height')) return 'typography';
+// Infer the Tailwind v4 domain from a full Figma variable path
+// (e.g. `Color/primary`, `Primitives/spacing/4`). Lowercased + checked
+// against the canonical domain names AND common alternates, so a
+// designer's `Color/primary` matches the same domain as `color/primary`
+// or `Primitives/color/primary`. Without lowercasing, the categorizer
+// silently returned `unknown` for capitalized collections, which then
+// bypassed the per-domain normalization in valuesMatch and produced
+// false-positive conflicts (the original "primary" case from the user's
+// reactor file).
+function inferDomain(figmaPath) {
+  if (!figmaPath) return 'unknown';
+  const lc = String(figmaPath).toLowerCase();
+  if (lc.startsWith('color/')   || lc.includes('/color/'))   return 'color';
+  if (lc.startsWith('spacing/') || lc.includes('/spacing/')) return 'spacing';
+  if (lc.startsWith('space/')   || lc.includes('/space/'))   return 'spacing';
+  if (lc.startsWith('radius/')  || lc.includes('/radius/'))  return 'radius';
+  if (lc.startsWith('shadow/')  || lc.includes('/shadow/'))  return 'shadow';
+  if (lc.startsWith('typography/') || lc.includes('/typography/')) return 'typography';
+  if (lc.startsWith('font/')    || lc.includes('/font/') ||
+      lc.includes('text-') || lc.includes('line-height')) return 'typography';
   return 'unknown';
 }
 
@@ -31,6 +44,37 @@ function isLocalAlias(v) {
   return typeof v === 'string' && /^var\(--[A-Za-z0-9_-]+\)$/i.test(v.trim());
 }
 
+// Follow a code-side `var(--X)` alias through the parsed theme until we
+// reach a concrete literal (or a chain dead-end). Returns ALL possible
+// terminal values — the alias might resolve to different literals in
+// light vs dark modes; if the Figma side matches any of them we treat
+// the variables as semantically equal. Returns null when the chain
+// can't be resolved (variable not defined anywhere we know of, or
+// infinite-loop guard tripped).
+function resolveLocalAlias(value, theme, depth = 0) {
+  if (depth > 8) return null;
+  if (typeof value !== 'string') return [value];
+  const m = /^var\(\s*(--[A-Za-z0-9_-]+)\s*(?:,[^)]*)?\)$/i.exec(value.trim());
+  if (!m) return [value]; // already a literal
+  const target = m[1];
+  const candidates = [];
+  if (theme.primitives && theme.primitives[target] != null) candidates.push(theme.primitives[target]);
+  if (theme.exposure   && theme.exposure[target]   != null) candidates.push(theme.exposure[target]);
+  if (theme.light      && theme.light[target]      != null) candidates.push(theme.light[target]);
+  if (theme.dark       && theme.dark[target]       != null) candidates.push(theme.dark[target]);
+  if (candidates.length === 0) return null;
+  const out = [];
+  for (const c of candidates) {
+    const next = resolveLocalAlias(c, theme, depth + 1);
+    if (next) {
+      for (const r of next) {
+        if (!out.includes(r)) out.push(r);
+      }
+    }
+  }
+  return out.length > 0 ? out : null;
+}
+
 function isFigmaAlias(v) {
   return v != null && typeof v === 'object' && v.type === 'VARIABLE_ALIAS';
 }
@@ -38,7 +82,11 @@ function isFigmaAlias(v) {
 function compareOne(figmaPath, figmaValue, theme, mode) {
   const cssVar = figmaToCssVar(figmaPath);
   const token = strippedToken(figmaPath);
-  const domain = inferDomain(token);
+  // Pass the FULL figma path (not the collection-stripped token) so
+  // inferDomain can use the collection name as a domain signal —
+  // `color/primary` matches `color`, `Primitives/spacing/4` matches
+  // `spacing` via the tier-collection branch.
+  const domain = inferDomain(figmaPath);
   const localValue = lookupLocal(theme, cssVar, mode);
 
   if (localValue === undefined || localValue === null) {
@@ -49,17 +97,27 @@ function compareOne(figmaPath, figmaValue, theme, mode) {
       local: null,
       mode,
       domain,
-      hint: 'Run /adhd:pull-design-system to import this token.',
+      hint: 'Run /adhd:pull-tokens to import this token.',
     };
   }
   // Both sides agree this is an alias relationship — no surface-value comparison
   // is meaningful. The primitive-level comparison catches real drift in the
-  // underlying targets. Mixed alias-vs-literal still falls through to the value
-  // comparison below (where it may produce a false-positive conflict until the
-  // SKILL emits resolved figma values).
+  // underlying targets.
   if (isLocalAlias(localValue) && isFigmaAlias(figmaValue)) {
     return null;
   }
+  // Code side is a `var(--X)` alias and Figma side is a literal. Without
+  // resolution this throws inside normalizeColor and falls back to "not
+  // equal" — false-conflict on every shadcn-style setup where
+  // `--color-primary: var(--primary)` exposes a semantic. Resolve the
+  // chain across primitives/light/dark and accept any matching mode.
+  if (isLocalAlias(localValue) && !isFigmaAlias(figmaValue)) {
+    const resolved = resolveLocalAlias(localValue, theme);
+    if (resolved && resolved.some(r => valuesMatch(figmaValue, r, domain))) {
+      return null;
+    }
+    return { token, status: 'conflict', figma: figmaValue, local: localValue, mode, domain, resolvedLocal: resolved };
+  }
   if (valuesMatch(figmaValue, localValue, domain)) {
     return null; // same, no violation
   }
diff --git a/plugins/adhd/lib/lint-engine/variable-namer.js b/plugins/adhd/lib/lint-engine/variable-namer.js
new file mode 100644
index 0000000..f6d3068
--- /dev/null
+++ b/plugins/adhd/lib/lint-engine/variable-namer.js
@@ -0,0 +1,401 @@
+'use strict';
+
+// STRUCT011 — variable-name compliance.
+//
+// Splits each Figma variable name on `/` (Figma's path separator) and checks
+// each segment against the project's naming convention. Per-segment is the
+// right granularity: `color/brand/500` has three segments that each need to
+// individually match kebab/Pascal/camel — treating the whole name as one
+// string fails since `/` isn't valid in any convention.
+//
+// Returns: an array of `{ name, suggestion }` for variables whose name
+// doesn't match. The suggestion is a best-effort rewrite into the target
+// convention (splits words on case transitions, hyphens, underscores;
+// numerics stay in place).
+
+function caseMatchesSegment(segment, convention) {
+  if (convention === false || convention == null) return true;
+  if (convention === 'kebab-case') return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(segment);
+  if (convention === 'PascalCase') return /^[A-Z][a-zA-Z0-9]*$/.test(segment);
+  if (convention === 'camelCase')  return /^[a-z][a-zA-Z0-9]*$/.test(segment);
+  return true;
+}
+
+// Word-split: handles "BrandPrimary", "brand-primary", "brand_primary",
+// "color500", "HTMLParser" → ["html","parser"], etc.
+function splitWords(segment) {
+  return segment
+    .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
+    .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
+    .replace(/([a-zA-Z])([0-9])/g, '$1 $2')
+    .replace(/([0-9])([a-zA-Z])/g, '$1 $2')
+    .split(/[-_\s]+/)
+    .filter(Boolean)
+    .map(w => w.toLowerCase());
+}
+
+function toCase(segment, convention) {
+  const words = splitWords(segment);
+  if (words.length === 0) return segment;
+  if (convention === 'kebab-case') return words.join('-');
+  if (convention === 'PascalCase') return words.map(w => w[0].toUpperCase() + w.slice(1)).join('');
+  if (convention === 'camelCase') {
+    return words.map((w, i) => i === 0 ? w : w[0].toUpperCase() + w.slice(1)).join('');
+  }
+  return segment;
+}
+
+// Variable keys arrive as `/` from the SKILL's serializer.
+// The collection name (e.g. "Primitives", "Semantic") is conventionally
+// PascalCase in Figma regardless of the project's variable-naming convention
+// — same treatment variable-categorizer already gives it via `strippedToken`.
+// We only check segments AFTER the collection.
+function suggestName(name, convention) {
+  const segments = name.split('/');
+  if (segments.length <= 1) return name;
+  const [collection, ...rest] = segments;
+  return [collection, ...rest.map(s => toCase(s, convention))].join('/');
+}
+
+function checkVariableNames(varNames, convention) {
+  if (convention === false || convention == null) return [];
+  const out = [];
+  for (const name of varNames) {
+    const segments = name.split('/');
+    // Skip collection-prefix-only entries (`foo` with no slash); nothing to check.
+    if (segments.length <= 1) continue;
+    const checked = segments.slice(1);
+    const allGood = checked.every(s => caseMatchesSegment(s, convention));
+    if (!allGood) {
+      out.push({ name, suggestion: suggestName(name, convention) });
+    }
+  }
+  return out;
+}
+
+// ---------------------------------------------------------------------------
+// STRUCT011 — Tailwind v4 domain "did you mean?" check.
+//
+// Second half of the variable-naming rule. The first half (above) checks the
+// case convention; this half checks that the first segment AFTER the
+// collection maps to a Tailwind v4 token-domain prefix. Both halves emit
+// under the same rule code (STRUCT011) and get aggregated into one
+// annotation — designers see "variable naming compliance" as a single
+// concern, not two separate things to chase down.
+//
+// Why this matters: a variable named `Primitives/colur/brand-500` is
+// perfectly kebab-case, so the case half passes — but `colur` doesn't map
+// to anything in Tailwind v4. Code gen sees an unrecognized namespace and
+// either drops the var or surfaces it as a one-off alias.
+//
+// This rule catches three classes of issue:
+//   1. Synonyms — designers writing the natural-language form instead of
+//      Tailwind's canonical name (`colors/...`, `space/...`, `shadows/...`,
+//      `screens/...`, etc.).
+//   2. Typos — `colur/brand-500`, `radiu/sm`. Caught via Levenshtein distance.
+//   3. Genuinely unknown prefixes — `widget/...`, `random/...`. Flagged with
+//      the list of recognized domains so the designer can pick one.
+//
+// Suggestions come from a hand-curated synonym table first (high precision),
+// falling back to Levenshtein distance ≤ 2 against the canonical domain list.
+// Distance 3+ → "no good match"; report lists the canonical set instead.
+
+const TAILWIND_DOMAINS = [
+  'color', 'spacing', 'text', 'font', 'font-weight',
+  'tracking', 'leading', 'radius', 'shadow',
+  'breakpoint', 'ease', 'animate',
+];
+
+const DOMAIN_SYNONYMS = {
+  // Pluralization
+  'colors': 'color',
+  'shadows': 'shadow',
+  'radii': 'radius',
+  'animations': 'animate',
+  'breakpoints': 'breakpoint',
+  'easings': 'ease',
+  'fonts': 'font',
+  // Common alternates
+  'colour': 'color',
+  'colours': 'color',
+  'space': 'spacing',
+  'spaces': 'spacing',
+  'screen': 'breakpoint',
+  'screens': 'breakpoint',
+  'media': 'breakpoint',
+  'transition': 'ease',
+  'easing': 'ease',
+  'animation': 'animate',
+  'border-radius': 'radius',
+  'rounded': 'radius',
+  'font-family': 'font',
+  'font-size': 'text',
+  'fontsize': 'text',
+  'font-weights': 'font-weight',
+  'fontweight': 'font-weight',
+  'weight': 'font-weight',
+  'letter-spacing': 'tracking',
+  'letterspacing': 'tracking',
+  'line-height': 'leading',
+  'lineheight': 'leading',
+};
+
+function levenshtein(a, b) {
+  if (a === b) return 0;
+  if (!a.length) return b.length;
+  if (!b.length) return a.length;
+  let v0 = Array.from({ length: b.length + 1 }, (_, i) => i);
+  let v1 = new Array(b.length + 1);
+  for (let i = 0; i < a.length; i++) {
+    v1[0] = i + 1;
+    for (let j = 0; j < b.length; j++) {
+      const cost = a[i] === b[j] ? 0 : 1;
+      v1[j + 1] = Math.min(v1[j] + 1, v0[j + 1] + 1, v0[j] + cost);
+    }
+    [v0, v1] = [v1, v0];
+  }
+  return v0[b.length];
+}
+
+// Returns { kind: 'known' } | { kind: 'synonym', suggestion } |
+//   { kind: 'typo', suggestion, distance } | { kind: 'unknown' }
+function classifyDomain(segment) {
+  const lower = segment.toLowerCase();
+  if (TAILWIND_DOMAINS.includes(lower)) return { kind: 'known' };
+  if (DOMAIN_SYNONYMS[lower]) {
+    return { kind: 'synonym', suggestion: DOMAIN_SYNONYMS[lower] };
+  }
+  // Fall back to Levenshtein. Distance 1–2 = likely typo. 3+ = probably not
+  // a typo — flag as unknown so the user picks from the canonical list.
+  const candidates = TAILWIND_DOMAINS
+    .map(d => ({ domain: d, distance: levenshtein(lower, d) }))
+    .sort((a, b) => a.distance - b.distance);
+  const best = candidates[0];
+  if (best.distance <= 2) {
+    return { kind: 'typo', suggestion: best.domain, distance: best.distance };
+  }
+  return { kind: 'unknown' };
+}
+
+// Normalize a collection name for domain-matching: lowercase, collapse
+// separators (`+`, ` `, `-`, `_`) to `-`, drop the rest. "Color" → "color",
+// "Type + Effects" → "type-effects", "Radius" → "radius".
+function normalizeCollectionName(name) {
+  return name.toLowerCase()
+    .replace(/[\s+\-_]+/g, '-')
+    .replace(/[^a-z0-9-]/g, '')
+    .replace(/^-+|-+$/g, '');
+}
+
+// True when the collection name itself acts as the Tailwind domain — the
+// variable name within doesn't need another domain prefix. `Color/gold`,
+// `Radius/sm`, `Spacing/sm` are all valid. `Primitives/...` and
+// `Semantic/...` are not (they're not domain names).
+function collectionIsDomain(collection) {
+  const norm = normalizeCollectionName(collection);
+  const classification = classifyDomain(norm);
+  return classification.kind === 'known' || classification.kind === 'synonym';
+}
+
+// Returns an array of `{ name, domainSegment, classification }` for vars whose
+// post-collection first segment doesn't match a known Tailwind v4 domain.
+// Skipped:
+//   - Names with no path segments after the collection (nothing to classify).
+//   - Names whose COLLECTION already names the domain (`Color/gold` ok —
+//     "gold" doesn't need its own domain prefix).
+function checkVariableDomains(varNames) {
+  const out = [];
+  for (const name of varNames) {
+    const segments = name.split('/');
+    if (segments.length <= 1) continue;
+    if (collectionIsDomain(segments[0])) continue;
+    const domainSegment = segments[1]; // first segment AFTER collection
+    const classification = classifyDomain(domainSegment);
+    if (classification.kind !== 'known') {
+      out.push({ name, domainSegment, classification });
+    }
+  }
+  return out;
+}
+
+// ---------------------------------------------------------------------------
+// Canonical target builder — gives each variable a SINGLE concrete rename
+// target that combines the case + domain concerns. This is what
+// `cli.js` emits in STRUCT011 messages: actionable end-state names, not
+// per-segment hints.
+//
+// Three classes of result:
+//   - `ok`         — name is already in the right shape; nothing to do.
+//   - `rename`     — produced a single target; designer renames to that.
+//   - `no-mapping` — no Tailwind v4 domain detected anywhere in the path,
+//                    AND the collection isn't a recognized tier. Surface
+//                    the canonical list so the designer picks one.
+//
+// Conventions assumed:
+//   - "Primitives" / "Semantic" / "Tokens" / "Base" / "Theme" are TIER
+//     collections — they bundle multiple domains by design, so the
+//     internal structure should follow `//<...>`. Renames
+//     preserve the tier and just fix case + ensure the domain segment is
+//     canonical.
+//   - Otherwise, when the collection name itself is a Tailwind domain
+//     (Color, Radius, Spacing, …), it's preserved.
+//   - When the collection is unrecognized AND a rest segment names a
+//     domain, the suggestion MOVES the variable into a domain-named
+//     collection — e.g. "Type + Effects/Font-Size/Body" → "Text/body".
+
+const TIER_COLLECTIONS = new Set([
+  'primitives', 'semantic', 'tokens', 'base', 'theme',
+]);
+
+// Leaf-name keywords that hint at a specific Tailwind v4 domain. When the
+// leaf hints at a DIFFERENT domain than the path's primary signal, we
+// surface the ambiguity instead of confidently picking one.
+// Example: "Type + Effects/Line-Height/Letter Space 0" — path says leading
+// (via Line-Height), leaf says letter-spacing (tracking). The variable
+// could be either; the designer has to decide.
+const LEAF_DOMAIN_HINTS = [
+  // The `spac(?:e|ing)` group covers both "letter space" (Figma designers
+  // often write it this way) and "letter spacing" / "letter-spacing" (CSS
+  // form). Anchoring with \b ensures we strip the full phrase on rename
+  // suggestion, not just the prefix — otherwise "Letter Space 0" loses
+  // "letter spac" and we suggest "Tracking/e-0".
+  { pattern: /\bletter[\s\-_]?spac(?:e|ing)\b/i, domain: 'tracking' },
+  { pattern: /\bline[\s\-_]?height\b/i, domain: 'leading'  },
+  { pattern: /\bfont[\s\-_]?size\b/i,   domain: 'text'     },
+  { pattern: /\bfont[\s\-_]?weight\b/i, domain: 'font-weight' },
+  { pattern: /\bfont[\s\-_]?family\b/i, domain: 'font'     },
+];
+
+function leafHint(leaf) {
+  for (const { pattern, domain } of LEAF_DOMAIN_HINTS) {
+    if (pattern.test(leaf)) return domain;
+  }
+  return null;
+}
+
+// Specific concepts Tailwind v4 doesn't expose as a token domain. Detecting
+// them lets the no-mapping message say "opacity is applied via class
+// modifiers" instead of just listing the canonical domain set.
+function detectKnownNonDomain(segments) {
+  const joined = segments.join(' ').toLowerCase();
+  if (/\bopacity\b/.test(joined)) {
+    return {
+      concept: 'opacity',
+      hint: 'Tailwind v4 has no "opacity" domain — opacity is applied via class modifiers (e.g. `bg-white/50`), not stored as variables. Consider deleting this variable if it isn\'t actively consumed by Tailwind utilities.',
+    };
+  }
+  return null;
+}
+
+function titleCaseDomain(d) {
+  return d.split('-').map(w => w ? w[0].toUpperCase() + w.slice(1) : w).join('');
+}
+
+function suggestTargetName(name) {
+  const segments = name.split('/');
+  if (segments.length < 2) return { name, kind: 'ok' };
+
+  const collection = segments[0];
+  const rest = segments.slice(1);
+  const collNorm = normalizeCollectionName(collection);
+
+  // (1) Collection is a TIER (Primitives, Semantic, …). Preserve tier,
+  // ensure the first rest segment is a canonical domain, kebab the leaves.
+  if (TIER_COLLECTIONS.has(collNorm)) {
+    const firstRestClass = classifyDomain(rest[0]);
+    let normalizedRest = [...rest];
+    if (firstRestClass.kind === 'synonym') {
+      normalizedRest[0] = firstRestClass.suggestion;
+    } else if (firstRestClass.kind === 'typo' || firstRestClass.kind === 'unknown') {
+      // Tier + unrecognized inner: can't auto-rename safely. Hint the user.
+      return {
+        name, kind: 'no-mapping',
+        reason: `Inside the "${collection}" tier, the segment "${rest[0]}" doesn't match any Tailwind v4 domain (color, spacing, text, font, font-weight, tracking, leading, radius, shadow, breakpoint, ease, animate).`,
+      };
+    }
+    const kebabRest = normalizedRest.map(s => toCase(s, 'kebab-case')).filter(Boolean).join('/');
+    const target = kebabRest ? `${collection}/${kebabRest}` : collection;
+    return target === name ? { name, kind: 'ok' } : { name, kind: 'rename', target };
+  }
+
+  // (2) Collection IS a Tailwind domain or its synonym. Preserve the
+  // collection name verbatim (designer's casing choice) and kebab-case the
+  // rest. A canonical "synonym" rename still suggests the canonical form.
+  const collectionClass = classifyDomain(collNorm);
+  if (collectionClass.kind === 'known' || collectionClass.kind === 'synonym') {
+    const canonicalCollection = collectionClass.kind === 'synonym'
+      ? titleCaseDomain(collectionClass.suggestion)
+      : collection;
+    const kebabRest = rest.map(s => toCase(s, 'kebab-case')).filter(Boolean).join('/');
+    const target = kebabRest ? `${canonicalCollection}/${kebabRest}` : canonicalCollection;
+    return target === name ? { name, kind: 'ok' } : { name, kind: 'rename', target };
+  }
+
+  // (3) Unknown collection. Walk rest looking for a domain hint. If found,
+  // suggest MOVING the variable to a domain-named collection.
+  let targetDomain = null;
+  let domainIndex = -1;
+  for (let i = 0; i < rest.length; i++) {
+    const c = classifyDomain(rest[i]);
+    if (c.kind === 'known' || c.kind === 'synonym') {
+      targetDomain = c.kind === 'known' ? rest[i].toLowerCase() : c.suggestion;
+      domainIndex = i;
+      break;
+    }
+  }
+  if (!targetDomain) {
+    // No path-based domain hint, but check for known non-Tailwind concepts
+    // (like opacity) so we can surface concept-specific guidance instead of
+    // the generic "expected one of: ...".
+    const knownConcept = detectKnownNonDomain(rest);
+    return {
+      name, kind: 'no-mapping',
+      reason: knownConcept
+        ? knownConcept.hint
+        : `No Tailwind v4 domain found in path. Expected one of: ${TAILWIND_DOMAINS.join(', ')}. Consider whether this variable maps to one of those domains, or if it should be removed.`,
+    };
+  }
+  const collectionTitle = titleCaseDomain(targetDomain);
+  const kept = rest.filter((_, i) => i !== domainIndex);
+  const kebabRest = kept.map(s => toCase(s, 'kebab-case')).filter(Boolean).join('/');
+  const target = kebabRest ? `${collectionTitle}/${kebabRest}` : collectionTitle;
+
+  // Detect ambiguity: the leaf's own keywords hint at a DIFFERENT domain
+  // than the path-derived one. Common case: a letter-spacing variable filed
+  // inside a "Line-Height" folder. Surface both options instead of
+  // confidently picking the wrong target.
+  const leaf = rest[rest.length - 1];
+  const hint = leafHint(leaf);
+  if (hint && hint !== targetDomain) {
+    const altTitle = titleCaseDomain(hint);
+    // Drop the leaf-keyword phrase from the alternate-collection rename so
+    // it doesn't repeat itself. For "Letter Space 0" moved to Tracking,
+    // the rename target inside Tracking should be just "0" — not
+    // "letter-space-0" which would be redundant with the collection name.
+    const stripped = leaf
+      .toLowerCase()
+      .replace(LEAF_DOMAIN_HINTS.find(h => h.domain === hint).pattern, '')
+      .trim();
+    const altLeaf = toCase(stripped, 'kebab-case') || toCase(leaf, 'kebab-case');
+    const keptForAlt = kept.slice(0, -1).map(s => toCase(s, 'kebab-case')).filter(Boolean);
+    const altPath = [...keptForAlt, altLeaf].filter(Boolean).join('/');
+    const alternate = altPath ? `${altTitle}/${altPath}` : altTitle;
+    return {
+      name, kind: 'ambiguous', target, alternate,
+      primaryReason: `path suggests ${targetDomain}`,
+      alternateReason: `leaf "${leaf}" suggests ${hint}`,
+    };
+  }
+  return { name, kind: 'rename', target };
+}
+
+function buildVariableSuggestions(varNames) {
+  return varNames.map(suggestTargetName).filter(s => s.kind !== 'ok');
+}
+
+module.exports = {
+  checkVariableNames, caseMatchesSegment, suggestName, toCase,
+  checkVariableDomains, classifyDomain, collectionIsDomain,
+  normalizeCollectionName, TAILWIND_DOMAINS,
+  suggestTargetName, buildVariableSuggestions, TIER_COLLECTIONS,
+};
diff --git a/plugins/adhd/lib/pull-component/__tests__/config-state.test.js b/plugins/adhd/lib/pull-component/__tests__/config-state.test.js
new file mode 100644
index 0000000..837a588
--- /dev/null
+++ b/plugins/adhd/lib/pull-component/__tests__/config-state.test.js
@@ -0,0 +1,102 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { findComponentBlock, readComponentState, writeComponentState } = require('../config-state');
+
+const SAMPLE_CONFIG = `const config = {
+  figma: { url: "https://figma.com/design/abc/Test" },
+  components: {
+    "app/components/Button": {
+      figma: { url: "https://figma.com/design/abc?node-id=1-1" },
+    },
+    "app/components/Card": {
+      figma: { url: "https://figma.com/design/abc?node-id=2-2" },
+      pulledAt: "2026-05-01T10:00:00.000Z",
+      fingerprint: "deadbeef",
+    },
+  },
+  naming: "kebab-case",
+};
+export default config;`;
+
+test('findComponentBlock: locates the value block for a path key', () => {
+  const block = findComponentBlock(SAMPLE_CONFIG, 'app/components/Button');
+  assert.ok(block);
+  assert.equal(SAMPLE_CONFIG[block.openAt], '{');
+  assert.equal(SAMPLE_CONFIG[block.closeAt], '}');
+  // Body should contain the figma sub-block.
+  assert.match(block.body, /figma:\s*\{\s*url:/);
+});
+
+test('findComponentBlock: returns null for unknown paths', () => {
+  assert.equal(findComponentBlock(SAMPLE_CONFIG, 'app/components/Nope'), null);
+});
+
+test('readComponentState: returns null when no fingerprint stored yet', () => {
+  // Button block has no pulledAt/fingerprint — treat as "never pulled."
+  assert.equal(readComponentState(SAMPLE_CONFIG, 'app/components/Button'), null);
+});
+
+test('readComponentState: returns { pulledAt, fingerprint } when present', () => {
+  const state = readComponentState(SAMPLE_CONFIG, 'app/components/Card');
+  assert.deepEqual(state, { pulledAt: '2026-05-01T10:00:00.000Z', fingerprint: 'deadbeef' });
+});
+
+test('readComponentState: ignores fields inside nested blocks (figma: { url } is not the fingerprint)', () => {
+  // If `pulledAt` or `fingerprint` appeared inside a nested block by
+  // accident, the brace-counted scan would correctly ignore them.
+  const tricky = `const config = {
+    components: {
+      "x": {
+        figma: { url: "fake-fingerprint-inside", pulledAt: "fake" },
+      },
+    },
+  };`;
+  // Only top-level pulledAt/fingerprint count — there are none here.
+  assert.equal(readComponentState(tricky, 'x'), null);
+});
+
+test('writeComponentState: inserts pulledAt + fingerprint when absent', () => {
+  const next = writeComponentState(SAMPLE_CONFIG, 'app/components/Button', {
+    pulledAt: '2026-05-12T14:30:00.000Z',
+    fingerprint: 'a1b2c3d4',
+  });
+  // Round-trip: reading should now find the values.
+  const state = readComponentState(next, 'app/components/Button');
+  assert.deepEqual(state, { pulledAt: '2026-05-12T14:30:00.000Z', fingerprint: 'a1b2c3d4' });
+  // Original Card entry untouched.
+  const card = readComponentState(next, 'app/components/Card');
+  assert.deepEqual(card, { pulledAt: '2026-05-01T10:00:00.000Z', fingerprint: 'deadbeef' });
+});
+
+test('writeComponentState: replaces existing pulledAt + fingerprint values', () => {
+  const next = writeComponentState(SAMPLE_CONFIG, 'app/components/Card', {
+    pulledAt: '2026-05-13T09:00:00.000Z',
+    fingerprint: 'cafef00d',
+  });
+  const state = readComponentState(next, 'app/components/Card');
+  assert.deepEqual(state, { pulledAt: '2026-05-13T09:00:00.000Z', fingerprint: 'cafef00d' });
+  // Button entry stays empty (no fingerprint stored).
+  assert.equal(readComponentState(next, 'app/components/Button'), null);
+});
+
+test('writeComponentState: throws when the component path is missing from config', () => {
+  assert.throws(
+    () => writeComponentState(SAMPLE_CONFIG, 'app/components/Nope', { pulledAt: 'x', fingerprint: 'y' }),
+    /Component not found/,
+  );
+});
+
+test('writeComponentState: preserves surrounding fields and trailing commas', () => {
+  const next = writeComponentState(SAMPLE_CONFIG, 'app/components/Button', {
+    pulledAt: '2026-05-12T14:30:00.000Z',
+    fingerprint: 'a1b2c3d4',
+  });
+  // The original `figma: { url: ... }` is still there.
+  assert.match(next, /"app\/components\/Button":\s*\{[\s\S]*figma:\s*\{\s*url:/);
+  // The other component (Card) is intact.
+  assert.match(next, /"app\/components\/Card":\s*\{[\s\S]*pulledAt: "2026-05-01T10:00:00\.000Z"/);
+  // No syntax wreckage — config still has the closing structure.
+  assert.match(next, /\};\s*\nexport default config;/);
+});
diff --git a/plugins/adhd/lib/pull-component/__tests__/fingerprint.test.js b/plugins/adhd/lib/pull-component/__tests__/fingerprint.test.js
new file mode 100644
index 0000000..448331a
--- /dev/null
+++ b/plugins/adhd/lib/pull-component/__tests__/fingerprint.test.js
@@ -0,0 +1,72 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { computeFingerprint, canonicalJson, relevantConfigFields } = require('../fingerprint');
+
+test('canonicalJson: produces same string regardless of key order', () => {
+  const a = { figma: { url: 'x' }, naming: 'kebab' };
+  const b = { naming: 'kebab', figma: { url: 'x' } };
+  assert.equal(canonicalJson(a), canonicalJson(b));
+});
+
+test('canonicalJson: nested keys sort independently', () => {
+  const a = { x: { c: 1, a: 2, b: 3 } };
+  assert.equal(canonicalJson(a), '{"x":{"a":2,"b":3,"c":1}}');
+});
+
+test('canonicalJson: arrays preserve order (semantic)', () => {
+  assert.equal(canonicalJson([3, 1, 2]), '[3,1,2]');
+});
+
+test('computeFingerprint: returns 8 hex chars', () => {
+  const fp = computeFingerprint({ figma: 'x' });
+  assert.match(fp, /^[0-9a-f]{8}$/);
+});
+
+test('computeFingerprint: identical inputs hash identically', () => {
+  const a = computeFingerprint({ x: 1, y: 2 });
+  const b = computeFingerprint({ y: 2, x: 1 });
+  assert.equal(a, b);
+});
+
+test('computeFingerprint: any change to the input changes the hash', () => {
+  // The asymmetric failure mode: false positives (re-pull when output
+  // would be identical) are fine; false negatives (skip when output
+  // would differ) would be a correctness bug. Confirm changes propagate.
+  const base = computeFingerprint({ figma: { url: 'x' } });
+  const changed = computeFingerprint({ figma: { url: 'y' } });
+  assert.notEqual(base, changed);
+  // Even a deeply-nested change flips the hash.
+  const deepBase = computeFingerprint({ a: { b: { c: 1 } } });
+  const deepChanged = computeFingerprint({ a: { b: { c: 2 } } });
+  assert.notEqual(deepBase, deepChanged);
+});
+
+test('relevantConfigFields: extracts pull-affecting config bits', () => {
+  const out = relevantConfigFields({
+    figma: { url: 'https://figma.com/design/abc' },
+    naming: 'PascalCase',
+    cssEntry: 'src/app/globals.css',
+    components: { 'x': {} },
+  });
+  // Only fields that affect generated code make it in.
+  assert.deepEqual(out, { naming: 'PascalCase', cssEntry: 'src/app/globals.css' });
+});
+
+test('relevantConfigFields: handles missing optional fields', () => {
+  const out = relevantConfigFields({ figma: { url: 'x' } });
+  assert.equal(out.naming, 'kebab-case'); // default
+  assert.equal(out.cssEntry, null);
+});
+
+test('fingerprint changes when config naming changes (the invalidation case)', () => {
+  // The whole point of including config in the fingerprint: if a
+  // designer changes `naming` from kebab to Pascal, the same Figma
+  // input now produces different code. We must NOT skip on the next
+  // pull just because Figma is unchanged.
+  const figmaExtract = { name: 'Button', variants: [{ size: 'sm' }] };
+  const kebab = computeFingerprint({ figma: figmaExtract, config: relevantConfigFields({ naming: 'kebab-case' }) });
+  const pascal = computeFingerprint({ figma: figmaExtract, config: relevantConfigFields({ naming: 'PascalCase' }) });
+  assert.notEqual(kebab, pascal);
+});
diff --git a/plugins/adhd/lib/pull-component/__tests__/instance-resolver.test.js b/plugins/adhd/lib/pull-component/__tests__/instance-resolver.test.js
new file mode 100644
index 0000000..4e46657
--- /dev/null
+++ b/plugins/adhd/lib/pull-component/__tests__/instance-resolver.test.js
@@ -0,0 +1,171 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const {
+  resolveInstance,
+  nodeIdFromUrl,
+  pascalSlugFromPath,
+  importPathFor,
+  readExportName,
+} = require('../instance-resolver');
+
+test('nodeIdFromUrl: converts URL node-id form (A-B) to Figma ID form (A:B)', () => {
+  assert.equal(
+    nodeIdFromUrl('https://figma.com/design/abc?node-id=123-456'),
+    '123:456',
+  );
+  assert.equal(
+    nodeIdFromUrl('https://www.figma.com/design/abc/Test?node-id=91-18&t=foo'),
+    '91:18',
+  );
+});
+
+test('nodeIdFromUrl: returns null when no node-id present', () => {
+  assert.equal(nodeIdFromUrl('https://figma.com/design/abc/Test'), null);
+  assert.equal(nodeIdFromUrl(null), null);
+  assert.equal(nodeIdFromUrl(123), null);
+});
+
+test('pascalSlugFromPath: derives a PascalCase fallback name', () => {
+  assert.equal(pascalSlugFromPath('components/user-avatar/index.tsx'), 'UserAvatar');
+  assert.equal(pascalSlugFromPath('app/components/Button.tsx'), 'Button');
+  assert.equal(pascalSlugFromPath('app/cards/info-card/index.tsx'), 'InfoCard');
+});
+
+test('importPathFor: rewrites file path to @/-aliased form', () => {
+  assert.equal(
+    importPathFor('components/user-avatar/index.tsx'),
+    '@/components/user-avatar',
+  );
+  assert.equal(
+    importPathFor('app/cards/info-card.tsx'),
+    '@/app/cards/info-card',
+  );
+});
+
+const SAMPLE_CONFIG = `const config = {
+  figma: { url: "https://figma.com/design/abc/Test" },
+  components: {
+    "components/user-avatar/index.tsx": {
+      figma: { url: "https://figma.com/design/abc?node-id=123-456" },
+    },
+    "components/info-card/index.tsx": {
+      figma: { url: "https://figma.com/design/abc?node-id=789-1000" },
+    },
+  },
+  naming: "kebab-case",
+};
+export default config;`;
+
+test('resolveInstance: matched, file does not exist → uses PascalCase slug as exportName', () => {
+  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolve-instance-'));
+  const out = resolveInstance({
+    configSrc: SAMPLE_CONFIG,
+    componentId: '123:456',
+    repoRoot: tmpDir,
+  });
+  assert.equal(out.matched, true);
+  assert.equal(out.relPath, 'components/user-avatar/index.tsx');
+  assert.equal(out.importPath, '@/components/user-avatar');
+  assert.equal(out.fileExists, false);
+  assert.equal(out.exportName, 'UserAvatar');
+});
+
+test('resolveInstance: matched, file exists → reads exportName from "export function Name"', () => {
+  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolve-instance-'));
+  fs.mkdirSync(path.join(tmpDir, 'components/user-avatar'), { recursive: true });
+  fs.writeFileSync(
+    path.join(tmpDir, 'components/user-avatar/index.tsx'),
+    'export function MyCustomAvatar() { return null; }\n',
+  );
+  const out = resolveInstance({
+    configSrc: SAMPLE_CONFIG,
+    componentId: '123:456',
+    repoRoot: tmpDir,
+  });
+  assert.equal(out.matched, true);
+  assert.equal(out.fileExists, true);
+  // exportName comes from the file, not the slug.
+  assert.equal(out.exportName, 'MyCustomAvatar');
+});
+
+test('resolveInstance: matched, exists with "export default function Name"', () => {
+  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolve-instance-'));
+  fs.mkdirSync(path.join(tmpDir, 'components/user-avatar'), { recursive: true });
+  fs.writeFileSync(
+    path.join(tmpDir, 'components/user-avatar/index.tsx'),
+    'export default function Avatar() { return null; }\n',
+  );
+  const out = resolveInstance({
+    configSrc: SAMPLE_CONFIG,
+    componentId: '123:456',
+    repoRoot: tmpDir,
+  });
+  assert.equal(out.exportName, 'Avatar');
+});
+
+test('resolveInstance: matched, exists with "export const Name = ..."', () => {
+  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolve-instance-'));
+  fs.mkdirSync(path.join(tmpDir, 'components/user-avatar'), { recursive: true });
+  fs.writeFileSync(
+    path.join(tmpDir, 'components/user-avatar/index.tsx'),
+    'export const StyledAvatar = ({ size }) => null;\n',
+  );
+  const out = resolveInstance({
+    configSrc: SAMPLE_CONFIG,
+    componentId: '123:456',
+    repoRoot: tmpDir,
+  });
+  assert.equal(out.exportName, 'StyledAvatar');
+});
+
+test('resolveInstance: unmatched component-id → matched: false', () => {
+  const out = resolveInstance({
+    configSrc: SAMPLE_CONFIG,
+    componentId: '999:999',
+    repoRoot: '/tmp',
+  });
+  assert.equal(out.matched, false);
+});
+
+test('resolveInstance: config without components map → matched: false', () => {
+  const out = resolveInstance({
+    configSrc: 'export default { figma: { url: "x" } };',
+    componentId: '123:456',
+    repoRoot: '/tmp',
+  });
+  assert.equal(out.matched, false);
+});
+
+test('resolveInstance: handles entries without a figma.url (e.g. work-in-progress entries)', () => {
+  // An adhd.config.ts mid-edit might have a component entry without
+  // its figma URL filled in yet. resolveInstance should skip those
+  // gracefully, not throw.
+  const partial = `const config = {
+    components: {
+      "components/incomplete/index.tsx": { /* no figma yet */ },
+      "components/user-avatar/index.tsx": {
+        figma: { url: "https://figma.com/design/abc?node-id=123-456" },
+      },
+    },
+  };`;
+  const out = resolveInstance({
+    configSrc: partial,
+    componentId: '123:456',
+    repoRoot: '/tmp',
+  });
+  assert.equal(out.matched, true);
+  assert.equal(out.relPath, 'components/user-avatar/index.tsx');
+});
+
+test('readExportName: returns null when file is missing or has no export', () => {
+  assert.equal(readExportName('/nonexistent/path.tsx'), null);
+  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'export-name-'));
+  const empty = path.join(tmpDir, 'empty.tsx');
+  fs.writeFileSync(empty, 'const internal = 1;\n');
+  assert.equal(readExportName(empty), null);
+});
diff --git a/plugins/adhd/lib/pull-component/__tests__/resolve-write-target.test.js b/plugins/adhd/lib/pull-component/__tests__/resolve-write-target.test.js
new file mode 100644
index 0000000..5adeab3
--- /dev/null
+++ b/plugins/adhd/lib/pull-component/__tests__/resolve-write-target.test.js
@@ -0,0 +1,262 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { resolveWriteTarget, findDefinitionLayers, isAlias, aliasTarget } = require('../resolve-write-target');
+
+const theme = (parts) => ({
+  primitives: parts.primitives || {},
+  exposure:   parts.exposure   || {},
+  light:      parts.light      || {},
+  dark:       parts.dark       || {},
+});
+
+test('isAlias / aliasTarget recognize var() syntax', () => {
+  assert.equal(isAlias('var(--primary)'), true);
+  assert.equal(isAlias('var(--primary, #000)'), true);
+  assert.equal(isAlias('  var(--primary)  '), true);
+  assert.equal(isAlias('#0a0a0a'), false);
+  assert.equal(isAlias('0.25rem'), false);
+  assert.equal(aliasTarget('var(--primary)'), '--primary');
+  assert.equal(aliasTarget('var(--gold-500, #fallback)'), '--gold-500');
+  assert.equal(aliasTarget('#abc'), null);
+});
+
+test('findDefinitionLayers returns every layer that defines the cssVar, in cascade order', () => {
+  const t = theme({
+    primitives: { '--gold': '#c5a572' },
+    exposure:   { '--gold': 'var(--gold)' },        // unusual but possible
+    light:      { '--gold': '#c5a572' },
+    dark:       { '--gold': '#8b6f3e' },
+  });
+  const out = findDefinitionLayers('--gold', t);
+  assert.deepEqual(out.map(l => l.layer), ['primitive', 'exposure', 'light', 'dark']);
+});
+
+// ─── STRUCT015 case: variable missing from code entirely ─────────────
+
+test('missing-everywhere: writes as new @theme primitive', () => {
+  const t = theme({});
+  const actions = resolveWriteTarget('--color-gold', '#c5a572', t);
+  assert.deepEqual(actions, [
+    { kind: 'set-primitive', cssVar: '--color-gold', value: '#c5a572' },
+  ]);
+});
+
+// ─── Simple primitive cases ─────────────────────────────────────────
+
+test('primitive literal: writes directly to @theme primitives', () => {
+  const t = theme({ primitives: { '--color-gold': '#aaaaaa' } });
+  const actions = resolveWriteTarget('--color-gold', '#c5a572', t);
+  assert.deepEqual(actions, [
+    { kind: 'set-primitive', cssVar: '--color-gold', value: '#c5a572' },
+  ]);
+});
+
+// ─── Shadcn-style alias chain: --color-primary exposes --primary ────
+
+test('alias chain (exposure → light literal): writes to :root light', () => {
+  // The user\'s shadcn case. --color-primary lives only in @theme inline
+  // as `var(--primary)`. --primary is defined in :root (light). The
+  // write target must be --primary at :root, NOT --color-primary at
+  // @theme — overwriting the exposure entry would replace the alias
+  // with a literal and break dark-mode propagation.
+  const t = theme({
+    exposure: { '--color-primary': 'var(--primary)' },
+    light:    { '--primary': '#0a0a0a' },
+  });
+  const actions = resolveWriteTarget('--color-primary', '#1a1a1a', t);
+  assert.deepEqual(actions, [
+    { kind: 'set-semantic', cssVar: '--primary', mode: 'light', value: '#1a1a1a' },
+  ]);
+});
+
+test('alias chain (exposure → light + dark literals): defaults to writing light only (conservative)', () => {
+  // Figma reports a single mode value; we don\'t know what dark should
+  // be. Writing to both would silently flatten the designer\'s dark
+  // intent. Conservative default = light only; opts.bothModes overrides.
+  const t = theme({
+    exposure: { '--color-primary': 'var(--primary)' },
+    light:    { '--primary': '#0a0a0a' },
+    dark:     { '--primary': '#ededed' },
+  });
+  const actions = resolveWriteTarget('--color-primary', '#1a1a1a', t);
+  assert.deepEqual(actions, [
+    { kind: 'set-semantic', cssVar: '--primary', mode: 'light', value: '#1a1a1a' },
+  ]);
+});
+
+test('alias chain (exposure → light + dark literals) with bothModes: writes both light and dark', () => {
+  const t = theme({
+    exposure: { '--color-primary': 'var(--primary)' },
+    light:    { '--primary': '#0a0a0a' },
+    dark:     { '--primary': '#ededed' },
+  });
+  const actions = resolveWriteTarget('--color-primary', '#1a1a1a', t, { bothModes: true });
+  assert.deepEqual(actions, [
+    { kind: 'set-semantic', cssVar: '--primary', mode: 'light', value: '#1a1a1a' },
+    { kind: 'set-semantic', cssVar: '--primary', mode: 'dark', value: '#1a1a1a' },
+  ]);
+});
+
+test('alias chain (exposure → dark only): writes to :root dark', () => {
+  // Asymmetric case: variable only defined for dark mode. Write goes there.
+  const t = theme({
+    exposure: { '--color-overlay': 'var(--overlay)' },
+    dark:     { '--overlay': 'rgba(0,0,0,0.5)' },
+  });
+  const actions = resolveWriteTarget('--color-overlay', 'rgba(0,0,0,0.75)', t);
+  assert.deepEqual(actions, [
+    { kind: 'set-semantic', cssVar: '--overlay', mode: 'dark', value: 'rgba(0,0,0,0.75)' },
+  ]);
+});
+
+// ─── Multi-hop alias chains ─────────────────────────────────────────
+
+test('two-hop chain: --color-x → --x → --y (literal in primitives) writes to --y in @theme', () => {
+  const t = theme({
+    primitives: { '--y': '#abc' },
+    exposure:   { '--color-x': 'var(--x)' },
+    light:      { '--x': 'var(--y)' },
+  });
+  const actions = resolveWriteTarget('--color-x', '#def', t);
+  // --x is an alias in :root light; it forwards to --y. --y is a literal
+  // primitive. Write lands at --y in @theme.
+  assert.deepEqual(actions, [
+    { kind: 'set-primitive', cssVar: '--y', value: '#def' },
+  ]);
+});
+
+test('three-hop chain bottoms out cleanly', () => {
+  const t = theme({
+    primitives: { '--root-value': '#000' },
+    exposure:   { '--surface-on-canvas': 'var(--surface-default)' },
+    light:      { '--surface-default': 'var(--root-value)' },
+  });
+  const actions = resolveWriteTarget('--surface-on-canvas', '#111', t);
+  assert.deepEqual(actions, [
+    { kind: 'set-primitive', cssVar: '--root-value', value: '#111' },
+  ]);
+});
+
+// ─── Defensive guards: cycles, runaways, broken refs ────────────────
+
+test('alias cycle: stops gracefully, falls back to primitive write', () => {
+  const t = theme({
+    light: { '--a': 'var(--b)', '--b': 'var(--a)' },
+  });
+  const actions = resolveWriteTarget('--a', '#fff', t);
+  // No literal terminal; cycle detection lands back at writing as primitive.
+  assert.equal(actions.length, 1);
+  assert.equal(actions[0].kind, 'set-primitive');
+});
+
+test('alias to undefined variable: falls back to primitive write at the dangling cssVar', () => {
+  const t = theme({
+    exposure: { '--color-primary': 'var(--primary)' },
+    // --primary is undefined anywhere
+  });
+  const actions = resolveWriteTarget('--color-primary', '#fff', t);
+  // Chain resolves to --primary which isn\'t defined → STRUCT015 path
+  // (write as new primitive at the dangling target).
+  assert.deepEqual(actions, [
+    { kind: 'set-primitive', cssVar: '--primary', value: '#fff' },
+  ]);
+});
+
+test('depth-bounded: pathological 20-hop chain doesn\'t loop forever', () => {
+  const t = theme({
+    light: Object.fromEntries(
+      Array.from({ length: 20 }, (_, i) => [`--v${i}`, `var(--v${i + 1})`]),
+    ),
+  });
+  // No terminal — bottoms out at depth limit, falls back to primitive write
+  // at SOME var in the chain. Just confirm we don\'t hang.
+  const actions = resolveWriteTarget('--v0', '#fff', t);
+  assert.ok(actions.length > 0);
+  assert.equal(actions[0].kind, 'set-primitive');
+});
+
+// ─── Idempotent behavior ────────────────────────────────────────────
+
+test('writing the same value again still resolves to the same write target', () => {
+  const t = theme({
+    exposure: { '--color-primary': 'var(--primary)' },
+    light:    { '--primary': '#0a0a0a' },
+  });
+  const a = resolveWriteTarget('--color-primary', '#1a1a1a', t);
+  const b = resolveWriteTarget('--color-primary', '#1a1a1a', t);
+  assert.deepEqual(a, b);
+});
+
+// ─── End-to-end: resolveWriteTarget actions → applyToCss → categorizer clean
+
+test('end-to-end: shadcn alias chain — action applies to :root, categorizer sees the new value, no remaining conflict', () => {
+  // The full round-trip for the user\'s reported bug. globals.css has
+  // the shadcn semantic-via-exposure pattern. Figma reports a different
+  // primary color. resolveWriteTarget figures out where to write, then
+  // applyToCss does the actual CSS edit, then the categorizer re-runs
+  // and sees no conflict.
+  const { applyToCss } = require('../../design-system/code-writer');
+  const { categorizeVariables } = require('../../lint-engine/variable-categorizer');
+  const { parseTheme } = require('../../lint-engine/theme-parser');
+  const cssBefore = `
+    :root { --primary: #0a0a0a; }
+    @theme inline { --color-primary: var(--primary); }
+  `;
+  // Figma wants this primary to be a different color.
+  const figmaRgb = { r: 0.1, g: 0.1, b: 0.1, a: 1 };
+  const figmaHex = '#1a1a1a';
+
+  // 1. Confirm the categorizer currently surfaces a conflict.
+  const themeBefore = parseTheme(cssBefore);
+  const before = categorizeVariables({ 'color/primary': figmaRgb }, themeBefore);
+  assert.equal(before.length, 1, 'should surface a conflict before write');
+  assert.equal(before[0].status, 'conflict');
+
+  // 2. Resolve the write target and apply it.
+  const actions = resolveWriteTarget('--color-primary', figmaHex, themeBefore);
+  assert.deepEqual(actions, [
+    { kind: 'set-semantic', cssVar: '--primary', mode: 'light', value: figmaHex },
+  ]);
+  const cssAfter = applyToCss(cssBefore, actions);
+
+  // 3. The :root --primary value is now what Figma wanted.
+  assert.match(cssAfter, /:root\s*\{[^}]*--primary:\s*#1a1a1a/);
+  // The exposure layer is untouched (still aliasing --primary).
+  assert.match(cssAfter, /@theme inline\s*\{[^}]*--color-primary:\s*var\(--primary\)/);
+
+  // 4. Re-categorize against the updated theme — conflict is gone.
+  const themeAfter = parseTheme(cssAfter);
+  const after = categorizeVariables({ 'color/primary': figmaRgb }, themeAfter);
+  assert.equal(after.length, 0, 'conflict should clear after the alias-aware write');
+});
+
+test('end-to-end: STRUCT015 missing variable — applyToCss adds to @theme, then no conflict', () => {
+  const { applyToCss } = require('../../design-system/code-writer');
+  const { categorizeVariables } = require('../../lint-engine/variable-categorizer');
+  const { parseTheme } = require('../../lint-engine/theme-parser');
+  const cssBefore = `@theme { --existing: #fff; }\n:root {}\n`;
+  // Pick a value whose RGB form rounds cleanly: 199/192/153 → #c7c099.
+  const figmaRgb = { r: 199 / 255, g: 192 / 255, b: 153 / 255, a: 1 };
+  const hex = '#c7c099';
+
+  // 1. Before: variable is missing.
+  const themeBefore = parseTheme(cssBefore);
+  const before = categorizeVariables({ 'color/gold': figmaRgb }, themeBefore);
+  assert.equal(before.length, 1);
+  assert.equal(before[0].status, 'missing');
+
+  // 2. Resolve write target — variable not defined anywhere → primitive.
+  const actions = resolveWriteTarget('--color-gold', hex, themeBefore);
+  assert.deepEqual(actions, [
+    { kind: 'set-primitive', cssVar: '--color-gold', value: hex },
+  ]);
+  const cssAfter = applyToCss(cssBefore, actions);
+  assert.match(cssAfter, new RegExp(`@theme\\s*\\{[^}]*--color-gold:\\s*${hex}`));
+
+  // 3. Re-categorize: clean.
+  const themeAfter = parseTheme(cssAfter);
+  const after = categorizeVariables({ 'color/gold': figmaRgb }, themeAfter);
+  assert.equal(after.length, 0);
+});
diff --git a/plugins/adhd/lib/pull-component/cli.js b/plugins/adhd/lib/pull-component/cli.js
index 78f18c1..e3f4774 100644
--- a/plugins/adhd/lib/pull-component/cli.js
+++ b/plugins/adhd/lib/pull-component/cli.js
@@ -2,7 +2,14 @@
 'use strict';
 
 const fs = require('node:fs');
+const path = require('node:path');
 const { readComponentMapping, addComponentMapping, reverseLookupPath } = require('./config-writer');
+const { computeFingerprint, relevantConfigFields } = require('./fingerprint');
+const { readComponentState, writeComponentState } = require('./config-state');
+const { resolveWriteTarget } = require('./resolve-write-target');
+const { resolveInstance } = require('./instance-resolver');
+const { parseTheme } = require('../lint-engine/theme-parser');
+const { figmaToCssVar } = require('../lint-engine/name-normalizer');
 
 function parseArgs(argv) {
   const args = { _: [] };
@@ -18,8 +25,36 @@ function parseArgs(argv) {
 function printUsage() {
   console.log(`Usage:
   cli.js config-write --config  --path  --figma-url 
-  cli.js config-read --config  --path 
-  cli.js config-reverse --config  --figma-url `);
+  cli.js config-read  --config  --path 
+  cli.js config-reverse --config  --figma-url 
+  cli.js fingerprint-check --config  --path  --ctx  --vars 
+  cli.js fingerprint-write --config  --path  --ctx  --vars 
+  cli.js resolve-actions   --globals  --figma-path  --value  [--both-modes]
+  cli.js resolve-instance  --config  --component-id  [--repo-root ]
+
+fingerprint-check:
+  Computes the fingerprint of the fresh Figma extract + relevant config bits
+  and compares to the stored fingerprint in adhd.config.ts. Writes JSON to
+  stdout: { current, stored, match }. Exit 0 always — the SKILL branches on
+  the parsed output.
+
+fingerprint-write:
+  Computes the fingerprint and writes it (plus an ISO pulledAt timestamp)
+  into adhd.config.ts at components.. Used after a successful pull.`);
+}
+
+// Parse adhd.config.ts text for the fields that affect pull output.
+// Permissive regex — same approach as parsePushTokensFromConfig in
+// lib/design-system/dispositions.js. The schema is small and stable
+// enough that a TS evaluator isn't worth the dependency.
+function parsePullRelevantConfig(src) {
+  const naming = (/naming\s*:\s*["']([^"']+)["']/.exec(src) || [])[1] || 'kebab-case';
+  const cssEntry = (/cssEntry\s*:\s*["']([^"']+)["']/.exec(src) || [])[1] || null;
+  return { naming, cssEntry };
+}
+
+function readJsonOrEmpty(p) {
+  try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
 }
 
 function main() {
@@ -63,6 +98,75 @@ function main() {
     process.exit(0);
   }
 
+  if (cmd === 'fingerprint-check') {
+    if (!args.config || !args.path || !args.ctx || !args.vars) {
+      console.error('Usage: fingerprint-check --config  --path  --ctx  --vars ');
+      process.exit(2);
+    }
+    const configSrc = fs.readFileSync(args.config, 'utf8');
+    const ctx = readJsonOrEmpty(args.ctx);
+    const vars = readJsonOrEmpty(args.vars);
+    const current = computeFingerprint({
+      figma: { ctx, vars },
+      config: relevantConfigFields(parsePullRelevantConfig(configSrc)),
+    });
+    const stored = readComponentState(configSrc, args.path);
+    process.stdout.write(JSON.stringify({
+      current,
+      stored,
+      match: !!(stored && stored.fingerprint === current),
+    }));
+    process.exit(0);
+  }
+
+  if (cmd === 'resolve-actions') {
+    if (!args.globals || !args['figma-path'] || !args.value) {
+      console.error('Usage: resolve-actions --globals  --figma-path  --value  [--both-modes]');
+      process.exit(2);
+    }
+    const css = fs.readFileSync(args.globals, 'utf8');
+    const theme = parseTheme(css);
+    const cssVar = figmaToCssVar(args['figma-path']);
+    const opts = 'both-modes' in args ? { bothModes: true } : {};
+    const actions = resolveWriteTarget(cssVar, args.value, theme, opts);
+    process.stdout.write(JSON.stringify({ cssVar, actions }, null, 2));
+    process.exit(0);
+  }
+
+  if (cmd === 'resolve-instance') {
+    if (!args.config || !args['component-id']) {
+      console.error('Usage: resolve-instance --config  --component-id  [--repo-root ]');
+      process.exit(2);
+    }
+    const configSrc = fs.readFileSync(args.config, 'utf8');
+    const out = resolveInstance({
+      configSrc,
+      componentId: args['component-id'],
+      repoRoot: args['repo-root'] || path.dirname(path.resolve(args.config)),
+    });
+    process.stdout.write(JSON.stringify(out, null, 2));
+    process.exit(out.matched ? 0 : 1);
+  }
+
+  if (cmd === 'fingerprint-write') {
+    if (!args.config || !args.path || !args.ctx || !args.vars) {
+      console.error('Usage: fingerprint-write --config  --path  --ctx  --vars ');
+      process.exit(2);
+    }
+    const configSrc = fs.readFileSync(args.config, 'utf8');
+    const ctx = readJsonOrEmpty(args.ctx);
+    const vars = readJsonOrEmpty(args.vars);
+    const fingerprint = computeFingerprint({
+      figma: { ctx, vars },
+      config: relevantConfigFields(parsePullRelevantConfig(configSrc)),
+    });
+    const pulledAt = new Date().toISOString();
+    const next = writeComponentState(configSrc, args.path, { pulledAt, fingerprint });
+    fs.writeFileSync(args.config, next);
+    process.stdout.write(JSON.stringify({ fingerprint, pulledAt }));
+    process.exit(0);
+  }
+
   console.error('Unknown subcommand: ' + cmd);
   process.exit(2);
 }
diff --git a/plugins/adhd/lib/pull-component/config-state.js b/plugins/adhd/lib/pull-component/config-state.js
new file mode 100644
index 0000000..3fa4eba
--- /dev/null
+++ b/plugins/adhd/lib/pull-component/config-state.js
@@ -0,0 +1,133 @@
+'use strict';
+
+// Read + write per-component pull state (pulledAt + fingerprint) in
+// adhd.config.ts. The user wants this in the config rather than a
+// sidecar because pull state is "true state we should be following" —
+// a fingerprint mismatch on next pull means the source has drifted.
+//
+// State shape inside `components: { '': { ... } }`:
+//
+//   components: {
+//     'app/components/Button': {
+//       figma: { url: '...' },
+//       pulledAt: '2026-05-12T14:30:00.000Z',
+//       fingerprint: 'a1b2c3d4',
+//     },
+//   }
+//
+// Brace-counted parsing (not greedy regex) so nested `figma: { url }`
+// doesn't confuse the boundary detection.
+
+function escapeForRegex(s) {
+  return s.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&');
+}
+
+// Locate the `{ ... }` value block for the given component-path key
+// within the components map. Returns `{ openAt, closeAt, body }` —
+// `openAt` and `closeAt` are absolute indices of the opening and
+// closing braces. Returns null when the path key isn't found.
+function findComponentBlock(src, componentPath) {
+  if (!src) return null;
+  const keyPattern = new RegExp(
+    '["\']' + escapeForRegex(componentPath) + '["\']\\s*:\\s*\\{',
+  );
+  const m = keyPattern.exec(src);
+  if (!m) return null;
+  const openAt = m.index + m[0].length - 1;
+  let depth = 1;
+  let i = openAt + 1;
+  while (i < src.length && depth > 0) {
+    if (src[i] === '{') depth++;
+    else if (src[i] === '}') depth--;
+    if (depth === 0) break;
+    i++;
+  }
+  if (depth !== 0) return null;
+  return { openAt, closeAt: i, body: src.slice(openAt + 1, i) };
+}
+
+// Read pulledAt + fingerprint from a component block. Returns null when
+// the component or either field is missing — caller treats that as
+// "no cached fingerprint, must pull fresh".
+function readComponentState(src, componentPath) {
+  const block = findComponentBlock(src, componentPath);
+  if (!block) return null;
+  // Match only top-level fields within the block (depth 0). Use a
+  // brace-counted scan so a nested `figma: { url: "..." }` doesn't
+  // shadow a real top-level `fingerprint` field.
+  const findField = (name) => {
+    const re = new RegExp(name + '\\s*:\\s*["\']([^"\']+)["\']', 'g');
+    let d = 0;
+    let i = 0;
+    while (i < block.body.length) {
+      const ch = block.body[i];
+      if (ch === '{') { d++; i++; continue; }
+      if (ch === '}') { d--; i++; continue; }
+      if (d === 0) {
+        re.lastIndex = i;
+        const m = re.exec(block.body);
+        if (m && m.index === i) return m[1];
+      }
+      i++;
+    }
+    return null;
+  };
+  const pulledAt = findField('pulledAt');
+  const fingerprint = findField('fingerprint');
+  if (!pulledAt || !fingerprint) return null;
+  return { pulledAt, fingerprint };
+}
+
+// Upsert pulledAt + fingerprint into the component's block. If the
+// fields exist (any quoting style), their values are replaced; if not,
+// they're inserted before the closing `}` with consistent indentation
+// matched from the surrounding block.
+//
+// Throws when the component path isn't in the config — caller should
+// guard, since pull-component requires the path to be configured anyway.
+function writeComponentState(src, componentPath, { pulledAt, fingerprint }) {
+  const block = findComponentBlock(src, componentPath);
+  if (!block) {
+    throw new Error('Component not found in adhd.config.ts: ' + componentPath);
+  }
+  // Update if present, insert if not — same logic for both fields.
+  const upsert = (currentSrc, blockOpenAt, blockCloseAt, name, value) => {
+    const localBody = currentSrc.slice(blockOpenAt + 1, blockCloseAt);
+    const re = new RegExp('(' + name + '\\s*:\\s*)["\'][^"\']*["\']');
+    const m = re.exec(localBody);
+    if (m) {
+      // Replace just the quoted value, preserving the existing quote style.
+      const valueStart = blockOpenAt + 1 + m.index + m[1].length;
+      const valueEnd = valueStart + (m[0].length - m[1].length);
+      const quote = currentSrc[valueStart];
+      return currentSrc.slice(0, valueStart) + quote + value + quote + currentSrc.slice(valueEnd);
+    }
+    // Insert before the closing `}`. Indent one nesting deeper than
+    // the closing brace's line — the brace sits at the block's outer
+    // indent, so its leading whitespace + 2 spaces gives us the inner
+    // indent the existing entries use.
+    const before = currentSrc.slice(0, blockCloseAt);
+    const lastNl = before.lastIndexOf('\n');
+    const closingLine = before.slice(lastNl + 1);
+    const closingIndent = /^\s*/.exec(closingLine)[0];
+    const indent = closingIndent + '  ';
+    // Ensure the prior content ends in `,` — adhd configs always use
+    // trailing commas, so an entry without one would be malformed.
+    let prefix = '';
+    const trimmedPrior = before.slice(0, lastNl).trimEnd();
+    if (trimmedPrior.length > 0 && !trimmedPrior.endsWith(',') && !trimmedPrior.endsWith('{')) {
+      prefix = ',';
+    }
+    const insertion = prefix + '\n' + indent + name + ': "' + value + '",';
+    return currentSrc.slice(0, lastNl) + insertion + currentSrc.slice(lastNl);
+  };
+  let out = src;
+  // Order matters — pulledAt first so re-finding the block uses fresh indices.
+  out = upsert(out, block.openAt, block.closeAt, 'pulledAt', pulledAt);
+  // Re-find the block since indices shifted.
+  const block2 = findComponentBlock(out, componentPath);
+  out = upsert(out, block2.openAt, block2.closeAt, 'fingerprint', fingerprint);
+  return out;
+}
+
+module.exports = { findComponentBlock, readComponentState, writeComponentState };
diff --git a/plugins/adhd/lib/pull-component/config-writer.js b/plugins/adhd/lib/pull-component/config-writer.js
index 1f4b344..4c63475 100644
--- a/plugins/adhd/lib/pull-component/config-writer.js
+++ b/plugins/adhd/lib/pull-component/config-writer.js
@@ -327,4 +327,10 @@ module.exports = {
   readComponentMapping,
   reverseLookupPath,
   addComponentMapping,
+  // Lower-level parsing primitives — used by instance-resolver to
+  // walk the components map looking for a Figma node-id match.
+  findConfigObjectRange,
+  findComponentsRange,
+  iterateObjectEntries,
+  findFigmaUrlInEntry,
 };
diff --git a/plugins/adhd/lib/pull-component/fingerprint.js b/plugins/adhd/lib/pull-component/fingerprint.js
new file mode 100644
index 0000000..0687bd0
--- /dev/null
+++ b/plugins/adhd/lib/pull-component/fingerprint.js
@@ -0,0 +1,59 @@
+'use strict';
+
+// Per-component fingerprints for pull-component / pull-all-components.
+//
+// After a successful pull we record an 8-char SHA-256 prefix in
+// adhd.config.ts (next to `pulledAt`). On the next pull we hash the
+// fresh Figma extract + the adhd-config fields that affect pull output
+// (naming convention etc.) and compare. Match → early-exit, nothing
+// changed since last pull, skip the parse/diff/write loop.
+//
+// Fail mode is intentionally false-positive: any change to anything in
+// the hashed input forces a re-sync. False negatives (skip when output
+// would differ) would be a silent correctness bug, so we'd rather pay
+// for occasional redundant re-syncs.
+//
+// 8 hex characters = 32 bits of fingerprint space. Collisions across
+// the dozens-to-hundreds of components a typical project tracks are
+// astronomically unlikely; the lookup happens by component path
+// anyway, so even a hash collision wouldn't cross-contaminate.
+
+const crypto = require('node:crypto');
+
+// Stable JSON: keys sorted at every level. JSON.stringify's iteration
+// order is V8-stable in practice but not guaranteed by the spec; the
+// canonical form removes that dependency.
+function canonicalJson(value) {
+  if (value === null || typeof value !== 'object') return JSON.stringify(value);
+  if (Array.isArray(value)) {
+    return '[' + value.map(canonicalJson).join(',') + ']';
+  }
+  const keys = Object.keys(value).sort();
+  return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalJson(value[k])).join(',') + '}';
+}
+
+// Hash an arbitrary input (extract + relevant config bits, etc.) to an
+// 8-hex-char Git-style short SHA. Caller controls what goes in.
+function computeFingerprint(input) {
+  const canonical = canonicalJson(input);
+  return crypto.createHash('sha256').update(canonical).digest('hex').slice(0, 8);
+}
+
+// Extract the adhd-config fields that change pull-component's OUTPUT
+// (not just its execution). Anything in here goes into the fingerprint
+// alongside the Figma extract — changing naming-convention or cssEntry
+// must invalidate cached fingerprints because the same Figma input
+// produces different code with the new config.
+//
+// Intentionally narrow: `figma.url` doesn't affect output (it's where
+// to fetch from, not what to write); `--annotate` / `--allow-unbound`
+// are flags that don't change a successful pull's generated code.
+// Add fields here if/when they're observed to affect output.
+function relevantConfigFields(config) {
+  return {
+    naming: config && config.naming != null ? config.naming : 'kebab-case',
+    cssEntry: config && config.cssEntry ? String(config.cssEntry) : null,
+  };
+}
+
+module.exports = { computeFingerprint, canonicalJson, relevantConfigFields };
diff --git a/plugins/adhd/lib/pull-component/instance-resolver.js b/plugins/adhd/lib/pull-component/instance-resolver.js
new file mode 100644
index 0000000..f31b3c5
--- /dev/null
+++ b/plugins/adhd/lib/pull-component/instance-resolver.js
@@ -0,0 +1,123 @@
+'use strict';
+
+// Resolve a Figma component-set ID (e.g. "123:456") to its React
+// import metadata using `adhd.config.ts`'s `components: { ... }` map.
+// Powers pull-component's scaffold-mode handling of INSTANCE children:
+// when the simple-layout-driven rubric sees a node binding
+// `mainComponent` of another tracked component, this resolver produces
+// the import path / export name so the generated JSX can read
+// `` with a proper import at the top.
+//
+// Per the design choice ("(a) refuse / abort"), when a Figma instance's
+// mainComponent isn't in adhd.config.ts, this resolver returns
+// `{ matched: false }` and the SKILL aborts with a "pull this first"
+// message. The user pulls the dependency, then re-runs the parent.
+
+const fs = require('node:fs');
+const path = require('node:path');
+const {
+  findConfigObjectRange,
+  findComponentsRange,
+  iterateObjectEntries,
+  findFigmaUrlInEntry,
+} = require('./config-writer');
+
+// Extract the Figma node-id query parameter from a design URL and
+// convert it from the URL form (`A-B`) to the internal Figma ID form
+// (`A:B`). Returns null when the URL has no node-id, isn't a Figma
+// design URL, or otherwise can't be parsed.
+function nodeIdFromUrl(url) {
+  if (typeof url !== 'string') return null;
+  const m = /[?&]node-id=([^&]+)/.exec(url);
+  if (!m) return null;
+  // The URL form uses `-` between major:minor; the API uses `:`. Only
+  // the FIRST `-` is the separator — variant IDs can themselves
+  // contain hyphens (e.g. `123-456-7` → `123:456-7` is wrong; the
+  // correct conversion is `123:456` and the trailing `-7` is part of
+  // a deeper id, but Figma's URL exporter uses `-` consistently). The
+  // standard rule the rest of ADHD uses is to swap the FIRST hyphen
+  // only; mirror that here.
+  const dec = decodeURIComponent(m[1]);
+  const i = dec.indexOf('-');
+  if (i < 0) return dec;
+  return dec.slice(0, i) + ':' + dec.slice(i + 1);
+}
+
+// PascalCase a file path's slug for use as a fallback export name when
+// the target file doesn't exist yet (or its export name can't be
+// inferred via Read). Same rule sync-docs uses.
+function pascalSlugFromPath(relPath) {
+  const noExt = relPath.replace(/\.(t|j)sx?$/, '').replace(/\/index$/, '');
+  const slug = noExt.split('/').pop() || '';
+  return slug.split(/[-_]+/).filter(Boolean).map(w => w[0].toUpperCase() + w.slice(1)).join('');
+}
+
+// Derive a `@/`-aliased import path from a relative file path. Mirrors
+// the sync-docs config-parser's `importPathFor` so generated imports
+// look native in a Next.js consumer repo.
+function importPathFor(relPath) {
+  return '@/' + relPath.replace(/\.(t|j)sx?$/, '').replace(/\/index$/, '');
+}
+
+// Best-effort read of the exported component name from a TS/TSX file.
+// Looks for `export default function Name`, `export function Name`,
+// `export const Name`, or `export default Name`. Returns null when no
+// match — caller falls back to the PascalCase slug.
+function readExportName(absPath) {
+  let src;
+  try { src = fs.readFileSync(absPath, 'utf8'); }
+  catch { return null; }
+  const patterns = [
+    /export\s+default\s+function\s+([A-Z][A-Za-z0-9_]*)/,
+    /export\s+function\s+([A-Z][A-Za-z0-9_]*)/,
+    /export\s+const\s+([A-Z][A-Za-z0-9_]*)\s*=/,
+    /export\s+default\s+([A-Z][A-Za-z0-9_]*)\s*;?/,
+  ];
+  for (const re of patterns) {
+    const m = re.exec(src);
+    if (m) return m[1];
+  }
+  return null;
+}
+
+// Main resolver. Walks every component entry in adhd.config.ts,
+// extracts each entry's figma node-id, converts to the internal ID
+// form, and matches against `componentId`. Returns:
+//   { matched: true, relPath, absPath, importPath, exportName, fileExists }
+// when the component-id is tracked, with `fileExists` indicating
+// whether the React file is on disk yet (the SKILL surfaces this
+// distinction so the "pull this dependency first" message points at
+// the right next step).
+//
+// Returns `{ matched: false }` when the component-id isn't in the
+// config — caller aborts with a clear error.
+function resolveInstance({ configSrc, componentId, repoRoot }) {
+  const cfg = findConfigObjectRange(configSrc);
+  if (!cfg) return { matched: false };
+  const comps = findComponentsRange(configSrc, cfg);
+  if (!comps) return { matched: false };
+
+  for (const entry of iterateObjectEntries(configSrc, comps)) {
+    if (configSrc[entry.valueStart] !== '{') continue;
+    const urlInfo = findFigmaUrlInEntry(configSrc, { start: entry.valueStart, end: entry.valueEnd });
+    if (!urlInfo) continue;
+    const entryId = nodeIdFromUrl(urlInfo.urlText);
+    if (entryId !== componentId) continue;
+
+    const relPath = entry.key;
+    const absPath = repoRoot ? path.join(repoRoot, relPath) : relPath;
+    const fileExists = fs.existsSync(absPath);
+    const exportName = (fileExists ? readExportName(absPath) : null) || pascalSlugFromPath(relPath);
+    return {
+      matched: true,
+      relPath,
+      absPath,
+      importPath: importPathFor(relPath),
+      exportName,
+      fileExists,
+    };
+  }
+  return { matched: false };
+}
+
+module.exports = { resolveInstance, nodeIdFromUrl, pascalSlugFromPath, importPathFor, readExportName };
diff --git a/plugins/adhd/lib/pull-component/resolve-write-target.js b/plugins/adhd/lib/pull-component/resolve-write-target.js
new file mode 100644
index 0000000..2b646e3
--- /dev/null
+++ b/plugins/adhd/lib/pull-component/resolve-write-target.js
@@ -0,0 +1,114 @@
+'use strict';
+
+// Given a CSS variable name and a parsed theme, compute the action(s)
+// needed to make that variable resolve to a target value in globals.css.
+// Walks the alias chain until it reaches the literal source (the bottom
+// of the chain), so writes land in the layer that actually controls
+// rendering — never in the @theme inline exposure layer (which is just
+// a re-export) and never on top of an existing alias relationship.
+//
+// Returns an array of actions in the shape consumed by
+// `lib/design-system/code-writer.js`'s `applyToCss`:
+//   { kind: 'set-primitive',  cssVar, value }
+//   { kind: 'set-semantic',   cssVar, mode: 'light' | 'dark', value }
+//
+// Multiple actions can be returned when a mode-less write target
+// resolves through alias chains to BOTH :root and :root[data-theme="dark"]
+// — but only when explicitly requested via `opts.bothModes: true`. The
+// safer default (single conservative write) covers the most common
+// case where a designer picks "Take Figma's value" and Figma reports
+// just one mode: we write to light, the designer keeps control of dark.
+
+const VAR_REF_RE = /^var\(\s*(--[A-Za-z0-9_-]+)\s*(?:,[^)]*)?\)$/i;
+
+function isAlias(value) {
+  return typeof value === 'string' && VAR_REF_RE.test(value.trim());
+}
+
+function aliasTarget(value) {
+  const m = VAR_REF_RE.exec(String(value).trim());
+  return m ? m[1] : null;
+}
+
+// Find every layer where `cssVar` is defined. Order reflects which
+// layer "wins" at runtime: primitives + exposure flow into the cascade
+// first, then :root (light), then :root[data-theme="dark"] / @media
+// dark. For write-target resolution, we walk in cascade order and stop
+// at the first LITERAL value (not an alias).
+function findDefinitionLayers(cssVar, theme) {
+  const layers = [];
+  if (theme.primitives && theme.primitives[cssVar] != null) {
+    layers.push({ layer: 'primitive', value: theme.primitives[cssVar] });
+  }
+  if (theme.exposure && theme.exposure[cssVar] != null) {
+    layers.push({ layer: 'exposure', value: theme.exposure[cssVar] });
+  }
+  if (theme.light && theme.light[cssVar] != null) {
+    layers.push({ layer: 'light', value: theme.light[cssVar] });
+  }
+  if (theme.dark && theme.dark[cssVar] != null) {
+    layers.push({ layer: 'dark', value: theme.dark[cssVar] });
+  }
+  return layers;
+}
+
+// Walk the alias chain starting from `cssVar`, returning the action(s)
+// needed to put `value` at the literal source. Bounded recursion guards
+// against pathological cycles.
+function resolveWriteTarget(cssVar, value, theme, opts = {}) {
+  return walk(cssVar, value, theme, opts, 0, new Set());
+}
+
+function walk(cssVar, value, theme, opts, depth, visited) {
+  if (depth > 8 || visited.has(cssVar)) {
+    // Cycle or runaway chain — fall back to writing as a primitive.
+    return [{ kind: 'set-primitive', cssVar, value }];
+  }
+  visited.add(cssVar);
+
+  const layers = findDefinitionLayers(cssVar, theme);
+
+  // Variable not defined anywhere — it's missing from code. Land it in
+  // @theme as a new primitive. This is the STRUCT015 "Add to globals.css"
+  // case for variables Figma reports but code has never declared.
+  if (layers.length === 0) {
+    return [{ kind: 'set-primitive', cssVar, value }];
+  }
+
+  // Walk through the layers in cascade order. The first LITERAL we find
+  // is the source of truth; the first ALIAS sends us deeper. Don't mix
+  // — if primitive/exposure layers are aliases AND :root layers carry
+  // literals, the literals win at runtime so we write there.
+  const literalLayers = layers.filter(l => !isAlias(l.value));
+  if (literalLayers.length === 0) {
+    // Every defined layer is an alias — follow the first one's target.
+    // (Same target across layers is the common case; if they diverge
+    // we just take primitive > exposure > light > dark order.)
+    const next = aliasTarget(layers[0].value);
+    if (!next) {
+      // Defensive — isAlias was true but target couldn't be parsed.
+      return [{ kind: 'set-primitive', cssVar, value }];
+    }
+    return walk(next, value, theme, opts, depth + 1, visited);
+  }
+
+  // Pick a write strategy from the literal layers:
+  //  - primitive layer literal → set-primitive at this cssVar
+  //  - exposure-only literal (unusual) → treat as primitive write
+  //  - light-only literal → set-semantic light
+  //  - dark-only literal → set-semantic dark
+  //  - BOTH light + dark → conservative default: write to light only;
+  //    pass opts.bothModes to write to both.
+  const has = (layer) => literalLayers.some(l => l.layer === layer);
+  if (has('primitive') || has('exposure')) {
+    return [{ kind: 'set-primitive', cssVar, value }];
+  }
+  const actions = [];
+  if (has('light')) actions.push({ kind: 'set-semantic', cssVar, mode: 'light', value });
+  if (has('dark') && (opts.bothModes || !has('light'))) {
+    actions.push({ kind: 'set-semantic', cssVar, mode: 'dark', value });
+  }
+  return actions;
+}
+
+module.exports = { resolveWriteTarget, findDefinitionLayers, isAlias, aliasTarget };
diff --git a/plugins/adhd/lib/push-component/__tests__/cli.test.js b/plugins/adhd/lib/push-component/__tests__/cli.test.js
index 08f9fb2..a961c3d 100644
--- a/plugins/adhd/lib/push-component/__tests__/cli.test.js
+++ b/plugins/adhd/lib/push-component/__tests__/cli.test.js
@@ -203,4 +203,58 @@ test('preflight subcommand produces a lint report', () => {
   assert.equal(result.status, 0, result.stderr);
   const report = fs.readFileSync(out, 'utf8');
   assert.match(report, /ADHD/);
+
+  // The preflight CLI also writes a JSON sidecar next to the markdown report
+  // (containing the engine's full structured output with per-violation nodeIds).
+  // This is what /adhd:push-component reads when --annotate is set.
+  const sidecar = out.replace(/\.md$/, '.json');
+  assert.ok(fs.existsSync(sidecar), 'expected JSON sidecar next to report');
+  const parsed = JSON.parse(fs.readFileSync(sidecar, 'utf8'));
+  assert.ok(Array.isArray(parsed.structure));
+  assert.ok(Array.isArray(parsed.variable));
+});
+
+test('preflight forwards --var-id-map to the lint engine (required for STRUCT015/016 per-layer)', () => {
+  // Without varidmap the lint engine can't bridge node-level
+  // boundVariables (referenced by Figma id) to the variable names it
+  // reasons about — and Phase 10.7's interactive resolution depends on
+  // those rules firing. This guard test ensures the flag survives the
+  // push-component → lint-engine subprocess hop.
+  const ctx = tmp('ctx.json', JSON.stringify({
+    id: '5:1', name: 'X', type: 'COMPONENT_SET',
+    componentPropertyDefinitions: { size: { type: 'VARIANT', defaultValue: 'sm', variantOptions: ['xs', 'sm'] } },
+    children: [
+      {
+        id: '5:2', name: 'X/size=xs', type: 'COMPONENT', variantProperties: { size: 'xs' }, layoutMode: 'VERTICAL',
+        boundVariables: { letterSpacing: { id: 'VAR:bad', type: 'VARIABLE_ALIAS' } },
+        children: [],
+      },
+      {
+        id: '5:3', name: 'X/size=sm', type: 'COMPONENT', variantProperties: { size: 'sm' }, layoutMode: 'VERTICAL',
+        children: [],
+      },
+    ],
+  }));
+  // Figma variable bound at letter-spacing is in the spacing domain — STRUCT012.
+  const vars = tmp('vars.json', JSON.stringify({ 'Spacing/4': '1rem' }));
+  const idMap = tmp('varidmap.json', JSON.stringify({ 'VAR:bad': 'Spacing/4' }));
+  const css = tmp('globals.css', '@theme {}');
+  const cfg = tmp('adhd.config.ts', 'export default { naming: "kebab-case" };');
+  const out = tmp('report.md', '');
+  const result = spawnSync('node', [
+    CLI, 'preflight',
+    '--design-context', ctx,
+    '--variable-defs', vars,
+    '--var-id-map', idMap,
+    '--globals-css', css,
+    '--config', cfg,
+    '--output', out,
+  ], { encoding: 'utf8' });
+
+  const sidecar = JSON.parse(fs.readFileSync(out.replace(/\.md$/, '.json'), 'utf8'));
+  // STRUCT012 fires per-layer for the cross-domain binding. Confirms the
+  // varidmap forwarding worked end-to-end through the spawnSync call.
+  const struct012 = sidecar.structure.filter(v => v.rule === 'STRUCT012');
+  assert.ok(struct012.length > 0, 'STRUCT012 should fire when var-id-map enables per-layer binding checks');
+  assert.equal(result.status, 1);
 });
diff --git a/plugins/adhd/lib/push-component/cli.js b/plugins/adhd/lib/push-component/cli.js
index 1948f15..28dcad5 100644
--- a/plugins/adhd/lib/push-component/cli.js
+++ b/plugins/adhd/lib/push-component/cli.js
@@ -33,7 +33,7 @@ function printUsage() {
   cli.js parse  --output  [--import-path ] [--max-variants ]
   cli.js generate-preview --manifest  --output 
   cli.js consolidation-script --manifest  --captured-page-id  --reverse-index  --output 
-  cli.js preflight --design-context  --variable-defs  --globals-css  --config  --output `);
+  cli.js preflight --design-context  --variable-defs  --globals-css  --config  --output  [--var-id-map ]`);
 }
 
 function inferImportPath(componentPath) {
@@ -413,7 +413,10 @@ function main() {
     // symmetric-pipeline assertion — same code path as /adhd:lint.
     const lintCli = path.resolve(__dirname, '..', 'lint-engine', 'cli.js');
     const { spawnSync } = require('node:child_process');
-    const result = spawnSync('node', [
+    // Capture stdout (the engine's JSON summary including per-violation nodeIds)
+    // instead of inheriting, so the skill can pass it to the annotation script
+    // when --annotate is set. Stderr still inherits — engine warnings stay visible.
+    const lintArgs = [
       lintCli,
       '--design-context', args['design-context'],
       '--variable-defs', args['variable-defs'],
@@ -422,7 +425,20 @@ function main() {
       '--target', 'PushComponent Preflight',
       '--target-url', 'about:blank',
       '--output', args.output,
-    ], { encoding: 'utf8', stdio: 'inherit' });
+    ];
+    // Forward --var-id-map when provided — required for STRUCT011 per-layer,
+    // STRUCT012, STRUCT015, and STRUCT016 to fire. Older callers that don't
+    // pass it get the legacy aggregated emissions; newer callers (the SKILL
+    // after the fingerprint commit) always pass it.
+    if (args['var-id-map']) {
+      lintArgs.push('--var-id-map', args['var-id-map']);
+    }
+    const result = spawnSync('node', lintArgs, { encoding: 'utf8', stdio: ['inherit', 'pipe', 'inherit'] });
+    // Sidecar JSON next to the markdown report.
+    const sidecar = args.output.replace(/\.md$/, '.json');
+    fs.writeFileSync(sidecar, result.stdout ?? '');
+    // Echo stdout to the parent process too, preserving prior behavior.
+    if (result.stdout) process.stdout.write(result.stdout);
     process.exit(result.status ?? 1);
   }
 
diff --git a/plugins/adhd/lib/sync-docs/README.md b/plugins/adhd/lib/sync-docs/README.md
new file mode 100644
index 0000000..8e85892
--- /dev/null
+++ b/plugins/adhd/lib/sync-docs/README.md
@@ -0,0 +1,19 @@
+# lib/sync-docs
+
+Deterministic helpers for `/adhd:sync-docs`. The skill (at
+`plugins/adhd/skills/sync-docs/SKILL.md`) is the orchestrator; this
+library is the testable engine.
+
+Modules:
+- `token-parser.js` — extract design-system tokens from a globals.css `@theme` block
+- `prop-parser.js` — extract a component's prop interface
+- `slug.js` — component path → URL slug
+- `config-parser.js` — parse `adhd.config.ts` at sync time (components + cssEntry)
+- `next-config-patcher.js` — idempotent patch of next.config.{ts,mjs,js}
+- `robots-patcher.js` — idempotent patch of public/robots.txt
+- `route-installer.js` — write the seven generated files at the target path. Only `componentMap.tsx` is per-sync; the rest are committed-once boilerplate. The token-domain catalog lives as a named export on the layout (no separate file).
+- `templates.js` — page template strings (with substitution placeholders)
+- `cli.js` — orchestrator surface invoked by SKILL.md
+
+See `docs/superpowers/specs/2026-05-11-adhd-install-design-system-docs-route.md`
+for the historical spec.
diff --git a/plugins/adhd/lib/sync-docs/__fixtures__/avatar.tsx b/plugins/adhd/lib/sync-docs/__fixtures__/avatar.tsx
new file mode 100644
index 0000000..b03e749
--- /dev/null
+++ b/plugins/adhd/lib/sync-docs/__fixtures__/avatar.tsx
@@ -0,0 +1,18 @@
+export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl";
+export type AvatarShape = "circle" | "square";
+
+export interface AvatarProps {
+  name: string;
+  src?: string;
+  size?: AvatarSize;
+  shape?: AvatarShape;
+  status?: "online" | "away" | "offline";
+  count?: number;
+  hidden?: boolean;
+  onClick?: (e: React.MouseEvent) => void;
+  children?: React.ReactNode;
+}
+
+export function Avatar({ name, size = "md" }: AvatarProps) {
+  return {name};
+}
diff --git a/plugins/adhd/lib/sync-docs/__fixtures__/globals.css b/plugins/adhd/lib/sync-docs/__fixtures__/globals.css
new file mode 100644
index 0000000..07d5006
--- /dev/null
+++ b/plugins/adhd/lib/sync-docs/__fixtures__/globals.css
@@ -0,0 +1,47 @@
+@import "tailwindcss";
+
+@theme {
+  --color-zinc-50: oklch(0.985 0 0);
+  --color-zinc-900: oklch(0.21 0.034 264.665);
+  --color-brand-500: #5e3aee;
+
+  --spacing: 0.25rem;
+
+  --text-xs: 0.75rem;
+  --text-xs--line-height: 1rem;
+  --text-base: 1rem;
+  --text-base--line-height: 1.5rem;
+
+  --radius-sm: 0.25rem;
+  --radius-lg: 0.5rem;
+
+  --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+  --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
+  --inset-shadow-sm: inset 0 1px 2px 0 rgb(0 0 0 / 0.05);
+  --drop-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.05);
+
+  --font-sans: "Inter", system-ui, sans-serif;
+  --font-mono: "JetBrains Mono", monospace;
+
+  --font-weight-normal: 400;
+  --font-weight-bold: 700;
+
+  --tracking-tight: -0.025em;
+  --tracking-wide: 0.025em;
+
+  --leading-tight: 1.25;
+  --leading-loose: 2;
+
+  --breakpoint-sm: 40rem;
+  --breakpoint-md: 48rem;
+
+  --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
+
+  --animate-spin: spin 1s linear infinite;
+
+  --some-mystery-var: 42;
+}
+
+@theme inline {
+  --color-alias-bg: var(--color-zinc-50);
+}
diff --git a/plugins/adhd/lib/sync-docs/__tests__/cli.test.js b/plugins/adhd/lib/sync-docs/__tests__/cli.test.js
new file mode 100644
index 0000000..33ae960
--- /dev/null
+++ b/plugins/adhd/lib/sync-docs/__tests__/cli.test.js
@@ -0,0 +1,132 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { spawnSync } = require('node:child_process');
+const path = require('node:path');
+
+const CLI = path.resolve(__dirname, '..', 'cli.js');
+
+test('cli with --help prints subcommand usage and exits 0', () => {
+  const r = spawnSync('node', [CLI, '--help'], { encoding: 'utf8' });
+  assert.equal(r.status, 0);
+  assert.match(r.stdout, /Usage:/);
+  assert.match(r.stdout, /parse-tokens/);
+  assert.match(r.stdout, /parse-props/);
+  assert.match(r.stdout, /slug/);
+  assert.match(r.stdout, /patch-next-config/);
+  assert.match(r.stdout, /patch-robots/);
+  assert.match(r.stdout, /detect-install/);
+  assert.match(r.stdout, /install/);
+});
+
+test('cli with no args exits 2', () => {
+  assert.equal(spawnSync('node', [CLI], { encoding: 'utf8' }).status, 2);
+});
+
+test('cli with unknown subcommand exits 2', () => {
+  assert.equal(spawnSync('node', [CLI, 'unknown'], { encoding: 'utf8' }).status, 2);
+});
+
+const fs = require('node:fs');
+const os = require('node:os');
+
+function tmp(filename, content) {
+  const p = path.join(os.tmpdir(), 'adhd-ids-' + Date.now() + '-' + Math.random().toString(16).slice(2, 8) + '-' + filename);
+  fs.writeFileSync(p, content);
+  return p;
+}
+
+const FX_CSS = path.resolve(__dirname, '..', '__fixtures__', 'globals.css');
+const FX_AVATAR = path.resolve(__dirname, '..', '__fixtures__', 'avatar.tsx');
+
+test('parse-tokens subcommand outputs token JSON', () => {
+  const out = tmp('tokens.json', '');
+  const r = spawnSync('node', [CLI, 'parse-tokens', '--css', FX_CSS, '--output', out], { encoding: 'utf8' });
+  assert.equal(r.status, 0, r.stderr);
+  const t = JSON.parse(fs.readFileSync(out, 'utf8'));
+  assert.ok(t.colors.length > 0);
+});
+
+test('parse-props subcommand outputs props JSON', () => {
+  const out = tmp('props.json', '');
+  const r = spawnSync('node', [CLI, 'parse-props', '--source', FX_AVATAR, '--output', out], { encoding: 'utf8' });
+  assert.equal(r.status, 0, r.stderr);
+  const p = JSON.parse(fs.readFileSync(out, 'utf8'));
+  assert.equal(p.componentName, 'Avatar');
+  assert.ok(p.props.size.values.length === 5);
+});
+
+test('slug subcommand outputs slug map JSON', () => {
+  const out = tmp('slugs.json', '');
+  const r = spawnSync('node', [CLI, 'slug', '--paths', 'app/components/avatar/index.tsx,app/components/avatar-group/index.tsx', '--output', out], { encoding: 'utf8' });
+  assert.equal(r.status, 0, r.stderr);
+  const m = JSON.parse(fs.readFileSync(out, 'utf8'));
+  assert.equal(m['app/components/avatar/index.tsx'], 'avatar');
+});
+
+test('patch-next-config subcommand mutates the file in place', () => {
+  const cfg = tmp('next.config.ts', `import type { NextConfig } from "next";\nconst nextConfig: NextConfig = {};\nexport default nextConfig;\n`);
+  const r = spawnSync('node', [CLI, 'patch-next-config', '--config', cfg, '--route-url', '/-docs'], { encoding: 'utf8' });
+  assert.equal(r.status, 0, r.stderr);
+  const after = fs.readFileSync(cfg, 'utf8');
+  assert.match(after, /pageExtensions:\s*process\.env\.NODE_ENV/);
+});
+
+test('patch-robots subcommand mutates the file in place; creates if missing', () => {
+  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'adhd-ids-robots-'));
+  const robots = path.join(root, 'robots.txt');
+  const r = spawnSync('node', [CLI, 'patch-robots', '--robots', robots, '--route-url', '/-docs'], { encoding: 'utf8' });
+  assert.equal(r.status, 0, r.stderr);
+  const after = fs.readFileSync(robots, 'utf8');
+  assert.match(after, /Disallow: \/-docs/);
+});
+
+test('detect-install subcommand prints existing install paths to stdout', () => {
+  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'adhd-ids-detect-'));
+  fs.mkdirSync(path.join(root, 'app', '(design-system)', '-docs'), { recursive: true });
+  fs.writeFileSync(
+    path.join(root, 'app', '(design-system)', '-docs', 'layout.tsx'),
+    '// design-system-docs-route — auto-generated installer artifact; safe to edit.\nexport default function L({ children }) { return children; }\n',
+  );
+  const r = spawnSync('node', [CLI, 'detect-install', '--app-dir', root], { encoding: 'utf8' });
+  assert.equal(r.status, 0, r.stderr);
+  assert.match(r.stdout, /-docs\/layout\.tsx/);
+});
+
+test('install subcommand reads adhd.config.ts and generates componentMap.tsx', () => {
+  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'adhd-ids-install-'));
+  fs.mkdirSync(path.join(root, 'app'), { recursive: true });
+  // The new architecture requires adhd.config.ts at the project root — the CLI
+  // reads components + cssEntry from it and bakes them into the generated files.
+  fs.writeFileSync(path.join(root, 'adhd.config.ts'), `
+const config = {
+  components: {
+    "components/Logo.tsx": { figma: {} },
+  },
+};
+export default config;
+`);
+  const choices = tmp('choices.json', JSON.stringify({
+    projectRoot: root, groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only',
+  }));
+  const r = spawnSync('node', [CLI, 'install', '--config', choices], { encoding: 'utf8' });
+  assert.equal(r.status, 0, r.stderr);
+  const docsDir = path.join(root, 'app', '(design-system)', '-docs');
+  assert.ok(fs.existsSync(path.join(docsDir, 'page.design-system.tsx')));
+  assert.ok(fs.existsSync(path.join(docsDir, 'componentMap.tsx')));
+  const mapBody = fs.readFileSync(path.join(docsDir, 'componentMap.tsx'), 'utf8');
+  assert.match(mapBody, /import \* as \$cmp0 from "@\/components\/Logo"/);
+  assert.match(mapBody, /slug: "logo"/);
+});
+
+test('install subcommand aborts with a clear error when adhd.config.ts is missing', () => {
+  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'adhd-ids-install-no-config-'));
+  fs.mkdirSync(path.join(root, 'app'), { recursive: true });
+  const choices = tmp('choices.json', JSON.stringify({
+    projectRoot: root, groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only',
+  }));
+  const r = spawnSync('node', [CLI, 'install', '--config', choices], { encoding: 'utf8' });
+  assert.equal(r.status, 2);
+  assert.match(r.stderr, /adhd\.config\.ts/);
+});
diff --git a/plugins/adhd/lib/sync-docs/__tests__/config-parser.test.js b/plugins/adhd/lib/sync-docs/__tests__/config-parser.test.js
new file mode 100644
index 0000000..20369f8
--- /dev/null
+++ b/plugins/adhd/lib/sync-docs/__tests__/config-parser.test.js
@@ -0,0 +1,132 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const os = require('node:os');
+const {
+  readConfig,
+  parseComponents,
+  parseCssEntry,
+  slugFor,
+  importPathFor,
+} = require('../config-parser');
+
+function makeProject(configBody) {
+  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'adhd-cfg-'));
+  fs.writeFileSync(path.join(root, 'adhd.config.ts'), configBody);
+  return root;
+}
+
+test('parseComponents extracts the keys of the components map', () => {
+  const src = `
+const config = {
+  components: {
+    "components/design-system/logo/index.tsx": { figma: {} },
+    "src/components/Button.tsx": { figma: {} },
+  },
+};
+export default config;
+`;
+  const paths = parseComponents(src);
+  assert.deepEqual(paths, [
+    'components/design-system/logo/index.tsx',
+    'src/components/Button.tsx',
+  ]);
+});
+
+test('parseComponents returns [] when no components map is defined', () => {
+  assert.deepEqual(parseComponents('const config = { figma: { url: "x" } };'), []);
+});
+
+test('parseCssEntry returns the configured cssEntry, defaulting to app/globals.css', () => {
+  assert.equal(parseCssEntry('const config = { cssEntry: "src/app/globals.css" };'), 'src/app/globals.css');
+  assert.equal(parseCssEntry('const config = {};'), 'app/globals.css');
+});
+
+test('slugFor strips .tsx/.ts and /index, lowercasing the last segment', () => {
+  assert.equal(slugFor('components/design-system/logo/index.tsx'), 'logo');
+  assert.equal(slugFor('src/components/Button.tsx'), 'button');
+  assert.equal(slugFor('app/widgets/PrimaryNav.ts'), 'primarynav');
+});
+
+test('importPathFor prepends @/ and strips .tsx/.ts and /index', () => {
+  assert.equal(importPathFor('components/design-system/logo/index.tsx'), '@/components/design-system/logo');
+  assert.equal(importPathFor('src/components/Button.tsx'), '@/src/components/Button');
+});
+
+test('readConfig returns components + cssEntry derived from adhd.config.ts', () => {
+  const root = makeProject(`
+const config = {
+  components: {
+    "components/design-system/logo/index.tsx": { figma: { url: "x" } },
+  },
+  cssEntry: "app/globals.css",
+};
+export default config;
+`);
+  const r = readConfig(root);
+  assert.deepEqual(r.components, [{
+    slug: 'logo',
+    rawPath: 'components/design-system/logo/index.tsx',
+    importPath: '@/components/design-system/logo',
+    figmaUrl: 'x',
+    pulledAt: null,
+  }]);
+  assert.equal(r.cssEntry, 'app/globals.css');
+});
+
+test('readConfig extracts figma.url per component, falling back to null when absent', () => {
+  const root = makeProject(`
+const config = {
+  components: {
+    "components/logo/index.tsx":   { figma: { url: "https://www.figma.com/design/abc?node-id=1-1" } },
+    "components/button/index.tsx": { /* no figma block */ },
+  },
+};
+export default config;
+`);
+  const r = readConfig(root);
+  const logo = r.components.find(c => c.slug === 'logo');
+  const button = r.components.find(c => c.slug === 'button');
+  assert.equal(logo.figmaUrl, 'https://www.figma.com/design/abc?node-id=1-1');
+  assert.equal(button.figmaUrl, null);
+});
+
+test('readConfig extracts pulledAt per component, falling back to null when absent', () => {
+  // pulledAt is the timestamp /adhd:pull-component records on a
+  // successful pull (alongside the fingerprint). The docs route shows
+  // it on each component page so designers know how fresh the code is.
+  const root = makeProject(`
+const config = {
+  components: {
+    "components/logo/index.tsx":   { figma: { url: "x" }, pulledAt: "2026-05-12T14:30:00.000Z", fingerprint: "a1b2c3d4" },
+    "components/button/index.tsx": { figma: { url: "y" } },
+  },
+};
+export default config;
+`);
+  const r = readConfig(root);
+  const logo = r.components.find(c => c.slug === 'logo');
+  const button = r.components.find(c => c.slug === 'button');
+  assert.equal(logo.pulledAt, '2026-05-12T14:30:00.000Z');
+  assert.equal(button.pulledAt, null);
+});
+
+test('readConfig throws if adhd.config.ts is missing', () => {
+  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'adhd-cfg-missing-'));
+  assert.throws(() => readConfig(root), /ENOENT|no such file/);
+});
+
+test('readConfig handles an empty components map cleanly', () => {
+  const root = makeProject(`
+const config = {
+  components: {},
+};
+export default config;
+`);
+  const r = readConfig(root);
+  assert.deepEqual(r.components, []);
+  assert.equal(r.cssEntry, 'app/globals.css');
+});
diff --git a/plugins/adhd/lib/sync-docs/__tests__/next-config-patcher.test.js b/plugins/adhd/lib/sync-docs/__tests__/next-config-patcher.test.js
new file mode 100644
index 0000000..cc23306
--- /dev/null
+++ b/plugins/adhd/lib/sync-docs/__tests__/next-config-patcher.test.js
@@ -0,0 +1,170 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { patchNextConfig, isPatched } = require('../next-config-patcher');
+
+const TS_MINIMAL = `import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+  images: {
+    remotePatterns: [{ protocol: "https", hostname: "i.pravatar.cc" }],
+  },
+};
+
+export default nextConfig;
+`;
+
+const TS_ALREADY_PATCHED = `import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+  pageExtensions: process.env.NODE_ENV === 'production'
+    ? ['ts', 'tsx']
+    : ['ts', 'tsx', 'design-system.ts', 'design-system.tsx'],
+  images: {
+    remotePatterns: [{ protocol: "https", hostname: "i.pravatar.cc" }],
+  },
+};
+
+export default nextConfig;
+`;
+
+const TS_WITH_DIFFERENT_PAGE_EXTENSIONS = `import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+  pageExtensions: ['mdx', 'ts', 'tsx'],
+};
+
+export default nextConfig;
+`;
+
+test('patches a minimal next.config.ts with the conditional pageExtensions block', () => {
+  const out = patchNextConfig(TS_MINIMAL);
+  assert.match(out, /pageExtensions:\s*process\.env\.NODE_ENV/);
+  assert.match(out, /'design-system\.tsx'/);
+  // Existing config preserved
+  assert.match(out, /images:/);
+  assert.match(out, /remotePatterns:/);
+});
+
+test('isPatched returns true after patching', () => {
+  const out = patchNextConfig(TS_MINIMAL);
+  assert.equal(isPatched(out), true);
+});
+
+test('patchNextConfig is idempotent when already patched', () => {
+  const out = patchNextConfig(TS_ALREADY_PATCHED);
+  assert.equal(out, TS_ALREADY_PATCHED);
+});
+
+test('isPatched returns false on an unpatched file', () => {
+  assert.equal(isPatched(TS_MINIMAL), false);
+});
+
+test('patchNextConfig refuses to silently overwrite an existing different pageExtensions; returns { conflict: true }', () => {
+  const r = patchNextConfig(TS_WITH_DIFFERENT_PAGE_EXTENSIONS, { detectOnly: true });
+  assert.equal(r.conflict, true);
+  assert.match(r.existing, /pageExtensions:\s*\['mdx'/);
+});
+
+test('patches with the dev-only conditional by default', () => {
+  // Default renderMode is dev-only — gates the page-extension swap on NODE_ENV.
+  const out = patchNextConfig(TS_MINIMAL);
+  assert.match(out, /process\.env\.NODE_ENV === 'production'/);
+  assert.doesNotMatch(out, /VERCEL_ENV/);
+});
+
+test('patches with the Vercel-preview conditional when renderMode: "vercel-preview"', () => {
+  // The compound condition excludes on Vercel production AND on any non-Vercel
+  // production deploy, while letting Vercel preview deploys render the route.
+  const out = patchNextConfig(TS_MINIMAL, { renderMode: 'vercel-preview' });
+  assert.match(out, /process\.env\.VERCEL_ENV === 'production'/);
+  // Also includes the !VERCEL && NODE_ENV='production' fallback for non-Vercel hosts.
+  assert.match(out, /!process\.env\.VERCEL/);
+  assert.match(out, /process\.env\.NODE_ENV === 'production'/);
+  assert.match(out, /'design-system\.tsx'/);
+});
+
+test('isPatched recognizes EITHER conditional shape as already-patched (idempotency)', () => {
+  const devOnly = patchNextConfig(TS_MINIMAL, { renderMode: 'dev-only' });
+  const vercelPreview = patchNextConfig(TS_MINIMAL, { renderMode: 'vercel-preview' });
+  assert.equal(isPatched(devOnly), true);
+  assert.equal(isPatched(vercelPreview), true);
+  // Re-running on a Vercel-preview-patched file is a no-op
+  assert.equal(patchNextConfig(vercelPreview, { renderMode: 'vercel-preview' }), vercelPreview);
+  // Re-running with a DIFFERENT renderMode on an already-patched file is also a
+  // no-op (sentinel detection ignores which env var gates the conditional). To
+  // switch modes, the user removes the marker line and re-syncs.
+  assert.equal(patchNextConfig(vercelPreview, { renderMode: 'dev-only' }), vercelPreview);
+});
+
+test('patchNextConfig throws on an unknown renderMode', () => {
+  assert.throws(
+    () => patchNextConfig(TS_MINIMAL, { renderMode: 'preview' }),
+    /Unknown renderMode: preview/,
+  );
+});
+
+test('vercel-preview mode also emits outputFileTracingIncludes when routeUrl + cssEntry are passed', () => {
+  // Without tracing, Vercel/serverless deploys don't bundle globals.css with
+  // the tokens function, fs.readFile throws ENOENT, and the page shows empty
+  // states for every token domain. This was the user's reported bug.
+  const out = patchNextConfig(TS_MINIMAL, {
+    renderMode: 'vercel-preview',
+    routeUrl: '/-docs',
+    cssEntry: 'app/globals.css',
+  });
+  assert.match(out, /pageExtensions:/);
+  assert.match(out, /outputFileTracingIncludes:/);
+  assert.match(out, /"\/-docs\/tokens\/\[domain\]":\s*\["\.\/app\/globals\.css"\]/);
+});
+
+test('everywhere mode emits ONLY outputFileTracingIncludes (no pageExtensions gate)', () => {
+  // "everywhere" ships files as plain .tsx with no extension gate, but the
+  // tokens page still runs on the serverless function in prod — it still
+  // needs the CSS source traced.
+  const out = patchNextConfig(TS_MINIMAL, {
+    renderMode: 'everywhere',
+    routeUrl: '/-docs',
+    cssEntry: 'src/app/globals.css',
+  });
+  assert.match(out, /outputFileTracingIncludes:/);
+  assert.match(out, /"\/-docs\/tokens\/\[domain\]":\s*\["\.\/src\/app\/globals\.css"\]/);
+  // No pageExtensions block in this mode.
+  assert.doesNotMatch(out, /pageExtensions:/);
+});
+
+test('dev-only mode does NOT emit tracing (page runs locally; no serverless bundle to trace)', () => {
+  const out = patchNextConfig(TS_MINIMAL, {
+    renderMode: 'dev-only',
+    routeUrl: '/-docs',
+    cssEntry: 'app/globals.css',
+  });
+  assert.match(out, /pageExtensions:/);
+  assert.doesNotMatch(out, /outputFileTracingIncludes:/);
+});
+
+test('isPatched recognizes a tracing-only "everywhere" patch as already-patched', () => {
+  const out = patchNextConfig(TS_MINIMAL, {
+    renderMode: 'everywhere',
+    routeUrl: '/-docs',
+    cssEntry: 'app/globals.css',
+  });
+  assert.equal(isPatched(out), true);
+  // Re-running on the patched output is a no-op.
+  assert.equal(
+    patchNextConfig(out, { renderMode: 'everywhere', routeUrl: '/-docs', cssEntry: 'app/globals.css' }),
+    out,
+  );
+});
+
+test('tracing key uses the supplied routeUrl, not a hardcoded path', () => {
+  // Some users pick a different route URL (e.g. /design-system). The tracing
+  // key must match THEIR route, not the default.
+  const out = patchNextConfig(TS_MINIMAL, {
+    renderMode: 'vercel-preview',
+    routeUrl: '/design-system',
+    cssEntry: 'app/globals.css',
+  });
+  assert.match(out, /"\/design-system\/tokens\/\[domain\]"/);
+});
diff --git a/plugins/adhd/lib/sync-docs/__tests__/prop-parser.test.js b/plugins/adhd/lib/sync-docs/__tests__/prop-parser.test.js
new file mode 100644
index 0000000..e4defef
--- /dev/null
+++ b/plugins/adhd/lib/sync-docs/__tests__/prop-parser.test.js
@@ -0,0 +1,62 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { parseProps } = require('../prop-parser');
+
+const SOURCE = fs.readFileSync(
+  path.resolve(__dirname, '..', '__fixtures__', 'avatar.tsx'),
+  'utf8',
+);
+
+test('returns the component name', () => {
+  const r = parseProps(SOURCE);
+  assert.equal(r.componentName, 'Avatar');
+});
+
+test('captures string props', () => {
+  const r = parseProps(SOURCE);
+  assert.deepEqual(r.props.name, { type: 'string', optional: false });
+  assert.deepEqual(r.props.src, { type: 'string', optional: true });
+});
+
+test('captures number and boolean props', () => {
+  const r = parseProps(SOURCE);
+  assert.deepEqual(r.props.count, { type: 'number', optional: true });
+  assert.deepEqual(r.props.hidden, { type: 'boolean', optional: true });
+});
+
+test('captures named-union references with their values', () => {
+  const r = parseProps(SOURCE);
+  assert.deepEqual(r.props.size, {
+    type: 'union', unionName: 'AvatarSize', values: ['xs', 'sm', 'md', 'lg', 'xl'], optional: true,
+  });
+  assert.deepEqual(r.props.shape, {
+    type: 'union', unionName: 'AvatarShape', values: ['circle', 'square'], optional: true,
+  });
+});
+
+test('captures inline literal unions', () => {
+  const r = parseProps(SOURCE);
+  assert.deepEqual(r.props.status, {
+    type: 'union', values: ['online', 'away', 'offline'], optional: true,
+  });
+});
+
+test('marks function props as `function` (toggle-skipped)', () => {
+  const r = parseProps(SOURCE);
+  assert.equal(r.props.onClick.type, 'function');
+});
+
+test('marks ReactNode props as `reactnode` (toggle-skipped)', () => {
+  const r = parseProps(SOURCE);
+  assert.equal(r.props.children.type, 'reactnode');
+});
+
+test('returns componentName=null when no exported function found', () => {
+  const r = parseProps('export const x = 42;');
+  assert.equal(r.componentName, null);
+  assert.deepEqual(r.props, {});
+});
diff --git a/plugins/adhd/lib/sync-docs/__tests__/robots-patcher.test.js b/plugins/adhd/lib/sync-docs/__tests__/robots-patcher.test.js
new file mode 100644
index 0000000..157d46c
--- /dev/null
+++ b/plugins/adhd/lib/sync-docs/__tests__/robots-patcher.test.js
@@ -0,0 +1,43 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { patchRobots } = require('../robots-patcher');
+
+test('creates robots.txt content if input is empty', () => {
+  const out = patchRobots('', '/-docs');
+  assert.match(out, /User-agent: \*/);
+  assert.match(out, /Disallow: \/-docs/);
+});
+
+test('creates robots.txt content if input is null/undefined', () => {
+  const out = patchRobots(null, '/-docs');
+  assert.match(out, /User-agent: \*/);
+  assert.match(out, /Disallow: \/-docs/);
+});
+
+test('appends a Disallow line to an existing robots.txt', () => {
+  const existing = `User-agent: *
+Disallow: /admin
+`;
+  const out = patchRobots(existing, '/-docs');
+  assert.match(out, /Disallow: \/admin/);
+  assert.match(out, /Disallow: \/-docs/);
+});
+
+test('idempotent: re-patching an already-patched robots.txt returns unchanged', () => {
+  const existing = `User-agent: *
+Disallow: /-docs
+`;
+  const out = patchRobots(existing, '/-docs');
+  assert.equal(out, existing);
+});
+
+test('idempotent: matching is exact (does not match /-docs-other)', () => {
+  const existing = `User-agent: *
+Disallow: /-docs-other
+`;
+  const out = patchRobots(existing, '/-docs');
+  assert.match(out, /Disallow: \/-docs-other/);
+  assert.match(out, /Disallow: \/-docs$/m);
+});
diff --git a/plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js b/plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js
new file mode 100644
index 0000000..71671f0
--- /dev/null
+++ b/plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js
@@ -0,0 +1,465 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const os = require('node:os');
+const { installRoute, detectExistingInstall, renderComponentMap } = require('../route-installer');
+
+function makeTempProject() {
+  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'adhd-sync-'));
+  fs.mkdirSync(path.join(root, 'app'), { recursive: true });
+  return root;
+}
+
+// A fixture component the installer can read for prop-baking tests.
+function writeLogoFixture(root) {
+  const dir = path.join(root, 'components/design-system/logo');
+  fs.mkdirSync(dir, { recursive: true });
+  fs.writeFileSync(path.join(dir, 'index.tsx'), `
+export type LogoSize = "sm" | "md" | "lg";
+
+export interface LogoProps {
+  size: LogoSize;
+  inverted?: boolean;
+  title?: string;
+}
+
+export default function Logo(props: LogoProps) {
+  return null;
+}
+`);
+}
+
+const SAMPLE_COMPONENTS = [
+  { slug: 'logo', rawPath: 'components/design-system/logo/index.tsx', importPath: '@/components/design-system/logo' },
+];
+
+test('installRoute writes the five generated files with renderMode: dev-only', () => {
+  const root = makeTempProject();
+  writeLogoFixture(root);
+  installRoute(root, {
+    groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only',
+    components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css',
+  });
+  const docsDir = path.join(root, 'app', '(design-system)', '-docs');
+  // Route files get the suffix so pageExtensions filters them in prod.
+  assert.ok(fs.existsSync(path.join(docsDir, 'layout.design-system.tsx')));
+  assert.ok(fs.existsSync(path.join(docsDir, 'page.design-system.tsx')));
+  assert.ok(fs.existsSync(path.join(docsDir, 'tokens', '[domain]', 'page.design-system.tsx')));
+  assert.ok(fs.existsSync(path.join(docsDir, 'components', '[component]', 'page.design-system.tsx')));
+  // componentMap is a plain .tsx module so TS module resolution finds it.
+  assert.ok(fs.existsSync(path.join(docsDir, 'componentMap.tsx')));
+  // Files we used to write but no longer do:
+  assert.ok(!fs.existsSync(path.join(docsDir, 'components', '[component]', 'error.design-system.tsx')));
+  assert.ok(!fs.existsSync(path.join(docsDir, 'components', '[component]', 'error.tsx')));
+  assert.ok(!fs.existsSync(path.join(docsDir, 'PropToggle.tsx')));
+  assert.ok(!fs.existsSync(path.join(docsDir, 'tokenDomains.tsx')));
+});
+
+test('installRoute writes plain .tsx files for route files with renderMode: "everywhere"', () => {
+  const root = makeTempProject();
+  writeLogoFixture(root);
+  installRoute(root, {
+    groupName: '(design-system)', routeSegment: '-docs', renderMode: 'everywhere',
+    components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css',
+  });
+  const docsDir = path.join(root, 'app', '(design-system)', '-docs');
+  assert.ok(fs.existsSync(path.join(docsDir, 'layout.tsx')));
+  assert.ok(fs.existsSync(path.join(docsDir, 'page.tsx')));
+  assert.ok(fs.existsSync(path.join(docsDir, 'tokens', '[domain]', 'page.tsx')));
+  assert.ok(fs.existsSync(path.join(docsDir, 'components', '[component]', 'page.tsx')));
+  assert.ok(fs.existsSync(path.join(docsDir, 'componentMap.tsx')));
+});
+
+test('installRoute uses .design-system suffix for both excluding renderModes', () => {
+  // Both 'dev-only' and 'vercel-preview' rely on pageExtensions to filter
+  // .design-system.tsx files in production builds. The choice of WHICH env var
+  // gates the filter is the next-config-patcher's concern, not the installer's.
+  for (const renderMode of ['dev-only', 'vercel-preview']) {
+    const root = makeTempProject();
+    writeLogoFixture(root);
+    installRoute(root, {
+      groupName: '(design-system)', routeSegment: '-docs', renderMode,
+      components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css',
+    });
+    const docsDir = path.join(root, 'app', '(design-system)', '-docs');
+    assert.ok(fs.existsSync(path.join(docsDir, 'layout.design-system.tsx')), `renderMode=${renderMode}`);
+    assert.ok(fs.existsSync(path.join(docsDir, 'page.design-system.tsx')), `renderMode=${renderMode}`);
+  }
+});
+
+test('installRoute throws on an unknown renderMode (typo-protection)', () => {
+  const root = makeTempProject();
+  writeLogoFixture(root);
+  assert.throws(
+    () => installRoute(root, {
+      groupName: '(design-system)', routeSegment: '-docs', renderMode: 'preview',
+      components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css',
+    }),
+    /Unknown renderMode: preview/,
+  );
+});
+
+test('installRoute defaults to renderMode: "dev-only" when none is provided', () => {
+  const root = makeTempProject();
+  writeLogoFixture(root);
+  installRoute(root, {
+    groupName: '(design-system)', routeSegment: '-docs',
+    // renderMode intentionally omitted
+    components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css',
+  });
+  const docsDir = path.join(root, 'app', '(design-system)', '-docs');
+  assert.ok(fs.existsSync(path.join(docsDir, 'layout.design-system.tsx')));
+});
+
+test('all written files start with the marker comment', () => {
+  const root = makeTempProject();
+  writeLogoFixture(root);
+  installRoute(root, {
+    groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only',
+    components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css',
+  });
+  const docsDir = path.join(root, 'app', '(design-system)', '-docs');
+  for (const f of [
+    'layout.design-system.tsx',
+    'page.design-system.tsx',
+    'tokens/[domain]/page.design-system.tsx',
+    'components/[component]/page.design-system.tsx',
+    'componentMap.tsx',
+  ]) {
+    const content = fs.readFileSync(path.join(docsDir, f), 'utf8');
+    assert.match(content, /design-system-docs-route/, `${f} missing marker`);
+  }
+});
+
+test('componentMap.tsx has explicit static imports per registered component', () => {
+  const root = makeTempProject();
+  writeLogoFixture(root);
+  installRoute(root, {
+    groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only',
+    components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css',
+  });
+  const body = fs.readFileSync(
+    path.join(root, 'app', '(design-system)', '-docs', 'componentMap.tsx'),
+    'utf8',
+  );
+  assert.match(body, /import \* as \$cmp0 from "@\/components\/design-system\/logo"/);
+  assert.match(body, /slug: "logo"/);
+  assert.match(body, /rawPath: "components\/design-system\/logo\/index\.tsx"/);
+  assert.match(body, /module: \$cmp0/);
+  assert.doesNotMatch(body, /await\s+import\(`/);
+});
+
+test('componentMap.tsx carries the figmaUrl field per component (for the docs-page "open in Figma" link)', () => {
+  const root = makeTempProject();
+  writeLogoFixture(root);
+  installRoute(root, {
+    groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only',
+    components: [
+      { slug: 'logo', rawPath: 'components/design-system/logo/index.tsx',
+        importPath: '@/components/design-system/logo',
+        figmaUrl: 'https://www.figma.com/design/abc?node-id=1-1' },
+      { slug: 'button', rawPath: 'components/button.tsx',
+        importPath: '@/components/button', figmaUrl: null },
+    ],
+    cssEntry: 'app/globals.css',
+  });
+  const body = fs.readFileSync(
+    path.join(root, 'app', '(design-system)', '-docs', 'componentMap.tsx'),
+    'utf8',
+  );
+  // Logo entry carries the URL literal
+  assert.match(body, /slug: "logo".*figmaUrl: "https:\/\/www\.figma\.com\/design\/abc\?node-id=1-1"/);
+  // Button entry has null when no figma URL was provided
+  assert.match(body, /slug: "button".*figmaUrl: null/);
+});
+
+test('componentMap.tsx bakes prop schemas read from each component source at sync time', () => {
+  // The component page no longer does fs reads — props are baked here. Test
+  // verifies that the LogoProps interface (size: union, inverted: boolean,
+  // title: string) is preserved verbatim in the generated map.
+  const root = makeTempProject();
+  writeLogoFixture(root);
+  installRoute(root, {
+    groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only',
+    components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css',
+  });
+  const body = fs.readFileSync(
+    path.join(root, 'app', '(design-system)', '-docs', 'componentMap.tsx'),
+    'utf8',
+  );
+  // The whole entry is a single JS line — match the inline JSON body.
+  assert.match(body, /props: \{[^}]*"size":\{"type":"union","values":\["sm","md","lg"\],"optional":false\}/);
+  assert.match(body, /"inverted":\{"type":"boolean","optional":true\}/);
+  assert.match(body, /"title":\{"type":"string","optional":true\}/);
+});
+
+test('componentMap.tsx handles an empty components list', () => {
+  const root = makeTempProject();
+  installRoute(root, {
+    groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only',
+    components: [], cssEntry: 'app/globals.css',
+  });
+  const body = fs.readFileSync(
+    path.join(root, 'app', '(design-system)', '-docs', 'componentMap.tsx'),
+    'utf8',
+  );
+  assert.doesNotMatch(body, /import \* as \$cmp/);
+  assert.match(body, /const ENTRIES.*=\s*\[\]/);
+});
+
+test('componentMap.tsx handles a missing component source file (empty props baked)', () => {
+  // If a component listed in adhd.config.ts doesn't exist on disk, sync shouldn't
+  // crash — it bakes `{}` for that entry's props. The page then shows "No prop
+  // interface detected at sync time" which is the right signal.
+  const root = makeTempProject();
+  // Note: we DON'T call writeLogoFixture — the file is missing on purpose.
+  installRoute(root, {
+    groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only',
+    components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css',
+  });
+  const body = fs.readFileSync(
+    path.join(root, 'app', '(design-system)', '-docs', 'componentMap.tsx'),
+    'utf8',
+  );
+  assert.match(body, /props: \{\}/);
+});
+
+test('layout bakes a UTC "Last built" timestamp in the sidebar header', () => {
+  const root = makeTempProject();
+  writeLogoFixture(root);
+  installRoute(root, {
+    groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only',
+    components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css',
+    // Injected Date for deterministic snapshot — production runs use `new Date()`.
+    now: new Date(Date.UTC(2026, 4, 11, 3, 14)), // 2026-05-11 03:14 UTC
+  });
+  const layout = fs.readFileSync(
+    path.join(root, 'app', '(design-system)', '-docs', 'layout.design-system.tsx'),
+    'utf8',
+  );
+  // Format: YYYY-MM-DD HH:MM UTC, baked verbatim
+  assert.match(layout, /Last built 2026-05-11 03:14 UTC/);
+  // Placeholder fully substituted
+  assert.doesNotMatch(layout, /__SYNC_AT__/);
+});
+
+test('layout sidebar links use absolute hrefs derived from the route segment', () => {
+  const root = makeTempProject();
+  writeLogoFixture(root);
+  installRoute(root, {
+    groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only',
+    components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css',
+  });
+  const layout = fs.readFileSync(
+    path.join(root, 'app', '(design-system)', '-docs', 'layout.design-system.tsx'),
+    'utf8',
+  );
+  assert.match(layout, /href=\{`\/-docs\/tokens\/\$\{d\.slug\}`\}/);
+  assert.match(layout, /href=\{`\/-docs\/components\/\$\{c\.slug\}`\}/);
+  assert.doesNotMatch(layout, /__ROUTE_PATH__/);
+});
+
+test('layout imports the static components array from componentMap', () => {
+  const root = makeTempProject();
+  writeLogoFixture(root);
+  installRoute(root, {
+    groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only',
+    components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css',
+  });
+  const layout = fs.readFileSync(
+    path.join(root, 'app', '(design-system)', '-docs', 'layout.design-system.tsx'),
+    'utf8',
+  );
+  assert.match(layout, /import \{ components \} from "\.\/componentMap"/);
+  assert.doesNotMatch(layout, /from "node:fs|from "node:path/);
+});
+
+test('tokens page imports TOKEN_DOMAINS from layout.design-system with renderMode: dev-only', () => {
+  const root = makeTempProject();
+  writeLogoFixture(root);
+  installRoute(root, {
+    groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only',
+    components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css',
+  });
+  const tokensPage = fs.readFileSync(
+    path.join(root, 'app', '(design-system)', '-docs', 'tokens', '[domain]', 'page.design-system.tsx'),
+    'utf8',
+  );
+  assert.match(tokensPage, /from "\.\.\/\.\.\/layout\.design-system"/);
+  assert.doesNotMatch(tokensPage, /__LAYOUT_MODULE__/);
+});
+
+test('tokens page imports TOKEN_DOMAINS from layout (no suffix) with renderMode: "everywhere"', () => {
+  const root = makeTempProject();
+  writeLogoFixture(root);
+  installRoute(root, {
+    groupName: '(design-system)', routeSegment: '-docs', renderMode: 'everywhere',
+    components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css',
+  });
+  const tokensPage = fs.readFileSync(
+    path.join(root, 'app', '(design-system)', '-docs', 'tokens', '[domain]', 'page.tsx'),
+    'utf8',
+  );
+  assert.match(tokensPage, /from "\.\.\/\.\.\/layout"/);
+  assert.doesNotMatch(tokensPage, /__LAYOUT_MODULE__/);
+});
+
+test('tokens page bakes the configured cssEntry path as a constant', () => {
+  const root = makeTempProject();
+  writeLogoFixture(root);
+  installRoute(root, {
+    groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only',
+    components: SAMPLE_COMPONENTS, cssEntry: 'src/app/globals.css',
+  });
+  const tokensPage = fs.readFileSync(
+    path.join(root, 'app', '(design-system)', '-docs', 'tokens', '[domain]', 'page.design-system.tsx'),
+    'utf8',
+  );
+  assert.match(tokensPage, /CSS_ENTRY = "src\/app\/globals\.css"/);
+  assert.doesNotMatch(tokensPage, /adhd\.config\.ts/);
+});
+
+test('component page is a client component with inline PropToggle and no fs reads', () => {
+  const root = makeTempProject();
+  writeLogoFixture(root);
+  installRoute(root, {
+    groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only',
+    components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css',
+  });
+  const componentPage = fs.readFileSync(
+    path.join(root, 'app', '(design-system)', '-docs', 'components', '[component]', 'page.design-system.tsx'),
+    'utf8',
+  );
+  // "use client" sits after the marker comment (two `//` lines), so strip leading
+  // comments before checking the directive is the first real statement.
+  assert.match(componentPage.replace(/^(?:\/\/[^\n]*\n)+/, ''), /^["']use client["']/);
+  // Uses hooks instead of async params/searchParams.
+  assert.match(componentPage, /useParams/);
+  assert.match(componentPage, /useSearchParams/);
+  assert.match(componentPage, /useRouter/);
+  // PropToggle is inlined, not imported.
+  assert.match(componentPage, /function PropToggle\(/);
+  assert.doesNotMatch(componentPage, /from "\.\.\/PropToggle"|from "\.\.\/\.\.\/PropToggle"/);
+  // No fs reads — everything's baked into componentMap.
+  assert.doesNotMatch(componentPage, /from "node:fs|from "node:path/);
+});
+
+test('component page shows a "not in static map" message when slug is missing', () => {
+  const root = makeTempProject();
+  writeLogoFixture(root);
+  installRoute(root, {
+    groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only',
+    components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css',
+  });
+  const componentPage = fs.readFileSync(
+    path.join(root, 'app', '(design-system)', '-docs', 'components', '[component]', 'page.design-system.tsx'),
+    'utf8',
+  );
+  assert.match(componentPage, /Not in the static map/);
+  assert.match(componentPage, /\/adhd:sync-docs/);
+});
+
+test('detectExistingInstall returns marker-bearing files', () => {
+  const root = makeTempProject();
+  writeLogoFixture(root);
+  installRoute(root, {
+    groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only',
+    components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css',
+  });
+  const found = detectExistingInstall(root);
+  assert.ok(found.length >= 5);
+  assert.ok(found.every(p => p.includes('-docs')));
+});
+
+test('detectExistingInstall returns [] when no marker is present', () => {
+  const root = makeTempProject();
+  assert.deepEqual(detectExistingInstall(root), []);
+});
+
+test('re-running installRoute overwrites files cleanly', () => {
+  const root = makeTempProject();
+  writeLogoFixture(root);
+  installRoute(root, {
+    groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only',
+    components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css',
+  });
+  const layoutPath = path.join(root, 'app', '(design-system)', '-docs', 'layout.design-system.tsx');
+  fs.writeFileSync(layoutPath, 'corrupted');
+  installRoute(root, {
+    groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only',
+    components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css',
+  });
+  const after = fs.readFileSync(layoutPath, 'utf8');
+  assert.match(after, /design-system-docs-route/);
+  assert.match(after, /DesignSystemDocsLayout/);
+});
+
+test('re-sync removes stale files from previous template layouts', () => {
+  // Mirrors actual upgrade paths: previous installer versions wrote a separate
+  // tokenDomains.tsx + PropToggle.tsx + error.design-system.tsx. Re-syncing
+  // should clean them all up because they carry the marker.
+  const root = makeTempProject();
+  writeLogoFixture(root);
+  const docsDir = path.join(root, 'app', '(design-system)', '-docs');
+  fs.mkdirSync(path.join(docsDir, 'components', '[component]'), { recursive: true });
+  const stale = [
+    path.join(docsDir, 'tokenDomains.tsx'),
+    path.join(docsDir, 'PropToggle.tsx'),
+    path.join(docsDir, 'components', '[component]', 'error.design-system.tsx'),
+  ];
+  for (const p of stale) fs.writeFileSync(p, '// design-system-docs-route — stale\nexport {};\n');
+
+  const r = installRoute(root, {
+    groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only',
+    components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css',
+  });
+
+  for (const p of stale) {
+    assert.ok(!fs.existsSync(p), `stale ${path.basename(p)} should be removed`);
+    assert.ok(r.removed.includes(p));
+  }
+});
+
+test('installRoute preserves user files that lack the marker', () => {
+  const root = makeTempProject();
+  writeLogoFixture(root);
+  const docsDir = path.join(root, 'app', '(design-system)', '-docs');
+  fs.mkdirSync(docsDir, { recursive: true });
+  const userFile = path.join(docsDir, 'user-notes.tsx');
+  fs.writeFileSync(userFile, '// user wrote this\nexport const NOTE = "keep";\n');
+
+  installRoute(root, {
+    groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only',
+    components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css',
+  });
+
+  assert.ok(fs.existsSync(userFile));
+});
+
+test('installRoute supports an empty groupName (no route group)', () => {
+  const root = makeTempProject();
+  writeLogoFixture(root);
+  installRoute(root, {
+    groupName: '', routeSegment: '-docs', renderMode: 'dev-only',
+    components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css',
+  });
+  const docsDir = path.join(root, 'app', '-docs');
+  assert.ok(fs.existsSync(path.join(docsDir, 'layout.design-system.tsx')));
+  assert.ok(fs.existsSync(path.join(docsDir, 'componentMap.tsx')));
+});
+
+test('renderComponentMap is exposed (standalone snapshot helper, takes projectRoot)', () => {
+  // The renderComponentMap export takes (projectRoot, components) so it can
+  // read each component's source file for prop baking. With a non-existent
+  // root, props bake to {} but the rest of the output is well-formed.
+  const body = renderComponentMap('/nonexistent', [
+    { slug: 'logo', rawPath: 'components/Logo.tsx', importPath: '@/components/Logo' },
+  ]);
+  assert.match(body, /design-system-docs-route/);
+  assert.match(body, /import \* as \$cmp0 from "@\/components\/Logo"/);
+  assert.match(body, /slug: "logo"/);
+  assert.match(body, /props: \{\}/);
+});
diff --git a/plugins/adhd/lib/sync-docs/__tests__/slug.test.js b/plugins/adhd/lib/sync-docs/__tests__/slug.test.js
new file mode 100644
index 0000000..333064c
--- /dev/null
+++ b/plugins/adhd/lib/sync-docs/__tests__/slug.test.js
@@ -0,0 +1,43 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { slugFor, slugMap } = require('../slug');
+
+test('slugs a simple component path', () => {
+  assert.equal(slugFor('app/components/avatar/index.tsx'), 'avatar');
+});
+
+test('preserves hyphens', () => {
+  assert.equal(slugFor('app/components/avatar-group/index.tsx'), 'avatar-group');
+});
+
+test('handles files without /index.tsx', () => {
+  assert.equal(slugFor('app/components/Logo.tsx'), 'logo');
+});
+
+test('lowercases', () => {
+  assert.equal(slugFor('app/components/AvatarGroup/index.tsx'), 'avatargroup');
+});
+
+test('slugMap returns { path: slug } for unique paths', () => {
+  const paths = [
+    'app/components/avatar/index.tsx',
+    'app/components/avatar-group/index.tsx',
+  ];
+  assert.deepEqual(slugMap(paths), {
+    'app/components/avatar/index.tsx': 'avatar',
+    'app/components/avatar-group/index.tsx': 'avatar-group',
+  });
+});
+
+test('slugMap disambiguates collisions by prepending parent dir', () => {
+  const paths = [
+    'app/components/avatar/index.tsx',
+    'app/design-system/avatar/index.tsx',
+  ];
+  const m = slugMap(paths);
+  assert.equal(new Set(Object.values(m)).size, 2, 'slugs must be unique');
+  // Both contain "avatar"; we expect e.g. "components-avatar" and "design-system-avatar"
+  assert.ok(Object.values(m).every(s => s.includes('avatar')));
+});
diff --git a/plugins/adhd/lib/sync-docs/__tests__/templates.test.js b/plugins/adhd/lib/sync-docs/__tests__/templates.test.js
new file mode 100644
index 0000000..50bd62c
--- /dev/null
+++ b/plugins/adhd/lib/sync-docs/__tests__/templates.test.js
@@ -0,0 +1,175 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const {
+  MARKER_COMMENT,
+  LAYOUT_TSX,
+  INDEX_PAGE_TSX,
+  TOKENS_PAGE_TSX,
+  COMPONENT_PAGE_TSX,
+  COMPONENT_MAP_TSX,
+} = require('../templates');
+
+test('MARKER_COMMENT is a stable, non-ADHD-referencing string', () => {
+  assert.match(MARKER_COMMENT, /design-system-docs-route/);
+  assert.match(MARKER_COMMENT, /auto-generated installer artifact; safe to edit/);
+  assert.equal(/adhd/i.test(MARKER_COMMENT), false, 'must not reference ADHD');
+});
+
+test('LAYOUT_TSX starts with the marker comment', () => {
+  assert.ok(LAYOUT_TSX.startsWith(MARKER_COMMENT));
+});
+
+test('LAYOUT_TSX sets robots: noindex / nofollow', () => {
+  assert.match(LAYOUT_TSX, /robots:\s*\{[^}]*index:\s*false[^}]*follow:\s*false/);
+});
+
+test('LAYOUT_TSX declares and named-exports the TOKEN_DOMAINS catalog', () => {
+  // Single source of truth lives in the layout. Tokens page imports it from there.
+  assert.match(LAYOUT_TSX, /export const TOKEN_DOMAINS: TokenDomain\[\]/);
+  assert.match(LAYOUT_TSX, /export type TokenDomain/);
+  for (const label of [
+    'Colors', 'Spacing', 'Typography', 'Font Families', 'Font Weights',
+    'Tracking', 'Leading', 'Radius', 'Shadows', 'Breakpoints', 'Easing', 'Animation',
+  ]) {
+    assert.match(LAYOUT_TSX, new RegExp(`label: "${label}"`), `missing domain label: ${label}`);
+  }
+  assert.match(LAYOUT_TSX, /slug:\s*"colors".*varPrefix:\s*"--color-".*tailwindDocs:/s);
+});
+
+test('TOKENS_PAGE_TSX imports the catalog from the layout via a __LAYOUT_MODULE__ placeholder', () => {
+  // The path depends on prod-exclusion (`layout` vs `layout.design-system`) — the
+  // installer substitutes it. Template body should carry the placeholder verbatim.
+  assert.match(TOKENS_PAGE_TSX, /import \{ TOKEN_DOMAINS, type TokenDomain \} from "__LAYOUT_MODULE__"/);
+});
+
+test('LAYOUT_TSX imports the static components array from componentMap', () => {
+  assert.match(LAYOUT_TSX, /import \{ components \} from "\.\/componentMap"/);
+  // No fs/path imports — the layout is a pure render.
+  assert.doesNotMatch(LAYOUT_TSX, /from "node:fs|from "node:path/);
+});
+
+test('LAYOUT_TSX is a sync (non-async) server component', () => {
+  assert.doesNotMatch(LAYOUT_TSX, /export default async function/);
+  assert.match(LAYOUT_TSX, /export default function DesignSystemDocsLayout/);
+});
+
+test('INDEX_PAGE_TSX is a landing page describing the static-import flow', () => {
+  assert.match(INDEX_PAGE_TSX, /Design System/);
+  assert.match(INDEX_PAGE_TSX, /statically imported/);
+  assert.match(INDEX_PAGE_TSX, /re-run/);
+});
+
+test('INDEX_PAGE_TSX has no Troubleshooting section', () => {
+  assert.doesNotMatch(INDEX_PAGE_TSX, /Troubleshooting/);
+  assert.match(INDEX_PAGE_TSX, /\/adhd:sync-docs/);
+});
+
+test('TOKENS_PAGE_TSX reads globals.css from a baked CSS_ENTRY constant', () => {
+  assert.match(TOKENS_PAGE_TSX, /const CSS_ENTRY = "__CSS_ENTRY__"/);
+  assert.match(TOKENS_PAGE_TSX, /parseTokens/);
+  assert.doesNotMatch(TOKENS_PAGE_TSX, /adhd\.config\.ts/);
+});
+
+test('TOKENS_PAGE_TSX does not inline the TOKEN_DOMAINS list', () => {
+  assert.doesNotMatch(TOKENS_PAGE_TSX, /const TOKEN_DOMAINS = \[/);
+});
+
+test('COMPONENT_PAGE_TSX is a client component', () => {
+  // The page must be a client component so PropToggle can be inlined and
+  // useSearchParams/useRouter can drive URL state without a separate file.
+  const afterMarker = COMPONENT_PAGE_TSX.replace(MARKER_COMMENT, '');
+  assert.match(afterMarker, /^["']use client["']/);
+});
+
+test('COMPONENT_PAGE_TSX uses getComponent from the static componentMap (no fs reads, no dynamic import)', () => {
+  assert.match(COMPONENT_PAGE_TSX, /import \{ components, getComponent, type PropSchema \} from "\.\.\/\.\.\/componentMap"/);
+  assert.doesNotMatch(COMPONENT_PAGE_TSX, /await\s+import\(`/);
+  // No server-side fs reads — the page is fully client.
+  assert.doesNotMatch(COMPONENT_PAGE_TSX, /from "node:fs|from "node:path/);
+});
+
+test('COMPONENT_PAGE_TSX inlines PropToggle (no separate PropToggle.tsx file)', () => {
+  // The PropToggle UI lives in the page itself now. No import from "../PropToggle".
+  assert.match(COMPONENT_PAGE_TSX, /function PropToggle\(/);
+  assert.doesNotMatch(COMPONENT_PAGE_TSX, /from "\.\.\/PropToggle"|from "\.\.\/\.\.\/PropToggle"/);
+});
+
+test('COMPONENT_PAGE_TSX reads URL state via useSearchParams + useParams hooks', () => {
+  assert.match(COMPONENT_PAGE_TSX, /useParams/);
+  assert.match(COMPONENT_PAGE_TSX, /useSearchParams/);
+  assert.match(COMPONENT_PAGE_TSX, /router\.replace/);
+});
+
+test('COMPONENT_PAGE_TSX shows a "Not in the static map" branch for unknown slugs', () => {
+  assert.match(COMPONENT_PAGE_TSX, /Not in the static map/);
+  assert.match(COMPONENT_PAGE_TSX, /re-run.*\/adhd:sync-docs/i);
+});
+
+test('COMPONENT_MAP_TSX has the substitution placeholders the installer fills in', () => {
+  assert.match(COMPONENT_MAP_TSX, /__COMPONENT_IMPORTS__/);
+  assert.match(COMPONENT_MAP_TSX, /__COMPONENT_ENTRIES__/);
+  assert.match(COMPONENT_MAP_TSX, /export function getComponent/);
+  assert.match(COMPONENT_MAP_TSX, /export const components/);
+  assert.match(COMPONENT_MAP_TSX, /export type PropSchema/);
+});
+
+test('COMPONENT_MAP_TSX declares figmaUrl on the ComponentEntry shape', () => {
+  // Powers the "open in Figma" link on each component page. Null when the
+  // user hasn't set a Figma URL for that component in adhd.config.ts.
+  assert.match(COMPONENT_MAP_TSX, /figmaUrl:\s*string \| null/);
+});
+
+test('COMPONENT_PAGE_TSX renders a Figma link with ↗ when figmaUrl is present', () => {
+  // Link is opt-in: only shown when the entry's figmaUrl isn't null.
+  // Opens in a new tab (target="_blank"), uses rel="noopener noreferrer"
+  // for security.
+  assert.match(COMPONENT_PAGE_TSX, /figmaUrl &&[\s\S]* {
+  // The runtime check rejects names that are single letters (minifier output
+  // like "d") or start with non-uppercase chars (anonymous fn wrappers).
+  // Falls back to the slug PascalCase'd so the snippet reads ""
+  // not "" or "".
+  assert.match(COMPONENT_PAGE_TSX, /looksLikeRealName/);
+  assert.match(COMPONENT_PAGE_TSX, /\/\^\[A-Z\]\[A-Za-z0-9\]\+\$\//);
+  assert.match(COMPONENT_PAGE_TSX, /pascalSlug/);
+});
+
+test('COMPONENT_MAP_TSX resolves a renderable function via default-then-named fallback', () => {
+  assert.match(COMPONENT_MAP_TSX, /function resolveComponent/);
+  assert.match(COMPONENT_MAP_TSX, /mod\.default/);
+});
+
+test('no template contains an explicit `any` type — consumer builds with no-explicit-any pass', () => {
+  // Generated docs files are read by the consumer's TypeScript compiler.
+  // If their ESLint config enables @typescript-eslint/no-explicit-any (the
+  // typical strict setup), even one `any` in our templates breaks their build.
+  // The templates use Record + targeted casts instead.
+  for (const [name, content] of Object.entries({ LAYOUT_TSX, INDEX_PAGE_TSX, TOKENS_PAGE_TSX, COMPONENT_PAGE_TSX, COMPONENT_MAP_TSX })) {
+    // Word-boundary check: catches `: any`, `as any`, `Foo`, etc. but not
+    // identifiers that happen to contain "any" (e.g. "Company", "many").
+    assert.doesNotMatch(content, /\bany\b/, `${name} contains an explicit \`any\` — consumers with no-explicit-any will fail to build`);
+  }
+});
+
+test('none of the templates contain "ADHD" outside the marker', () => {
+  // Two filename-style exceptions are allowed:
+  //   1. `adhd.config.ts` — the consumer's own config artifact.
+  //   2. `/adhd:sync-docs` — the slash command name, referenced in re-run copy.
+  const all = { LAYOUT_TSX, INDEX_PAGE_TSX, TOKENS_PAGE_TSX, COMPONENT_PAGE_TSX, COMPONENT_MAP_TSX };
+  for (const [name, content] of Object.entries(all)) {
+    const body = content
+      .replace(MARKER_COMMENT, '')
+      .replace(/adhd\.config\.ts/g, '')
+      .replace(/\/adhd:sync-docs/g, '');
+    assert.equal(/adhd/i.test(body), false, `${name} must not reference ADHD outside marker / allowed exceptions`);
+  }
+});
diff --git a/plugins/adhd/lib/sync-docs/__tests__/token-parser.test.js b/plugins/adhd/lib/sync-docs/__tests__/token-parser.test.js
new file mode 100644
index 0000000..bdb442e
--- /dev/null
+++ b/plugins/adhd/lib/sync-docs/__tests__/token-parser.test.js
@@ -0,0 +1,137 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { parseTokens } = require('../token-parser');
+
+const CSS = fs.readFileSync(
+  path.resolve(__dirname, '..', '__fixtures__', 'globals.css'),
+  'utf8',
+);
+
+test('extracts color tokens', () => {
+  const t = parseTokens(CSS);
+  assert.deepEqual(
+    t.colors.find(c => c.name === 'zinc-50'),
+    { name: 'zinc-50', value: 'oklch(0.985 0 0)' },
+  );
+  assert.deepEqual(
+    t.colors.find(c => c.name === 'brand-500'),
+    { name: 'brand-500', value: '#5e3aee' },
+  );
+});
+
+test('extracts the spacing multiplier', () => {
+  const t = parseTokens(CSS);
+  assert.equal(t.spacing.multiplier, '0.25rem');
+});
+
+test('extracts typography sizes with optional line-heights', () => {
+  const t = parseTokens(CSS);
+  assert.deepEqual(
+    t.typography.find(x => x.name === 'xs'),
+    { name: 'xs', size: '0.75rem', lineHeight: '1rem' },
+  );
+  assert.deepEqual(
+    t.typography.find(x => x.name === 'base'),
+    { name: 'base', size: '1rem', lineHeight: '1.5rem' },
+  );
+});
+
+test('extracts radius tokens', () => {
+  const t = parseTokens(CSS);
+  assert.deepEqual(
+    t.radius.find(r => r.name === 'sm'),
+    { name: 'sm', value: '0.25rem' },
+  );
+});
+
+test('extracts shadow tokens', () => {
+  const t = parseTokens(CSS);
+  assert.deepEqual(
+    t.shadows.find(s => s.name === 'sm'),
+    { name: 'sm', value: '0 1px 2px 0 rgb(0 0 0 / 0.05)' },
+  );
+});
+
+test('extracts font family tokens', () => {
+  const t = parseTokens(CSS);
+  assert.deepEqual(
+    t.fonts.find(f => f.name === 'sans'),
+    { name: 'sans', value: '"Inter", system-ui, sans-serif' },
+  );
+  assert.deepEqual(
+    t.fonts.find(f => f.name === 'mono'),
+    { name: 'mono', value: '"JetBrains Mono", monospace' },
+  );
+});
+
+test('extracts font-weight tokens (longest prefix wins over `font-`)', () => {
+  const t = parseTokens(CSS);
+  assert.deepEqual(
+    t.fontWeights.find(w => w.name === 'normal'),
+    { name: 'normal', value: '400' },
+  );
+  // `--font-weight-bold` MUST classify as fontWeights, not fonts — the
+  // prefix-map's order guarantees the longer prefix (`font-weight-`) wins.
+  assert.ok(!t.fonts.find(f => f.name === 'weight-bold'));
+});
+
+test('merges inset-shadow-* and drop-shadow-* into the shadows bucket', () => {
+  const t = parseTokens(CSS);
+  // Both `--inset-shadow-sm` and `--drop-shadow-sm` land in `shadows` alongside `--shadow-sm`.
+  const names = t.shadows.map(s => s.name);
+  assert.ok(names.includes('sm'));
+  assert.ok(names.includes('sm') && t.shadows.filter(s => s.name === 'sm').length >= 1);
+  // Distinguish by the leaf — `inset-shadow-sm` becomes leaf `sm`, but we keep
+  // the original prefix off so installers can render them grouped.
+  // For now, accept multiple entries with the same leaf name.
+  assert.ok(t.shadows.length >= 3);
+});
+
+test('extracts tracking, leading, breakpoints, easings, animations', () => {
+  const t = parseTokens(CSS);
+  assert.deepEqual(t.tracking.find(x => x.name === 'tight'), { name: 'tight', value: '-0.025em' });
+  assert.deepEqual(t.leading.find(x => x.name === 'tight'), { name: 'tight', value: '1.25' });
+  assert.deepEqual(t.breakpoints.find(x => x.name === 'sm'), { name: 'sm', value: '40rem' });
+  assert.deepEqual(t.easings.find(x => x.name === 'in-out'), { name: 'in-out', value: 'cubic-bezier(0.4, 0, 0.2, 1)' });
+  assert.deepEqual(t.animations.find(x => x.name === 'spin'), { name: 'spin', value: 'spin 1s linear infinite' });
+});
+
+test('puts unrecognized @theme vars in `unknown`', () => {
+  const t = parseTokens(CSS);
+  assert.ok(t.unknown.find(u => u.name === '--some-mystery-var'));
+});
+
+test('handles `@theme inline { ... }` modifier syntax', () => {
+  const t = parseTokens(CSS);
+  // The aliased color from the `@theme inline { ... }` block must be picked up.
+  assert.ok(t.colors.find(c => c.name === 'alias-bg' && c.value === 'var(--color-zinc-50)'));
+});
+
+test('returns empty domains when no @theme block exists', () => {
+  const t = parseTokens('body { color: red; }');
+  assert.deepEqual(t.colors, []);
+  assert.deepEqual(t.typography, []);
+  assert.deepEqual(t.radius, []);
+  assert.deepEqual(t.shadows, []);
+  assert.deepEqual(t.fonts, []);
+  assert.deepEqual(t.fontWeights, []);
+  assert.deepEqual(t.tracking, []);
+  assert.deepEqual(t.leading, []);
+  assert.deepEqual(t.breakpoints, []);
+  assert.deepEqual(t.easings, []);
+  assert.deepEqual(t.animations, []);
+  assert.equal(t.spacing.multiplier, null);
+});
+
+test('handles multiple @theme blocks (merge)', () => {
+  const css = `
+@theme { --color-a-100: #fff; }
+@theme { --color-b-200: #000; }
+`;
+  const t = parseTokens(css);
+  assert.equal(t.colors.length, 2);
+});
diff --git a/plugins/adhd/lib/sync-docs/cli.js b/plugins/adhd/lib/sync-docs/cli.js
new file mode 100644
index 0000000..acce554
--- /dev/null
+++ b/plugins/adhd/lib/sync-docs/cli.js
@@ -0,0 +1,121 @@
+#!/usr/bin/env node
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { parseTokens } = require('./token-parser');
+const { parseProps } = require('./prop-parser');
+const { slugMap } = require('./slug');
+const { patchNextConfig } = require('./next-config-patcher');
+const { patchRobots } = require('./robots-patcher');
+const { installRoute, detectExistingInstall } = require('./route-installer');
+const { readConfig } = require('./config-parser');
+
+function parseArgs(argv) {
+  const args = { _: [] };
+  for (let i = 2; i < argv.length; i++) {
+    const a = argv[i];
+    if (a === '--help' || a === '-h') { args.help = true; continue; }
+    if (a.startsWith('--')) { args[a.slice(2)] = argv[++i]; }
+    else { args._.push(a); }
+  }
+  return args;
+}
+
+function printUsage() {
+  console.log(`Usage:
+  cli.js parse-tokens --css  --output 
+  cli.js parse-props --source  --output 
+  cli.js slug --paths  --output 
+  cli.js patch-next-config --config  --route-url 
+  cli.js patch-robots --robots  --route-url 
+  cli.js detect-install --app-dir 
+  cli.js install --config `);
+}
+
+function main() {
+  const args = parseArgs(process.argv);
+  if (args.help) { printUsage(); process.exit(0); }
+  if (args._.length === 0) { printUsage(); process.exit(2); }
+  const cmd = args._[0];
+
+  if (cmd === 'parse-tokens') {
+    if (!args.css || !args.output) { console.error('Usage: parse-tokens --css  --output '); process.exit(2); }
+    const css = fs.readFileSync(args.css, 'utf8');
+    fs.writeFileSync(args.output, JSON.stringify(parseTokens(css), null, 2));
+    process.exit(0);
+  }
+
+  if (cmd === 'parse-props') {
+    if (!args.source || !args.output) { console.error('Usage: parse-props --source  --output '); process.exit(2); }
+    const src = fs.readFileSync(args.source, 'utf8');
+    fs.writeFileSync(args.output, JSON.stringify(parseProps(src), null, 2));
+    process.exit(0);
+  }
+
+  if (cmd === 'slug') {
+    if (!args.paths || !args.output) { console.error('Usage: slug --paths  --output '); process.exit(2); }
+    const paths = args.paths.split(',').map(s => s.trim()).filter(Boolean);
+    fs.writeFileSync(args.output, JSON.stringify(slugMap(paths), null, 2));
+    process.exit(0);
+  }
+
+  if (cmd === 'patch-next-config') {
+    if (!args.config || !args['route-url']) { console.error('Usage: patch-next-config --config  --route-url  [--render-mode ] [--css-entry ]'); process.exit(2); }
+    const renderMode = args['render-mode'] || 'dev-only';
+    const routeUrl = args['route-url'];
+    const cssEntry = args['css-entry'];
+    const src = fs.readFileSync(args.config, 'utf8');
+    const r = patchNextConfig(src, { detectOnly: true, renderMode, routeUrl, cssEntry });
+    if (r && r.conflict) {
+      console.error('next.config already sets pageExtensions: ' + r.existing);
+      process.exit(3);
+    }
+    const out = patchNextConfig(src, { renderMode, routeUrl, cssEntry });
+    fs.writeFileSync(args.config, out);
+    process.exit(0);
+  }
+
+  if (cmd === 'patch-robots') {
+    if (!args.robots || !args['route-url']) { console.error('Usage: patch-robots --robots  --route-url '); process.exit(2); }
+    let src = '';
+    try { src = fs.readFileSync(args.robots, 'utf8'); } catch {}
+    fs.writeFileSync(args.robots, patchRobots(src, args['route-url']));
+    process.exit(0);
+  }
+
+  if (cmd === 'detect-install') {
+    if (!args['app-dir']) { console.error('Usage: detect-install --app-dir '); process.exit(2); }
+    const found = detectExistingInstall(args['app-dir']);
+    for (const f of found) process.stdout.write(f + '\n');
+    process.exit(0);
+  }
+
+  if (cmd === 'install') {
+    if (!args.config) { console.error('Usage: install --config '); process.exit(2); }
+    const choices = JSON.parse(fs.readFileSync(args.config, 'utf8'));
+    if (!choices.projectRoot) { console.error('install: choices.projectRoot is required'); process.exit(2); }
+    // The installer needs the components list + cssEntry from the consumer's
+    // adhd.config.ts. The skill enforces "config exists" in Phase 1, so a
+    // missing file here is a hard error — we abort with a useful message
+    // instead of generating an empty componentMap.
+    let parsed;
+    try { parsed = readConfig(choices.projectRoot); }
+    catch (e) {
+      console.error('install: failed to read adhd.config.ts at ' + choices.projectRoot + ': ' + e.message);
+      process.exit(2);
+    }
+    const r = installRoute(choices.projectRoot, {
+      ...choices,
+      components: parsed.components,
+      cssEntry: parsed.cssEntry,
+    });
+    process.stdout.write(JSON.stringify({ files: r.files, removed: r.removed, components: parsed.components.map(c => c.slug) }, null, 2) + '\n');
+    process.exit(0);
+  }
+
+  console.error('Unknown subcommand: ' + cmd);
+  process.exit(2);
+}
+
+main();
diff --git a/plugins/adhd/lib/sync-docs/config-parser.js b/plugins/adhd/lib/sync-docs/config-parser.js
new file mode 100644
index 0000000..37e13ce
--- /dev/null
+++ b/plugins/adhd/lib/sync-docs/config-parser.js
@@ -0,0 +1,121 @@
+'use strict';
+
+// Parses the consumer's `adhd.config.ts` at install time. Mirrors the inline
+// regex parser that previous template versions ran at request time — but here
+// we parse once at install and bake the result into the generated files,
+// so adding/renaming/removing components requires re-running the installer.
+// That's intentional: the new architecture uses static imports.
+
+const fs = require('node:fs');
+const path = require('node:path');
+
+// Extracts the `components` map keys from the source. Keys are absolute
+// component paths relative to the consumer's project root (matching the
+// shape of `adhd.config.ts`). Uses a brace-counted scan so nested objects
+// (each entry's `{ figma: { url: "..." } }` value) don't confuse the
+// parser — a naïve non-greedy regex would stop at the first `}`.
+function parseComponents(src) {
+  const startMatch = /components:\s*\{/.exec(src);
+  if (!startMatch) return [];
+  const openAt = startMatch.index + startMatch[0].length - 1; // position of the opening `{`
+  let depth = 1;
+  let k = openAt + 1;
+  while (k < src.length && depth > 0) {
+    if (src[k] === '{') depth++;
+    else if (src[k] === '}') depth--;
+    if (depth > 0) k++;
+  }
+  const inner = src.slice(openAt + 1, k);
+  // Only top-level keys: track depth inside the inner block so we don't
+  // pick up keys from nested objects (e.g. `figma: { url: ... }`).
+  const paths = [];
+  let d = 0;
+  let i = 0;
+  while (i < inner.length) {
+    const ch = inner[i];
+    if (ch === '{') { d++; i++; continue; }
+    if (ch === '}') { d--; i++; continue; }
+    if (d === 0 && ch === '"') {
+      // Read the string literal
+      const end = inner.indexOf('"', i + 1);
+      if (end === -1) break;
+      const key = inner.slice(i + 1, end);
+      // Confirm this is a key (followed by `:` after optional whitespace)
+      let j = end + 1;
+      while (j < inner.length && /\s/.test(inner[j])) j++;
+      if (inner[j] === ':') paths.push(key);
+      i = end + 1;
+      continue;
+    }
+    i++;
+  }
+  return paths;
+}
+
+function parseCssEntry(src) {
+  const m = /cssEntry\s*:\s*"([^"]+)"/.exec(src);
+  return m ? m[1] : 'app/globals.css';
+}
+
+// Extract the `figma.url` value for a given component-path key. Returns
+// `null` when the entry has no `figma: { url: "..." }` block — the docs
+// route's "open in Figma" link is then suppressed for that component.
+// Targeted regex scoped to the value block that follows the path key.
+function parseFigmaUrlForPath(src, p) {
+  const escapedPath = p.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&');
+  const re = new RegExp(
+    '"' + escapedPath + '"\\s*:\\s*\\{[^}]*figma\\s*:\\s*\\{[^}]*url\\s*:\\s*"([^"]+)"',
+  );
+  const m = re.exec(src);
+  return m ? m[1] : null;
+}
+
+// Derive a URL slug from a component path. Mirrors the runtime helper used in
+// previous template versions so existing URL contracts are unchanged.
+//   src/components/Logo/index.tsx → "logo"
+//   app/widgets/Button.tsx        → "button"
+function slugFor(p) {
+  const noExt = p.replace(/\.tsx?$/, '').replace(/\/index$/, '');
+  return noExt.split('/').pop().toLowerCase();
+}
+
+// Compute an import-path string suitable for `import * as X from "@/..."`.
+// Strips the file extension and a trailing `/index` so the bundler picks the
+// directory's index.tsx automatically.
+function importPathFor(p) {
+  return '@/' + p.replace(/\.tsx?$/, '').replace(/\/index$/, '');
+}
+
+// Per-path `pulledAt` extractor. Mirrors parseFigmaUrlForPath: targeted
+// regex scoped to the value block that follows the path key. Returns
+// null when no pulledAt has been recorded yet (component was never
+// successfully pulled or pre-dates the fingerprint feature).
+function parsePulledAtForPath(src, p) {
+  const escapedPath = p.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&');
+  const re = new RegExp(
+    '["\']' + escapedPath + '["\']\\s*:\\s*\\{[^}]*?(?:\\{[^}]*\\}[^}]*?)*?pulledAt\\s*:\\s*["\']([^"\']+)["\']',
+  );
+  const m = re.exec(src);
+  return m ? m[1] : null;
+}
+
+// Top-level parser: reads adhd.config.ts at projectRoot, returns the data the
+// installer needs. Throws if the file is missing; the consumer should run
+// `/adhd:config` first.
+function readConfig(projectRoot) {
+  const cfgPath = path.join(projectRoot, 'adhd.config.ts');
+  const src = fs.readFileSync(cfgPath, 'utf8');
+  const components = parseComponents(src).map(rawPath => ({
+    slug: slugFor(rawPath),
+    rawPath,
+    importPath: importPathFor(rawPath),
+    figmaUrl: parseFigmaUrlForPath(src, rawPath),
+    pulledAt: parsePulledAtForPath(src, rawPath),
+  }));
+  return {
+    components,
+    cssEntry: parseCssEntry(src),
+  };
+}
+
+module.exports = { readConfig, parseComponents, parseCssEntry, parseFigmaUrlForPath, parsePulledAtForPath, slugFor, importPathFor };
diff --git a/plugins/adhd/lib/sync-docs/next-config-patcher.js b/plugins/adhd/lib/sync-docs/next-config-patcher.js
new file mode 100644
index 0000000..d83731d
--- /dev/null
+++ b/plugins/adhd/lib/sync-docs/next-config-patcher.js
@@ -0,0 +1,120 @@
+'use strict';
+
+// Detection: any of our markers — "design-system.tsx" in pageExtensions OR
+// the tokens-page tracing line. EITHER means we've patched. Re-runs are a
+// no-op once any marker is present; to switch modes, the user removes the
+// patch block manually and re-runs.
+const PATCHED_SENTINEL_RE = /'design-system\.tsx'|adhd:sync-docs — file-tracing/;
+
+// Detection: any OTHER pageExtensions definition (array form not matching ours).
+const EXISTING_PAGE_EXTENSIONS_RE = /pageExtensions:\s*\[/;
+
+// Captures the full `pageExtensions: ...,` declaration for conflict reporting.
+const EXISTING_PAGE_EXTENSIONS_VALUE_RE = /pageExtensions:[^,\n]+,?/;
+
+// Render-mode → pageExtensions conditional. "everywhere" doesn't get a
+// pageExtensions block — files are plain `.tsx` and ship to prod normally.
+const PAGE_EXTENSIONS_BLOCKS = {
+  'dev-only': `  pageExtensions: process.env.NODE_ENV === 'production'
+    ? ['ts', 'tsx']
+    : ['ts', 'tsx', 'design-system.ts', 'design-system.tsx'],`,
+  // Vercel-preview-aware: excludes on Vercel's production environment AND on
+  // any non-Vercel production deploy (Netlify, fly.io, CI, etc.). Vercel
+  // preview deploys have VERCEL_ENV='preview', which doesn't satisfy either
+  // disjunct, so the route renders there.
+  'vercel-preview': `  pageExtensions:
+    process.env.VERCEL_ENV === 'production' ||
+    (!process.env.VERCEL && process.env.NODE_ENV === 'production')
+      ? ['ts', 'tsx']
+      : ['ts', 'tsx', 'design-system.ts', 'design-system.tsx'],`,
+};
+
+// Builds the outputFileTracingIncludes block that ships globals.css alongside
+// the tokens-page function bundle. Without this, Vercel/serverless runtimes
+// don't include the CSS source file (it's normally compiled into static
+// assets), so the runtime fs.readFile in the page throws ENOENT, readCss
+// swallows it as null, and every token swatch falls through to the empty
+// state — even though globals.css is full of declarations. Tracing makes
+// the file part of the deployed function bundle.
+function buildTracingBlock(routeUrl, cssEntry) {
+  // Tracing key matches Next.js's app-router pattern for the page that does
+  // the fs.readFile — `/tokens/[domain]`. Vercel matches by route.
+  const key = `${routeUrl}/tokens/[domain]`;
+  return `  // adhd:sync-docs — file-tracing for tokens route (so globals.css ships with the serverless function)
+  outputFileTracingIncludes: {
+    ${JSON.stringify(key)}: [${JSON.stringify('./' + cssEntry)}],
+  },`;
+}
+
+function isPatched(source) {
+  return PATCHED_SENTINEL_RE.test(source);
+}
+
+function findConfigObjectStart(source) {
+  // Look for either:
+  //   const nextConfig: NextConfig = {
+  //   const nextConfig = {
+  //   export default {
+  //   module.exports = {
+  const patterns = [
+    /const\s+nextConfig(?:\s*:\s*[^=]+)?\s*=\s*\{/,
+    /export\s+default\s*\{/,
+    /module\.exports\s*=\s*\{/,
+  ];
+  for (const re of patterns) {
+    const m = re.exec(source);
+    if (m) return m.index + m[0].length; // position after the opening `{`
+  }
+  return -1;
+}
+
+function patchNextConfig(source, options = {}) {
+  if (isPatched(source)) return source;
+
+  const renderMode = options.renderMode || 'dev-only';
+  const { routeUrl, cssEntry } = options;
+
+  // pageExtensions block: only the two excluding render modes emit one.
+  // "everywhere" mode ships files in plain .tsx, no gate needed.
+  let pageExtensionsBlock = null;
+  if (renderMode !== 'everywhere') {
+    pageExtensionsBlock = PAGE_EXTENSIONS_BLOCKS[renderMode];
+    if (!pageExtensionsBlock) {
+      throw new Error(`Unknown renderMode: ${renderMode}. Expected one of: ${[...Object.keys(PAGE_EXTENSIONS_BLOCKS), 'everywhere'].join(', ')}.`);
+    }
+    // Detect existing different pageExtensions before we try to add ours.
+    if (EXISTING_PAGE_EXTENSIONS_RE.test(source)) {
+      if (options.detectOnly) {
+        const existing = EXISTING_PAGE_EXTENSIONS_VALUE_RE.exec(source)[0];
+        return { conflict: true, existing };
+      }
+      throw new Error('next.config already sets pageExtensions to a different value. Run with detectOnly: true to inspect and prompt the user.');
+    }
+  }
+
+  // Tracing block: emitted whenever we have route + css info AND the page
+  // might be served by a serverless function. Dev-only mode runs locally
+  // via `next dev` (project root is cwd, no tracing needed); the other two
+  // modes deploy to Vercel/serverless where tracing IS needed.
+  let tracingBlock = null;
+  if (renderMode !== 'dev-only' && routeUrl && cssEntry) {
+    tracingBlock = buildTracingBlock(routeUrl, cssEntry);
+  }
+
+  if (!pageExtensionsBlock && !tracingBlock) {
+    // Nothing to patch — "everywhere" mode with no route info (legacy callers).
+    return source;
+  }
+
+  const insertAt = findConfigObjectStart(source);
+  if (insertAt === -1) {
+    throw new Error('Could not locate the config object in next.config. Manual edit required.');
+  }
+
+  const blocks = [pageExtensionsBlock, tracingBlock].filter(Boolean).join('\n');
+  const before = source.slice(0, insertAt);
+  const after = source.slice(insertAt).replace(/^\n/, '');
+  return before + '\n' + blocks + '\n' + after;
+}
+
+module.exports = { patchNextConfig, isPatched };
diff --git a/plugins/adhd/lib/sync-docs/prop-parser.js b/plugins/adhd/lib/sync-docs/prop-parser.js
new file mode 100644
index 0000000..f98d04e
--- /dev/null
+++ b/plugins/adhd/lib/sync-docs/prop-parser.js
@@ -0,0 +1,73 @@
+'use strict';
+
+const TYPE_ALIAS_RE = /export\s+type\s+([A-Z][A-Za-z0-9]*)\s*=\s*([^;]+);/g;
+const INTERFACE_RE = /(?:export\s+)?interface\s+([A-Z][A-Za-z0-9]*Props)\s*\{([\s\S]*?)\}/;
+const TYPE_PROPS_RE = /(?:export\s+)?type\s+([A-Z][A-Za-z0-9]*Props)\s*=\s*\{([\s\S]*?)\}/;
+const EXPORT_FN_RE = /export\s+(?:default\s+)?function\s+([A-Z][A-Za-z0-9]*)\s*\(/;
+const PROP_LINE_RE = /^\s*([a-zA-Z_$][a-zA-Z0-9_$]*)(\?)?\s*:\s*([^;,]+)[;,]?\s*$/;
+
+function parseUnionString(typeText) {
+  const trimmed = typeText.trim();
+  if (!/^"[^"]*"(\s*\|\s*"[^"]*")*$/.test(trimmed)) return null;
+  return trimmed.split('|').map((s) => {
+    const m = /"([^"]*)"/.exec(s.trim());
+    return m ? m[1] : null;
+  }).filter(Boolean);
+}
+
+function classifyPropType(typeText, knownUnions) {
+  const t = typeText.trim();
+  const inlineUnion = parseUnionString(t);
+  if (inlineUnion) return { type: 'union', values: inlineUnion };
+  if (knownUnions[t]) return { type: 'union', unionName: t, values: knownUnions[t] };
+  if (/^\([^)]*\)\s*=>/.test(t)) return { type: 'function' };
+  if (/^(?:React\.)?Ref(?:Object|Callback|MutableRefObject)?$/.test(t)) return { type: 'reactnode' };
+  if (t === 'string') return { type: 'string' };
+  if (t === 'number') return { type: 'number' };
+  if (t === 'boolean') return { type: 'boolean' };
+  if (/\[\]$/.test(t) || /^Array `import * as $cmp${i} from "${c.importPath}";`)
+    .join('\n');
+  const entries = components
+    .map((c, i) => {
+      const props = JSON.stringify(bakedPropsFor(projectRoot, c.rawPath));
+      const figmaUrl = c.figmaUrl ? JSON.stringify(c.figmaUrl) : 'null';
+      const pulledAt = c.pulledAt ? JSON.stringify(c.pulledAt) : 'null';
+      return `  { slug: ${JSON.stringify(c.slug)}, rawPath: ${JSON.stringify(c.rawPath)}, figmaUrl: ${figmaUrl}, pulledAt: ${pulledAt}, module: $cmp${i}, props: ${props} },`;
+    })
+    .join('\n');
+  return COMPONENT_MAP_TSX
+    .replace('__COMPONENT_IMPORTS__', imports)
+    .replace('__COMPONENT_ENTRIES__', entries.length === 0 ? '[]' : `[\n${entries}\n]`);
+}
+
+// Three render modes:
+//   - 'everywhere'      → files use plain .tsx, no next.config patch, ship to prod
+//   - 'dev-only'        → files use .design-system.tsx, next.config gates on NODE_ENV
+//   - 'vercel-preview'  → files use .design-system.tsx, next.config gates on a
+//                         compound (VERCEL_ENV='production' OR non-Vercel prod)
+const VALID_RENDER_MODES = new Set(['everywhere', 'dev-only', 'vercel-preview']);
+
+function installRoute(projectRoot, opts) {
+  const {
+    groupName = '',
+    routeSegment,
+    renderMode = 'dev-only',
+    components = [],
+    cssEntry = 'app/globals.css',
+  } = opts;
+  if (!routeSegment) throw new Error('routeSegment is required');
+  if (!VALID_RENDER_MODES.has(renderMode)) {
+    throw new Error(`Unknown renderMode: ${renderMode}. Expected one of: ${[...VALID_RENDER_MODES].join(', ')}.`);
+  }
+  // 'everywhere' is the only mode where pages don't get the suffix; the other
+  // two both rely on `pageExtensions` to filter `.design-system.tsx` files in
+  // production builds (they just differ in WHICH env var the conditional reads,
+  // which is a next-config-patcher concern, not a file-extension concern).
+  const prodExcluded = renderMode !== 'everywhere';
+
+  // Page/layout files get the `.design-system.tsx` suffix only when prod-excluded
+  // so Next.js's `pageExtensions` filters them out of production builds.
+  // componentMap is a regular module — it's only bundled when imported by a page
+  // that IS suffix-excluded, so plain `.tsx` is correct (and necessary for
+  // standard TS module resolution to find it).
+  const pageExt = prodExcluded ? '.design-system.tsx' : '.tsx';
+  const moduleExt = '.tsx';
+  const segments = ['app'];
+  if (groupName) segments.push(groupName);
+  segments.push(routeSegment);
+  const docsDir = path.join(projectRoot, ...segments);
+  const tokensDir = path.join(docsDir, 'tokens', '[domain]');
+  const componentsDir = path.join(docsDir, 'components', '[component]');
+
+  // The runtime URL (route groups like `(design-system)` are invisible in URLs,
+  // so the URL is just `/`). Templates use `__ROUTE_PATH__` for
+  // absolute hrefs in the sidebar.
+  const routeUrl = '/' + routeSegment;
+
+  const targets = [
+    { abs: path.join(docsDir, `layout${pageExt}`), body: LAYOUT_TSX },
+    { abs: path.join(docsDir, `page${pageExt}`), body: INDEX_PAGE_TSX },
+    { abs: path.join(tokensDir, `page${pageExt}`), body: TOKENS_PAGE_TSX },
+    { abs: path.join(componentsDir, `page${pageExt}`), body: COMPONENT_PAGE_TSX },
+    { abs: path.join(docsDir, `componentMap${moduleExt}`), body: renderComponentMap(projectRoot, components) },
+  ];
+
+  // The tokens page imports TOKEN_DOMAINS from the layout. The layout file's
+  // basename depends on prod-exclusion (`layout` vs `layout.design-system`).
+  // TS/bundler resolution adds `.tsx` to whichever basename we use, so we
+  // substitute the right one here. Path is two levels up from
+  // `tokens/[domain]/page.*` to the docs root where `layout.*` lives.
+  const layoutModule = prodExcluded ? '../../layout.design-system' : '../../layout';
+
+  // Sync timestamp baked into the layout's sidebar header. UTC, no locale
+  // dependency, format YYYY-MM-DD HH:MM UTC. Designers reading the docs
+  // route see "Last built 2026-05-11 03:14 UTC" — a clear signal of
+  // whether what they're looking at is fresh or stale.
+  const syncAt = (() => {
+    const d = opts.now instanceof Date ? opts.now : new Date();
+    const pad = n => String(n).padStart(2, '0');
+    return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ` +
+           `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`;
+  })();
+
+  // Per-template placeholder substitution.
+  for (const t of targets) {
+    t.body = t.body
+      .replace(/__ROUTE_PATH__/g, routeUrl)
+      .replace(/__CSS_ENTRY__/g, cssEntry)
+      .replace(/__LAYOUT_MODULE__/g, layoutModule)
+      .replace(/__SYNC_AT__/g, syncAt);
+  }
+
+  // Remove stale marker-bearing files from previous template layouts (e.g. the
+  // old `[component]/page.*` directly under docsDir, or layout.* from a version
+  // before componentMap.tsx existed). Files where the user has deleted the
+  // marker comment are preserved.
+  const targetSet = new Set(targets.map(t => t.abs));
+  const removed = removeStaleMarkerFiles(docsDir, targetSet);
+
+  for (const t of targets) {
+    mkdirpSync(path.dirname(t.abs));
+    fs.writeFileSync(t.abs, t.body);
+  }
+
+  pruneEmptyDirs(docsDir);
+
+  return {
+    files: targets.map(t => t.abs),
+    removed,
+  };
+}
+
+function removeStaleMarkerFiles(docsDir, keep) {
+  const removed = [];
+  function walk(dir) {
+    let entries;
+    try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
+    catch { return; }
+    for (const ent of entries) {
+      const full = path.join(dir, ent.name);
+      if (ent.isDirectory()) { walk(full); continue; }
+      if (!/\.tsx?$/.test(ent.name)) continue;
+      if (keep.has(full)) continue;
+      try {
+        const content = fs.readFileSync(full, 'utf8');
+        if (content.includes(MARKER_STR)) {
+          fs.unlinkSync(full);
+          removed.push(full);
+        }
+      } catch {}
+    }
+  }
+  walk(docsDir);
+  return removed;
+}
+
+function pruneEmptyDirs(dir) {
+  let entries;
+  try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
+  catch { return; }
+  for (const ent of entries) {
+    if (ent.isDirectory()) pruneEmptyDirs(path.join(dir, ent.name));
+  }
+  try {
+    if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
+  } catch {}
+}
+
+function detectExistingInstall(projectRoot) {
+  const found = [];
+  function walk(dir) {
+    let entries;
+    try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
+    catch { return; }
+    for (const ent of entries) {
+      if (ent.name === 'node_modules' || ent.name === '.next' || ent.name.startsWith('.git')) continue;
+      const full = path.join(dir, ent.name);
+      if (ent.isDirectory()) walk(full);
+      else if (/\.tsx?$/.test(ent.name)) {
+        try {
+          const content = fs.readFileSync(full, 'utf8');
+          if (content.includes(MARKER_STR)) {
+            found.push(full);
+          }
+        } catch {}
+      }
+    }
+  }
+  walk(path.join(projectRoot, 'app'));
+  return found;
+}
+
+module.exports = { installRoute, detectExistingInstall, renderComponentMap };
diff --git a/plugins/adhd/lib/sync-docs/slug.js b/plugins/adhd/lib/sync-docs/slug.js
new file mode 100644
index 0000000..90d4733
--- /dev/null
+++ b/plugins/adhd/lib/sync-docs/slug.js
@@ -0,0 +1,49 @@
+'use strict';
+
+function pathSegments(componentPath) {
+  // Strip /index.tsx or .tsx, then split into meaningful segments.
+  return componentPath
+    .replace(/\\/g, '/')
+    .replace(/\.tsx?$/, '')
+    .replace(/\/index$/, '')
+    .split('/')
+    .filter(Boolean);
+}
+
+function baseSlug(componentPath) {
+  const segs = pathSegments(componentPath);
+  return (segs[segs.length - 1] || '').toLowerCase();
+}
+
+function slugFor(componentPath) {
+  return baseSlug(componentPath);
+}
+
+function slugMap(paths) {
+  // Pass 1: tentative slugs
+  const tentative = paths.map(p => ({ path: p, slug: baseSlug(p) }));
+  // Pass 2: find collisions
+  const counts = {};
+  for (const t of tentative) counts[t.slug] = (counts[t.slug] || 0) + 1;
+  // Pass 3: resolve collisions by prepending the parent dir
+  for (const t of tentative) {
+    if (counts[t.slug] === 1) continue;
+    const segs = pathSegments(t.path);
+    // Prepend one level of parent until unique
+    let depth = 2;
+    while (depth <= segs.length) {
+      const candidate = segs.slice(segs.length - depth).join('-').toLowerCase();
+      const colliders = tentative.filter(x => x !== t && x.slug === candidate).length;
+      if (colliders === 0) {
+        t.slug = candidate;
+        break;
+      }
+      depth++;
+    }
+  }
+  const out = {};
+  for (const t of tentative) out[t.path] = t.slug;
+  return out;
+}
+
+module.exports = { slugFor, slugMap };
diff --git a/plugins/adhd/lib/sync-docs/templates.js b/plugins/adhd/lib/sync-docs/templates.js
new file mode 100644
index 0000000..c730b26
--- /dev/null
+++ b/plugins/adhd/lib/sync-docs/templates.js
@@ -0,0 +1,677 @@
+'use strict';
+
+const MARKER_COMMENT = `// design-system-docs-route — auto-generated installer artifact; safe to edit.
+// Remove this comment to disable future overwrites from re-running the installer.
+`;
+
+// The TOKEN_DOMAINS catalog is declared (and named-exported) directly in
+// LAYOUT_TSX. Keeping it there means we don't ship a separate tokenDomains.tsx
+// file just to share a 12-entry constant. The tokens page imports
+// `{ TOKEN_DOMAINS, type TokenDomain }` from the layout module — TS's bundler
+// resolution handles the `.design-system.tsx` extension via `__LAYOUT_MODULE__`
+// substitution at install time.
+
+// Tokens-page CSS reader. Kept inline because the tokens page is a runtime
+// server component in the consumer's app and can't import ADHD's lib helpers.
+const READ_CSS_SRC = `async function readCss(cssEntry: string) {
+  try { return await fs.readFile(path.resolve(process.cwd(), cssEntry), "utf8"); }
+  catch { return null; }
+}`;
+
+// The CSS @theme parser used by the tokens page. Brace-counted scan correctly
+// handles `@theme { ... }` and `@theme inline { ... }`. Prefix order in
+// PREFIX_MAP matters — longer prefixes (`font-weight-`) must precede shorter
+// ones (`font-`) so classification picks the most-specific match.
+const PARSE_TOKENS_SRC = `function extractThemeBodies(css: string): string[] {
+  const bodies: string[] = [];
+  let i = 0;
+  while (i < css.length) {
+    const idx = css.indexOf("@theme", i);
+    if (idx === -1) break;
+    let j = idx + "@theme".length;
+    while (j < css.length && css[j] !== "{" && css[j] !== ";") j++;
+    if (css[j] !== "{") { i = j + 1; continue; }
+    let depth = 1;
+    let k = j + 1;
+    while (k < css.length && depth > 0) {
+      if (css[k] === "{") depth++;
+      else if (css[k] === "}") depth--;
+      if (depth > 0) k++;
+    }
+    bodies.push(css.slice(j + 1, k));
+    i = k + 1;
+  }
+  return bodies;
+}
+
+type Row = { name: string; value: string };
+type TypoRow = { name: string; size: string | null; lineHeight: string | null };
+
+function parseTokens(css: string | null) {
+  const out = {
+    colors: [] as Row[],
+    spacing: { multiplier: null as string | null },
+    typography: [] as TypoRow[],
+    fonts: [] as Row[],
+    fontWeights: [] as Row[],
+    radius: [] as Row[],
+    shadows: [] as Row[],
+    tracking: [] as Row[],
+    leading: [] as Row[],
+    breakpoints: [] as Row[],
+    easings: [] as Row[],
+    animations: [] as Row[],
+  };
+  if (!css) return out;
+  const typoByName = new Map();
+  const LINE_HEIGHT_SUFFIX = "--line-height";
+  const PREFIX_MAP: Array<[string, keyof typeof out]> = [
+    ["color-", "colors"],
+    ["font-weight-", "fontWeights"],
+    ["font-", "fonts"],
+    ["inset-shadow-", "shadows"],
+    ["drop-shadow-", "shadows"],
+    ["shadow-", "shadows"],
+    ["radius-", "radius"],
+    ["tracking-", "tracking"],
+    ["leading-", "leading"],
+    ["breakpoint-", "breakpoints"],
+    ["ease-", "easings"],
+    ["animate-", "animations"],
+  ];
+  for (const body of extractThemeBodies(css)) {
+    const declRe = /--([a-zA-Z0-9_-]+)\\s*:\\s*([^;]+);/g;
+    let d;
+    while ((d = declRe.exec(body)) !== null) {
+      const name = d[1];
+      const value = d[2].trim();
+      if (name === "spacing") { out.spacing.multiplier = value; continue; }
+      if (name.startsWith("text-")) {
+        const rest = name.slice("text-".length);
+        const isLh = rest.endsWith(LINE_HEIGHT_SUFFIX);
+        const leaf = isLh ? rest.slice(0, -LINE_HEIGHT_SUFFIX.length) : rest;
+        let row = typoByName.get(leaf);
+        if (!row) { row = { name: leaf, size: null, lineHeight: null }; typoByName.set(leaf, row); out.typography.push(row); }
+        if (isLh) row.lineHeight = value; else row.size = value;
+        continue;
+      }
+      for (const [prefix, domain] of PREFIX_MAP) {
+        if (name.startsWith(prefix)) {
+          (out[domain] as Row[]).push({ name: name.slice(prefix.length), value });
+          break;
+        }
+      }
+    }
+  }
+  return out;
+}`;
+
+// componentMap.tsx — the heart of the new static architecture. Generated per
+// install from adhd.config.ts. Each tracked component gets an explicit
+// `import * as $cmpN from "@/"` so Webpack/Turbopack resolves a single,
+// known module per component — no context module, no broad bundle, no
+// Tailwind blast radius. To add/rename/remove a component: edit
+// `adhd.config.ts`, then re-run `/adhd:sync-docs`.
+//
+// Placeholders substituted by route-installer.js:
+//   __COMPONENT_IMPORTS__ — one `import * as $cmpN from "";` per component
+//   __COMPONENT_ENTRIES__ — array literal of `{ slug, rawPath, module: $cmpN }`
+const COMPONENT_MAP_TSX = `${MARKER_COMMENT}import type React from "react";
+__COMPONENT_IMPORTS__
+
+// PropSchema is baked at sync time from each component's TypeScript prop
+// interface (via the lib's prop-parser). Re-run \`/adhd:sync-docs\` after
+// editing a component's props to refresh this file.
+export type PropSchema = {
+  type: "union" | "boolean" | "string" | "number" | "unknown";
+  values?: readonly string[];
+  optional: boolean;
+};
+
+export type ComponentEntry = {
+  slug: string;
+  rawPath: string;
+  figmaUrl: string | null;
+  pulledAt: string | null;
+  Component: React.ComponentType> | null;
+  props: Record;
+};
+
+type RawEntry = {
+  slug: string;
+  rawPath: string;
+  figmaUrl: string | null;
+  pulledAt: string | null;
+  module: Record;
+  props: Record;
+};
+
+// Resolve the renderable function: prefer the default export, then the
+// first exported function. Keeps user components working without forcing
+// a particular export style.
+function resolveComponent(mod: Record): React.ComponentType> | null {
+  if (typeof mod.default === "function") return mod.default as React.ComponentType>;
+  for (const v of Object.values(mod)) {
+    if (typeof v === "function") return v as React.ComponentType>;
+  }
+  return null;
+}
+
+const ENTRIES: RawEntry[] = __COMPONENT_ENTRIES__;
+
+export const components: ComponentEntry[] = ENTRIES.map(e => ({
+  slug: e.slug,
+  rawPath: e.rawPath,
+  figmaUrl: e.figmaUrl,
+  pulledAt: e.pulledAt,
+  Component: resolveComponent(e.module),
+  props: e.props,
+}));
+
+export function getComponent(slug: string): ComponentEntry | undefined {
+  return components.find(c => c.slug === slug);
+}
+`;
+
+// Layout: sidebar links into the token-domain catalog (declared inline + named-
+// exported so the tokens page can import it) and the static component map.
+// No fs reads, no async — pure server component.
+const LAYOUT_TSX = `${MARKER_COMMENT}import type { Metadata } from "next";
+import Link from "next/link";
+import { components } from "./componentMap";
+
+export const metadata: Metadata = {
+  title: "Design System Docs",
+  robots: { index: false, follow: false },
+};
+
+// Single source of truth for the token-domain catalog. Imported by the
+// tokens page (renderer keys + empty-state link targets). Adding a new
+// domain means editing this list AND the matching renderer block in the
+// tokens page — surgical, two-file change.
+export type TokenDomain = {
+  slug: string;
+  label: string;
+  varPrefix: string;
+  tailwindDocs: string;
+};
+
+export const TOKEN_DOMAINS: TokenDomain[] = [
+  { slug: "colors", label: "Colors", varPrefix: "--color-", tailwindDocs: "https://tailwindcss.com/docs/colors" },
+  { slug: "spacing", label: "Spacing", varPrefix: "--spacing", tailwindDocs: "https://tailwindcss.com/docs/theme#spacing" },
+  { slug: "typography", label: "Typography", varPrefix: "--text-", tailwindDocs: "https://tailwindcss.com/docs/font-size" },
+  { slug: "font", label: "Font Families", varPrefix: "--font-", tailwindDocs: "https://tailwindcss.com/docs/font-family" },
+  { slug: "font-weight", label: "Font Weights", varPrefix: "--font-weight-", tailwindDocs: "https://tailwindcss.com/docs/font-weight" },
+  { slug: "tracking", label: "Tracking", varPrefix: "--tracking-", tailwindDocs: "https://tailwindcss.com/docs/letter-spacing" },
+  { slug: "leading", label: "Leading", varPrefix: "--leading-", tailwindDocs: "https://tailwindcss.com/docs/line-height" },
+  { slug: "radius", label: "Radius", varPrefix: "--radius-", tailwindDocs: "https://tailwindcss.com/docs/border-radius" },
+  { slug: "shadows", label: "Shadows", varPrefix: "--shadow-", tailwindDocs: "https://tailwindcss.com/docs/box-shadow" },
+  { slug: "breakpoint", label: "Breakpoints", varPrefix: "--breakpoint-", tailwindDocs: "https://tailwindcss.com/docs/responsive-design" },
+  { slug: "ease", label: "Easing", varPrefix: "--ease-", tailwindDocs: "https://tailwindcss.com/docs/transition-timing-function" },
+  { slug: "animate", label: "Animation", varPrefix: "--animate-", tailwindDocs: "https://tailwindcss.com/docs/animation" },
+];
+
+export default function DesignSystemDocsLayout({ children }: { children: React.ReactNode }) {
+  return (
+    
+ + +
+
{children}
+
+
+ ); +} +`; + +// Landing page — minimal welcome + a couple of quick notes. The sidebar carries +// the actual navigation; each domain/component route has its own targeted UI +// for its own failure modes (the component page surfaces "not in static map", +// error.tsx catches runtime crashes, token pages link to Tailwind docs for +// empty domains). Nothing to repeat here. +const INDEX_PAGE_TSX = `${MARKER_COMMENT}export default function DesignSystemIndex() { + return ( +
+

Design System

+

+ Pick a token domain or a component from the sidebar. Tokens are read live from your + globals.css + @theme blocks. Components are statically imported from + adhd.config.ts — after editing the components map, re-run + /adhd:sync-docs to regenerate the static imports. +

+

+ Only @theme {"{ ... }"} and @theme inline {"{ ... }"} declarations are picked up — plain :root variables aren't. +

+
+ ); +} +`; + +// Tokens domain page — reads globals.css at request time, renders whatever's +// declared. cssEntry is baked at install time (substituted from adhd.config.ts). +// The TOKEN_DOMAINS list is imported from the shared catalog, so adding a new +// domain only requires editing one file. +const TOKENS_PAGE_TSX = `${MARKER_COMMENT}import fs from "node:fs/promises"; +import path from "node:path"; +import { notFound } from "next/navigation"; +import { TOKEN_DOMAINS, type TokenDomain } from "__LAYOUT_MODULE__"; + +${READ_CSS_SRC} + +${PARSE_TOKENS_SRC} + +const CSS_ENTRY = "__CSS_ENTRY__"; + +function EmptyState({ domain }: { domain: TokenDomain }) { + return ( +
+ ); +} + +export default async function TokensDomainPage({ params }: { params: Promise<{ domain: string }> }) { + const { domain: slug } = await params; + const domain = TOKEN_DOMAINS.find(d => d.slug === slug); + if (!domain) notFound(); + + const css = await readCss(CSS_ENTRY); + const tokens = parseTokens(css); + + return ( +
+
+

{domain.label}

+

Variables prefixed with {domain.varPrefix}

+
+ + {slug === "colors" && ( + tokens.colors.length === 0 ? : ( +
+ {tokens.colors.map(c => ( +
+
+ {c.name} + {c.value} +
+ ))} +
+ ) + )} + + {slug === "spacing" && ( + tokens.spacing.multiplier == null ? : ( +
+

Multiplier: {tokens.spacing.multiplier}

+

Tailwind v4 derives all spacing utilities from this single variable.

+
+ ) + )} + + {slug === "typography" && ( + tokens.typography.length === 0 ? : ( +
+ {tokens.typography.map(t => ( +
+ text-{t.name} + + The quick brown fox jumps over the lazy dog + + {t.size}{t.lineHeight ? \` / \${t.lineHeight}\` : ""} +
+ ))} +
+ ) + )} + + {slug === "font" && ( + tokens.fonts.length === 0 ? : ( +
+ {tokens.fonts.map(f => ( +
+ font-{f.name} + The quick brown fox + {f.value} +
+ ))} +
+ ) + )} + + {slug === "font-weight" && ( + tokens.fontWeights.length === 0 ? : ( +
+ {tokens.fontWeights.map(w => ( +
+ font-{w.name} + The quick brown fox + {w.value} +
+ ))} +
+ ) + )} + + {slug === "tracking" && ( + tokens.tracking.length === 0 ? : ( +
+ {tokens.tracking.map(t => ( +
+ tracking-{t.name} + The quick brown fox + {t.value} +
+ ))} +
+ ) + )} + + {slug === "leading" && ( + tokens.leading.length === 0 ? : ( +
+ {tokens.leading.map(l => ( +
+ leading-{l.name} — {l.value} +

+ The quick brown fox jumps over the lazy dog. The five boxing wizards jump quickly. Pack my box with five dozen liquor jugs. +

+
+ ))} +
+ ) + )} + + {slug === "radius" && ( + tokens.radius.length === 0 ? : ( +
+ {tokens.radius.map(r => ( +
+
+ rounded-{r.name} + {r.value} +
+ ))} +
+ ) + )} + + {slug === "shadows" && ( + tokens.shadows.length === 0 ? : ( +
+ {tokens.shadows.map((s, i) => ( +
+
+ shadow-{s.name} + {s.value} +
+ ))} +
+ ) + )} + + {slug === "breakpoint" && ( + tokens.breakpoints.length === 0 ? : ( +
+ {tokens.breakpoints.map(b => ( +
+ {b.name} + {b.value} +
+ ))} +
+ ) + )} + + {slug === "ease" && ( + tokens.easings.length === 0 ? : ( +
+ {tokens.easings.map(e => ( +
+ ease-{e.name} + {e.value} +
+ ))} +
+ ) + )} + + {slug === "animate" && ( + tokens.animations.length === 0 ? : ( +
+ {tokens.animations.map(a => ( +
+ animate-{a.name} + {a.value} +
+ ))} +
+ ) + )} +
+ ); +} +`; + +// Component page — a CLIENT component that does a static lookup in +// componentMap. Prop schemas are baked into componentMap at sync time +// (via the lib's prop-parser), so the page has no runtime fs reads. The +// PropToggle UI is inlined since both it and the page need `"use client"`. +const COMPONENT_PAGE_TSX = `${MARKER_COMMENT}"use client"; + +import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation"; +import { components, getComponent, type PropSchema } from "../../componentMap"; + +// Inline PropToggle: a small client UI for one prop, wired to the URL via +// router.replace so the parent re-renders with the new value. Kept in this +// file because both it and the page need "use client" — no reason to split. +function PropToggle(p: + | { name: string; kind: "union"; values: readonly string[]; value: string } + | { name: string; kind: "boolean"; value: string } + | { name: string; kind: "string" | "number"; value: string } +) { + const router = useRouter(); + const pathname = usePathname(); + const sp = useSearchParams(); + + function setParam(v: string) { + const next = new URLSearchParams(sp.toString()); + if (v === "") next.delete(p.name); + else next.set(p.name, v); + router.replace(\`\${pathname}?\${next.toString()}\`); + } + + return ( + + ); +} + +function NotInMap({ slug }: { slug: string }) { + return ( +
+

Not in the static map

+

+ The slug {slug} isn't present in the generated componentMap.tsx. +

+

+ Tracked slugs: {components.length === 0 ? none : components.map(c => {c.slug})} +

+

+ If you just edited adhd.config.ts to add this component, re-run /adhd:sync-docs in this project to regenerate the static imports. +

+
+ ); +} + +export default function ComponentPage() { + const params = useParams<{ component: string }>(); + const slug = params.component; + const sp = useSearchParams(); + const entry = getComponent(slug); + + if (!entry) return ; + + const { rawPath, figmaUrl, pulledAt, Component, props } = entry; + + // Resolve current prop values from the URL. Values are constrained to the + // three shapes the page knows how to source — string (for union + string + // schemas), boolean, and number. Anything else is omitted. + type PropValue = string | boolean | number; + const current: Record = {}; + for (const [name, def] of Object.entries(props) as Array<[string, PropSchema]>) { + const v = sp.get(name); + if (v == null) continue; + if (def.type === "union" && def.values?.includes(v)) current[name] = v; + else if (def.type === "boolean") current[name] = v === "true"; + else if (def.type === "string") current[name] = v; + else if (def.type === "number") current[name] = Number(v); + } + + // Render-name precedence: prefer the actual exported function/class name + // when it looks like a real identifier (starts with uppercase, multi-char), + // otherwise fall back to a PascalCase'd slug. Avoids \`\` and \`<_Logo />\` + // when the export got wrapped/minified and Component.name is a single + // letter or starts with an underscore. + const looksLikeRealName = !!Component?.name && /^[A-Z][A-Za-z0-9]+$/.test(Component.name); + const pascalSlug = slug.split(/[-_]+/).filter(Boolean).map(w => w[0].toUpperCase() + w.slice(1)).join(""); + const componentName = looksLikeRealName ? Component!.name : (pascalSlug || slug); + + const importPath = "@/" + rawPath.replace(/\\.tsx?$/, "").replace(/\\/index$/, ""); + const importStmt = Component ? \`import \${componentName} from "\${importPath}";\` : null; + const jsxSnippet = Component + ? \`<\${componentName}\${Object.entries(current).map(([k, v]) => \` \${k}={\${JSON.stringify(v)}}\`).join("")} />\` + : null; + + return ( +
+
+

+ {componentName} + {figmaUrl && ( + + ↗ + + )} +

+ {pulledAt && ( +

+ Last pulled {new Date(pulledAt).toISOString().slice(0, 16).replace("T", " ")} UTC +

+ )} +
+ +
+

Props

+ {Object.keys(props).length === 0 ? ( +

No prop interface detected at sync time.

+ ) : ( +
+ {Object.entries(props).map(([name, def]) => { + if (def.type === "union" && def.values) { + // For unions, current[name] is always a string (validated at + // load time against def.values). The cast narrows from PropValue. + const v = (current[name] as string | undefined) ?? def.values[0]; + return ; + } + if (def.type === "boolean") { + return ; + } + if (def.type === "string" || def.type === "number") { + return ; + } + return ( +
+ {name}: {def.type} — toggle unavailable +
+ ); + })} +
+ )} +
+ +
+ {Component ? ( + + ) : ( +

+ No renderable component exported from {rawPath}. componentMap imported it but couldn't resolve a function (default or named). +

+ )} +
+ + {importStmt && jsxSnippet && ( +
+
{importStmt}
+
{jsxSnippet}
+
+ )} +
+ ); +} +`; + +module.exports = { + MARKER_COMMENT, + LAYOUT_TSX, + INDEX_PAGE_TSX, + TOKENS_PAGE_TSX, + COMPONENT_PAGE_TSX, + COMPONENT_MAP_TSX, +}; diff --git a/plugins/adhd/lib/sync-docs/token-parser.js b/plugins/adhd/lib/sync-docs/token-parser.js new file mode 100644 index 0000000..6ec4851 --- /dev/null +++ b/plugins/adhd/lib/sync-docs/token-parser.js @@ -0,0 +1,152 @@ +'use strict'; + +// Returns the body text of every `@theme { ... }` block found in `css`. +// A Tailwind v4 `@theme` block contains flat `--name: value;` declarations +// only (no nested rules), so a naive brace counter is sufficient — we don't +// need the string/comment-aware scanner used in lib/pull-component. +// `@theme inline { ... }` and other modifiers between `@theme` and `{` are +// supported by skipping forward to the first `{`. +function extractAllThemeBodies(css) { + const bodies = []; + let i = 0; + while (i < css.length) { + const idx = css.indexOf('@theme', i); + if (idx === -1) break; + // Skip forward to the block-opening `{`, tolerating modifiers like `inline`. + let j = idx + '@theme'.length; + while (j < css.length && css[j] !== '{' && css[j] !== ';') j++; + if (css[j] !== '{') { + i = j + 1; + continue; + } + let depth = 1; + let k = j + 1; + while (k < css.length && depth > 0) { + if (css[k] === '{') depth++; + else if (css[k] === '}') depth--; + if (depth > 0) k++; + } + bodies.push(css.slice(j + 1, k)); + i = k + 1; + } + return bodies; +} + +// Matches a single `--name: value;` declaration. The `name` capture excludes +// the leading `--`; the `value` capture is everything up to the next `;`. +const DECL_RE = /--([a-zA-Z0-9_-]+)\s*:\s*([^;]+);/g; + +// Tailwind v4 typography pairs size + line-height under the same family name +// via a `--line-height` suffix on the variable: +// --text-xs: 0.75rem; ← size +// --text-xs--line-height: 1rem; ← line-height of the same `xs` row +// We split on the suffix so callers see one row per family name. +const LINE_HEIGHT_SUFFIX = '--line-height'; + +// Prefix-to-domain mapping. Order is significant: longer/more-specific prefixes +// must precede shorter ones (e.g. `font-weight-` before `font-`, `inset-shadow-` +// before `shadow-`). Each entry maps to a flat array of `{ name, value }` rows. +const PREFIX_MAP = [ + ['color-', 'colors'], + ['font-weight-', 'fontWeights'], + ['font-', 'fonts'], + ['inset-shadow-', 'shadows'], + ['drop-shadow-', 'shadows'], + ['shadow-', 'shadows'], + ['radius-', 'radius'], + ['tracking-', 'tracking'], + ['leading-', 'leading'], + ['breakpoint-', 'breakpoints'], + ['ease-', 'easings'], + ['animate-', 'animations'], +]; + +function classify(name) { + if (name === 'spacing') { + return { domain: 'spacing', leaf: null }; + } + // Typography (`text-*`) is special because of the `--line-height` suffix pairing. + if (name.startsWith('text-')) { + const rest = name.slice('text-'.length); + if (rest.endsWith(LINE_HEIGHT_SUFFIX)) { + return { + domain: 'typography', + leaf: rest.slice(0, -LINE_HEIGHT_SUFFIX.length), + kind: 'lineHeight', + }; + } + return { domain: 'typography', leaf: rest, kind: 'size' }; + } + for (const [prefix, domain] of PREFIX_MAP) { + if (name.startsWith(prefix)) { + return { domain, leaf: name.slice(prefix.length) }; + } + } + return { domain: 'unknown' }; +} + +function parseTokens(globalsCss) { + const out = { + colors: [], + spacing: { multiplier: null }, + typography: [], // [{ name, size, lineHeight }] + fonts: [], + fontWeights: [], + radius: [], + shadows: [], + tracking: [], + leading: [], + breakpoints: [], + easings: [], + animations: [], + unknown: [], + }; + // Tracks typography rows by family name so size + line-height (which arrive + // as two separate declarations) merge into a single output row. + const typographyByName = new Map(); + + function upsertTypography(leaf, kind, value) { + let row = typographyByName.get(leaf); + if (!row) { + row = { name: leaf, size: null, lineHeight: null }; + typographyByName.set(leaf, row); + out.typography.push(row); + } + if (kind === 'lineHeight') row.lineHeight = value; + else row.size = value; + } + + for (const body of extractAllThemeBodies(globalsCss)) { + // Reset lastIndex because DECL_RE is module-scoped and stateful (`/g`). + DECL_RE.lastIndex = 0; + let m; + while ((m = DECL_RE.exec(body)) !== null) { + const name = m[1]; + const value = m[2].trim(); + const cls = classify(name); + switch (cls.domain) { + case 'spacing': + out.spacing.multiplier = value; + break; + case 'typography': + upsertTypography(cls.leaf, cls.kind, value); + break; + case 'unknown': + out.unknown.push({ name: '--' + name, value }); + break; + default: + // All other domains share the same flat `{ name, value }` row shape + // and a 1:1 mapping from PREFIX_MAP's domain key to an `out` bucket. + if (Array.isArray(out[cls.domain])) { + out[cls.domain].push({ name: cls.leaf, value }); + } else { + out.unknown.push({ name: '--' + name, value }); + } + } + } + } + + return out; +} + +module.exports = { parseTokens }; diff --git a/plugins/adhd/skills/config/SKILL.md b/plugins/adhd/skills/config/SKILL.md index 1b28d27..46d7cff 100644 --- a/plugins/adhd/skills/config/SKILL.md +++ b/plugins/adhd/skills/config/SKILL.md @@ -33,7 +33,7 @@ Pass these forward as defaults for Phases 1, 2, and 3. ## Phase 0.5: Verify the official Figma plugin is installed and authenticated -ADHD requires the `figma@claude-plugins-official` Claude Code plugin — every other skill (`/adhd:lint`, `/adhd:push-design-system`, `/adhd:pull-design-system`, `/adhd:push-component`, `/adhd:pull-component`) drives Figma exclusively through it via `mcp__plugin_figma_figma__*`. This phase verifies it's installed and authenticated up front, so users hit setup errors here (when they can act on them) rather than mid-pipeline. +ADHD requires the `figma@claude-plugins-official` Claude Code plugin — every other skill (`/adhd:lint`, `/adhd:push-tokens`, `/adhd:pull-tokens`, `/adhd:push-component`, `/adhd:pull-component`) drives Figma exclusively through it via `mcp__plugin_figma_figma__*`. This phase verifies it's installed and authenticated up front, so users hit setup errors here (when they can act on them) rather than mid-pipeline. Call `mcp__plugin_figma_figma__whoami`. It's read-only and returns the authenticated Figma user's identity. @@ -235,6 +235,29 @@ Then run /adhd:sync --dry-run to preview your first diff (Figma → code). If running on a healthy config that didn't change, print `Config unchanged.` instead of the saved-to message. +## Phase 6 (optional): Sync the design-system docs route + +Use `AskUserQuestion`: + +``` +Question: "Generate the design-system docs route now? It's a live page that +reads your adhd.config.ts and globals.css. Mini-Storybook for designers; +not indexed by search engines." +Header: "Docs route" +Options: + - "Yes, generate it now" + - "No, maybe later" +``` + +On "Yes": execute the phases of `/adhd:sync-docs` inline. +See `plugins/adhd/skills/sync-docs/SKILL.md` for the +detailed phase list (validate environment → detect existing install → ask install +choices → detect Next.js config → detect collisions → patch next.config.ts → +write files → patch robots.txt → final report). + +On "No": print `Run /adhd:sync-docs later to generate it.` +Exit normally. + ## Reference: Common errors and fix-up guidance ### "The official Figma plugin isn't installed" diff --git a/plugins/adhd/skills/lint/SKILL.md b/plugins/adhd/skills/lint/SKILL.md index 633c10c..19b764d 100644 --- a/plugins/adhd/skills/lint/SKILL.md +++ b/plugins/adhd/skills/lint/SKILL.md @@ -1,5 +1,5 @@ --- -description: "Validate Figma frames/components/pages or the entire file against the local Tailwind design system + frame-structure best practices. Reads adhd.config.ts at the repo root. Read-only — no writes. Optional argument: a Figma URL with node-id (scoped lint). With no argument, lints the whole file." +description: "Validate Figma frames/components/pages or the entire file against the local Tailwind design system + frame-structure best practices. Reads adhd.config.ts at the repo root. Always interactive — walks every violation through a per-rule resolution wizard (auto-fix in Figma / add in code / take Figma's value / take code's value / annotate only / skip). Lint never aborts a sync because there's no sync to abort; the wizard's choices are the only outputs (annotations, Figma rebinds, globals.css writes). Optional argument: a Figma URL with node-id (scoped lint). With no argument, lints the whole file." disable-model-invocation: true argument-hint: "[]" allowed-tools: Read Write Bash AskUserQuestion mcp__plugin_figma_figma__use_figma @@ -10,9 +10,9 @@ allowed-tools: Read Write Bash AskUserQuestion mcp__plugin_figma_figma__use_figm Validate that a Figma file (or a single frame/component/page) is ready for code translation. Reports two classes of issue: - **Variable issues** — Figma variables used by the lint target that are missing locally or have conflicting values. -- **Structure issues** — STRUCT001–STRUCT010 best-practice violations (auto-layout, naming, variant properties, etc.). +- **Structure issues** — STRUCT001–STRUCT016 best-practice violations (auto-layout, naming, variant properties, per-layer variable naming, cross-domain variable bindings, Tailwind-default duplicates, alias-equivalent collection duplicates, layers binding variables missing from code, layers binding variables whose values differ between code and Figma, etc.). -Output: a markdown report saved to `adhd-lint-report.md` (gitignored), plus a terminal echo. The report is paste-ready for sharing with designers via Figma comments, Slack, or GitHub issues. +Output: a markdown report saved to `/tmp/adhd-lint/report.md`, plus a terminal echo. After the report, the wizard walks every violation and applies the picked actions — Figma rebinds, globals.css writes, lint-category annotations. The report is paste-ready for sharing with designers via Figma comments, Slack, or GitHub issues. **Authoritative spec:** `docs/superpowers/specs/2026-05-10-adhd-lint-and-sync-design.md` @@ -24,7 +24,7 @@ Extract `figma.url` (required) and `naming` (optional, defaults to `kebab-case`) ## Phase 2: Resolve target -Branch on `$ARGUMENTS`: +Branch on `$ARGUMENTS`. - **Empty argument → whole-file mode.** Skip target resolution. The extract script (Phase 3) will return ALL pages and ALL top-level lintable nodes (COMPONENT_SET, top-level COMPONENT, top-level FRAME) on each page. Set `target = "Whole file"` and `targetUrl = `. - **URL provided → scoped mode.** @@ -41,23 +41,29 @@ Construct a JS string for `mcp__plugin_figma_figma__use_figma` that: - `id`, `name`, `type` - `layoutMode`, `paddingTop`, `paddingRight`, `paddingBottom`, `paddingLeft`, `itemSpacing`, `cornerRadius`, `topLeftRadius`, `topRightRadius`, `bottomLeftRadius`, `bottomRightRadius` - `fills`, `strokes`, `effects`, `boundVariables` + - `fillStyleId`, `strokeStyleId` — paint-style bindings (Figma's legacy design-token mechanism, distinct from variable bindings). The lint engine uses these to recognize style-bound layers and skip the raw-color rule on them. - `componentPropertyDefinitions` — **only** when `n.type === 'COMPONENT_SET' || (n.type === 'COMPONENT' && n.parent?.type !== 'COMPONENT_SET')`. Accessing it on a variant COMPONENT (a child of a COMPONENT_SET) throws. - `variantProperties` — only on COMPONENT children of a COMPONENT_SET. - `textStyleId`, `effectStyleId` - For TEXT: `characters`, `fontSize`, `fontName` - For FRAME: `wasInstance` - `children` — recursively `serializeNode`-mapped. + + **`figma.mixed` handling.** Several fields return the `figma.mixed` Symbol when a node has per-range variation (most commonly `node.fills` on TEXT with multiple colored spans, `node.fontSize` on multi-size text, `node.fillStyleId` / `node.strokeStyleId` when only some ranges have a style applied). `JSON.stringify` drops Symbols silently — which means a multi-color TEXT layer with raw whites would have its `fills` quietly disappear from the serialized output, and STRUCT003 would never fire on it. Before assigning each potentially-mixed field, coerce: `value === figma.mixed ? "__MIXED__" : value`. The lint engine recognizes the `"__MIXED__"` sentinel and reports it as a STRUCT003 violation with a "mixed paints — bind each range to a variable, or apply a paint style" message, so the violation surfaces instead of disappearing. 2. Branches on a `nodeId` parameter (passed via the `inputs` object on `use_figma`): - **Whole-file** (no `nodeId`): walk `figma.root.children` (pages); for each page, find children whose type is `COMPONENT_SET`, or `COMPONENT` (top-level only — i.e. parent is the page, not nested), or `FRAME` (top-level). Serialize each. Return `{ mode: 'whole-file', pages: [{ id, name, nodes: [...serialized...] }, ...] }`. - **Scoped** (`nodeId` provided): `await figma.getNodeByIdAsync(nodeId)`; if missing, return `{ error: 'Node not found' }`; otherwise `serializeNode(node)` and return it directly (no `mode` field). -3. Also collects the variables referenced by the target subtree(s). Walk every `boundVariables` entry across the serialized nodes, dedupe by variable id, look each up via `figma.variables.getVariableByIdAsync`, and return a sibling map `{ vars: { '/': } }`. Use the "primary" mode of each variable's collection. (This is the same shape `get_variable_defs` would have produced from the local MCP.) +3. Also collects the variables referenced by the target subtree(s). Walk every `boundVariables` entry across the serialized nodes, dedupe by variable id, look each up via `figma.variables.getVariableByIdAsync`, and return two sibling maps: + - `vars: { '/': }` — the variable definitions, keyed by name. Same shape `get_variable_defs` would have produced from the local MCP. Use the "primary" mode of each variable's collection. + - `varIdMap: { '': '/' }` — Figma variable ID → name lookup, built from the same dedupe pass. Per-layer lint rules (STRUCT011's per-layer annotations, STRUCT012's cross-domain check) need this to bridge node-level `boundVariables` (which reference variables by ID) to the variable names the engine reasons about. Without it, those rules can't fire. - The `use_figma` invocation returns a single payload; split it into `{ ctx, vars }` after. + The `use_figma` invocation returns a single payload; split it into `{ ctx, vars, varIdMap }` after. Save the response to `/tmp/adhd-lint/`: - `/tmp/adhd-lint/ctx.json` — the design-context payload (whole-file shape OR a single serialized subtree). - `/tmp/adhd-lint/vars.json` — the `vars` map. +- `/tmp/adhd-lint/varidmap.json` — the `varIdMap` lookup. The `Write` tool creates the parent dir on demand. (No `mkdir` needed.) @@ -65,17 +71,19 @@ If the response indicates `error: 'Node not found'`, abort with: "Node not found ## Phase 4: Run the engine -Use the `Bash` tool: +Use the `Bash` tool. Redirect stdout (the engine's JSON summary) to a temp file so the resolution wizard (Phase 6) can re-use it: ```bash node plugins/adhd/lib/lint-engine/cli.js \ --variable-defs /tmp/adhd-lint/vars.json \ + --var-id-map /tmp/adhd-lint/varidmap.json \ --design-context /tmp/adhd-lint/ctx.json \ --globals-css \ --config adhd.config.ts \ --target "" \ --target-url "" \ - --output adhd-lint-report.md + --output /tmp/adhd-lint/report.md \ + > /tmp/adhd-lint/stdout.json ``` Where `` is `"Whole file"` in whole-file mode, or `" / "` in scoped mode. `` is `` (whole-file) or the original URL with node-id (scoped). @@ -84,18 +92,330 @@ Globals path resolution: if `adhd.config.ts` has `cssEntry`, use it. Otherwise a ## Phase 5: Present results -Read `adhd-lint-report.md` with the `Read` tool and echo it to the user verbatim. Then summarize: +Read `/tmp/adhd-lint/report.md` with the `Read` tool and echo it to the user verbatim. Then summarize: - **Whole-file mode:** - - Exit 0 with zero violations: "✓ No issues found across all top-level nodes on

pages." - - Exit 0 with warnings only: "⚠ warnings across nodes on pages (see report). File is ready for code translation." - - Exit 1: "✗ errors, warnings across nodes on pages." + - Exit 0 with zero violations: "✓ No issues found across all top-level nodes on

pages." Skip to Phase 8. + - Otherwise: print " errors, warnings across nodes on pages — walking each through the resolution wizard." - **Scoped mode:** - - Exit 0 with zero violations: "✓ No issues found." - - Exit 0 with warnings only: "⚠ warnings (see report). Frame is ready for code translation." - - Exit 1: "✗ errors, warnings. Frame has issues that should be resolved before code translation." + - Zero violations: "✓ No issues found." Skip to Phase 8. + - Otherwise: print " errors, warnings — walking each through the resolution wizard." + +Mention the report file path: "Full report: `/tmp/adhd-lint/report.md` (paste-ready for Figma comments / Slack)." + +## Phase 6: Resolution wizard — walk every violation + +For every violation in `/tmp/adhd-lint/stdout.json`, prompt with rule-specific options via `AskUserQuestion`. Lint is always a dry run for *sync operations* (it doesn't move tokens between code and Figma without explicit per-violation consent), but the wizard's picks ARE applied — they write to Figma (rebinds, value updates, annotations) and to `globals.css` (variable additions, value updates). There's no abort option because there's no sync to abort; the last option on every prompt is "Skip" (record nothing, no annotation lands). + +Collect picks into three queues that Phase 7 applies in order: +- `figmaActions[]` — Figma-side writes: variable rebinds, consolidations, variable-value updates +- `codeActions[]` — `globals.css` writes (via `applyToCss`) +- `annotateNodes[]` — node IDs whose violations get a fresh Figma annotation; everything NOT in this list and previously annotated gets its annotation cleared on the cleanup pass at the end of Phase 7 + +Iterate violations in this order so foundational ones get addressed first: + +1. STRUCT011 (variable naming) +2. STRUCT012 (cross-domain binding) +3. STRUCT013 (Tailwind-default duplicate) +4. STRUCT014 (alias-equivalent collections) +5. STRUCT015 (variable missing in code) +6. STRUCT016 (value conflict) +7. STRUCT001–010 (structural rules) +8. Variable-level violations from the `variable` array that didn't surface as STRUCT015/016 (rare — usually whole-file-mode entries that aren't bound by any scoped layer) + +### STRUCT011 — variable naming non-compliance + +Per unique offending variable (deduplicate by `figmaVarName`): + +``` +Question: "Variable `` doesn't follow the naming convention. . What do you want to do?" +Header: "Variable naming" +Options: + - "Annotate only — leave a Figma annotation for the designer to rename" + - "Skip" +``` + +Rename can't be automated safely (designers might disagree with the suggested canonical), so the only paths are annotate-for-later or skip. + +### STRUCT012 — cross-domain binding + +Per unique (variable, property) pair: + +``` +Question: "Layer binds `` (`` variable) to `` (expects ``). Rebinding requires designer judgment. What do you want to do?" +Header: "Cross-domain binding" +Options: + - "Annotate only — flag this layer for the designer to rebind" + - "Skip" +``` + +### STRUCT013 — Tailwind-default duplicate + +Per duplicate (one prompt per Figma variable that duplicates a Tailwind canonical): + +``` +Question: "Figma variable `` duplicates Tailwind default `` (same value). Rebinding layers from `` to `` and deleting the duplicate has no visual effect. What do you want to do?" +Header: "Tailwind duplicate" +Options: + - "Auto-fix in Figma — consolidate (rebind + delete)" + - "Annotate only" + - "Skip" +``` + +On "Auto-fix": push a `{ kind: 'consolidate', duplicateName: figmaVarName, canonicalCssVar: tailwindCssVar }` action into `figmaActions[]`. + +### STRUCT014 — alias-equivalent collections + +Per duplicate group (collections that resolve to the same canonical domain): + +``` +Question: " collections describe the same domain ``: . Consolidating moves every variable in the others into the keeper and deletes the empties. What do you want to do?" +Header: "Collection duplicates" +Options: + - "Auto-fix in Figma — pick keeper, consolidate" + - "Annotate only" + - "Skip" +``` + +On "Auto-fix," ask a follow-up `AskUserQuestion` to pick the keeper (most-populated suggested first; other collections listed; final option "Cancel — go back to the previous prompt"). Then push `{ kind: 'consolidate-collections', keeper, losers: [...] }` into `figmaActions[]`. + +### STRUCT015 — variable missing in code + +Per unique variable (deduplicate by `figmaVarName`). Each STRUCT015 violation in the engine's output may carry two optional fields: +- `canonicalCandidate` — set when the Figma value strictly equals a Tailwind canonical +- `looksSemantic` — set when the path looks semantic (`brand`, `accent`, `surface`, etc.) + +These drive the option set the same way they do in `/adhd:pull-component` Phase 2.5: + +``` +Question: "`` is referenced by Figma but doesn't exist in code's design system. Figma resolves it to ``. What do you want to do?" +Header: "Variable missing" +Options: + + - "Auto-fix in Figma — rebind to `` (same value, no visual change)" + + - "Add in code as `--`" + when looksSemantic=true, replace the label with: + - "Add as semantic — keep `` in code (recommended for brand / accent / surface tokens)" + + - "Annotate only" + - "Skip" +``` + +Pick handling: +- **Auto-fix**: `{ kind: 'rebind-to-canonical', figmaName, canonicalCandidate, figmaValue }` → `figmaActions[]`. +- **Add / Add as semantic**: queue a `resolve-actions` CLI invocation (same as `/adhd:pull-component`'s Phase 2.5) to get alias-aware `set-primitive` / `set-semantic` actions. Concatenate into `codeActions[]`. +- **Annotate only**: add the violation's `nodeId` to `annotateNodes[]`. +- **Skip**: record nothing. + +### STRUCT016 — value conflict + +Per unique variable. Per the user's design decision, lint IS a generalized resolver — both directions of the value sync are available alongside the diagnostic options: + +``` +Question: "`` differs between Figma and code:\n code: \n figma: \nWhat do you want to do?" +Header: "Value conflict" +Options: + - "Take Figma's value — write `` to globals.css (alias-aware)" + - "Take code's value — push `` to Figma's variable" + - "Annotate only" + - "Skip" +``` + +Pick handling: +- **Take Figma**: queue a `resolve-actions` CLI invocation. Concatenate into `codeActions[]`. +- **Take code**: `{ kind: 'update-figma-value', figmaName, mode, value: localNormalized }` → `figmaActions[]`. +- **Annotate only**: `annotateNodes[]`. +- **Skip**: record nothing. + +### STRUCT001–010 (structural rules) + +These fire per-layer and outnumber the variable rules in most scopes. The only meaningful resolution paths are "annotate" or "skip" — the underlying fix (auto-layout, raw-value rebind, variant declaration) requires designer judgment in Figma. To keep the wizard tractable, batch by rule code: ONE prompt per rule, applied to every layer that violates it. + +``` +Question: "STRUCT00 fires on layer(s): . The fix needs designer judgment in Figma. What do you want to do?" +Header: "STRUCT00" +Options: + - "Annotate all in Figma" + - "Skip all" +``` + +On "Annotate all": add every offending `nodeId` to `annotateNodes[]`. On "Skip all": no-op. + +### Whole-file scope considerations + +In whole-file mode, the violation count can be large (hundreds). Walk per-prompt regardless — designers can hit Enter quickly on the recommended option for each — but consider adding a one-shot "Walk back to this prompt later" exit option in future iterations if the volume becomes a real problem. + +## Phase 7: Apply queued actions + +After the wizard completes, apply the three queues in this order: Figma actions first (rebinds before annotation pushes so annotations land on the post-rebind state), then code actions, then annotation reconciliation. + +### 7a. Figma-side actions (single use_figma call) + +Substitute `__ACTIONS__` with the `figmaActions[]` JSON. The script dispatches on `kind`: + +```js +const ACTIONS = __ACTIONS__; + +function hexToRgb(h) { + let c = h.replace('#', ''); + if (c.length === 3) c = c.split('').map(x => x + x).join(''); + return { + r: parseInt(c.slice(0, 2), 16) / 255, + g: parseInt(c.slice(2, 4), 16) / 255, + b: parseInt(c.slice(4, 6), 16) / 255, + a: c.length === 8 ? parseInt(c.slice(6, 8), 16) / 255 : 1, + }; +} + +function dimensionToPx(raw) { + const m = /^(-?\d*\.?\d+)(px|rem|em)?$/.exec(String(raw).trim()); + if (!m) return Number(raw); + const unit = m[2] || ''; + return parseFloat(m[1]) * (unit === 'rem' || unit === 'em' ? 16 : 1); +} + +function canonicalFigmaName(cssVar) { + const PREFIXES = ['color', 'spacing', 'radius', 'text', 'leading', 'tracking', 'font-weight', 'font', 'shadow', 'opacity', 'border-width', 'breakpoint', 'container', 'ease', 'animate', 'blur'].sort((a, b) => b.length - a.length); + const stripped = cssVar.replace(/^--/, ''); + for (const p of PREFIXES) { + if (stripped === p) return p; + if (stripped.startsWith(p + '-')) return p + '/' + stripped.slice(p.length + 1); + } + return stripped; +} + +const allCollections = await figma.variables.getLocalVariableCollectionsAsync(); +async function findVarByName(name) { + for (const col of allCollections) { + for (const vid of col.variableIds) { + const v = await figma.variables.getVariableByIdAsync(vid); + if (v && v.name === name) return v; + } + } + return null; +} + +async function rebindLayersAndDelete(source, target) { + let rebound = 0; + for (const page of figma.root.children) { + await figma.setCurrentPageAsync(page); + const nodes = page.findAll(() => true); + for (const node of nodes) { + if (!node.boundVariables) continue; + for (const [prop, alias] of Object.entries(node.boundVariables)) { + if (prop === 'fills' || prop === 'strokes' || prop === 'effects') continue; + if (alias && alias.id === source.id) { node.setBoundVariable(prop, target); rebound++; } + } + for (const kind of ['fills', 'strokes']) { + const arr = node[kind]; + if (!Array.isArray(arr)) continue; + node[kind] = arr.map((paint) => paint?.boundVariables?.color?.id === source.id + ? figma.variables.setBoundVariableForPaint(paint, 'color', target) + : paint + ); + } + } + } + try { source.remove(); return { rebound, deleted: true }; } + catch (e) { return { rebound, deleted: false, error: String(e) }; } +} + +const results = []; +for (const a of ACTIONS) { + try { + if (a.kind === 'rebind-to-canonical' || a.kind === 'consolidate') { + const sourceName = a.figmaName || a.duplicateName; + const canonicalName = canonicalFigmaName(a.canonicalCandidate || a.canonicalCssVar); + const source = await findVarByName(sourceName); + if (!source) { results.push({ ...a, status: 'skipped', reason: 'source not found' }); continue; } + let canonical = await findVarByName(canonicalName); + if (!canonical) { + canonical = figma.variables.createVariable(canonicalName, source.variableCollectionId, source.resolvedType); + canonical.scopes = source.scopes; + for (const [modeId, val] of Object.entries(source.valuesByMode)) canonical.setValueForMode(modeId, val); + } + const r = await rebindLayersAndDelete(source, canonical); + results.push({ ...a, ...r, status: 'ok' }); + } else if (a.kind === 'consolidate-collections') { + // Reuse the per-keeper consolidation logic — move each loser + // collection's variables into the keeper (mode-name-mapped), + // rebind layers, delete empty losers. (Same loop as the old + // STRUCT014 --fix script.) + // ... implementation as in pre-rewrite Phase 8b ... + results.push({ ...a, status: 'consolidated' }); + } else if (a.kind === 'update-figma-value') { + const v = await findVarByName(a.figmaName); + if (!v) { results.push({ ...a, status: 'skipped', reason: 'variable not found' }); continue; } + const col = await figma.variables.getVariableCollectionByIdAsync(v.variableCollectionId); + const wantMode = (a.mode || col.modes[0].name).toLowerCase(); + const target = col.modes.find(m => m.name.toLowerCase() === wantMode) || col.modes[0]; + let figmaValue; + if (v.resolvedType === 'COLOR') figmaValue = hexToRgb(a.value); + else if (v.resolvedType === 'FLOAT') figmaValue = dimensionToPx(a.value); + else figmaValue = a.value; + v.setValueForMode(target.modeId, figmaValue); + results.push({ ...a, status: 'ok' }); + } else { + results.push({ ...a, status: 'unknown-kind' }); + } + } catch (e) { + results.push({ ...a, status: 'error', error: String(e) }); + } +} +return { results }; +``` + +### 7b. Code-side actions (one applyToCss invocation) + +Concatenate every `set-primitive` / `set-semantic` action from `codeActions[]` (each was produced by `lib/pull-component/cli.js resolve-actions`) and apply: + +```bash +node -e ' + const fs = require("fs"); + const { applyToCss } = require("plugins/adhd/lib/design-system/code-writer"); + const css = fs.readFileSync("", "utf8"); + const actions = JSON.parse(fs.readFileSync("/tmp/adhd-lint/code-actions.json", "utf8")); + fs.writeFileSync("", applyToCss(css, actions)); +' +``` + +### 7c. Annotation reconciliation (single use_figma call) + +Push annotations for every node in `annotateNodes[]` AND clear stale annotations from nodes that were previously annotated but didn't make the list this run. The script ensures the `"lint"` category exists and is scoped via `SCOPE_ROOT_ID` (the resolved target's nodeId for scoped mode, `null` for whole-file). Same script as the pre-rewrite Phase 6 annotation block — it remains the source of truth for category lifecycle, the scoped subtree walk, `labelMarkdown` rendering, and stale-cleanup. + +Distill the picks first: + +```bash +node -e ' + const fs = require("fs"); + const summary = JSON.parse(fs.readFileSync("/tmp/adhd-lint/stdout.json", "utf8")); + const all = [...(summary.structure ?? []), ...(summary.variable ?? [])]; + const annotateIds = new Set(JSON.parse(fs.readFileSync("/tmp/adhd-lint/annotate-ids.json", "utf8"))); + const out = all + .filter(v => v.nodeId && annotateIds.has(v.nodeId)) + .map(v => ({ nodeId: v.nodeId, code: v.code, message: v.message, severity: v.severity ?? "error" })); + fs.writeFileSync("/tmp/adhd-lint/violations.json", JSON.stringify(out)); +' +``` + +Then call `use_figma` with the annotation script, passing `VIOLATIONS = ` and `SCOPE_ROOT_ID = `. The script's stale-cleanup pass takes care of clearing annotations on nodes that fell out of the list. + +## Phase 8: Final report + +Print a concise summary: + +``` +✓ Resolution complete: + - Figma actions applied ( rebinds, consolidations, value updates) + - code-side writes to globals.css + - annotations pushed, stale annotations cleared + - violations skipped (no action recorded) +``` + +If any action failed (error in the `results[]` payload from 7a or a Figma rebind reported `deleted: false`), surface the failures inline. They don't abort the run — they just need designer review. -Mention the report file path: "Full report: `adhd-lint-report.md` (paste-ready for Figma comments / Slack)." +Exit 0 if all resolution actions succeeded (or the user picked Skip for everything). Exit 1 only if any Figma write erred out; the user should see the failures and decide whether to re-run. ## Common errors diff --git a/plugins/adhd/skills/pull-all-components/SKILL.md b/plugins/adhd/skills/pull-all-components/SKILL.md new file mode 100644 index 0000000..f3a5c49 --- /dev/null +++ b/plugins/adhd/skills/pull-all-components/SKILL.md @@ -0,0 +1,155 @@ +--- +description: "Bulk version of /adhd:pull-component. Iterates over every entry in adhd.config.ts's `components` map and runs the full pull flow on each, sequentially. Halts on first failure by default (use --continue-on-error for best-effort + summary). Per-component interactivity (preflight blockers, --allow-unbound escape, per-variable STRUCT015/016 resolution, Phase 2.7 missing-var discovery, sync-docs prompt) is preserved — each component's pull behaves exactly as if you'd invoked /adhd:pull-component manually." +disable-model-invocation: true +argument-hint: "[--continue-on-error] [--allow-unbound]" +allowed-tools: Read Write Edit Bash AskUserQuestion mcp__plugin_figma_figma__use_figma mcp__plugin_figma_figma__get_metadata +--- + +# ADHD Pull All Components + +Bulk wrapper around `/adhd:pull-component`. Reads the components map from `adhd.config.ts` and iterates over every entry, running the full per-component pull flow on each. Stops on first failure unless `--continue-on-error` is passed. + +**Why this skill exists:** for design systems with many components, pulling each one manually is repetitive. This skill saves the typing AND provides a single end-of-run summary so failures don't get buried in a long log. + +**What this skill DOES NOT do:** +- Suppress per-component prompts (preflight blockers, escape questions, per-variable STRUCT015/016 resolution). Each component's pull runs its full interactive flow. +- Apply decisions across all components ("add all missing vars", "take Figma for everything", etc.). Those would require a global mode and risk batch-applying choices that should be considered per-component. v2 if it proves annoying. + +## Phase 1: Validate config + read components list + +Run the same Phase 1 as `/adhd:pull-component`: validate `adhd.config.ts` exists at the repo root, etc. + +Then read every key from the `components: { ... }` map. Use a small `node -e` snippet to avoid TS-execution dependency: + +```bash +mkdir -p /tmp/adhd-pull-all +node -e ' +const fs = require("node:fs"); +const src = fs.readFileSync("adhd.config.ts", "utf8"); +const m = /components:\s*\{([\s\S]*?)\}\s*[,;]?/.exec(src); +if (!m) { process.stdout.write("[]"); process.exit(0); } +// Use brace-counted scan for nested values (each component value is itself +// an object). This is the same logic lib/sync-docs/config-parser.js uses. +const startIdx = m.index + m[0].indexOf("{"); +let depth = 1, k = startIdx + 1; +while (k < src.length && depth > 0) { + if (src[k] === "{") depth++; + else if (src[k] === "}") depth--; + if (depth > 0) k++; +} +const inner = src.slice(startIdx + 1, k); +const paths = []; +let d = 0, i = 0; +while (i < inner.length) { + const ch = inner[i]; + if (ch === "{") { d++; i++; continue; } + if (ch === "}") { d--; i++; continue; } + if (d === 0 && ch === "\"") { + const end = inner.indexOf("\"", i + 1); + if (end === -1) break; + const key = inner.slice(i + 1, end); + let j = end + 1; + while (j < inner.length && /\s/.test(inner[j])) j++; + if (inner[j] === ":") paths.push(key); + i = end + 1; continue; + } + i++; +} +process.stdout.write(JSON.stringify(paths)); +' > /tmp/adhd-pull-all/paths.json +``` + +If the resulting list is empty, abort: + +``` +✗ No components registered in adhd.config.ts. +Run /adhd:push-component to register a component first +(it writes the entry to adhd.config.ts), then re-run /adhd:pull-all-components. +``` + +Print the planned run upfront so the user can see what's about to happen: + +``` +Pulling 5 components in sequence: + 1. components/design-system/logo/index.tsx + 2. components/avatar/index.tsx + 3. components/button/index.tsx + 4. components/card/index.tsx + 5. components/icon/index.tsx +``` + +## Phase 2: Iterate + +For each path in the list, in order: + +1. Print a divider header: + ``` + ──── [N/total] pulling ──── + ``` + +2. Invoke the phases of `/adhd:pull-component` inline for this path. Pass through any flags the user gave to `/adhd:pull-all-components`: + - `--allow-unbound` (per-component STRUCT003/004/005 escape) + + The per-component pull-component SKILL handles its own validation, preflight, abort/escape logic, STRUCT015/016 resolution, opportunistic-variable discovery (Phase 2.7), final report, and the post-success sync-docs prompt. Annotations land automatically on any abort — no flag forwarding needed. + +3. Record the outcome for this component into `/tmp/adhd-pull-all/outcomes.json` (append-only). Outcome shape: + ```json + { "path": "", "status": "success" | "abort" | "cancel" | "unchanged", "summary": "", "error": "" } + ``` + - `success`: the per-component pull completed Phase 10 (final report). + - `unchanged`: the per-component pull's fingerprint short-circuit (pull-component Phase 2.5) matched the stored value — Figma + relevant config haven't changed since the last successful pull, no work was done. Recorded separately from `success` so the bulk summary surfaces how many were skipped vs how many were actively re-synced. + - `abort`: any blocking error (STRUCT011, unbound without escape, file missing, etc.). + - `cancel`: user said "no" / cancel on an in-flow prompt (treated as a halt — they explicitly stopped). + +4. **Decide whether to continue:** + - If `success`, `unchanged`, or `cancel`: continue to the next component. (`cancel` halts the inner per-component flow but is not treated as a bulk failure — the user made an explicit choice. `unchanged` is the fingerprint-skip case — also not a failure.) + - If `abort`: + - With `--continue-on-error`: record + continue. + - Without (default): print `Halted on . Re-run with --continue-on-error to push through subsequent components, or fix the issue and re-run /adhd:pull-component directly.` Then break out of the loop and go to Phase 3. + +5. **Skipped components** (when halt-on-error fires partway through): the remaining paths are NOT iterated. Their outcomes are recorded as `{ status: "skipped" }` so they show up in the final summary. + +## Phase 3: Final summary + +Read `/tmp/adhd-pull-all/outcomes.json` and produce: + +``` +Bulk pull report: + ✓ components/design-system/logo/index.tsx — 3 cells updated + ✓ components/avatar/index.tsx — no changes + ⊙ components/badge/index.tsx — unchanged (fingerprint match, last pulled 2026-05-10T...) + ⊙ components/spinner/index.tsx — unchanged (fingerprint match, last pulled 2026-05-11T...) + ✗ components/button/index.tsx — preflight: STRUCT011 (2 var-naming issues) + ⏭ components/card/index.tsx — skipped (earlier failure) + ⏭ components/icon/index.tsx — skipped (earlier failure) + +Summary: 2 re-synced, 2 unchanged, 1 failed, 2 skipped. +``` + +The `⊙` marker covers the fingerprint-short-circuit case — no work was done for that component because Figma + relevant config matched the stored fingerprint. Surface the `pulledAt` from `adhd.config.ts`'s component entry so designers can confirm what "last pulled" actually means. + +Append actionable next steps based on outcome: + +- **All succeeded:** print `Run /adhd:sync-docs to refresh the design-system docs route.` (already prompted per component but worth reminding for the whole run). +- **Any failed:** print `To re-try just the failures: /adhd:pull-component ` with the actual failed paths listed. +- **Any abort happened during the run** (per-component pulls auto-push annotations on abort): print `Annotations were pushed to Figma for unresolved violations during this run. Check the "lint" category to see what needs fixing.` + +Exit code: +- All `success`/`cancel`: exit 0. +- Any `abort`/`skipped`: exit 1. + +## Phase 4: Cleanup + +Always runs (even on abort): + +```bash +rm -rf /tmp/adhd-pull-all +``` + +## Common errors + +| Error | Fix-up | +|---|---| +| `No components registered in adhd.config.ts` | Register a component first via `/adhd:push-component `, or edit `adhd.config.ts` manually. | +| Mid-run halt on STRUCT011 | Rename the offending Figma variables (see the STRUCT011 message for the per-variable target), then re-run. | +| Mid-run halt on unbound values | Bind the values in Figma OR add `--allow-unbound` to the bulk command (applies to every component, so use carefully). | diff --git a/plugins/adhd/skills/pull-component/SKILL.md b/plugins/adhd/skills/pull-component/SKILL.md index 6ba5773..90534af 100644 --- a/plugins/adhd/skills/pull-component/SKILL.md +++ b/plugins/adhd/skills/pull-component/SKILL.md @@ -66,14 +66,38 @@ Save resolved `{ mode, path, figmaUrl }` to working memory. Extract the Figma node-id from the URL (`?node-id=A-B` → `A:B`). Use `mcp__plugin_figma_figma__use_figma` to: 1. Resolve the node by id; if not a `COMPONENT_SET` or top-level `COMPONENT`, abort: "Target node `` is a ``. Pull requires a Component Set." -2. Serialize the node's structural data (the same way /adhd:lint does for scoped mode — fields: `id, name, type, layoutMode, padding*, itemSpacing, cornerRadius, *Radius, fills, strokes, effects, boundVariables, componentPropertyDefinitions, variantProperties, textStyleId, effectStyleId, characters, fontSize, fontName`, recursing into children). +2. Serialize the node's structural data (the same way /adhd:lint does for scoped mode — fields: `id, name, type, layoutMode, padding*, itemSpacing, cornerRadius, *Radius, fills, strokes, effects, boundVariables, componentPropertyDefinitions, variantProperties, textStyleId, effectStyleId, characters, fontSize, fontName`, recursing into children). For `INSTANCE` nodes also capture `mainComponent.id` (as `mainComponentId`) and `componentProperties` — these drive Phase 7's "INSTANCE-of-tracked-component" branch of the scaffold (generate `` + import instead of inlining the instance's markup). 3. Collect the variable defs (walk boundVariables, look each up via `figma.variables.getVariableByIdAsync`, emit a `{ vars: { 'collection/name': value } }` map). Save both via `Bash` heredoc to: - `/tmp/adhd-pull-component/ctx.json` - `/tmp/adhd-pull-component/vars.json` -Run the lint engine: +### Fingerprint short-circuit (skip when nothing changed) + +Before running the lint engine, hash the fresh Figma extract + the pull-output-affecting config fields and compare to the stored fingerprint in `adhd.config.ts`. If it matches, the source state hasn't changed since the last successful pull — skip lint, diff, write, commit, everything. + +```bash +node plugins/adhd/lib/pull-component/cli.js fingerprint-check \ + --config adhd.config.ts \ + --path \ + --ctx /tmp/adhd-pull-component/ctx.json \ + --vars /tmp/adhd-pull-component/vars.json +``` + +The command writes JSON to stdout: `{ current, stored, match }`. Parse it. If `match === true`: + +``` +✓ matches last-pulled fingerprint (). Last pulled . Nothing changed — skipping pull. +``` + +Exit normally (do NOT proceed to lint, diff, or any apply). The user has nothing to review and nothing committed. + +If `match === false` (no stored fingerprint, OR Figma/config changed), continue with the lint engine run below. The new fingerprint will be persisted in Phase 10.5 after a successful pull. + +The fingerprint is intentionally false-positive biased: any change to the Figma extract or to pull-affecting config fields (`naming`, `cssEntry`) flips it. This means occasional re-syncs when the regenerated code would have been identical — preferable to false negatives where a real change goes unsynced. + +### Run the lint engine ```bash mkdir -p /tmp/adhd-pull-component @@ -84,12 +108,35 @@ node plugins/adhd/lib/lint-engine/cli.js \ --config adhd.config.ts \ --target "PullComponent Preflight" \ --target-url "" \ - --output /tmp/adhd-pull-component/preflight.md + --output /tmp/adhd-pull-component/preflight.md \ + > /tmp/adhd-pull-component/stdout.json ``` +The stdout redirect captures the engine's JSON summary for later abort-time annotation (see below). + Use the globals.css path from `config.cssEntry` if set, otherwise auto-detect: `example/app/globals.css` → `app/globals.css` → `src/app/globals.css`. -Use `Read` on `/tmp/adhd-pull-component/preflight.md`. Scan for STRUCT003/004/005 (variable-binding errors). Other rules' violations are noted for the final report but don't block. +Use `Read` on `/tmp/adhd-pull-component/preflight.md`. Four classes of violation block the pull: + +- **STRUCT003/004/005** — variable-binding errors (layer uses raw color, typography, or effect that isn't bound to a variable or paint style). The `--allow-unbound` escape applies here; without it, abort. +- **STRUCT011** — variable-naming non-compliance (case violation OR Tailwind v4 domain mismatch). No escape — the designer fixes the names in Figma before the pull can proceed. +- **STRUCT012** — cross-domain bindings (e.g. spacing variable bound to letter-spacing). No escape — the fix is to rebind the layer in Figma. +- **STRUCT015** — layer binds a Figma variable that doesn't exist in code's design system. Interactive per-variable resolution (see below). +- **STRUCT016** — layer binds a Figma variable whose value in Figma differs from code's. Interactive per-variable resolution (see below). + +**Annotations land automatically on abort.** Whenever the pull aborts due to any blocking violation — including a "Don't sync" pick on a STRUCT015 / STRUCT016 prompt — every blocking violation is pushed to Figma as a "lint"-category annotation before the abort completes. No flag is needed; the annotations are the designer's record of what to fix. On successful pulls (zero unresolved blocking violations) annotations from prior runs are cleared so Figma stays clean. + +Other rules' violations are noted for the final report but don't block. + +### Abort-time annotation helper (called from every abort path below) + +Whenever this SKILL aborts due to blocking violations, push every blocking violation to Figma as a "lint"-category annotation BEFORE printing the abort message and exiting. The mechanic mirrors `/adhd:lint` Phase 6: + +1. Distill violations from `/tmp/adhd-pull-component/stdout.json` to `/tmp/adhd-pull-component/violations.json` using the same `node -e` snippet as lint Phase 6 (filters to `nodeId`-bearing entries). +2. Call `mcp__plugin_figma_figma__use_figma` with the annotation script from `/adhd:lint` Phase 6, passing `SCOPE_ROOT_ID = ` so stale-annotation cleanup is bounded to this Component Set's subtree — never wipes annotations on unrelated frames. +3. If there are zero `nodeId`-bearing violations (rare — typically only happens on whole-file scope issues), skip the annotation step silently and continue to the abort message. + +On successful pulls (no abort), the same annotation script runs with `VIOLATIONS = []` so any prior-run annotations within the scope are cleared — Figma stays clean. **If variable-binding errors exist:** @@ -108,7 +155,7 @@ Check whether the escape is active: These need to be bound to design-system variables before we can pull. The designer can: 1. Bind them in Figma (right-click the layer → "Apply variable") 2. Or create new variables if these are new design tokens, then run - /adhd:pull-design-system first, then re-run /adhd:pull-component + /adhd:pull-tokens first, then re-run /adhd:pull-component We don't generate arbitrary Tailwind classes like text-[20px] or h-[80px] in your code — those would leak the design system the moment they shipped. @@ -131,6 +178,357 @@ Continue? [Y] yes / [N] no (abort) On `no` or no answer, abort. On `yes`, note which entries will be off-system; you'll prefix their applied values with the `// adhd:off-system — ` comment in Phase 7. +**If STRUCT011 violations exist (variable-naming non-compliance):** + +Abort unconditionally — there is no escape. STRUCT011 reports either a case-convention mismatch on a variable name or an unrecognized Tailwind v4 domain prefix; in both cases the fix is the designer renaming the variable in Figma, not the pull adapting around it. If we proceeded, the pulled component's lookup tables would reference variables that either don't bind to anything in code's `@theme` or land in code with the wrong name shape — drift we can't reliably round-trip on the next `/adhd:push-component`. + +Read the STRUCT011 message from the preflight report verbatim (it already names every offender + suggested rename) and print it under a banner: + +``` +✗ Cannot pull — the Figma Component Set has variables that don't follow the design-system naming conventions: + + + +Fix the variable names in Figma (right-click each variable → "Rename") +and re-run /adhd:pull-component. There's no escape for this — bad names +would land in your code's lookup tables and drift on the next push. +``` + +If BOTH STRUCT011 AND variable-binding errors are present, surface STRUCT011 first (it's the more fundamental fix — bind-errors might be tolerable with `--allow-unbound`, but bad names always need fixing first). + +**If STRUCT015 violations exist (layer binds variable that doesn't exist in code):** + +Walk each UNIQUE variable (multiple layers can bind the same variable — prompt once per variable, not once per layer) with `AskUserQuestion`. Each STRUCT015 entry on the lint summary has fields `figmaName`, `figmaValueNormalized` (the hex/px string derived from the Figma value — emit this from the preflight if not already present, see below), and the list of `nodeId`s that bind it. + +Normalize the Figma value to a write-ready string first. Use a small inline `node -e` against `lib/lint-engine/value-normalizer.js`: + +```bash +node -e ' + const fs = require("fs"); + const { normalizeColor, normalizeDimension } = require("plugins/adhd/lib/lint-engine/value-normalizer"); + const stdout = JSON.parse(fs.readFileSync("/tmp/adhd-pull-component/stdout.json", "utf8")); + const missing = (stdout.variable || []).filter(v => v.status === "missing"); + for (const m of missing) { + try { + if (m.domain === "color") m.figmaValueNormalized = normalizeColor(m.figma); + else m.figmaValueNormalized = normalizeDimension(m.figma); + } catch { m.figmaValueNormalized = null; } + } + fs.writeFileSync("/tmp/adhd-pull-component/missing-resolved.json", JSON.stringify(missing)); +' +``` + +For each entry (skip those whose `figmaValueNormalized` is null — value is too complex to write automatically; surface the variable in the final abort summary instead): + +Each STRUCT015 violation in the preflight JSON may carry two optional fields the prompt builder reads: + +- `canonicalCandidate` — set when the Figma value strictly equals a Tailwind canonical (e.g. `Font-Size/Body = 14px` ↔ `--text-sm = 0.875rem`). Surfaces the "Auto-fix: rebind in Figma" option. +- `looksSemantic` — set when the Figma path looks semantic (`brand`, `accent`, `surface`, `background`, `primary`, etc.). Used to label the "Add as semantic" option prominently, so a brand color that coincidentally matches a Tailwind palette entry doesn't get accidentally rebound. + +Build the option list per violation. Always include the abort-flavored picks. Only surface "Auto-fix" when `canonicalCandidate` is present. Label "Add to globals.css" as "Add as semantic variable" when `looksSemantic` is true. + +``` +Question: "`` is bound in this component but doesn't exist in code's design system. Figma resolves it to ``. What do you want to do?" +Header: "Variable missing" +Options: + + - "Auto-fix in Figma — rebind to `` (same value, no visual change)" + + - "Add in code as `--`" + when looksSemantic=true, replace the label with: + - "Add as semantic — keep `` in code (recommended for brand / accent / surface tokens)" + + - "Don't sync — annotate and abort" + - "Abort the pull (no annotation change)" +``` + +The `` is "Tailwind's `` has the same value." appended to the question text when a canonical exists; omit otherwise. + +Pick handling: +- **Auto-fix**: record `{ figmaName, canonicalCandidate, figmaValue }` to a running `auto-fix-input` array. Applied via use_figma in the "Apply OR abort" step below (rebind every layer + delete the source variable, same mechanic as STRUCT013 / STRUCT014 `--fix`). +- **Add (semantic or not)**: record `{ figmaName, value: figmaValueNormalized }` to a running `actions-input` array. +- **Don't sync / Abort**: set `abortIntent = true` and continue collecting picks. The difference: "Don't sync" guarantees annotations land via the helper above; "Abort" leaves annotations as-is. + +**If STRUCT016 violations exist (layer binds variable whose value differs from code's):** + +Same per-unique-variable pattern. Each entry includes `figma` (raw) and `local` (resolved literal). Normalize both via the same value-normalizer step. Then: + +``` +Question: "`` differs between Figma and code:\n code: \n figma: \nWhat do you want to do?" +Header: "Value conflict" +Options: + - "Take Figma's value (update globals.css)" + - "Don't sync — leave the annotation in Figma and abort" + - "Abort the pull (no annotation change)" +``` + +If "Take Figma," record `{ figmaName, value: figma-normalized }` to `actions-input` and continue. If "Don't sync" or "Abort," set `abortIntent = true` and continue collecting picks. Same abort-flavor distinction as STRUCT015 above. + +### Apply OR abort + +**If `abortIntent === true`** (any STRUCT015 / STRUCT016 prompt got a "Don't sync" or "Abort" pick), OR **any unresolved blocking class remains** (STRUCT011 / STRUCT012 / unescaped STRUCT003/4/5): + +1. Call the abort-time annotation helper to push every blocking violation to Figma. +2. Print the abort message: + +``` +✗ Cannot pull — variable issues remain. Recap: + + + +Either resolve these in Figma (rebind layers, fix variable values, or +rename) and re-run, or pick "Add to globals.css" / "Take Figma's +value" on the per-variable prompts above. + + +``` + +3. Exit 1. + +**Otherwise**, every STRUCT015 / STRUCT016 prompt was resolved (every pick was "Auto-fix" / "Add" / "Take Figma's value"). Apply the queued writes — Figma-side first, then code-side. + +### Apply auto-fix actions (Figma side) + +For each entry in `auto-fix-input`, run a use_figma rebind that mirrors STRUCT013's consolidation pattern: find the source variable by name, find or create the canonical destination variable in the right collection, walk every layer (across all pages) rewriting bindings from source → canonical, then delete the now-unreferenced source. Skip the entry if the source can't be found (already-rebound or deleted between extract and fix). Surface any rebind failures (paint mutations on instance overrides, etc.) in the final report — the rest of the run still proceeds. + +Substitute `__ACTIONS__` with the `auto-fix-input` JSON. Each entry is `{ figmaName: "typography/Font-Size/Body", canonicalCandidate: "--text-sm", figmaValue: 14 }`. The script handles the figma-side mutation: + +```js +const ACTIONS = __ACTIONS__; + +// Resolve the canonical's Figma name from its --css-var form. +// "--text-sm" → looking for a variable named "text/sm" (drop the +// leading `--` and replace single hyphens with slashes, but only for +// the canonical Tailwind families — leave the last segment intact so +// "text-sm" doesn't become "text/sm"). For Tailwind's known prefixes +// the split rule is: prefix + remaining → "/". +function canonicalFigmaName(cssVar) { + const PREFIXES = ['color', 'spacing', 'radius', 'text', 'leading', 'tracking', 'font-weight', 'font', 'shadow', 'opacity', 'border-width', 'breakpoint', 'container', 'ease', 'animate', 'blur']; + const stripped = cssVar.replace(/^--/, ''); + for (const p of PREFIXES.sort((a, b) => b.length - a.length)) { + if (stripped === p) return p; + if (stripped.startsWith(p + '-')) return p + '/' + stripped.slice(p.length + 1); + } + return stripped; +} + +const allCollections = await figma.variables.getLocalVariableCollectionsAsync(); +async function findVarByName(name) { + for (const col of allCollections) { + for (const vid of col.variableIds) { + const v = await figma.variables.getVariableByIdAsync(vid); + if (v && v.name === name) return v; + } + } + return null; +} + +const results = []; +for (const action of ACTIONS) { + const source = await findVarByName(action.figmaName); + if (!source) { + results.push({ ...action, status: 'skipped', reason: 'source variable not found' }); + continue; + } + const canonicalName = canonicalFigmaName(action.canonicalCandidate); + let canonical = await findVarByName(canonicalName); + if (!canonical) { + // Create the canonical in the same collection as the source so the + // designer's existing organization is respected. (If they pushed + // Tailwind tokens into a different collection earlier, the + // alias-aware collection lookup in figma-write-script handles it.) + canonical = figma.variables.createVariable(canonicalName, source.variableCollectionId, source.resolvedType); + canonical.scopes = source.scopes; + for (const [modeId, val] of Object.entries(source.valuesByMode)) { + canonical.setValueForMode(modeId, val); + } + } + + let rebound = 0; + for (const page of figma.root.children) { + await figma.setCurrentPageAsync(page); + const nodes = page.findAll(() => true); + for (const node of nodes) { + if (!node.boundVariables) continue; + for (const [prop, alias] of Object.entries(node.boundVariables)) { + if (prop === 'fills' || prop === 'strokes' || prop === 'effects') continue; + if (alias && alias.id === source.id) { node.setBoundVariable(prop, canonical); rebound++; } + } + for (const kind of ['fills', 'strokes']) { + const arr = node[kind]; + if (!Array.isArray(arr)) continue; + node[kind] = arr.map((paint) => paint?.boundVariables?.color?.id === source.id + ? figma.variables.setBoundVariableForPaint(paint, 'color', canonical) + : paint + ); + } + } + } + try { source.remove(); results.push({ ...action, canonicalName, rebound, status: 'ok' }); } + catch (e) { results.push({ ...action, canonicalName, rebound, status: 'rebound-only', error: String(e) }); } +} +return { results }; +``` + +### Apply add / take-Figma actions (code side) + +For each entry in `actions-input`, resolve the alias chain via the CLI to find where the write should actually land. The resolver handles the shadcn-style exposure pattern (`--color-primary: var(--primary)` in `@theme inline` → write lands at `--primary` in `:root`, not at `--color-primary` in `@theme`), avoiding the trap of overwriting an alias with a literal and breaking dark-mode propagation. + +```bash +node plugins/adhd/lib/pull-component/cli.js resolve-actions \ + --globals \ + --figma-path "" \ + --value "" +``` + +The command returns `{ cssVar, actions: [{ kind, ... }, ...] }`. Concatenate the `actions` arrays across every entry into a single `applyToCss`-shaped action list. + +Apply via a `node -e` one-liner against `applyToCss`: + +```bash +node -e ' + const fs = require("fs"); + const { applyToCss } = require("plugins/adhd/lib/design-system/code-writer"); + const css = fs.readFileSync("", "utf8"); + const actions = JSON.parse(fs.readFileSync("/tmp/adhd-pull-component/struct015-016-actions.json", "utf8")); + fs.writeFileSync("", applyToCss(css, actions)); +' +``` + +After writing, re-run the lint engine (cheap, no MCP call needed — same `cli.js` invocation as before) and confirm zero remaining STRUCT015 / STRUCT016 violations. If any remain (unusual — typically only happens when the resolver hit an alias cycle and fell back to a defensive write), fall back to the abort path above; the designer needs to look at the file by hand. + +After a successful preflight pass, call the annotation helper one more time with `VIOLATIONS = []` so any prior-run annotations are cleared from the scope — Figma reflects the new clean state. + +Priority when multiple block-classes are present: STRUCT011 / STRUCT012 first (unconditional abort), then STRUCT003/004/005 (raw values — `--allow-unbound` escape), then STRUCT015 + STRUCT016 (interactive prompts). + +## Phase 2.7: Opportunistic variable discovery + +Once preflight passes (no STRUCT011, no unbound errors or escape engaged), check the lint engine's variable mismatches in `/tmp/adhd-pull-component/stdout.json`. The categorizer reports two interesting statuses for our purpose: + +- **`status: "missing"`** — Figma has the variable, code's `globals.css` doesn't. New to the design system. (The lint engine merges Tailwind v4's default theme into the comparison BEFORE evaluating "missing" — so vars like `Color/white` that Tailwind already provides won't surface here. Never propose adding something to globals.css that Tailwind covers implicitly.) +- **`status: "conflict"`** — both sides have the variable but values disagree. NOT touched by pull-component; this is `/adhd:pull-tokens`'s job. + +Split missing further by the categorizer's `mode` field: + +- `mode === undefined` → **primitive**. Auto-addable: a single `@theme` entry. +- `mode === "light" | "dark"` → **semantic with modes**. Needs coordinated `:root`, `.dark`, and `@theme inline` edits — too much surface to do as a side-effect of pull-component. Surfaced in the prompt with a "run /adhd:pull-tokens for these" note. + +If `missingPrimitives.length === 0 && missingSemantics.length === 0 && conflicts.length === 0`, skip this phase entirely. + +### Per-variable prompts (same shape as Phase 2.5's STRUCT015 resolution) + +Phase 2.7 surfaces missing variables that Figma KNOWS ABOUT but no layer in this component binds — they were declared in the file but their values aren't actually referenced by the pulled subtree. The per-variable prompt is the same as Phase 2.5's STRUCT015 prompt, just sourced from a different list. Reuse the `canonicalCandidate` / `looksSemantic` fields the lint engine attaches to each missing entry (variable-categorizer's output already includes domain; the categorizer can be enhanced to attach the same auto-fix metadata STRUCT015 violations carry, or the SKILL can call `findCanonicalForValue` inline via a small node -e). + +For each missing primitive, prompt: + +``` +Question: "`` is referenced by Figma but doesn't exist in code's design system. Figma resolves it to ``. What do you want to do?" +Header: "Variable missing" +Options: + + - "Auto-fix in Figma — rebind to `` (same value, no visual change)" + + - "Add in code as `--`" + when looksSemantic=true, replace the label with: + - "Add as semantic — keep `` in code (recommended for brand / accent / surface tokens)" + + - "Skip — continue without adding (no annotation; the component pull works fine without it)" + - "Annotate and abort" +``` + +Pick handling matches Phase 2.5's flow: +- **Auto-fix**: queue into `auto-fix-input` (the same array Phase 2.5 builds). Applied via the same use_figma rebind script in Phase 2.5's "Apply" step. +- **Add / Add as semantic**: queue into `actions-input` (also shared with Phase 2.5). +- **Skip**: record nothing. The pull proceeds. Phase 2.7's missing variables aren't tied to layers in this scope (Phase 2.5's STRUCT015 would have caught those) — so there's nothing to annotate, and skipping is safe; the variable just stays out of code. +- **Annotate and abort**: set `abortIntent = true`. Falls through Phase 2.5's "Apply OR abort" path, which calls the abort-time annotation helper before exit. If the variable IS bound by a layer (rare in Phase 2.7's discovery list — implies the binding lives in a deeper part of the file outside the lint scope), the annotation lands on that node; if it's unbound by any scoped layer, the abort still completes without a new annotation. The pull stops in both cases. + +Since Phase 2.7 runs AFTER Phase 2.5, the action arrays might already contain entries. Append rather than reset. + +### Computing `canonicalCandidate` for Phase 2.7 entries + +Phase 2.5's STRUCT015 violations get `canonicalCandidate` attached by `cli.js` because they're tied to specific layer bindings. Phase 2.7's source is the raw variable-categorizer output, which doesn't have the field attached. Compute it inline before building the prompts: + +```bash +node -e ' + const fs = require("fs"); + const { findCanonicalForValue, looksSemantic } = require("plugins/adhd/lib/lint-engine/canonical-matcher"); + const { parseTheme } = require("plugins/adhd/lib/lint-engine/theme-parser"); + const { synthesizeTailwindUtilityScale } = require("plugins/adhd/lib/design-system/code-parser"); + const path = require("path"); + // Build the same primitives map cli.js builds. + const defaultsCss = fs.readFileSync(path.resolve("plugins/adhd/lib/design-system/tailwind-defaults.css"), "utf8") + .replace(/@theme\s+default\s+inline\s*\{/g, "@theme inline {") + .replace(/@theme\s+default\s*\{/g, "@theme {"); + const userCss = fs.readFileSync("", "utf8"); + const userTheme = parseTheme(userCss); + const defaults = parseTheme(defaultsCss).primitives; + for (const t of synthesizeTailwindUtilityScale()) { + if (!(t.cssVar in defaults)) defaults[t.cssVar] = t.values.default.value; + } + const primitives = { ...defaults, ...userTheme.primitives }; + // Enrich every "missing" variable-categorizer entry. + const stdout = JSON.parse(fs.readFileSync("/tmp/adhd-pull-component/stdout.json", "utf8")); + const enriched = (stdout.variable || []).filter(v => v.status === "missing").map(v => ({ + ...v, + canonicalCandidate: findCanonicalForValue(v.token, v.figma, primitives, { domain: v.domain }), + looksSemantic: looksSemantic(v.token), + })); + fs.writeFileSync("/tmp/adhd-pull-component/phase27-missing.json", JSON.stringify(enriched)); +' +``` + +Then iterate `phase27-missing.json` to build the prompts. + +Skip this phase if conflicts exist BUT no missing — there's nothing additive to do, just print: `Note: variable value(s) differ between Figma and code. Run /adhd:pull-tokens to reconcile.` Then continue (conflicts don't block pull-component v1; the pull works with code's value, drift is reported in the final report). + +## Phase 2.8: INSTANCE-of-tracked pre-flight + +Only runs when scaffold mode produces a simple-layout-driven component (see Phase 7's rubric) AND the captured tree contains at least one INSTANCE node. Update mode skips this entirely — the existing React file already has its imports; we don't rewrite them. + +Walk every INSTANCE node in `/tmp/adhd-pull-component/ctx.json`, collecting unique `mainComponentId` values. For each, resolve against `adhd.config.ts`'s components map: + +```bash +node plugins/adhd/lib/pull-component/cli.js resolve-instance \ + --config adhd.config.ts \ + --component-id "" +``` + +The command prints JSON with `{ matched, relPath, importPath, exportName, fileExists }` and exits 0 when matched, 1 when not. Save the resolved metadata per instance into `/tmp/adhd-pull-component/resolved-instances.json` keyed by `mainComponentId` — Phase 7 reads it to wire up imports and JSX. + +**If any INSTANCE is unmatched** (resolve-instance exited 1), abort the pull with a dependency-pull instruction: + +``` +✗ Cannot pull — it has Figma instances of components that aren't tracked in adhd.config.ts: + + • (mainComponentId: ) — not in components map + +Pull each missing component first, then re-run this command: + + /adhd:pull-component https://figma.com/design/?node-id=- + ... + /adhd:pull-component + +Tracked dependencies must exist in adhd.config.ts so the parent component's +JSX can import them. Without that, we'd generate a placeholder the developer +has to wire by hand — and dependency-ordered pulls keep the design system +clean. +``` + +The error lists each unmatched instance with its Figma name + component-id. The user runs the dependency pulls (in any order — they're independent), then re-runs the parent. + +**If any matched INSTANCE's `fileExists` is false** (config has the mapping but the React file isn't on disk), surface as a warning: + +``` +⚠ imports from , but the file at + doesn't exist yet. The pull proceeds with the import statement; the developer + needs to run /adhd:pull-component to create the dependency before + the parent compiles cleanly. +``` + +The warning doesn't block — the import statement still gets generated. The developer sees a compile error if they try to use the parent without pulling the dep, which is the same UX as any missing import in a TypeScript project. + +When every INSTANCE resolves cleanly (matched, file exists), this phase exits silently and Phase 3 proceeds. + ## Phase 3: Read both sides **React side (update mode only):** use `Read` on `` (from Phase 2). Identify: @@ -432,9 +830,107 @@ export function Logo({ colour = "dark", className = "" }: LogoProps) { This is first-pass code, not a stub. The developer can iterate from a working component. -**For layout-driven components** (cards, buttons, forms — when the Figma variants contain multiple children, text, or nested frames): +**For simple-layout-driven components** (a single root frame with a small, unambiguous tree of children — text + shape primitives + at most one level of nested frame): + +When the Figma source is trivially translatable, reconstruct real JSX. The lookup tables generated above carry the variant-driven values; the JSX wires them in. This produces first-pass code that renders correctly, not a stub. + +The translation rules: -Reconstructing JSX from a flattened Figma capture is unreliable, so keep the stub: +- **Root FRAME / COMPONENT** with `layoutMode === 'HORIZONTAL'` → ``; `'VERTICAL'` → ``. Use `` for inline-style containers (small components like avatars, badges, chips); use `

` when the auto-layout's sizing implies a block-level container (`primaryAxisSizingMode === 'FIXED'` width on horizontal layouts, etc.). +- **Auto-layout properties** map directly to Tailwind utilities: + - `padding{Top,Right,Bottom,Left}` → `pt-/pr-/pb-/pl-` (use bound variable name when present; otherwise `p-[px]` arbitrary form) + - `itemSpacing` → `gap-{name}` from the bound variable, or `gap-[px]` arbitrary + - `primaryAxisAlignItems === 'CENTER'` and `counterAxisAlignItems === 'CENTER'` → `items-center justify-center` +- **fills with `boundVariables.color`** → `bg-{strippedVarName}` (e.g. `color/gold` → `bg-gold`). For semantic names (brand, surface, accent, etc.), the Tailwind class follows the same naming convention as `@theme inline` exposes. +- **strokes with `boundVariables.color`** → `border-{strippedVarName}` plus a `border` or `border-{N}` width class if `strokeWeight` is set. +- **cornerRadius / *Radius with `boundVariables`** → `rounded-{strippedVarName}` (e.g. `radius/sm` → `rounded-sm`). +- **opacity bound** → use the canonical opacity utility for the value (`opacity-50`, etc.) rather than a CSS-var binding — Tailwind handles opacity via class modifiers, not variables (see STRUCT011's opacity hint). +- **Children:** + - **TEXT nodes** with static `characters` → `literal text` if the character is the same across variants. If it varies per instance (designer placeholders like "A", "B" for an avatar's initial), expose as a prop (`initial?: string`) defaulting to the first variant's value. + - **TEXT with bound `fontSize`** → wrap text in a span with `className={TABLE[variantValue]}` where TABLE is the appropriate lookup the SKILL just generated (typically `*_TEXT_SIZE`). + - **TEXT with bound `lineHeight` / `letterSpacing` / `fontWeight`** → same pattern; concatenate classes from the relevant lookup tables. + - **Nested FRAME (only one level deep allowed for this branch)** → another `` / `
` with the same auto-layout translation. + - **Shape primitives** (RECTANGLE/ELLIPSE/etc. that aren't part of an SVG composition) → typically don't generate; the auto-layout container's `bg-` already handles solid color backgrounds. If a shape is decorative, use the vector-driven branch instead. + - **INSTANCE of a tracked component** (e.g. an `Avatar` instance inside a `Card`) → resolve the instance's `mainComponentId` against `adhd.config.ts`'s `components` map via `node plugins/adhd/lib/pull-component/cli.js resolve-instance --config adhd.config.ts --component-id `. When it returns `matched: true`, generate `` and add an `import { ExportName } from "";` to the top of the file. Translate the instance's `componentProperties` into JSX props: convert each key to the target casing (kebab → camel, etc. per `adhd.config.ts`'s `naming`), pass string-literal variants as JSX strings (``), boolean variants as boolean attributes (``). Drop any visual override on the instance with a `// adhd: dropped override on ` comment — the developer either lifts the override into a new variant in Figma or accepts the unstyled instance. (See the "INSTANCE-of-tracked pre-flight" subsection below — if any INSTANCE in the captured tree is NOT in `adhd.config.ts`, the SKILL aborts BEFORE Phase 7 with a dependency-pull instruction; you never reach this branch with an unresolvable instance.) + +Concrete example (UserAvatar — root FRAME + 1 TEXT child): + +```tsx +export type UserAvatarSize = "sm" | "lg" | "xl"; + +export interface UserAvatarProps { + size?: UserAvatarSize; + initial?: string; + className?: string; +} + +export const USER_AVATAR_TEXT_SIZE: Record = { + sm: "text-sm", + lg: "text-sm", + xl: "text-2xl", +}; + +export const USER_AVATAR_TEXT_LEADING: Record = { + sm: "leading-normal", + lg: "leading-normal", + xl: "leading-7", +}; + +export const USER_AVATAR_BOX_SIZE: Record = { + sm: "w-8 h-8", + lg: "w-12 h-12", + xl: "w-16 h-16", +}; + +export function UserAvatar({ size = "sm", initial = "A", className = "" }: UserAvatarProps) { + return ( + + + {initial} + + + ); +} +``` + +Some judgment calls remain — Figma doesn't always carry explicit width/height for variants that "hug" their content, but the rendered component typically needs fixed boxes. Generate a `*_BOX_SIZE` (or equivalent) lookup with sane defaults derived from the variant's nominal size (`sm` → `w-8 h-8`, `lg` → `w-12 h-12`, `xl` → `w-16 h-16` — adjust to what the variant's actual rendered size looks like). Mark these in a comment as `// adhd: derived` so the developer knows which entries are designer-driven vs scaffold-derived. + +Concrete example (UserCard — root FRAME with a `UserAvatar` INSTANCE + a TEXT child). Phase 2.8 has already resolved the avatar's component-id to `{ importPath: "@/components/user-avatar", exportName: "UserAvatar" }`: + +```tsx +import { UserAvatar } from "@/components/user-avatar"; + +export type UserCardTheme = "neutral" | "brand"; + +export interface UserCardProps { + theme?: UserCardTheme; + name?: string; + initial?: string; + className?: string; +} + +export const USER_CARD_BG: Record = { + neutral: "bg-surface", + brand: "bg-brand-surface", +}; + +export function UserCard({ theme = "neutral", name = "Hugh", initial = "H", className = "" }: UserCardProps) { + return ( +
+ + {name} +
+ ); +} +``` + +The instance's `componentProperties` from the Figma extract (`{ Size: "lg" }`) became `size="lg"` on the JSX. The container handles its own auto-layout, padding, gap, and theme-driven background; the avatar is just a `` reference. Subsequent updates to the avatar component (renaming its export, adding props, changing variants) flow through the import — UserCard doesn't need to be re-pulled unless its own structure changes. + +**For complex-layout-driven components** (cards, forms, anything with conditional rendering across variants, depth > 2, or children that hide / show per-variant): + +Reconstructing JSX from a flattened Figma capture is unreliable. Keep the stub: ```tsx export type Size = "" | "" | ...; @@ -457,7 +953,13 @@ In this case the function body really is the developer's responsibility. The loo ### How to decide which branch -Look at the variant subtrees you captured in Phase 3. A component is vector-driven when, across all variants, the leaf nodes are predominantly `VECTOR` / `BOOLEAN_OPERATION` / `ELLIPSE` / `RECTANGLE` / `STAR` / `POLYGON` / `LINE`, and there are no TEXT nodes or nested FRAMEs with multiple children. If you're unsure, treat it as layout-driven and keep the stub — the user can re-run pull-component once the file exists if they want to iterate. +Walk down this rubric, picking the first matching branch: + +1. **Vector-driven** — across all variants, the leaf nodes are predominantly `VECTOR` / `BOOLEAN_OPERATION` / `ELLIPSE` / `RECTANGLE` / `STAR` / `POLYGON` / `LINE`, and there are no TEXT nodes or nested FRAMEs with multiple children. Inline SVG. +2. **Simple-layout-driven** — root is a single FRAME / COMPONENT with `layoutMode !== 'NONE'` (auto-layout, so positioning is unambiguous), the tree depth is ≤ 2 (root + one level of children), every direct child is TEXT / a shape primitive / a simple-shape-only FRAME / an `INSTANCE` of a component tracked in `adhd.config.ts` (resolved during Phase 2.8 — INSTANCE references collapse to a single `` JSX node from the parent's perspective, so they count as a single child for depth purposes regardless of the resolved component's complexity), and no variant axis hides / shows / reparents children (variant differences only affect bound values like sizes, colors, weights). Reconstruct real JSX per the translation rules above. +3. **Complex-layout-driven** — anything that doesn't satisfy (1) or (2). Stub. + +When the rubric is ambiguous between (2) and (3), prefer (3): a `` stub the developer fills in is strictly safer than mis-reconstructed JSX they then have to fix. The user can re-run /adhd:pull-component once the file exists, or hand-edit the function body — either way, the lookup tables stay in sync on subsequent pulls. ## Phase 8: Write mapping if scaffold mode @@ -495,6 +997,34 @@ Component file: Figma URL: ``` +## Phase 10.5: Persist fingerprint + +After a successful pull (any path that reaches Phase 10, including off-system escapes), write the fresh fingerprint + an ISO timestamp into `adhd.config.ts` so the next pull can short-circuit when nothing's changed. + +```bash +node plugins/adhd/lib/pull-component/cli.js fingerprint-write \ + --config adhd.config.ts \ + --path \ + --ctx /tmp/adhd-pull-component/ctx.json \ + --vars /tmp/adhd-pull-component/vars.json +``` + +The command updates two fields inside `components: { '': { ... } }`: + +```ts +components: { + '': { + figma: { url: '...' }, + pulledAt: '2026-05-12T14:30:00.000Z', // ISO from `new Date()` + fingerprint: 'a1b2c3d4', // 8-hex-char SHA-256 prefix + }, +} +``` + +If `adhd.config.ts` already had values, they're replaced; if not, they're inserted before the closing brace with consistent indentation. The fingerprint is the Git-style short form (8 chars = 32 bits of fingerprint space, comfortably collision-free for the dozens-to-hundreds of components a typical project tracks, and looked up by path anyway so global collisions don't matter). + +This phase NEVER runs on abort — only on successful application. + ## Phase 11: Cleanup Always runs (even on abort): @@ -503,6 +1033,29 @@ Always runs (even on abort): rm -rf /tmp/adhd-pull-component ``` +## Phase 12: Offer to sync the docs route + +Runs only on success (skip if Phase 5 aborted). Pulling a component updates its prop interface, which means the static map at `componentMap.tsx` may now be stale (its baked prop schemas were captured the last time `/adhd:sync-docs` ran). + +```bash +node plugins/adhd/lib/sync-docs/cli.js detect-install --app-dir . +``` + +- **Empty output** (route not installed): skip this phase silently. +- **Non-empty output** (route installed): use `AskUserQuestion`: + +``` +Question: "Re-sync the design-system docs route now? Pulling the component changed its props, so componentMap.tsx's baked schemas need refreshing." +Header: "Sync docs" +Options: + - "Yes, re-sync now" + - "No, skip" +``` + +On "Yes": execute the phases of `/adhd:sync-docs` inline. See `plugins/adhd/skills/sync-docs/SKILL.md` for the phase list. The docs route's existing install choices (route URL, group, render mode) are preserved — Phase 2 of sync-docs detects the existing install and offers "Update in place". + +On "No": print `Run /adhd:sync-docs later to refresh the docs route.` Exit normally. + --- ## Common errors diff --git a/plugins/adhd/skills/pull-design-system/SKILL.md b/plugins/adhd/skills/pull-tokens/SKILL.md similarity index 58% rename from plugins/adhd/skills/pull-design-system/SKILL.md rename to plugins/adhd/skills/pull-tokens/SKILL.md index 2157c7b..fd70406 100644 --- a/plugins/adhd/skills/pull-design-system/SKILL.md +++ b/plugins/adhd/skills/pull-tokens/SKILL.md @@ -1,23 +1,25 @@ --- -description: "Pull the design system (variables + named styles) from the configured Figma file into globals.css. Two-way diff with per-attribute conflict prompts; additive (never deletes from code). Reads adhd.config.ts at the repo root." +description: "Pull design tokens (variables + named styles) from the configured Figma file into globals.css. Two-way diff with per-attribute conflict prompts; additive (never deletes from code). Reads adhd.config.ts at the repo root. Pass --dry-run to preview without writing." disable-model-invocation: true -argument-hint: "" +argument-hint: "[--dry-run]" allowed-tools: Read Write Edit Bash AskUserQuestion mcp__plugin_figma_figma__use_figma --- -# ADHD Pull Design System +# ADHD Pull Tokens Pulls Figma's design tokens (variables + named styles) into the codebase's `globals.css`. Compares both sides; for each conflicting variable, prompts the user; for variables that exist only in Figma, creates them in code; for variables that exist only in code, leaves them alone (additive policy). +Pass `--dry-run` to see exactly what would be added or overwritten without making any changes — no prompts, no writes, no commits. + **Authoritative spec:** `docs/superpowers/specs/2026-05-10-adhd-push-pull-design-system.md` ## Phase 1: Validate config -(Same as /adhd:push-design-system Phase 1.) +(Same as /adhd:push-tokens Phase 1.) ## Phase 2: Read both sides -(Same as /adhd:push-design-system Phase 2 — read globals.css, run extract script via use_figma, save both to `/tmp/adhd-pull/`. Use Strategy B — chunked extraction — for any non-trivial design system; the MCP truncates single-shot responses around 20–30 KB and a full Tailwind v4 color collection blows past that limit. The push SKILL documents the chunked manifest + slice + `cli.js assemble-extract` flow.) +(Same as /adhd:push-tokens Phase 2 — read globals.css, run extract script via use_figma, save both to `/tmp/adhd-pull/`. Use Strategy B — chunked extraction — for any non-trivial design system; the MCP truncates single-shot responses around 20–30 KB and a full Tailwind v4 color collection blows past that limit. The push SKILL documents the chunked manifest + slice + `cli.js assemble-extract` flow.) ## Phase 3: Run the comparator @@ -30,6 +32,20 @@ node plugins/adhd/lib/design-system/cli.js compare \ If `conflict.length === 0` and `figmaOnly.length === 0`, print "Code is already in sync with Figma. No changes." and exit 0. +## Phase 3b: Dry run (only if `--dry-run` was passed) + +If the user invoked `/adhd:pull-tokens --dry-run`, print the preview from the comparator and exit BEFORE the prompt loop. The dry run is a pure discovery tool — no `AskUserQuestion`, no writes, no commits, no MCP traffic beyond Phase 2's extract: + +```bash +node plugins/adhd/lib/design-system/cli.js preview \ + --diff /tmp/adhd-pull/diff.json \ + --direction pull +``` + +The preview lists every variable that would be added to `globals.css` (one row per mode), every variable whose Figma/code values differ (showing both — the dry run intentionally doesn't pre-resolve in favor of either side), and the count of code-only variables that would stay untouched per the additive policy. Echo the output verbatim to the user, then print a one-line summary: `Dry run complete. Re-run without --dry-run to apply (you'll be prompted on each conflict).` Exit 0. + +If `--dry-run` was NOT passed, skip this phase and continue to Phase 4. + ## Phase 4: Resolve conflicts For each conflict in `diff.conflict`, use `AskUserQuestion` with: @@ -102,6 +118,29 @@ Print: - code-only variables left untouched (additive policy) ``` +## Phase 10: Offer to sync the docs route + +Runs only on success (skip if no changes were applied to `globals.css`). The docs route reads `globals.css` at request time, so the new tokens will appear without any code change — but if the user has also been editing components, re-syncing refreshes `componentMap.tsx`'s baked prop schemas at the same time. + +```bash +node plugins/adhd/lib/sync-docs/cli.js detect-install --app-dir . +``` + +- **Empty output** (route not installed): skip this phase silently. +- **Non-empty output** (route installed): use `AskUserQuestion`: + +``` +Question: "Re-sync the design-system docs route now? Tokens propagate live, but a re-sync also regenerates componentMap.tsx in case your components changed." +Header: "Sync docs" +Options: + - "Yes, re-sync now" + - "No, skip" +``` + +On "Yes": execute the phases of `/adhd:sync-docs` inline. See `plugins/adhd/skills/sync-docs/SKILL.md`. Existing install choices are preserved. + +On "No": print `Run /adhd:sync-docs later to refresh the docs route.` Exit normally. + ## Common errors (Same table as push, plus:) diff --git a/plugins/adhd/skills/push-all-components/SKILL.md b/plugins/adhd/skills/push-all-components/SKILL.md new file mode 100644 index 0000000..63bb287 --- /dev/null +++ b/plugins/adhd/skills/push-all-components/SKILL.md @@ -0,0 +1,108 @@ +--- +description: "Bulk version of /adhd:push-component. Iterates over every entry in adhd.config.ts's `components` map and runs the full push flow on each, sequentially. Halts on first failure by default (use --continue-on-error for best-effort + summary). Per-component interactivity (preview server start, capture, consolidation, preflight, per-variable STRUCT015/016 resolution, decide-or-rollback) is preserved — each component's push behaves exactly as if you'd invoked /adhd:push-component manually." +disable-model-invocation: true +argument-hint: "[--continue-on-error] [--max-variants ]" +allowed-tools: Read Write Edit Bash AskUserQuestion mcp__plugin_figma_figma__use_figma mcp__plugin_figma_figma__generate_figma_design +--- + +# ADHD Push All Components + +Bulk wrapper around `/adhd:push-component`. Reads the components map from `adhd.config.ts` and iterates over every entry, running the full per-component push flow on each. Stops on first failure unless `--continue-on-error` is passed. + +**Why this skill exists:** if you've made structural changes to multiple components in code (renamed props, added variants, updated tokens) and want to push them all to Figma, this saves the typing AND keeps the Next.js dev server warm across pushes — push-component auto-starts it on the first component, and subsequent ones reuse the running instance. + +**What this skill DOES NOT do:** +- Suppress per-component prompts (rollback decisions, per-variable resolution). Each push runs its full interactive flow. +- Apply decisions across all components ("rollback all on any failure", "take Figma for everything"). Per-component decisions stay per-component. + +## Phase 1: Validate config + read components list + +Run the same Phase 1 as `/adhd:push-component`: validate `adhd.config.ts` exists, etc. Then read every key from the `components: { ... }` map using the same `node -e` snippet as `pull-all-components` Phase 1 (brace-counted scan, output to `/tmp/adhd-push-all/paths.json`). + +If the resulting list is empty, abort: + +``` +✗ No components registered in adhd.config.ts. +Run /adhd:push-component to push a component for the first time +(it writes the entry to adhd.config.ts), then re-run /adhd:push-all-components. +``` + +Print the planned run upfront: + +``` +Pushing 5 components to Figma in sequence: + 1. components/design-system/logo/index.tsx + 2. components/avatar/index.tsx + ... +``` + +## Phase 2: Iterate + +For each path in the list, in order: + +1. Print a divider: + ``` + ──── [N/total] pushing ──── + ``` + +2. Invoke the phases of `/adhd:push-component` inline for this path. Pass through any flags the user gave to `/adhd:push-all-components`: + - `--max-variants ` (applied uniformly to every component's variant cap) + + The per-component push-component SKILL handles its own validation, dev-server start/check, capture, consolidation, preflight, STRUCT015/016 resolution, decide-or-rollback, final report, and the mapping write to `adhd.config.ts`. Annotations land automatically on any abort — no flag forwarding needed. + + **Dev-server reuse:** push-component's Phase 4 only starts the server if one isn't already running. The first push in the bulk run starts it (if not already up); subsequent pushes reuse the running instance. push-component's Phase 13 (cleanup) tears down the server only when it auto-started it for that single run — in the bulk case, push-component sees the server was already running and leaves it alone. **This skill's Phase 4 (below) is responsible for the final teardown.** + +3. Record the outcome into `/tmp/adhd-push-all/outcomes.json`: + ```json + { "path": "", "status": "success" | "abort" | "rollback" | "cancel", "summary": "", "error": "" } + ``` + - `success`: push-component completed Phase 12 (final report). + - `abort`: blocking error (file missing, capture failure, etc.). + - `rollback`: preflight produced errors and user (or default) chose to roll back the captured page. + - `cancel`: user explicitly stopped at an in-flow prompt. + +4. **Decide whether to continue:** + - `success` or `cancel`: continue. + - `rollback`: treated as failure for halt-on-error purposes. The user saw preflight errors and chose to roll back — they need to fix the source before bulk-pushing. + - `abort`: same as `rollback` — failure. + - With `--continue-on-error`: record + continue. + - Without: print the halt message and break out of the loop. + +5. Components after a halt are recorded as `skipped`. + +## Phase 3: Final summary + +``` +Bulk push report: + ✓ components/design-system/logo/index.tsx — 4 variants pushed, preflight clean + ✓ components/avatar/index.tsx — 6 variants pushed, 1 warning + ✗ components/button/index.tsx — rolled back (preflight errors) + ⏭ components/card/index.tsx — skipped (earlier failure) + +Summary: 2 succeeded, 1 failed, 1 skipped. +``` + +Actionable next steps: +- **All succeeded:** `All components are now in sync with Figma. Run /adhd:sync-docs if you want to refresh the design-system docs route.` +- **Any failed:** `To re-try just the failures: /adhd:push-component `. +- **Any rollback happened during the run** (per-component pushes auto-push annotations on rollback): `Annotations were pushed to Figma for unresolved violations during this run. Check the "lint" category to see what needs fixing.` + +Exit 0 if all `success`/`cancel`, else 1. + +## Phase 4: Cleanup (always runs) + +```bash +rm -rf /tmp/adhd-push-all +``` + +**Dev-server teardown:** if the bulk run was the thing that started the Next.js dev server (i.e., it wasn't already running when Phase 2 began), tear it down here. Use the same teardown helper push-component uses in its Phase 13. If the server was already running when the bulk started, leave it alone — it's the user's session. + +To check: at the start of Phase 2, record whether the dev server was up. Compare at Phase 4. Only kill the process if the bulk owned its lifecycle. + +## Common errors + +| Error | Fix-up | +|---|---| +| `No components registered in adhd.config.ts` | Run `/adhd:push-component ` once for a single component first to seed the mapping. | +| Mid-run rollback on preflight errors | Fix the source code issue (raw values, etc.), then re-push that component individually before resuming the bulk. | +| Dev-server start failure | Same fix as for `/adhd:push-component` solo runs — check the Next.js logs, port conflicts. | diff --git a/plugins/adhd/skills/push-component/SKILL.md b/plugins/adhd/skills/push-component/SKILL.md index aaf850c..8393c27 100644 --- a/plugins/adhd/skills/push-component/SKILL.md +++ b/plugins/adhd/skills/push-component/SKILL.md @@ -162,15 +162,19 @@ Call `mcp__plugin_figma_figma__use_figma` with that content as the `code` parame ## Phase 10: Run preflight lint -Extract the new Component Set's structural data using `mcp__plugin_figma_figma__use_figma` (similar to /adhd:lint's Phase 3 extraction). +Extract the new Component Set's structural data using `mcp__plugin_figma_figma__use_figma`. Match /adhd:lint's Phase 3 extraction shape — including the `varIdMap` sibling that bridges Figma variable IDs to their `/` form. Without it the lint engine can't run STRUCT011 per-layer / STRUCT012 / STRUCT015 / STRUCT016 (every per-layer binding rule depends on the bridge), and Phase 10.7's "Take code's value" path can't find the Figma variable id to update. -Save to `/tmp/adhd-push-component/ctx.json` (the design context) and `/tmp/adhd-push-component/vars.json` (referenced variables). +Save to: +- `/tmp/adhd-push-component/ctx.json` (the design context) +- `/tmp/adhd-push-component/vars.json` (resolved variable values by name) +- `/tmp/adhd-push-component/varidmap.json` (Figma id → name) Run: ```bash node plugins/adhd/lib/push-component/cli.js preflight \ --design-context /tmp/adhd-push-component/ctx.json \ --variable-defs /tmp/adhd-push-component/vars.json \ + --var-id-map /tmp/adhd-push-component/varidmap.json \ --globals-css example/app/globals.css \ --config adhd.config.ts \ --output /tmp/adhd-push-component/preflight-report.md @@ -178,15 +182,146 @@ node plugins/adhd/lib/push-component/cli.js preflight \ Read the report. Parse out error count and warning count. +The preflight CLI also writes a JSON sidecar with the engine's full structured output at `/tmp/adhd-push-component/preflight-report.json` (same path as the report, `.md` → `.json`). Phase 10.7 and the abort-time annotation helper both read from it. + +## Phase 10.5: Abort-time annotation helper + +Not a phase the SKILL invokes top-down — a helper called by Phase 10.7 and Phase 11 whenever push aborts due to violations. Pushes every blocking violation to Figma as a "lint"-category annotation BEFORE the rollback completes, so designers see what needs fixing the next time they open the file. Same mechanic as `/adhd:lint` Phase 6: + +1. Distill `/tmp/adhd-push-component/preflight-report.json` to `/tmp/adhd-push-component/violations.json` (same `node -e` snippet as lint Phase 6 — filters to `nodeId`-bearing entries). +2. Call `mcp__plugin_figma_figma__use_figma` with the annotation script, passing `SCOPE_ROOT_ID = `. Stale-annotation cleanup is bounded to this Component Set's subtree — never wipes unrelated frames. +3. If there are zero `nodeId`-bearing violations, skip silently. + +On successful pushes (no abort, Phase 11 takes the finalize path), the same helper runs with `VIOLATIONS = []` so any prior-run annotations are cleared and Figma reflects the new clean state. + +## Phase 10.7: Interactive STRUCT015 / STRUCT016 resolution + +Only runs when preflight has STRUCT015 or STRUCT016 violations. Mirrors `/adhd:pull-component`'s Phase 2.5 resolution prompts but with the Figma direction wired in: "Take code's value" pushes to Figma, "Take Figma's value" writes to `globals.css` via the same alias-aware resolver. + +Skip this phase if neither STRUCT015 nor STRUCT016 fired — Phase 11's clean-preflight or generic-error flow handles the rest. + +### Per-variable prompts (deduplicate by figmaName) + +Read `/tmp/adhd-push-component/preflight-report.json`. Group STRUCT015 + STRUCT016 violations by `figmaVarName` so a variable bound by multiple layers prompts ONCE, not once per layer. Normalize each Figma value to a write-ready string using `lib/lint-engine/value-normalizer.js` (same `node -e` snippet `/adhd:pull-component` uses); skip variables whose value won't normalize (rare — surfaces them in Phase 11 instead). + +For each unique STRUCT015 variable. The violation's `canonicalCandidate` (set when the value strictly equals a Tailwind canonical) and `looksSemantic` (set when the path looks semantic — brand, accent, etc.) drive which options appear and how they're labeled, same as `/adhd:pull-component`'s Phase 2.5. + +``` +Question: "`` is bound by this push but doesn't exist in code's design system. Figma resolves it to ``. What do you want to do?" +Header: "Variable missing" +Options: + + - "Auto-fix in Figma — rebind to `` (same value, no visual change)" + + - "Add in code as `--`" + when looksSemantic=true, replace the label with: + - "Add as semantic — keep `` in code (recommended for brand / accent / surface tokens)" + + - "Don't sync — annotate and roll back" + - "Roll back the push (no annotation change)" +``` + +The "Auto-fix" pick gets queued into `auto-fix-input` and applied via the same use_figma rebind script `/adhd:pull-component` Phase 2.5 uses. Subsequently, "Add" picks go to `code-side-actions`; "Don't sync" / "Roll back" picks set `abortIntent = true`. + +For each unique STRUCT016 variable: + +``` +Question: "`` differs between Figma and code:\n code: \n figma: \nWhat do you want to do?" +Header: "Value conflict" +Options: + - "Take code's value (update Figma to match code)" + - "Take Figma's value (write to globals.css; alias-aware)" + - "Don't sync — leave the annotation in Figma and roll back" + - "Roll back the push (no annotation change)" +``` + +For each "Add" / "Take code's value" / "Take Figma's value" pick, record into one of two arrays: +- `code-side-actions`: every "Add to globals.css" and "Take Figma's value" pick. Same shape as `/adhd:pull-component`'s actions — feed into `lib/pull-component/cli.js resolve-actions` per entry to get alias-aware `set-primitive` / `set-semantic` actions, concatenate into one list. +- `figma-side-actions`: every "Take code's value" pick. Shape: `{ figmaVarId, mode, value, valueDomain }` (need the Figma variable id; look it up by inverting `/tmp/adhd-push-component/varidmap.json` — same map the lint engine produced). + +For each "Don't sync" or "Roll back" pick, set `abortIntent = true` and continue collecting picks (so subsequent prompts still get answered — the designer might want to record decisions on the rest before the rollback fires). The two abort-flavored picks differ only in whether annotations get pushed: "Don't sync" guarantees they land (via the Phase 10.5 helper); "Roll back" leaves annotations as-is. + +If `abortIntent === true` after the prompt loop, skip the apply step and fall through to Phase 11's rollback path. The Phase 10.5 helper handles annotations for the "Don't sync" picks. + +### Apply code-side actions + +Same as `/adhd:pull-component`: one `node -e` invocation of `applyToCss` against the concatenated `code-side-actions` list. Writes are alias-aware via `resolveWriteTarget`. + +### Apply Figma-side actions + +Substitute `__ACTIONS__` with the `figma-side-actions` JSON and call `mcp__plugin_figma_figma__use_figma` with the script below. Each action updates one variable's value for one mode. Color and float values are converted from the code-side hex / dimension form into the channel-object / px-number form Figma's API expects. + +```js +const ACTIONS = __ACTIONS__; + +function hexToRgb(h) { + let c = h.replace('#', ''); + if (c.length === 3) c = c.split('').map(x => x + x).join(''); + const r = parseInt(c.slice(0, 2), 16) / 255; + const g = parseInt(c.slice(2, 4), 16) / 255; + const b = parseInt(c.slice(4, 6), 16) / 255; + const a = c.length === 8 ? parseInt(c.slice(6, 8), 16) / 255 : 1; + return { r, g, b, a }; +} + +function dimensionToPx(raw) { + const s = String(raw).trim(); + const m = /^(-?\d*\.?\d+)(px|rem|em)?$/.exec(s); + if (!m) return Number(s); + const n = parseFloat(m[1]); + const unit = m[2] || ''; + if (unit === 'rem' || unit === 'em') return n * 16; + return n; +} + +const results = []; +for (const a of ACTIONS) { + try { + const v = await figma.variables.getVariableByIdAsync(a.figmaVarId); + if (!v) { results.push({ ...a, status: 'skipped', reason: 'variable not found' }); continue; } + const col = await figma.variables.getVariableCollectionByIdAsync(v.variableCollectionId); + // Match the requested mode by name (case-insensitive). For mode-less + // (primitive) variables fall back to the collection's first mode — + // those collections have exactly one mode. + const wantMode = (a.mode || col.modes[0].name).toLowerCase(); + const target = col.modes.find(m => m.name.toLowerCase() === wantMode) || col.modes[0]; + + let figmaValue; + if (v.resolvedType === 'COLOR') figmaValue = hexToRgb(a.value); + else if (v.resolvedType === 'FLOAT') figmaValue = dimensionToPx(a.value); + else figmaValue = a.value; + + v.setValueForMode(target.modeId, figmaValue); + results.push({ ...a, status: 'ok' }); + } catch (e) { + results.push({ ...a, status: 'error', error: String(e) }); + } +} +return { results }; +``` + +### Re-run preflight + report + +After both apply steps land, re-invoke `plugins/adhd/lib/push-component/cli.js preflight` (cheap — no MCP call, same inputs against the updated `globals.css` + the same Figma extract). Confirm zero remaining STRUCT015 / STRUCT016 violations. If any survive (rare — usually an alias-cycle fallback or a normalization edge case), Phase 11 handles them via its existing keep-with-errors / roll-back prompt. Otherwise fall through to Phase 11 with a clean preflight. + +Print a one-line summary before continuing: + +``` +✓ Resolved variable issue(s) — code-side write(s), Figma-side update(s). +``` + ## Phase 11: Decide and finalize OR roll back -If preflight has zero errors: print "✓ Preflight clean" plus warning summary, then proceed to Phase 12. +Three sub-paths into this phase: -If preflight has errors: print the report. Use `AskUserQuestion`: -- "Keep the pushed page (you can fix in Figma manually)" -- "Roll back — delete the captured page and exit" +1. **Preflight clean, no `abortIntent`.** Call the Phase 10.5 helper with `VIOLATIONS = []` to clear any prior-run annotations, print "✓ Preflight clean" plus the warning summary, then proceed to Phase 12. + +2. **`abortIntent === true`** (from Phase 10.7's per-variable prompts). Call the Phase 10.5 helper to push annotations for the unresolved blocking violations (no AskUserQuestion needed — the designer already made the call). Then run the rollback script below, print "Rolled back. Annotations pushed to Figma for the unresolved violations. Fix in Figma and re-run." Exit 1. + +3. **Preflight has errors that didn't go through Phase 10.7's resolution loop** (e.g. STRUCT011 / STRUCT012 / unescaped STRUCT003-5). Same path as (2) — annotations land, rollback fires. No "Keep with errors" prompt; if blocking violations weren't resolvable through the structured prompts in Phase 10.7, the only safe action is rollback. + +The rollback script: -If user picks roll back: ```js // run via use_figma const page = await figma.getNodeByIdAsync(PAGE_ID); @@ -194,10 +329,6 @@ page.remove(); return { rolledBack: true }; ``` -Then print "Rolled back. No changes to Figma. Fix the issues in your component and re-run." Exit with code 1. - -If user picks keep, proceed to Phase 12. - ## Phase 11.5: Write component mapping to adhd.config.ts Only runs on the finalize path (skip on rollback — if the user chose roll back in Phase 11, the captured page is gone and there's no mapping to write). diff --git a/plugins/adhd/skills/push-design-system/SKILL.md b/plugins/adhd/skills/push-design-system/SKILL.md deleted file mode 100644 index 3904fd4..0000000 --- a/plugins/adhd/skills/push-design-system/SKILL.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -description: "Push the local design system (globals.css variables + named styles) into the configured Figma file. Two-way diff with per-attribute conflict prompts; additive (never deletes from Figma). Reads adhd.config.ts at the repo root." -disable-model-invocation: true -argument-hint: "" -allowed-tools: Read Write Edit Bash AskUserQuestion mcp__plugin_figma_figma__use_figma ---- - -# ADHD Push Design System - -Pushes the codebase's design tokens (variables + named styles) into the configured Figma file. Compares both sides; for each conflicting variable, prompts the user; for variables that exist only in code, creates them in Figma; for variables that exist only in Figma, leaves them alone (additive policy). - -**Authoritative spec:** `docs/superpowers/specs/2026-05-10-adhd-push-pull-design-system.md` - -## Phase 1: Validate config - -Read `adhd.config.ts` at the repo root with the `Read` tool. If it doesn't exist, abort: "Run /adhd:config first to set up ADHD." - -Extract `figma.url` (required) and `cssEntry` (optional; auto-detect `app/globals.css` then `src/app/globals.css`). Extract the file key from `figma.url` — the segment after `/design/`. - -## Phase 2: Read both sides - -Use the `Read` tool to read the resolved `globals.css` path. Save it to `/tmp/adhd-push/globals.css` via the `Write` tool. - -Use `mcp__plugin_figma_figma__use_figma` to extract the Figma side's state. Pick the right strategy based on file size: - -**Strategy A — single-shot (small files, ≲60 variables).** Read `plugins/adhd/lib/design-system/figma-extract-script.js` and pass the value of the exported `EXTRACT_SCRIPT` constant as the `code` parameter. Save the response JSON to `/tmp/adhd-push/figma.json` via the `Write` tool. - -**Strategy B — chunked (recommended for full Tailwind-v4 design systems).** The MCP `use_figma` response is truncated at roughly 20–30 KB, so a full color collection (≈300 vars × 2 modes) exceeds that ceiling and the single-shot script returns a half-baked, JSON-truncated payload. Use the paginated extractor instead: - -1. Read `plugins/adhd/lib/design-system/figma-extract-script.js`. The file exports an `EXTRACT_CHUNK_SCRIPT` template and a `CHUNK_SIZE` default. -2. **Manifest call.** Substitute `__INCLUDE_META__ = true` and `__VAR_INDEX__ = null` into the script, pass to `use_figma`. Save the response to `/tmp/adhd-push/chunks/00-manifest.json` via `Write`. -3. **Slice calls.** Read the manifest's `collections` array. For each collection, iterate `from = 0; from < variableCount; from += CHUNK_SIZE`. For each iteration, substitute `__INCLUDE_META__ = false` and `__VAR_INDEX__ = {collectionId: '', from, to: from + CHUNK_SIZE}` into the script, call `use_figma`, and write the response to `/tmp/adhd-push/chunks/--.json`. -4. **Assemble.** Run `node plugins/adhd/lib/design-system/cli.js assemble-extract --chunks-dir /tmp/adhd-push/chunks --output /tmp/adhd-push/figma.json`. The CLI merges the manifest + slices into the single-shot extract shape that `compare` expects, and throws if any collection's variable count doesn't match the manifest (catches truncated chunks). - -If Strategy A's response shows visible truncation (look for an unterminated JSON object or a `// truncated to kb` marker at the tail), fall back to Strategy B and re-run from step 1. Don't try to repair the truncated payload by hand. - -## Phase 3: Run the comparator - -Use `Bash`: -```bash -node plugins/adhd/lib/design-system/cli.js compare \ - --code /tmp/adhd-push/globals.css \ - --figma /tmp/adhd-push/figma.json \ - --output /tmp/adhd-push/diff.json -``` - -Read `/tmp/adhd-push/diff.json`. The diff has four arrays: `same`, `conflict`, `codeOnly`, `figmaOnly`. - -If `conflict.length === 0` and `codeOnly.length === 0`, print "Figma is already in sync with code. No changes." and exit 0. - -## Phase 4: Resolve conflicts via AskUserQuestion - -For each conflict in `diff.conflict`, use `AskUserQuestion` with these four options: -- "Keep Figma value (no change)" → resolution `{path, mode, winner: 'figma'}` -- "Use code value (overwrite Figma)" → `{path, mode, winner: 'code'}` -- "Use Figma's values for all N conflicts" → batch confirm (see below) -- "Use code's values for all N conflicts" → batch confirm - -If the user picks a batch option, follow up with another `AskUserQuestion`: -- "Apply all" → apply chosen winner to ALL remaining conflicts; continue without further per-conflict prompts -- "Cancel — go back to per-conflict review" → resume per-conflict loop at current position - -Build a `resolutions` array of `{path, mode, winner}` objects. Save it to `/tmp/adhd-push/resolutions.json` via the `Write` tool. - -## Phase 5: Build actions - -```bash -node plugins/adhd/lib/design-system/cli.js apply \ - --diff /tmp/adhd-push/diff.json \ - --resolutions /tmp/adhd-push/resolutions.json \ - --direction push \ - --output /tmp/adhd-push/actions.json -``` - -Read `/tmp/adhd-push/actions.json`. If empty, print "Nothing to apply." and exit 0. - -## Phase 6: Drift check (re-fetch Figma) - -Re-run the extract script via `use_figma` (same call as Phase 2). Save the response to `/tmp/adhd-push/figma-recheck.json`. Compare to `/tmp/adhd-push/figma.json` byte-for-byte: - -```bash -diff /tmp/adhd-push/figma.json /tmp/adhd-push/figma-recheck.json -``` - -If they differ, abort with: "Figma drifted during this run. Re-run /adhd:push-design-system to see fresh conflicts." Exit 1. - -## Phase 7: Apply actions to Figma - -Load the write script from `plugins/adhd/lib/design-system/figma-write-script.js`'s `WRITE_SCRIPT` export. Substitute `__ACTIONS__` with the contents of `/tmp/adhd-push/actions.json` (the actions array, JSON-stringified inline into the script). - -Call `mcp__plugin_figma_figma__use_figma` with the substituted script. The response contains `{ applied, errors }`. - -If `errors.length > 0`, print the error list and exit 1. - -## Phase 8: Final report - -Print: -``` -✓ Pushed to Figma: - - variables created - - conflicts resolved - - figma-only variables left untouched (additive policy) -``` - -## Common errors - -| Error | Fix-up guidance | -|---|---| -| `adhd.config.ts not found` | Run `/adhd:config`. | -| `globals.css not found` | Pass `cssEntry` in adhd.config.ts or place the file at `app/globals.css`. | -| `Figma drifted during this run` | Someone changed Figma while you were resolving conflicts. Re-run `/adhd:push-design-system`. | -| `Figma MCP unreachable` | Verify the figma plugin is installed: `claude plugin install figma@claude-plugins-official`. | diff --git a/plugins/adhd/skills/push-tokens/SKILL.md b/plugins/adhd/skills/push-tokens/SKILL.md new file mode 100644 index 0000000..53587f9 --- /dev/null +++ b/plugins/adhd/skills/push-tokens/SKILL.md @@ -0,0 +1,214 @@ +--- +description: "Push the local design tokens (globals.css variables + named styles) into the configured Figma file. Two-way diff with per-attribute conflict prompts; additive (never deletes from Figma). Runs an interactive 7-question disposition wizard on every invocation to control per-domain push policy (push the full Tailwind palette vs semantic-only, skip opacity, route shadows through effect styles, etc.). Reads adhd.config.ts at the repo root. Pass --dry-run to preview without writing." +disable-model-invocation: true +argument-hint: "[--dry-run]" +allowed-tools: Read Write Edit Bash AskUserQuestion mcp__plugin_figma_figma__use_figma +--- + +# ADHD Push Tokens + +Pushes the codebase's design tokens (variables + named styles) into the configured Figma file. Compares both sides; for each conflicting variable, prompts the user; for variables that exist only in code, creates them in Figma; for variables that exist only in Figma, leaves them alone (additive policy). + +Every invocation walks through a 7-question wizard (Phase 1.5) that controls per-domain push policy. Answer "all" for color/spacing to push the full Tailwind palette + your authored tokens; answer "semantic-only" / "authored-only" to push only your authored tokens. The wizard is the only knob — there's no global "push everything" flag because designers want conscious per-domain decisions. + +Pass `--dry-run` to see exactly what would be added or overwritten without making any changes — no prompts, no writes, no MCP traffic beyond the initial extract. + +**Authoritative spec:** `docs/superpowers/specs/2026-05-10-adhd-push-pull-design-system.md` + +## Phase 1: Validate config + +Read `adhd.config.ts` at the repo root with the `Read` tool. If it doesn't exist, abort: "Run /adhd:config first to set up ADHD." + +Extract `figma.url` (required) and `cssEntry` (optional; auto-detect `app/globals.css` then `src/app/globals.css`). Extract the file key from `figma.url` — the segment after `/design/`. + +## Phase 1.5: Disposition wizard (runs EVERY push) + +Walk the user through seven `AskUserQuestion` prompts to set per-domain push policy. The wizard runs on every invocation — including `--dry-run` — so the dry-run preview reflects exactly what the live push would do. No persistence: dispositions live in `/tmp/adhd-push/dispositions.json` for this run only. + +Issue each question in order. After each answer, append the resulting key/value to a running dispositions object. Question text and options: + +**1. Color** — `Header: "Color"` + Question: "Which color tokens should we push to Figma?" + - `"Push all (recommended for seeding Figma)"` → `color: "all"` + - `"Push semantic only (skip --color-zinc-*, --color-blue-*, etc.)"` → `color: "semantic-only"` + - `"Skip colors entirely"` → `color: "skip"` + +**2. Typography** — `Header: "Typography"` + Question: "Which typography scales should we push? (Font families always route to Figma text styles, never variables.)" + - `"Push all scales (text sizes, font-weights, leading, tracking)"` → `typography: "all"` + - `"Push sizes + weights only (skip leading + tracking)"` → `typography: "sizes-and-weights"` + - `"Skip typography variables"` → `typography: "skip"` + +**3. Spacing** — `Header: "Spacing"` + Question: "Which spacing tokens should we push?" + - `"Push the full Tailwind 0..96 scale"` → `spacing: "all"` + - `"Push only my authored spacing tokens (skip Tailwind scale)"` → `spacing: "authored-only"` + - `"Skip spacing"` → `spacing: "skip"` + +**4. Radius + border width** — `Header: "Radius / border"` + Question: "Push corner radius + border-width tokens?" + - `"Yes — these bind to Figma's corner radius and stroke weight"` → `radiusAndBorder: "push"` + - `"Skip"` → `radiusAndBorder: "skip"` + +**5. Shadow** — `Header: "Shadow"` + Question: "Push shadow tokens as Figma effect styles?" + - `"Yes, push as effect styles (Figma's native shadow channel)"` → `shadow: "effect-styles"` + - `"Skip — manage shadows directly in Figma"` → `shadow: "skip"` + +**6. Opacity** — `Header: "Opacity"` + Question: "Push opacity tokens? Tailwind applies opacity via `/` class modifiers, not variables." + - `"Skip (recommended — matches Tailwind's class-modifier pattern)"` → `opacity: "skip"` + - `"Push as variables anyway (for documentation)"` → `opacity: "push"` + +**7. Utility domains** — `Header: "Utilities"` + Question: "Push utility tokens that Figma doesn't natively consume (z-index, animate, ease, aspect, perspective, container, breakpoint, blur)?" + - `"Skip all (recommended — none of these bind to Figma properties)"` → `utilityDomains: "skip"` + - `"Push anyway for documentation"` → `utilityDomains: "push"` + +After all seven answers, write the dispositions object to `/tmp/adhd-push/dispositions.json` via the `Write` tool. Example shape: + +```json +{ + "color": "all", + "typography": "all", + "spacing": "all", + "radiusAndBorder": "push", + "shadow": "effect-styles", + "opacity": "skip", + "utilityDomains": "skip" +} +``` + +## Phase 2: Read both sides + +Use the `Read` tool to read the resolved `globals.css` path. Save it to `/tmp/adhd-push/globals.css` via the `Write` tool. + +Use `mcp__plugin_figma_figma__use_figma` to extract the Figma side's state. Pick the right strategy based on file size: + +**Strategy A — single-shot (small files, ≲60 variables).** Read `plugins/adhd/lib/design-system/figma-extract-script.js` and pass the value of the exported `EXTRACT_SCRIPT` constant as the `code` parameter. Save the response JSON to `/tmp/adhd-push/figma.json` via the `Write` tool. + +**Strategy B — chunked (recommended for full Tailwind-v4 design systems).** The MCP `use_figma` response is truncated at roughly 20–30 KB, so a full color collection (≈300 vars × 2 modes) exceeds that ceiling and the single-shot script returns a half-baked, JSON-truncated payload. Use the paginated extractor instead: + +1. Read `plugins/adhd/lib/design-system/figma-extract-script.js`. The file exports an `EXTRACT_CHUNK_SCRIPT` template and a `CHUNK_SIZE` default. +2. **Manifest call.** Substitute `__INCLUDE_META__ = true` and `__VAR_INDEX__ = null` into the script, pass to `use_figma`. Save the response to `/tmp/adhd-push/chunks/00-manifest.json` via `Write`. +3. **Slice calls.** Read the manifest's `collections` array. For each collection, iterate `from = 0; from < variableCount; from += CHUNK_SIZE`. For each iteration, substitute `__INCLUDE_META__ = false` and `__VAR_INDEX__ = {collectionId: '', from, to: from + CHUNK_SIZE}` into the script, call `use_figma`, and write the response to `/tmp/adhd-push/chunks/--.json`. +4. **Assemble.** Run `node plugins/adhd/lib/design-system/cli.js assemble-extract --chunks-dir /tmp/adhd-push/chunks --output /tmp/adhd-push/figma.json`. The CLI merges the manifest + slices into the single-shot extract shape that `compare` expects, and throws if any collection's variable count doesn't match the manifest (catches truncated chunks). + +If Strategy A's response shows visible truncation (look for an unterminated JSON object or a `// truncated to kb` marker at the tail), fall back to Strategy B and re-run from step 1. Don't try to repair the truncated payload by hand. + +## Phase 3: Run the comparator + +```bash +node plugins/adhd/lib/design-system/cli.js compare \ + --code /tmp/adhd-push/globals.css \ + --figma /tmp/adhd-push/figma.json \ + --output /tmp/adhd-push/diff.json +``` + +The comparator always surfaces every code-side token in `codeOnly` (including Tailwind defaults). Filtering happens at the action-builder layer based on the wizard's dispositions — that's where designer intent lives. + +Read `/tmp/adhd-push/diff.json`. The diff has four arrays: `same`, `conflict`, `codeOnly`, `figmaOnly`. + +If `conflict.length === 0` and `codeOnly.length === 0`, print "Figma is already in sync with code. No changes." and exit 0. + +## Phase 3b: Dry run (only if `--dry-run` was passed) + +If the user invoked `/adhd:push-tokens --dry-run`, build the action plan first (so the preview reflects the user's wizard answers — every disposition's effect shows in the output), then preview, then exit BEFORE the conflict prompts: + +```bash +# Build actions with the wizard's dispositions but no resolutions +# (dry-run never resolves conflicts — it surfaces them). +echo "[]" > /tmp/adhd-push/resolutions.json +node plugins/adhd/lib/design-system/cli.js apply \ + --diff /tmp/adhd-push/diff.json \ + --resolutions /tmp/adhd-push/resolutions.json \ + --dispositions /tmp/adhd-push/dispositions.json \ + --direction push \ + --output /tmp/adhd-push/actions.json + +node plugins/adhd/lib/design-system/cli.js preview \ + --diff /tmp/adhd-push/diff.json \ + --actions /tmp/adhd-push/actions.json \ + --direction push +``` + +The preview splits additions into two lanes: "Would add to Figma" (tokens the action builder would push) and "Would NOT add to Figma" (tokens filtered by the user's dispositions, grouped by reason). Conflicts surface separately. Echo the output verbatim, then print: `Dry run complete. Re-run without --dry-run to apply (you'll be prompted on each conflict and asked the disposition questions again).` Exit 0. + +If `--dry-run` was NOT passed, skip this phase and continue to Phase 4. + +## Phase 4: Resolve conflicts via AskUserQuestion + +For each conflict in `diff.conflict`, use `AskUserQuestion` with these four options: +- "Keep Figma value (no change)" → resolution `{path, mode, winner: 'figma'}` +- "Use code value (overwrite Figma)" → `{path, mode, winner: 'code'}` +- "Use Figma's values for all N conflicts" → batch confirm (see below) +- "Use code's values for all N conflicts" → batch confirm + +If the user picks a batch option, follow up with another `AskUserQuestion`: +- "Apply all" → apply chosen winner to ALL remaining conflicts; continue without further per-conflict prompts +- "Cancel — go back to per-conflict review" → resume per-conflict loop at current position + +Build a `resolutions` array of `{path, mode, winner}` objects. Save it to `/tmp/adhd-push/resolutions.json` via the `Write` tool. + +## Phase 5: Build actions + +Pass the wizard's dispositions so the action builder honors the user's per-domain choices. + +```bash +node plugins/adhd/lib/design-system/cli.js apply \ + --diff /tmp/adhd-push/diff.json \ + --resolutions /tmp/adhd-push/resolutions.json \ + --dispositions /tmp/adhd-push/dispositions.json \ + --direction push \ + --output /tmp/adhd-push/actions.json +``` + +Read `/tmp/adhd-push/actions.json`. Count `skip-by-disposition` entries — they're informational only (no Figma write happens for these). If the file contains only skip actions (no `create-variable` or `create-effect-style`), print "Nothing to push given your disposition choices." and exit 0. + +## Phase 6: Drift check (re-fetch Figma) + +Re-run the extract script via `use_figma` (same call as Phase 2). Save the response to `/tmp/adhd-push/figma-recheck.json`. Compare to `/tmp/adhd-push/figma.json` byte-for-byte: + +```bash +diff /tmp/adhd-push/figma.json /tmp/adhd-push/figma-recheck.json +``` + +If they differ, abort with: "Figma drifted during this run. Re-run /adhd:push-tokens to see fresh conflicts." Exit 1. + +## Phase 7: Apply actions to Figma + +Load the write script from `plugins/adhd/lib/design-system/figma-write-script.js`'s `WRITE_SCRIPT` export. Substitute `__ACTIONS__` with the contents of `/tmp/adhd-push/actions.json` (the actions array, JSON-stringified inline into the script). + +Call `mcp__plugin_figma_figma__use_figma` with the substituted script. The response contains `{ applied, errors }`. + +If `errors.length > 0`, print the error list and exit 1. + +## Phase 8: Final report + +Print: +``` +✓ Pushed to Figma: + - variables created + - conflicts resolved + - figma-only variables left untouched (additive policy) +``` + +Also count `skip-by-disposition` actions in `actions.json`, group them by `reason`, and append a section if any are present: + +``` + - token(s) skipped by your disposition choices: + × + × + ... +``` + +This is informational — the user already chose these in the wizard. Surfacing them in the report confirms the policy held end-to-end. + +## Common errors + +| Error | Fix-up guidance | +|---|---| +| `adhd.config.ts not found` | Run `/adhd:config`. | +| `globals.css not found` | Pass `cssEntry` in adhd.config.ts or place the file at `app/globals.css`. | +| `Figma drifted during this run` | Someone changed Figma while you were resolving conflicts. Re-run `/adhd:push-tokens`. | +| `Figma MCP unreachable` | Verify the figma plugin is installed: `claude plugin install figma@claude-plugins-official`. | diff --git a/plugins/adhd/skills/sync-docs/SKILL.md b/plugins/adhd/skills/sync-docs/SKILL.md new file mode 100644 index 0000000..6ffbeb8 --- /dev/null +++ b/plugins/adhd/skills/sync-docs/SKILL.md @@ -0,0 +1,207 @@ +--- +description: "Sync the design-system docs route in a Next.js consumer app. Sidebar + viewer layout: sidebar lists every Tailwind v4 token domain (colors, spacing, typography, font, font-weight, tracking, leading, radius, shadows, breakpoints, easing, animation) plus every component tracked in adhd.config.ts; the main pane renders the selected route. Tokens are read from globals.css at request time. Components are statically imported from adhd.config.ts at sync time — re-run this command after editing the components map. Component pages introspect props for URL-driven toggles. Optionally excluded from production builds via Next.js pageExtensions trick. Marker-comment detection makes it safe to re-run; stale files from earlier template layouts are cleaned up automatically." +disable-model-invocation: true +argument-hint: "" +allowed-tools: Read Write Edit Bash AskUserQuestion +--- + +# ADHD Sync Docs + +Generates (and re-generates) a design-system docs page in a Next.js App Router project. Tokens are read live from `globals.css`. Components are statically imported from `adhd.config.ts` at the moment this skill runs — **re-run after editing the components map** to sync the static imports. + +**Authoritative spec:** `docs/superpowers/specs/2026-05-11-adhd-install-design-system-docs-route.md` (historical name). + +## Invariants + +1. **No ADHD references in generated files** outside of two filename-style exceptions: the consumer's `adhd.config.ts` filename, and the slash-command name `/adhd:sync-docs` referenced in re-run copy. +2. **adhd.config.ts is NOT modified** by this skill. The skill reads it; the user owns it. +3. **All file writes are idempotent on re-run.** Marker-bearing files are replaced wholesale with the latest templates. Files where the user deleted the marker are left alone. Stale marker-bearing files from earlier template layouts are removed. +4. **Static component imports.** The skill parses `adhd.config.ts` and generates `componentMap.tsx` with explicit `import * as $cmpN from "@/..."` per registered component. The component page does a static lookup — no dynamic imports, no broad Webpack context modules, no Tailwind-blast-radius issues. + +## Phase 1: Validate consumer environment + +```bash +test -f adhd.config.ts || { echo "Missing adhd.config.ts. Run /adhd:config first."; exit 1; } +test -d app || { echo "Missing app/ directory. This installer requires the Next.js App Router."; exit 1; } +test -f package.json || { echo "No package.json at the working directory."; exit 1; } +``` + +Read `package.json` and confirm `next` is in `dependencies` or `devDependencies`. Warn if missing or version < 16; continue anyway. + +## Phase 2: Detect existing install + +```bash +node plugins/adhd/lib/sync-docs/cli.js detect-install --app-dir . +``` + +Output is newline-separated paths of files containing the marker comment. + +- **No matches:** fresh install. Proceed to Phase 3 with defaults. +- **One or more matches:** use `AskUserQuestion`: + - "Update in place" — re-write the listed marker-bearing files with the latest templates. + - "Move to new location" — Phase 3 reasks the install questions; files at the old location are NOT deleted (the user manages them). + - "Abort" — exit with no changes. + +If user chose "Update in place": derive `groupName` and `routeSegment` from the existing install's folder path, then skip Phase 3's first two questions (route URL, route group) and ask ONLY question 3 ("Exclude from production builds?") to confirm current state. Then proceed to Phase 4. + +## Phase 3: Ask installation choices + +Ask all three questions in a **single** `AskUserQuestion` call so the user sees them as one wizard-style prompt rather than three round-trips. The questions are independent — no branching between answers — so batching is safe. + +1. **Route URL** — default `/-docs`. Validate: starts with `/`, only `a-z0-9-/` characters, no leading `_`. +2. **Route group** — default `(design-system)`. Validate: parens-wrapped, alphanumerics + hyphens inside, OR empty string for "no group." +3. **Where should the docs route render?** — three options, default `Dev only`: + - **Dev only** — gates on `process.env.NODE_ENV === 'production'`. Excludes the route from any production build, on any host. + - **Dev + Vercel preview** — gates on `process.env.VERCEL_ENV === 'production' || (!process.env.VERCEL && process.env.NODE_ENV === 'production')`. Renders on Vercel preview deploys; excluded from Vercel production AND from any non-Vercel production deploy. + - **Everywhere** — no `pageExtensions` patch; route files use plain `.tsx` and ship to production (still `noindex`'d via `robots: { index: false, follow: false }` on the layout's metadata). + +Map the answer to the `renderMode` field passed downstream: + +| Answer label | `renderMode` | +|---|---| +| Dev only | `"dev-only"` | +| Dev + Vercel preview | `"vercel-preview"` | +| Everywhere | `"everywhere"` | + +If a custom "Other" answer fails validation, re-ask only that one question in a follow-up `AskUserQuestion` call. + +Derive `groupName` and `routeSegment` from these answers. Example: routeUrl `/-docs` → routeSegment `-docs`. The group is independent of the URL. + +## Phase 4: Detect Next.js config file + +```bash +for f in next.config.ts next.config.mjs next.config.js; do + test -f "$f" && echo "$f" && break +done +``` + +If none found: abort with "No next.config.* at the project root. Create one before running this installer." + +## Phase 5: Detect filesystem collisions + +```bash +TARGET="app/${GROUP}/${SEGMENT}" +test -e "$TARGET" && echo "EXISTS" || echo "FREE" +``` + +If `EXISTS` and Phase 2 didn't already mark this as an existing install: prompt "Path `` already exists but is not an installer artifact. Pick a different route or abort." + +## Phase 6: Patch next.config.ts + +Always run. The patcher emits up to two blocks depending on `renderMode`: + +- A `pageExtensions` conditional (skipped when `renderMode` is `"everywhere"` — those files ship to prod as plain `.tsx`, no gate needed). +- An `outputFileTracingIncludes` entry that ships `globals.css` alongside the tokens-page function bundle (emitted whenever the route is deployed to a serverless runtime — i.e. `vercel-preview` or `everywhere`; not needed for pure `dev-only` since `next dev` runs locally with the project root as `cwd`). + +Without tracing, the runtime `fs.readFile` in the tokens page returns `null` on Vercel/serverless deploys (the CSS source isn't bundled with the function by default), and every token domain falls through to the empty state — even though `globals.css` is full of declarations. + +```bash +node plugins/adhd/lib/sync-docs/cli.js patch-next-config \ + --config "" \ + --route-url "" \ + --render-mode "" \ + --css-entry "" +``` + +Exit codes: +- `0` — patched successfully (or already at the expected state; idempotent no-op). +- `3` — the file already sets `pageExtensions` to a different value. The CLI prints the existing value on stdout. +- non-zero, non-3 — the file's shape isn't safely patchable. Print the manual patch block (matching the chosen `renderMode`) and continue with file installs. + +**On exit code 3**, use `AskUserQuestion`: "Your next.config.ts sets pageExtensions to ``. How do you want to handle it? [Show me the manual patch and continue / Abort]." + +Automatic merging is NOT supported in v1. On "Show me the manual patch and continue," print the appropriate block(s) for the chosen `renderMode` and continue with Phase 7. Substitute `` and `` in the tracing block: + +```ts +// renderMode: "dev-only" — pageExtensions only (no tracing; runs locally via next dev) +pageExtensions: process.env.NODE_ENV === 'production' + ? ['ts', 'tsx'] + : ['ts', 'tsx', 'design-system.ts', 'design-system.tsx'], + +// renderMode: "vercel-preview" — pageExtensions AND tracing +pageExtensions: + process.env.VERCEL_ENV === 'production' || + (!process.env.VERCEL && process.env.NODE_ENV === 'production') + ? ['ts', 'tsx'] + : ['ts', 'tsx', 'design-system.ts', 'design-system.tsx'], +// adhd:sync-docs — file-tracing for tokens route (so globals.css ships with the serverless function) +outputFileTracingIncludes: { + '/tokens/[domain]': ['./'], +}, + +// renderMode: "everywhere" — tracing only +outputFileTracingIncludes: { + '/tokens/[domain]': ['./'], +}, +``` + +Tell the user to merge into their existing config by hand. On "Abort," exit with no further changes. + +## Phase 7: Write the page files + +```bash +node plugins/adhd/lib/sync-docs/cli.js install \ + --config +``` + +Where `` is a temp file with shape: +```json +{ + "projectRoot": ".", + "groupName": "(design-system)", + "routeSegment": "-docs", + "renderMode": "dev-only" +} +``` + +`renderMode` is one of `"dev-only"`, `"vercel-preview"`, or `"everywhere"` (from Phase 3's third question). The installer derives the file extension internally (`.design-system.tsx` for the two excluding modes, plain `.tsx` for `"everywhere"`). + +The CLI reads `adhd.config.ts` from `` to discover the components list and `cssEntry`, bakes them into the generated files (including a per-install `componentMap.tsx` with static imports), and prints the list of files it wrote plus the slugs that ended up in the map. + +If `adhd.config.ts` is missing, the CLI aborts with `install: failed to read adhd.config.ts ...`. Phase 1 has already guaranteed it exists, so this only fires if the file vanished between phases — rare, but surface the error verbatim. + +## Phase 8: Patch robots.txt + +```bash +node plugins/adhd/lib/sync-docs/cli.js patch-robots \ + --robots public/robots.txt \ + --route-url "" +``` + +If `public/` doesn't exist, create it first: +```bash +mkdir -p public +``` + +## Phase 9: Final report + +Print: +``` +✓ Design system docs synced. + + URL: http://localhost:3000 + Filesystem: app/// + Prod exclusion: + noindex meta: ON + robots.txt: Disallow added + Components: + +Run `npm run dev` and visit the URL to preview. + +Tokens are read from globals.css at request time, so editing globals.css just +works. Components are statically imported from adhd.config.ts — after adding, +renaming, or removing entries in the components map, re-run +/adhd:sync-docs to regenerate the static imports. + +Files where you've removed the marker comment are left alone. +``` + +## Common errors + +| Error | Fix-up | +|---|---| +| `Missing adhd.config.ts` | Run `/adhd:config` first. | +| `Missing app/ directory` | This installer requires the Next.js App Router (not Pages Router). | +| `No next.config.* at the project root` | Create one with a default export of `{}`. | +| `Path already exists but is not an installer artifact` | Pick a different route URL or move/delete the existing folder. | +| `next.config.ts sets pageExtensions to ` | Manually merge with the design-system conditional, or skip prod-exclusion. |