From 8de07dbce6b395e9cfae4806eb7446b72a234faf Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 14:33:05 -0400 Subject: [PATCH 01/79] Add spec for /adhd:install-design-system-docs-route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. No regen needed when components or tokens change. Key design choices: - Pure one-shot install. No designSystem block added to adhd.config.ts — install choices (route URL, route group, prod-exclusion) are encoded in the filesystem, not stored as durable state. - Route group `(design-system)` + hyphen-prefix URL `/-docs` by default. Group organizes future internal routes filesystem-side; hyphen prefix telegraphs "internal" in the URL itself. - 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 — zero bundle pollution. - User can opt out of prod-exclusion at install time. Some teams want the docs route reachable in deployed environments behind their own auth. noindex meta + robots.txt entry apply either way. - Ejection-friendly: generated files contain zero references to "ADHD." Only adhd.config.ts uses that name. Marker comment is generic: `// design-system-docs-route — auto-generated installer artifact; safe to edit.` - Triggered as an optional final phase of /adhd:config so first-time users get a one-stop setup. Available standalone for retroactive install. 20 acceptance criteria. Test plan covers unit tests for the helpers (token-parser, prop-parser, slug, config patchers) plus one integration test against a copy of example/ and a documented manual smoke test. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...1-adhd-install-design-system-docs-route.md | 477 ++++++++++++++++++ 1 file changed, 477 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-11-adhd-install-design-system-docs-route.md 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..be00901 --- /dev/null +++ b/docs/superpowers/specs/2026-05-11-adhd-install-design-system-docs-route.md @@ -0,0 +1,477 @@ +# /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). + +**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. +- Storage of user customizations on regen. v1 detects existing installs and prompts before overwriting; in-place updates preserve the layout file's customizations via Edit-not-Write. + +--- + +## 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." | + +### 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.

From c3c5d5bd7a580201ff4d045f27795c69eb230b0f Mon Sep 17 00:00:00 2001
From: Hugh Francis 
Date: Mon, 11 May 2026 14:37:56 -0400
Subject: [PATCH 02/79] spec: clarify re-run semantics for
 install-design-system-docs-route
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

User pointed out the spec was internally contradictory about re-run
behavior — said "storage of user customizations on regen is out of
scope" in one place and "in-place updates preserve customizations via
Edit-not-Write" in another. They don't agree, and the Edit-not-Write
claim isn't practical anyway: a meaningfully-improved template won't
have old_string matches that line up with the user's edits, so
wholesale Write is the only realistic mechanism.

The user's intent: re-running the skill should pick up improved
templates over time. As we ship better layouts and add new sections,
they want to re-run and get the latest version without manual file
edits.

Updated:
- "Out of scope" section: replace the contradictory bullet with an
  honest statement of the v1 model — wholesale replace files that
  still bear the marker; marker-removal is the user's opt-out escape.
- Phase 2: add explicit "Update-in-place semantics" subsection with
  a sample prompt showing which files will be replaced vs left alone.
- Phase 2: document the force-overwrite escape (re-add the marker
  comment to a previously-opted-out file to re-include it in updates).
- New acceptance criterion #21: re-running the skill across template
  versions reliably updates marker-bearing files and preserves
  marker-removed ones.

No structural changes. The mechanism (marker comment + Write on
update) was already in the spec; this commit aligns the prose and
makes the re-runnability promise explicit.

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 ...1-adhd-install-design-system-docs-route.md | 28 ++++++++++++++++++-
 1 file changed, 27 insertions(+), 1 deletion(-)

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
index be00901..0495a88 100644
--- 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
@@ -18,11 +18,16 @@
 
 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.
-- Storage of user customizations on regen. v1 detects existing installs and prompts before overwriting; in-place updates preserve the layout file's customizations via Edit-not-Write.
+- 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."
 
 ---
 
@@ -104,6 +109,26 @@ Scan `app/**/page.*tsx` and `app/**/layout.*tsx` for the marker comment. If foun
 | 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):
@@ -475,3 +500,4 @@ The install phases are documented in the standalone skill; `/adhd:config` refere
 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.

From 51b72225f1f8f3330f842d2208b305f5ee38bae0 Mon Sep 17 00:00:00 2001
From: Hugh Francis 
Date: Mon, 11 May 2026 14:43:53 -0400
Subject: [PATCH 03/79] Add implementation plan for
 /adhd:install-design-system-docs-route
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

13 tasks decomposing the spec into TDD steps:
- Task 1: scaffold lib + cli stub + CI step + module README
- Task 2: token-parser.js — extract @theme tokens from globals.css
- Task 3: prop-parser.js — extract component prop interface
- Task 4: slug.js — component path → URL slug + collision disambiguation
- Task 5: next-config-patcher.js — idempotent conditional pageExtensions patch
- Task 6: robots-patcher.js — idempotent Disallow entry
- Task 7: templates.js — layout / page / [component]/page / PropToggle as string consts
- Task 8: route-installer.js — write files at target path; marker-comment detection
- Task 9: cli.js — wire all subcommands
- Task 10: SKILL.md — 9-phase orchestrator
- Task 11: /adhd:config integration (optional final phase)
- Task 12: README + marketplace updates
- Task 13: smoke + PR

Acceptance criteria 1-21 mapped to specific tasks in the self-review.
~60 new unit tests planned. All TDD-style with concrete code blocks
per step.

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 ...1-adhd-install-design-system-docs-route.md | 2349 +++++++++++++++++
 1 file changed, 2349 insertions(+)
 create mode 100644 docs/superpowers/plans/2026-05-11-adhd-install-design-system-docs-route.md

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. From 47a4368e26740399048a1b27ab9947812bb17aa7 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 14:48:39 -0400 Subject: [PATCH 04/79] Scaffold lib/install-design-system-docs-route with cli stub --- .github/workflows/ci.yml | 2 ++ .../README.md | 18 ++++++++++ .../__tests__/cli.test.js | 29 +++++++++++++++ .../install-design-system-docs-route/cli.js | 36 +++++++++++++++++++ 4 files changed, 85 insertions(+) create mode 100644 plugins/adhd/lib/install-design-system-docs-route/README.md create mode 100644 plugins/adhd/lib/install-design-system-docs-route/__tests__/cli.test.js create mode 100644 plugins/adhd/lib/install-design-system-docs-route/cli.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f641179..79c021f 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 install-design-system-docs-route tests + run: node --test plugins/adhd/lib/install-design-system-docs-route/__tests__/ hygiene: name: project hygiene diff --git a/plugins/adhd/lib/install-design-system-docs-route/README.md b/plugins/adhd/lib/install-design-system-docs-route/README.md new file mode 100644 index 0000000..24a96cb --- /dev/null +++ b/plugins/adhd/lib/install-design-system-docs-route/README.md @@ -0,0 +1,18 @@ +# 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. diff --git a/plugins/adhd/lib/install-design-system-docs-route/__tests__/cli.test.js b/plugins/adhd/lib/install-design-system-docs-route/__tests__/cli.test.js new file mode 100644 index 0000000..4daf0f2 --- /dev/null +++ b/plugins/adhd/lib/install-design-system-docs-route/__tests__/cli.test.js @@ -0,0 +1,29 @@ +'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); +}); diff --git a/plugins/adhd/lib/install-design-system-docs-route/cli.js b/plugins/adhd/lib/install-design-system-docs-route/cli.js new file mode 100644 index 0000000..5dd68ea --- /dev/null +++ b/plugins/adhd/lib/install-design-system-docs-route/cli.js @@ -0,0 +1,36 @@ +#!/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(); From 8c37cbafb9dcf33e4d1fc334641323110430bb45 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 14:51:39 -0400 Subject: [PATCH 05/79] token-parser: extract colors/spacing/typography/radius/shadows from globals.css @theme --- .../__fixtures__/globals.css | 22 +++++ .../__tests__/token-parser.test.js | 80 +++++++++++++++++ .../token-parser.js | 89 +++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 plugins/adhd/lib/install-design-system-docs-route/__fixtures__/globals.css create mode 100644 plugins/adhd/lib/install-design-system-docs-route/__tests__/token-parser.test.js create mode 100644 plugins/adhd/lib/install-design-system-docs-route/token-parser.js diff --git a/plugins/adhd/lib/install-design-system-docs-route/__fixtures__/globals.css b/plugins/adhd/lib/install-design-system-docs-route/__fixtures__/globals.css new file mode 100644 index 0000000..df82b02 --- /dev/null +++ b/plugins/adhd/lib/install-design-system-docs-route/__fixtures__/globals.css @@ -0,0 +1,22 @@ +@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; +} diff --git a/plugins/adhd/lib/install-design-system-docs-route/__tests__/token-parser.test.js b/plugins/adhd/lib/install-design-system-docs-route/__tests__/token-parser.test.js new file mode 100644 index 0000000..7423f5c --- /dev/null +++ b/plugins/adhd/lib/install-design-system-docs-route/__tests__/token-parser.test.js @@ -0,0 +1,80 @@ +'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); +}); diff --git a/plugins/adhd/lib/install-design-system-docs-route/token-parser.js b/plugins/adhd/lib/install-design-system-docs-route/token-parser.js new file mode 100644 index 0000000..b553a38 --- /dev/null +++ b/plugins/adhd/lib/install-design-system-docs-route/token-parser.js @@ -0,0 +1,89 @@ +'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 }; From b3e937234f19d4239ef0d8cae949b62303151ec8 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 14:54:05 -0400 Subject: [PATCH 06/79] token-parser: clarify @theme parsing assumptions and switch on domain Conservative simplification pass on the new token parser: - Replace the if/else-if chain in parseTokens with a switch on cls.domain to match the project preference for explicit, flat control flow. - Factor the typography row upsert into a named helper so the size + line-height merge invariant has a clear home. - Add why-comments documenting (a) why a naive brace counter is safe for Tailwind v4 @theme blocks (declarations only, no nested rules), (b) why DECL_RE.lastIndex is reset each loop (module-scoped /g regex), and (c) how Tailwind's --line-height suffix pairs onto the same family. - Replace the indexOf('--line-height') check in classify with endsWith against a named constant; the suffix is always a suffix in v4. - Fix a stale comment that described extractAllThemeBodies as returning a single body / null when it returns an array. No behavior change. All 8 token-parser tests pass; full 294-test lib suite still green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../token-parser.js | 101 ++++++++++++------ 1 file changed, 71 insertions(+), 30 deletions(-) diff --git a/plugins/adhd/lib/install-design-system-docs-route/token-parser.js b/plugins/adhd/lib/install-design-system-docs-route/token-parser.js index b553a38..358e59b 100644 --- a/plugins/adhd/lib/install-design-system-docs-route/token-parser.js +++ b/plugins/adhd/lib/install-design-system-docs-route/token-parser.js @@ -1,17 +1,24 @@ 'use strict'; -// Extracts a single @theme block's body, or null. Brace-balanced across nested objects. +// 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 whitespace + optional modifiers like @theme inline + // 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; } - // Brace-balanced scan + if (css[j] !== '{') { + i = j + 1; + continue; + } let depth = 1; let k = j + 1; while (k < css.length && depth > 0) { @@ -25,20 +32,41 @@ function extractAllThemeBodies(css) { 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'; + 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('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' }; + 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' }; } - if (name.startsWith('radius-')) return { domain: 'radius', leaf: name.slice('radius-'.length) }; - if (name.startsWith('shadow-')) return { domain: 'shadows', leaf: name.slice('shadow-'.length) }; + 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' }; } @@ -51,34 +79,47 @@ function parseTokens(globalsCss) { shadows: [], 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); - 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 }); + switch (cls.domain) { + case 'colors': + out.colors.push({ name: cls.leaf, value }); + break; + case 'spacing': + out.spacing.multiplier = value; + break; + case 'typography': + upsertTypography(cls.leaf, cls.kind, value); + break; + case 'radius': + out.radius.push({ name: cls.leaf, value }); + break; + case 'shadows': + out.shadows.push({ name: cls.leaf, value }); + break; + default: + out.unknown.push({ name: '--' + name, value }); } } } From c8974520719acaaf00abaf5e6c0a433b8b54c336 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 14:55:29 -0400 Subject: [PATCH 07/79] prop-parser: extract component prop interface (unions, primitives, optional flag) --- .../__fixtures__/avatar.tsx | 18 +++++ .../__tests__/prop-parser.test.js | 62 ++++++++++++++++ .../prop-parser.js | 74 +++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 plugins/adhd/lib/install-design-system-docs-route/__fixtures__/avatar.tsx create mode 100644 plugins/adhd/lib/install-design-system-docs-route/__tests__/prop-parser.test.js create mode 100644 plugins/adhd/lib/install-design-system-docs-route/prop-parser.js diff --git a/plugins/adhd/lib/install-design-system-docs-route/__fixtures__/avatar.tsx b/plugins/adhd/lib/install-design-system-docs-route/__fixtures__/avatar.tsx new file mode 100644 index 0000000..b03e749 --- /dev/null +++ b/plugins/adhd/lib/install-design-system-docs-route/__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/install-design-system-docs-route/__tests__/prop-parser.test.js b/plugins/adhd/lib/install-design-system-docs-route/__tests__/prop-parser.test.js new file mode 100644 index 0000000..e4defef --- /dev/null +++ b/plugins/adhd/lib/install-design-system-docs-route/__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/install-design-system-docs-route/prop-parser.js b/plugins/adhd/lib/install-design-system-docs-route/prop-parser.js new file mode 100644 index 0000000..3d616b6 --- /dev/null +++ b/plugins/adhd/lib/install-design-system-docs-route/prop-parser.js @@ -0,0 +1,74 @@ +'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 Date: Mon, 11 May 2026 14:57:15 -0400 Subject: [PATCH 08/79] prop-parser: align names with push-component sibling Rename parseLiteralUnion->parseUnionString, classify->classifyPropType, and expand terse locals (m/pm/cls/optionalMark) so prop-parser reads like push-component/parse-component.js. Behavior unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../prop-parser.js | 39 +++++++++---------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/plugins/adhd/lib/install-design-system-docs-route/prop-parser.js b/plugins/adhd/lib/install-design-system-docs-route/prop-parser.js index 3d616b6..f98d04e 100644 --- a/plugins/adhd/lib/install-design-system-docs-route/prop-parser.js +++ b/plugins/adhd/lib/install-design-system-docs-route/prop-parser.js @@ -6,19 +6,19 @@ const TYPE_PROPS_RE = /(?:export\s+)?type\s+([A-Z][A-Za-z0-9]*Props)\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) { +function parseUnionString(typeText) { const trimmed = typeText.trim(); if (!/^"[^"]*"(\s*\|\s*"[^"]*")*$/.test(trimmed)) return null; - return trimmed.split('|').map(s => { + return trimmed.split('|').map((s) => { const m = /"([^"]*)"/.exec(s.trim()); return m ? m[1] : null; }).filter(Boolean); } -function classify(typeText, knownUnions) { +function classifyPropType(typeText, knownUnions) { const t = typeText.trim(); - const inline = parseLiteralUnion(t); - if (inline) return { type: 'union', values: inline }; + 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)? Date: Mon, 11 May 2026 14:58:25 -0400 Subject: [PATCH 09/79] =?UTF-8?q?slug:=20component=20path=20=E2=86=92=20UR?= =?UTF-8?q?L=20slug=20+=20collision=20disambiguation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/slug.test.js | 43 +++++++++++++++++++ .../install-design-system-docs-route/slug.js | 41 ++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 plugins/adhd/lib/install-design-system-docs-route/__tests__/slug.test.js create mode 100644 plugins/adhd/lib/install-design-system-docs-route/slug.js diff --git a/plugins/adhd/lib/install-design-system-docs-route/__tests__/slug.test.js b/plugins/adhd/lib/install-design-system-docs-route/__tests__/slug.test.js new file mode 100644 index 0000000..333064c --- /dev/null +++ b/plugins/adhd/lib/install-design-system-docs-route/__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/install-design-system-docs-route/slug.js b/plugins/adhd/lib/install-design-system-docs-route/slug.js new file mode 100644 index 0000000..5a91ac0 --- /dev/null +++ b/plugins/adhd/lib/install-design-system-docs-route/slug.js @@ -0,0 +1,41 @@ +'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 }; From 71cd1cd2cdb8aea76159938d32efacb66b02f967 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 14:59:37 -0400 Subject: [PATCH 10/79] slug: extract pathSegments helper to dedupe path normalization The strip-extension + strip-/index + split-on-slash chain ran in both baseSlug and slugMap's collision branch. Pulled it into a single helper so the two call sites share the same definition of "meaningful segments." Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/install-design-system-docs-route/slug.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/plugins/adhd/lib/install-design-system-docs-route/slug.js b/plugins/adhd/lib/install-design-system-docs-route/slug.js index 5a91ac0..90d4733 100644 --- a/plugins/adhd/lib/install-design-system-docs-route/slug.js +++ b/plugins/adhd/lib/install-design-system-docs-route/slug.js @@ -1,9 +1,17 @@ '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) { - // 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); + const segs = pathSegments(componentPath); return (segs[segs.length - 1] || '').toLowerCase(); } @@ -20,7 +28,7 @@ function slugMap(paths) { // 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); + const segs = pathSegments(t.path); // Prepend one level of parent until unique let depth = 2; while (depth <= segs.length) { From f4376591348152fc58a13b77c88df29429b59057 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 15:00:53 -0400 Subject: [PATCH 11/79] next-config-patcher: idempotent conditional pageExtensions patch --- .../__tests__/next-config-patcher.test.js | 68 +++++++++++++++++++ .../next-config-patcher.js | 63 +++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 plugins/adhd/lib/install-design-system-docs-route/__tests__/next-config-patcher.test.js create mode 100644 plugins/adhd/lib/install-design-system-docs-route/next-config-patcher.js diff --git a/plugins/adhd/lib/install-design-system-docs-route/__tests__/next-config-patcher.test.js b/plugins/adhd/lib/install-design-system-docs-route/__tests__/next-config-patcher.test.js new file mode 100644 index 0000000..a994874 --- /dev/null +++ b/plugins/adhd/lib/install-design-system-docs-route/__tests__/next-config-patcher.test.js @@ -0,0 +1,68 @@ +'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'/); +}); diff --git a/plugins/adhd/lib/install-design-system-docs-route/next-config-patcher.js b/plugins/adhd/lib/install-design-system-docs-route/next-config-patcher.js new file mode 100644 index 0000000..cd143ae --- /dev/null +++ b/plugins/adhd/lib/install-design-system-docs-route/next-config-patcher.js @@ -0,0 +1,63 @@ +'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 }; From bc1c657be7936691dc5c55e580e81bd1c6ed164d Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 15:02:56 -0400 Subject: [PATCH 12/79] next-config-patcher: align naming with sibling lib conventions Rename PATCHED_SENTINEL to PATCHED_SENTINEL_RE and lift the inline conflict-extraction regex to a named EXISTING_PAGE_EXTENSIONS_VALUE_RE, matching the `_RE` suffix convention used in pull-component/config-writer. Inline the newline-normalization in patchNextConfig so the leading-newline handling is symmetric and easier to read. Rename `opts` to `options`. No behavioral change; 30 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../next-config-patcher.js | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/plugins/adhd/lib/install-design-system-docs-route/next-config-patcher.js b/plugins/adhd/lib/install-design-system-docs-route/next-config-patcher.js index cd143ae..e3d9ee2 100644 --- a/plugins/adhd/lib/install-design-system-docs-route/next-config-patcher.js +++ b/plugins/adhd/lib/install-design-system-docs-route/next-config-patcher.js @@ -2,17 +2,20 @@ // 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'/; +const PATCHED_SENTINEL_RE = /pageExtensions:\s*process\.env\.NODE_ENV\s*===\s*['"]production['"][\s\S]*?'design-system\.tsx'/; -// Detection: any other pageExtensions definition. +// Detection: any other pageExtensions definition (array form). const EXISTING_PAGE_EXTENSIONS_RE = /pageExtensions:\s*\[/; +// Captures the full `pageExtensions: ...,` declaration for conflict reporting. +const EXISTING_PAGE_EXTENSIONS_VALUE_RE = /pageExtensions:[^,\n]+,?/; + 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); + return PATCHED_SENTINEL_RE.test(source); } function findConfigObjectStart(source) { @@ -33,13 +36,13 @@ function findConfigObjectStart(source) { return -1; } -function patchNextConfig(source, opts = {}) { +function patchNextConfig(source, options = {}) { 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]; + if (options.detectOnly) { + const existing = EXISTING_PAGE_EXTENSIONS_VALUE_RE.exec(source)[0]; return { conflict: true, existing }; } // Caller hasn't checked; we still refuse to silently merge. @@ -54,10 +57,10 @@ function patchNextConfig(source, opts = {}) { // 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/, ''); + // Strip any leading newline from the tail so it isn't duplicated; we always + // emit exactly one `\n` on each side of PATCH_BLOCK for clean formatting. + const after = source.slice(insertAt).replace(/^\n/, ''); + return before + '\n' + PATCH_BLOCK + '\n' + after; } module.exports = { patchNextConfig, isPatched }; From 827d349f3290a68f71b675de143bda982a6856ff Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 15:04:07 -0400 Subject: [PATCH 13/79] robots-patcher: idempotent Disallow entry for the docs route --- .../__tests__/robots-patcher.test.js | 43 +++++++++++++++++++ .../robots-patcher.js | 16 +++++++ 2 files changed, 59 insertions(+) create mode 100644 plugins/adhd/lib/install-design-system-docs-route/__tests__/robots-patcher.test.js create mode 100644 plugins/adhd/lib/install-design-system-docs-route/robots-patcher.js diff --git a/plugins/adhd/lib/install-design-system-docs-route/__tests__/robots-patcher.test.js b/plugins/adhd/lib/install-design-system-docs-route/__tests__/robots-patcher.test.js new file mode 100644 index 0000000..157d46c --- /dev/null +++ b/plugins/adhd/lib/install-design-system-docs-route/__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/install-design-system-docs-route/robots-patcher.js b/plugins/adhd/lib/install-design-system-docs-route/robots-patcher.js new file mode 100644 index 0000000..5d36c76 --- /dev/null +++ b/plugins/adhd/lib/install-design-system-docs-route/robots-patcher.js @@ -0,0 +1,16 @@ +'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 }; From 6242cb224ba3e5b19785d9fcaa9be0a170382f7d Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 15:07:53 -0400 Subject: [PATCH 14/79] templates: layout, index, component page, PropToggle (marker-prefixed, no ADHD refs) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/templates.test.js | 67 +++ .../templates.js | 386 ++++++++++++++++++ 2 files changed, 453 insertions(+) create mode 100644 plugins/adhd/lib/install-design-system-docs-route/__tests__/templates.test.js create mode 100644 plugins/adhd/lib/install-design-system-docs-route/templates.js diff --git a/plugins/adhd/lib/install-design-system-docs-route/__tests__/templates.test.js b/plugins/adhd/lib/install-design-system-docs-route/__tests__/templates.test.js new file mode 100644 index 0000000..cbd7749 --- /dev/null +++ b/plugins/adhd/lib/install-design-system-docs-route/__tests__/templates.test.js @@ -0,0 +1,67 @@ +'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', () => { + // The marker comment is allowed to precede the directive — Next.js strips + // leading comments and treats `"use client"` as the first real statement. + // Required so the marker-detection contract (Task 8) still applies. + const afterMarker = PROP_TOGGLE_TSX.replace(MARKER_COMMENT, ''); + assert.match(afterMarker, /^["']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', () => { + // The literal filename `adhd.config.ts` is the consumer's own config artifact + // (per the install spec) and is not a reference to the ADHD plugin/brand — + // it's an unavoidable filename the generated pages must read at runtime. + // Strip it before applying the "no ADHD references" rule. + for (const [name, content] of Object.entries({ LAYOUT_TSX, INDEX_PAGE_TSX, COMPONENT_PAGE_TSX, PROP_TOGGLE_TSX })) { + const body = content.replace(MARKER_COMMENT, '').replace(/adhd\.config\.ts/g, ''); + assert.equal(/adhd/i.test(body), false, `${name} must not reference ADHD outside marker`); + } +}); diff --git a/plugins/adhd/lib/install-design-system-docs-route/templates.js b/plugins/adhd/lib/install-design-system-docs-route/templates.js new file mode 100644 index 0000000..35e5fad --- /dev/null +++ b/plugins/adhd/lib/install-design-system-docs-route/templates.js @@ -0,0 +1,386 @@ +'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 yet.

: ( +
+ {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 }; From 54059c61b09a224d887b6857fefefe189ad2424a Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 15:10:30 -0400 Subject: [PATCH 15/79] route-installer: write the 4 page files; detect existing installs via marker --- .../__tests__/route-installer.test.js | 97 +++++++++++++++++++ .../route-installer.js | 64 ++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 plugins/adhd/lib/install-design-system-docs-route/__tests__/route-installer.test.js create mode 100644 plugins/adhd/lib/install-design-system-docs-route/route-installer.js diff --git a/plugins/adhd/lib/install-design-system-docs-route/__tests__/route-installer.test.js b/plugins/adhd/lib/install-design-system-docs-route/__tests__/route-installer.test.js new file mode 100644 index 0000000..3aba1cc --- /dev/null +++ b/plugins/adhd/lib/install-design-system-docs-route/__tests__/route-installer.test.js @@ -0,0 +1,97 @@ +'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'))); +}); diff --git a/plugins/adhd/lib/install-design-system-docs-route/route-installer.js b/plugins/adhd/lib/install-design-system-docs-route/route-installer.js new file mode 100644 index 0000000..61cfb59 --- /dev/null +++ b/plugins/adhd/lib/install-design-system-docs-route/route-installer.js @@ -0,0 +1,64 @@ +'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 }; From 764d07172f37b9236d598aa54719c6aec5a71ba9 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 15:12:50 -0400 Subject: [PATCH 16/79] cli: wire all subcommands (parse-tokens, parse-props, slug, patch-*, detect-install, install) --- .../__tests__/cli.test.js | 77 +++++++++++++++++++ .../install-design-system-docs-route/cli.js | 69 ++++++++++++++++- 2 files changed, 145 insertions(+), 1 deletion(-) diff --git a/plugins/adhd/lib/install-design-system-docs-route/__tests__/cli.test.js b/plugins/adhd/lib/install-design-system-docs-route/__tests__/cli.test.js index 4daf0f2..1b11092 100644 --- a/plugins/adhd/lib/install-design-system-docs-route/__tests__/cli.test.js +++ b/plugins/adhd/lib/install-design-system-docs-route/__tests__/cli.test.js @@ -27,3 +27,80 @@ test('cli with no args exits 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 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'))); +}); diff --git a/plugins/adhd/lib/install-design-system-docs-route/cli.js b/plugins/adhd/lib/install-design-system-docs-route/cli.js index 5dd68ea..6c15bd9 100644 --- a/plugins/adhd/lib/install-design-system-docs-route/cli.js +++ b/plugins/adhd/lib/install-design-system-docs-route/cli.js @@ -1,6 +1,15 @@ #!/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++) { @@ -28,7 +37,65 @@ function main() { 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. + + 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); } From c75f63e814fe7a615f649e1aca23fd569a71e43c Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 15:15:14 -0400 Subject: [PATCH 17/79] Add /adhd:install-design-system-docs-route skill --- .../install-design-system-docs-route/SKILL.md | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 plugins/adhd/skills/install-design-system-docs-route/SKILL.md diff --git a/plugins/adhd/skills/install-design-system-docs-route/SKILL.md b/plugins/adhd/skills/install-design-system-docs-route/SKILL.md new file mode 100644 index 0000000..2b573f4 --- /dev/null +++ b/plugins/adhd/skills/install-design-system-docs-route/SKILL.md @@ -0,0 +1,147 @@ +--- +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. | From ed6275f2b49493f02279a6b69098654fdc9165a4 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 15:16:13 -0400 Subject: [PATCH 18/79] SKILL: clarify Phase 6 exit-code handling and Phase 2 update-in-place branch Phase 6 previously said 're-run the CLI without detectOnly (currently errors; for v1, print ... and abort)' which mixed a forward-looking instruction with a v1 override in a parenthetical. Replaced with an explicit exit-code table and a two-option AskUserQuestion that drops the unsupported 'Yes / merge automatically' branch entirely. Phase 2's update-in-place handoff to Phase 6 silently overrode Phase 3's three-question flow. Made the directive explicit: derive group/segment from the existing folder, skip the first two questions, ask only the prod-exclusion one, then proceed to Phase 4. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../install-design-system-docs-route/SKILL.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/plugins/adhd/skills/install-design-system-docs-route/SKILL.md b/plugins/adhd/skills/install-design-system-docs-route/SKILL.md index 2b573f4..2774ce3 100644 --- a/plugins/adhd/skills/install-design-system-docs-route/SKILL.md +++ b/plugins/adhd/skills/install-design-system-docs-route/SKILL.md @@ -41,7 +41,7 @@ Output is newline-separated paths of files containing the marker comment. - "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. +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 @@ -80,9 +80,22 @@ node plugins/adhd/lib/install-design-system-docs-route/cli.js patch-next-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]." +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 (see below) and continue with file installs. -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. +**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 this block and continue with Phase 7: + +```ts +pageExtensions: process.env.NODE_ENV === 'production' + ? ['ts', 'tsx'] + : ['ts', 'tsx', 'design-system.ts', 'design-system.tsx'], +``` + +…and tell the user to merge it with their existing `pageExtensions` value by hand. On "Abort," exit with no further changes. ## Phase 7: Write the page files From 433881baeb699fcdec1a161c3a7c120a84065ebd Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 15:17:25 -0400 Subject: [PATCH 19/79] config: offer to install the design-system docs route as an optional final phase --- plugins/adhd/skills/config/SKILL.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/plugins/adhd/skills/config/SKILL.md b/plugins/adhd/skills/config/SKILL.md index 1b28d27..c8fd0fd 100644 --- a/plugins/adhd/skills/config/SKILL.md +++ b/plugins/adhd/skills/config/SKILL.md @@ -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): 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. + ## Reference: Common errors and fix-up guidance ### "The official Figma plugin isn't installed" From b3865692bcf90abe6ce373769499e3cefdcd5246 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 15:18:44 -0400 Subject: [PATCH 20/79] README + marketplace: document /adhd:install-design-system-docs-route --- .claude-plugin/marketplace.json | 2 +- README.md | 34 ++++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) 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/README.md b/README.md index 3ac95e5..294b67d 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ 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, seven slash commands are available: | Command | Args | Direction | What it does | |---|---|---|---| @@ -34,6 +34,7 @@ After install, six slash commands are available: | `/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: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. | 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. @@ -110,6 +111,37 @@ 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: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. + ### 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. From 794ff5c222cd80503263665f6d90a325d02d6b14 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 15:37:13 -0400 Subject: [PATCH 21/79] Fix two bugs surfaced by a real-world install in reactor-webapp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1: Index page Links navigated to the wrong URL. The index page used `` for component links. When the user is at `/-docs` (no trailing slash), browsers resolve `./logo` relative to `/-docs` as `/logo` — treating `-docs` like a file at the root, not a directory. Result: clicking a component nav'd to `/logo` instead of `/-docs/logo`. Fix: introduce a `__ROUTE_PATH__` placeholder in INDEX_PAGE_TSX. The installer substitutes it with the actual route URL (`/${routeSegment}`) at install time. The rendered href becomes the absolute path `${routePath}/${slug}` which resolves correctly regardless of how Next.js formats the parent URL. Bug 2: Component pages failed to compile with ENOENT on the build manifest. COMPONENT_PAGE_TSX imports `from "../PropToggle"`, but route-installer was writing the file as `PropToggle.design-system.tsx`. TypeScript's module resolution doesn't try `.design-system.tsx` as a default extension, so the import failed. Next.js can't compile the page → no build manifest → user sees ENOENT runtime error when navigating to `/-docs/`. Fix: write PropToggle as plain `PropToggle.tsx` regardless of prod-exclusion. The `.design-system` suffix is only needed for files that should be excluded from the production build via Next.js's `pageExtensions` conditional — and that mechanism only applies to route files (page/layout). PropToggle is a regular module: it's only bundled if its importing page is in the build, and the page IS suffix-excluded, so prod exclusion still works as intended. Tests: - 4 new route-installer test cases (PropToggle filename, Link href absolute, custom route segment substitution, import resolution). - Updated existing tests that asserted on the old PropToggle filename. - 347/347 lib tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/route-installer.test.js | 48 +++++++++++++++++-- .../route-installer.js | 32 +++++++++---- .../templates.js | 2 +- 3 files changed, 69 insertions(+), 13 deletions(-) diff --git a/plugins/adhd/lib/install-design-system-docs-route/__tests__/route-installer.test.js b/plugins/adhd/lib/install-design-system-docs-route/__tests__/route-installer.test.js index 3aba1cc..88f3340 100644 --- a/plugins/adhd/lib/install-design-system-docs-route/__tests__/route-installer.test.js +++ b/plugins/adhd/lib/install-design-system-docs-route/__tests__/route-installer.test.js @@ -13,7 +13,7 @@ function makeTempProject() { return root; } -test('installRoute writes 4 files with the .design-system.tsx extension when prodExcluded', () => { +test('installRoute writes page/layout files with .design-system.tsx extension when prodExcluded, but PropToggle is always plain .tsx', () => { const root = makeTempProject(); installRoute(root, { groupName: '(design-system)', @@ -21,10 +21,16 @@ test('installRoute writes 4 files with the .design-system.tsx extension when pro prodExcluded: true, }); const docsDir = path.join(root, 'app', '(design-system)', '-docs'); + // Route files (page/layout) 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, '[component]', 'page.design-system.tsx'))); - assert.ok(fs.existsSync(path.join(docsDir, 'PropToggle.design-system.tsx'))); + // PropToggle is a module imported by the page; it doesn't need the suffix for + // prod-exclusion (the page that imports it IS suffix-excluded, so nothing + // references PropToggle in prod). Plain `.tsx` keeps `import "../PropToggle"` + // resolvable via standard TS module resolution. + assert.ok(fs.existsSync(path.join(docsDir, 'PropToggle.tsx'))); + assert.ok(!fs.existsSync(path.join(docsDir, 'PropToggle.design-system.tsx'))); }); test('installRoute writes plain .tsx files when not prodExcluded', () => { @@ -49,13 +55,48 @@ test('all written files start with the marker comment', () => { 'layout.design-system.tsx', 'page.design-system.tsx', '[component]/page.design-system.tsx', - 'PropToggle.design-system.tsx', + 'PropToggle.tsx', ]) { const content = fs.readFileSync(path.join(docsDir, f), 'utf8'); assert.match(content, /design-system-docs-route/); } }); +test('index page Link href is absolute, using the configured route URL', () => { + // Relative hrefs like `./` resolve incorrectly from `/-docs` (no trailing + // slash) — they go to `/` instead of `/-docs/`. The installer + // substitutes the route URL at install time so the link is absolute. + const root = makeTempProject(); + installRoute(root, { groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true }); + const indexPage = fs.readFileSync( + path.join(root, 'app', '(design-system)', '-docs', 'page.design-system.tsx'), + 'utf8', + ); + assert.match(indexPage, /href=\{`\/-docs\/\$\{slug\}`\}/); + // The placeholder should be fully substituted — no `__ROUTE_PATH__` left over. + assert.doesNotMatch(indexPage, /__ROUTE_PATH__/); +}); + +test('index page Link href substitution honors a custom route segment', () => { + const root = makeTempProject(); + installRoute(root, { groupName: '(design-system)', routeSegment: 'design-system', prodExcluded: true }); + const indexPage = fs.readFileSync( + path.join(root, 'app', '(design-system)', 'design-system', 'page.design-system.tsx'), + 'utf8', + ); + assert.match(indexPage, /href=\{`\/design-system\/\$\{slug\}`\}/); +}); + +test('COMPONENT_PAGE_TSX imports PropToggle from "../PropToggle" (resolves to the plain .tsx file)', () => { + const root = makeTempProject(); + installRoute(root, { groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true }); + const componentPage = fs.readFileSync( + path.join(root, 'app', '(design-system)', '-docs', '[component]', 'page.design-system.tsx'), + 'utf8', + ); + assert.match(componentPage, /from "\.\.\/PropToggle"/); +}); + test('detectExistingInstall scans for the marker and returns matching files', () => { const root = makeTempProject(); installRoute(root, { groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true }); @@ -94,4 +135,5 @@ test('installRoute supports an empty groupName (no route group)', () => { 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'))); + assert.ok(fs.existsSync(path.join(docsDir, 'PropToggle.tsx'))); }); diff --git a/plugins/adhd/lib/install-design-system-docs-route/route-installer.js b/plugins/adhd/lib/install-design-system-docs-route/route-installer.js index 61cfb59..e36f52d 100644 --- a/plugins/adhd/lib/install-design-system-docs-route/route-installer.js +++ b/plugins/adhd/lib/install-design-system-docs-route/route-installer.js @@ -12,27 +12,41 @@ function installRoute(projectRoot, opts) { const { groupName = '', routeSegment, prodExcluded } = opts; if (!routeSegment) throw new Error('routeSegment is required'); - const ext = prodExcluded ? '.design-system.tsx' : '.tsx'; + // Page/layout files get the `.design-system.tsx` extension only when prod-excluded + // so Next.js's `pageExtensions` conditional filters them out of production builds. + const pageExt = prodExcluded ? '.design-system.tsx' : '.tsx'; + // PropToggle is a regular module (not a route file by name), so it doesn't need + // the `.design-system` suffix to be excluded — it's only bundled if its importing + // page is in the build, and the page IS suffix-excluded. Using a plain `.tsx` + // keeps the `import "../PropToggle"` in COMPONENT_PAGE_TSX resolvable. + const moduleExt = '.tsx'; const segments = ['app']; if (groupName) segments.push(groupName); segments.push(routeSegment); const docsDir = path.join(projectRoot, ...segments); const componentDir = path.join(docsDir, '[component]'); + // The runtime route URL (route groups like `(design-system)` are invisible in URLs, + // so the URL is just `/`). Templates use `__ROUTE_PATH__` as a + // placeholder for this — relative hrefs like `./` would resolve incorrectly + // when the current path is `/` without a trailing slash. + const routeUrl = '/' + routeSegment; + const indexBody = INDEX_PAGE_TSX.replace(/__ROUTE_PATH__/g, routeUrl); + 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); + fs.writeFileSync(path.join(docsDir, `layout${pageExt}`), LAYOUT_TSX); + fs.writeFileSync(path.join(docsDir, `page${pageExt}`), indexBody); + fs.writeFileSync(path.join(componentDir, `page${pageExt}`), COMPONENT_PAGE_TSX); + fs.writeFileSync(path.join(docsDir, `PropToggle${moduleExt}`), 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}`), + path.join(docsDir, `layout${pageExt}`), + path.join(docsDir, `page${pageExt}`), + path.join(componentDir, `page${pageExt}`), + path.join(docsDir, `PropToggle${moduleExt}`), ], }; } diff --git a/plugins/adhd/lib/install-design-system-docs-route/templates.js b/plugins/adhd/lib/install-design-system-docs-route/templates.js index 35e5fad..6bde9b5 100644 --- a/plugins/adhd/lib/install-design-system-docs-route/templates.js +++ b/plugins/adhd/lib/install-design-system-docs-route/templates.js @@ -170,7 +170,7 @@ export default async function DesignSystemIndex() { {cfg.components.map(p => { const slug = p.replace(/\\.tsx?$/, "").replace(/\\/index$/, "").split("/").pop()?.toLowerCase() ?? p; return ( - +
{slug}
{p}
From fda97b4bd01173302cfd5c02be00af86a0f04df4 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 15:56:41 -0400 Subject: [PATCH 22/79] docs-route: sidebar+viewer layout, expanded token domains, Tailwind-default empty states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback on the v1 install in reactor-webapp: 1. "No colors/spacing detected" with a populated globals.css — the inline parser in the index page used /@theme\s*\{/ which didn't match the `@theme inline { ... }` modifier syntax. The lib's token-parser already handled it correctly via a brace-counted scan; the inline duplicate in the page template did not. Templates now share the brace-counted scan. 2. "Only five domains, but Tailwind v4 tracks far more." Token parser and pages now cover 12 domains: colors, spacing, typography, font, font-weight, tracking, leading, radius, shadows, breakpoints, easing, animation. `inset-shadow-*` and `drop-shadow-*` merge into the shadows bucket. Prefix-map order ensures longer prefixes (`font-weight-`) win over shorter ones (`font-`). 3. "Empty states should reference Tailwind defaults, not 'no X detected'." Each domain renders an empty state that links to the relevant Tailwind v4 theme docs page. 4. "Put the domains in a sidebar; viewer on the right." Layout is now a persistent sidebar (token domains + component list, both read at request time) and a main pane that renders the active sub-route: app/(group)//layout.design-system.tsx — sidebar app/(group)//page.design-system.tsx — landing app/(group)//tokens/[domain]/page.* — per-domain renderer app/(group)//components/[component]/page.* — component viewer app/(group)//PropToggle.tsx — client toggle 5. Re-installer now removes marker-bearing files left over from older template layouts (e.g. the old `[component]/page.*` directly under docsDir is removed when the new `components/[component]/page.*` replaces it), and prunes the now-empty parent directories. User-authored files without the marker are preserved. README and SKILL frontmatter updated to describe the new layout. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 18 +- .../__fixtures__/globals.css | 25 + .../__tests__/route-installer.test.js | 126 ++++- .../__tests__/templates.test.js | 40 +- .../__tests__/token-parser.test.js | 59 +- .../route-installer.js | 109 +++- .../templates.js | 526 +++++++++++++----- .../token-parser.js | 56 +- .../install-design-system-docs-route/SKILL.md | 2 +- 9 files changed, 737 insertions(+), 224 deletions(-) diff --git a/README.md b/README.md index 294b67d..36798ca 100644 --- a/README.md +++ b/README.md @@ -122,12 +122,18 @@ Run once in your consumer repo: 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. +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. By default the route is excluded from production builds via Next.js's `pageExtensions` trick — files use the `.design-system.tsx` extension and diff --git a/plugins/adhd/lib/install-design-system-docs-route/__fixtures__/globals.css b/plugins/adhd/lib/install-design-system-docs-route/__fixtures__/globals.css index df82b02..07d5006 100644 --- a/plugins/adhd/lib/install-design-system-docs-route/__fixtures__/globals.css +++ b/plugins/adhd/lib/install-design-system-docs-route/__fixtures__/globals.css @@ -17,6 +17,31 @@ --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/install-design-system-docs-route/__tests__/route-installer.test.js b/plugins/adhd/lib/install-design-system-docs-route/__tests__/route-installer.test.js index 88f3340..ee9807f 100644 --- a/plugins/adhd/lib/install-design-system-docs-route/__tests__/route-installer.test.js +++ b/plugins/adhd/lib/install-design-system-docs-route/__tests__/route-installer.test.js @@ -13,7 +13,7 @@ function makeTempProject() { return root; } -test('installRoute writes page/layout files with .design-system.tsx extension when prodExcluded, but PropToggle is always plain .tsx', () => { +test('installRoute writes page/layout files with .design-system.tsx suffix when prodExcluded, but PropToggle is always plain .tsx', () => { const root = makeTempProject(); installRoute(root, { groupName: '(design-system)', @@ -24,11 +24,10 @@ test('installRoute writes page/layout files with .design-system.tsx extension wh // Route files (page/layout) 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, '[component]', 'page.design-system.tsx'))); - // PropToggle is a module imported by the page; it doesn't need the suffix for - // prod-exclusion (the page that imports it IS suffix-excluded, so nothing - // references PropToggle in prod). Plain `.tsx` keeps `import "../PropToggle"` - // resolvable via standard TS module resolution. + 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'))); + // PropToggle is a module imported by the component page; it doesn't need the + // suffix for prod-exclusion (the page that imports it IS suffix-excluded). assert.ok(fs.existsSync(path.join(docsDir, 'PropToggle.tsx'))); assert.ok(!fs.existsSync(path.join(docsDir, 'PropToggle.design-system.tsx'))); }); @@ -43,7 +42,8 @@ test('installRoute writes plain .tsx files when not prodExcluded', () => { 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, 'tokens', '[domain]', 'page.tsx'))); + assert.ok(fs.existsSync(path.join(docsDir, 'components', '[component]', 'page.tsx'))); assert.ok(fs.existsSync(path.join(docsDir, 'PropToggle.tsx'))); }); @@ -54,7 +54,8 @@ test('all written files start with the marker comment', () => { for (const f of [ 'layout.design-system.tsx', 'page.design-system.tsx', - '[component]/page.design-system.tsx', + 'tokens/[domain]/page.design-system.tsx', + 'components/[component]/page.design-system.tsx', 'PropToggle.tsx', ]) { const content = fs.readFileSync(path.join(docsDir, f), 'utf8'); @@ -62,46 +63,87 @@ test('all written files start with the marker comment', () => { } }); -test('index page Link href is absolute, using the configured route URL', () => { - // Relative hrefs like `./` resolve incorrectly from `/-docs` (no trailing - // slash) — they go to `/` instead of `/-docs/`. The installer - // substitutes the route URL at install time so the link is absolute. +test('layout sidebar links use absolute hrefs derived from the route segment', () => { + // The sidebar lives in the layout, so its links must use absolute hrefs + // (`/-docs/tokens/colors`, not `./tokens/colors`) — otherwise nested routes + // resolve from the current pathname instead of the docs root. const root = makeTempProject(); installRoute(root, { groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true }); - const indexPage = fs.readFileSync( - path.join(root, 'app', '(design-system)', '-docs', 'page.design-system.tsx'), + const layout = fs.readFileSync( + path.join(root, 'app', '(design-system)', '-docs', 'layout.design-system.tsx'), 'utf8', ); - assert.match(indexPage, /href=\{`\/-docs\/\$\{slug\}`\}/); + assert.match(layout, /href=\{`\/-docs\/tokens\/\$\{d\.slug\}`\}/); + assert.match(layout, /href=\{`\/-docs\/components\/\$\{c\.slug\}`\}/); // The placeholder should be fully substituted — no `__ROUTE_PATH__` left over. - assert.doesNotMatch(indexPage, /__ROUTE_PATH__/); + assert.doesNotMatch(layout, /__ROUTE_PATH__/); }); -test('index page Link href substitution honors a custom route segment', () => { +test('route URL substitution honors a custom route segment', () => { const root = makeTempProject(); installRoute(root, { groupName: '(design-system)', routeSegment: 'design-system', prodExcluded: true }); - const indexPage = fs.readFileSync( - path.join(root, 'app', '(design-system)', 'design-system', 'page.design-system.tsx'), + const layout = fs.readFileSync( + path.join(root, 'app', '(design-system)', 'design-system', 'layout.design-system.tsx'), 'utf8', ); - assert.match(indexPage, /href=\{`\/design-system\/\$\{slug\}`\}/); + assert.match(layout, /href=\{`\/design-system\/tokens\/\$\{d\.slug\}`\}/); }); -test('COMPONENT_PAGE_TSX imports PropToggle from "../PropToggle" (resolves to the plain .tsx file)', () => { +test('COMPONENT_PAGE_TSX imports PropToggle from "../../PropToggle" (now two levels deep)', () => { const root = makeTempProject(); installRoute(root, { groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true }); const componentPage = fs.readFileSync( - path.join(root, 'app', '(design-system)', '-docs', '[component]', 'page.design-system.tsx'), + path.join(root, 'app', '(design-system)', '-docs', 'components', '[component]', 'page.design-system.tsx'), 'utf8', ); - assert.match(componentPage, /from "\.\.\/PropToggle"/); + assert.match(componentPage, /from "\.\.\/\.\.\/PropToggle"/); +}); + +test('TOKENS_PAGE_TSX uses parser that handles `@theme inline { ... }` modifier syntax', () => { + const root = makeTempProject(); + installRoute(root, { groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true }); + const tokensPage = fs.readFileSync( + path.join(root, 'app', '(design-system)', '-docs', 'tokens', '[domain]', 'page.design-system.tsx'), + 'utf8', + ); + // The inline parser must use the brace-counted scan (NOT the old + // `/@theme\s*\{...\}/` regex that misses `@theme inline { ... }`). + assert.match(tokensPage, /extractThemeBodies/); + // No naïve `@theme\s*\{` regex anywhere. + assert.doesNotMatch(tokensPage, /@theme\\s\*\\\{/); +}); + +test('TOKENS_PAGE_TSX renders all expected token domains', () => { + const root = makeTempProject(); + installRoute(root, { groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true }); + const tokensPage = fs.readFileSync( + path.join(root, 'app', '(design-system)', '-docs', 'tokens', '[domain]', 'page.design-system.tsx'), + 'utf8', + ); + for (const slug of ['colors', 'spacing', 'typography', 'font', 'font-weight', + 'tracking', 'leading', 'radius', 'shadows', 'breakpoint', + 'ease', 'animate']) { + assert.match(tokensPage, new RegExp(`slug === "${slug}"`), `missing renderer for ${slug}`); + } +}); + +test('empty-state messaging references Tailwind defaults, not "no X detected"', () => { + const root = makeTempProject(); + installRoute(root, { groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true }); + const tokensPage = fs.readFileSync( + path.join(root, 'app', '(design-system)', '-docs', 'tokens', '[domain]', 'page.design-system.tsx'), + 'utf8', + ); + assert.match(tokensPage, /Tailwind v4 ships sensible defaults/); + // The misleading "No X detected" phrasing is gone. + assert.doesNotMatch(tokensPage, /No (colors|typography|radius|shadow) (tokens? )?detected/); }); 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.length >= 5); assert.ok(found.every(p => p.includes('-docs'))); }); @@ -117,23 +159,53 @@ test('detectExistingInstall does not match unrelated files', () => { assert.deepEqual(detectExistingInstall(root), []); }); -test('re-running installRoute is safe (overwrites files cleanly)', () => { +test('re-running installRoute 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 removes stale marker-bearing files from a previous install layout', () => { + // Simulate an older install where the component page lived at `[component]/page.*` + // directly under docsDir (the structure before the tokens/[domain] + components/[component] split). + const root = makeTempProject(); + const docsDir = path.join(root, 'app', '(design-system)', '-docs'); + const oldComponentDir = path.join(docsDir, '[component]'); + fs.mkdirSync(oldComponentDir, { recursive: true }); + const oldPath = path.join(oldComponentDir, 'page.design-system.tsx'); + fs.writeFileSync(oldPath, '// design-system-docs-route — stale\nexport default function Old() { return null; }\n'); + + const result = installRoute(root, { groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true }); + + // Stale file removed, reported in `removed`, and its now-empty parent dir pruned. + assert.ok(!fs.existsSync(oldPath), 'stale file should be deleted'); + assert.ok(result.removed.includes(oldPath)); + assert.ok(!fs.existsSync(oldComponentDir), 'empty `[component]` directory should be pruned'); +}); + +test('installRoute does NOT delete unrelated files (only marker-bearing ones)', () => { + const root = makeTempProject(); + const docsDir = path.join(root, 'app', '(design-system)', '-docs'); + // Pre-install a user-authored file under docsDir without the marker. + fs.mkdirSync(docsDir, { recursive: true }); + const userFile = path.join(docsDir, 'user-notes.tsx'); + fs.writeFileSync(userFile, '// user wrote this\nexport const NOTE = "keep me";\n'); + + installRoute(root, { groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true }); + + assert.ok(fs.existsSync(userFile), 'user file without marker must be preserved'); +}); + 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'))); assert.ok(fs.existsSync(path.join(docsDir, 'PropToggle.tsx'))); + assert.ok(fs.existsSync(path.join(docsDir, 'tokens', '[domain]', 'page.design-system.tsx'))); }); diff --git a/plugins/adhd/lib/install-design-system-docs-route/__tests__/templates.test.js b/plugins/adhd/lib/install-design-system-docs-route/__tests__/templates.test.js index cbd7749..d1bf90a 100644 --- a/plugins/adhd/lib/install-design-system-docs-route/__tests__/templates.test.js +++ b/plugins/adhd/lib/install-design-system-docs-route/__tests__/templates.test.js @@ -2,7 +2,7 @@ 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'); +const { MARKER_COMMENT, LAYOUT_TSX, INDEX_PAGE_TSX, TOKENS_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/); @@ -19,20 +19,40 @@ test('LAYOUT_TSX sets robots: noindex / nofollow', () => { }); test('LAYOUT_TSX has no ADHD references outside marker', () => { - // marker excluded - const body = LAYOUT_TSX.replace(MARKER_COMMENT, ''); + // marker excluded — the filename `adhd.config.ts` is the spec-allowed exception + // (the layout reads it to populate the Components sidebar list). + const body = LAYOUT_TSX.replace(MARKER_COMMENT, '').replace(/adhd\.config\.ts/g, ''); 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('LAYOUT_TSX renders sidebar nav linking every token domain', () => { + // The sidebar replaces the old single-page sections — every token domain + // gets its own entry in the layout's nav. + 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), `missing sidebar label: ${label}`); } }); -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('LAYOUT_TSX reads adhd.config.ts to populate the Components sidebar list', () => { + // The sidebar lists tracked components below the token domains — the layout + // must read adhd.config.ts at request time to know what to show. + assert.match(LAYOUT_TSX, /adhd\.config\.ts/); + assert.match(LAYOUT_TSX, /Components/); +}); + +test('INDEX_PAGE_TSX is a minimal landing page (sections moved to TOKENS_PAGE_TSX)', () => { + // The landing page now just welcomes the user; per-domain renderers live in + // the tokens page that the sidebar links to. + assert.match(INDEX_PAGE_TSX, /Design System/); + assert.match(INDEX_PAGE_TSX, /Pick a token domain|Pick a/); +}); + +test('TOKENS_PAGE_TSX reads globals.css to render tokens at request time', () => { + assert.match(TOKENS_PAGE_TSX, /globals\.css|cssEntry/); + assert.match(TOKENS_PAGE_TSX, /parseTokens/); }); test('COMPONENT_PAGE_TSX uses parametric template-string dynamic import', () => { @@ -60,7 +80,7 @@ test('none of the templates contain "ADHD" outside the marker', () => { // (per the install spec) and is not a reference to the ADHD plugin/brand — // it's an unavoidable filename the generated pages must read at runtime. // Strip it before applying the "no ADHD references" rule. - for (const [name, content] of Object.entries({ LAYOUT_TSX, INDEX_PAGE_TSX, COMPONENT_PAGE_TSX, PROP_TOGGLE_TSX })) { + for (const [name, content] of Object.entries({ LAYOUT_TSX, INDEX_PAGE_TSX, TOKENS_PAGE_TSX, COMPONENT_PAGE_TSX, PROP_TOGGLE_TSX })) { const body = content.replace(MARKER_COMMENT, '').replace(/adhd\.config\.ts/g, ''); assert.equal(/adhd/i.test(body), false, `${name} must not reference ADHD outside marker`); } diff --git a/plugins/adhd/lib/install-design-system-docs-route/__tests__/token-parser.test.js b/plugins/adhd/lib/install-design-system-docs-route/__tests__/token-parser.test.js index 7423f5c..bdb442e 100644 --- a/plugins/adhd/lib/install-design-system-docs-route/__tests__/token-parser.test.js +++ b/plugins/adhd/lib/install-design-system-docs-route/__tests__/token-parser.test.js @@ -56,9 +56,59 @@ test('extracts shadow tokens', () => { ); }); +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 === '--font-sans')); + 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', () => { @@ -67,6 +117,13 @@ test('returns empty domains when no @theme block exists', () => { 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); }); diff --git a/plugins/adhd/lib/install-design-system-docs-route/route-installer.js b/plugins/adhd/lib/install-design-system-docs-route/route-installer.js index e36f52d..1c1a538 100644 --- a/plugins/adhd/lib/install-design-system-docs-route/route-installer.js +++ b/plugins/adhd/lib/install-design-system-docs-route/route-installer.js @@ -2,7 +2,15 @@ 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'); +const { + LAYOUT_TSX, + INDEX_PAGE_TSX, + TOKENS_PAGE_TSX, + COMPONENT_PAGE_TSX, + PROP_TOGGLE_TSX, +} = require('./templates'); + +const MARKER_STR = 'design-system-docs-route'; function mkdirpSync(p) { fs.mkdirSync(p, { recursive: true }); @@ -18,39 +26,102 @@ function installRoute(projectRoot, opts) { // PropToggle is a regular module (not a route file by name), so it doesn't need // the `.design-system` suffix to be excluded — it's only bundled if its importing // page is in the build, and the page IS suffix-excluded. Using a plain `.tsx` - // keeps the `import "../PropToggle"` in COMPONENT_PAGE_TSX resolvable. + // keeps the `import "../../PropToggle"` in COMPONENT_PAGE_TSX resolvable. const moduleExt = '.tsx'; const segments = ['app']; if (groupName) segments.push(groupName); segments.push(routeSegment); const docsDir = path.join(projectRoot, ...segments); - const componentDir = path.join(docsDir, '[component]'); + const tokensDir = path.join(docsDir, 'tokens', '[domain]'); + const componentsDir = path.join(docsDir, 'components', '[component]'); // The runtime route URL (route groups like `(design-system)` are invisible in URLs, // so the URL is just `/`). Templates use `__ROUTE_PATH__` as a - // placeholder for this — relative hrefs like `./` would resolve incorrectly - // when the current path is `/` without a trailing slash. + // placeholder so absolute hrefs in the sidebar/landing resolve correctly. const routeUrl = '/' + routeSegment; - const indexBody = INDEX_PAGE_TSX.replace(/__ROUTE_PATH__/g, routeUrl); - mkdirpSync(docsDir); - mkdirpSync(componentDir); + // Files we're about to write. Anything else with our marker comment under + // `docsDir` is leftover from a previous installer version and gets removed + // below — that's how re-installs pick up structural changes (e.g. moving + // `[component]/` to `components/[component]/`). + 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, `PropToggle${moduleExt}`), body: PROP_TOGGLE_TSX }, + ]; + + // Substitute the `__ROUTE_PATH__` placeholder in every body that needs it + // (the layout sidebar links and the landing-page references). It's a no-op + // for bodies that don't contain the placeholder. + for (const t of targets) { + t.body = t.body.replace(/__ROUTE_PATH__/g, routeUrl); + } + + // Remove old marker-bearing files that aren't in the new target set. This + // lets users re-run the installer after structural changes (e.g. older + // versions put the component page at `[component]/page.*` directly under + // docsDir; new versions put it under `components/[component]/page.*`). + const targetSet = new Set(targets.map(t => t.abs)); + const removed = removeStaleMarkerFiles(docsDir, targetSet); + + // Now write the new files. Directories are created on demand. + for (const t of targets) { + mkdirpSync(path.dirname(t.abs)); + fs.writeFileSync(t.abs, t.body); + } - fs.writeFileSync(path.join(docsDir, `layout${pageExt}`), LAYOUT_TSX); - fs.writeFileSync(path.join(docsDir, `page${pageExt}`), indexBody); - fs.writeFileSync(path.join(componentDir, `page${pageExt}`), COMPONENT_PAGE_TSX); - fs.writeFileSync(path.join(docsDir, `PropToggle${moduleExt}`), PROP_TOGGLE_TSX); + // Best-effort cleanup of now-empty directories left behind by removed files + // (e.g. the old `app/.../-docs/[component]/` directory). + pruneEmptyDirs(docsDir); return { - files: [ - path.join(docsDir, `layout${pageExt}`), - path.join(docsDir, `page${pageExt}`), - path.join(componentDir, `page${pageExt}`), - path.join(docsDir, `PropToggle${moduleExt}`), - ], + files: targets.map(t => t.abs), + removed, }; } +// Walk `docsDir`, find every `.tsx` file containing the marker comment, and +// delete the ones that aren't in `keep`. Returns the list of removed paths. +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; +} + +// Recursively delete empty directories under (and including) `dir`. Skips `dir` +// itself if it's non-empty after the recursion. +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) { @@ -64,7 +135,7 @@ function detectExistingInstall(projectRoot) { else if (/\.tsx?$/.test(ent.name)) { try { const content = fs.readFileSync(full, 'utf8'); - if (content.includes('design-system-docs-route')) { + if (content.includes(MARKER_STR)) { found.push(full); } } catch {} diff --git a/plugins/adhd/lib/install-design-system-docs-route/templates.js b/plugins/adhd/lib/install-design-system-docs-route/templates.js index 6bde9b5..850dd9d 100644 --- a/plugins/adhd/lib/install-design-system-docs-route/templates.js +++ b/plugins/adhd/lib/install-design-system-docs-route/templates.js @@ -4,211 +4,444 @@ const MARKER_COMMENT = `// design-system-docs-route — auto-generated installer // 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}
-
- ); +// The list of token domains is shared verbatim between the sidebar (layout) and +// the token page (so the page can look up the right renderer by slug). Both +// copies use the same source string here, embedded into the templates below. +// The `tailwindDocs` field is the URL to Tailwind v4's relevant theme section, +// used in empty-state messaging. +const TOKEN_DOMAINS_SRC = `const TOKEN_DOMAINS = [ + { 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" }, +];`; + +// The CSS @theme parser shared between landing/tokens pages. Kept inline (not +// imported from the lib) because these are runtime server components in the +// consumer's app, with no access to ADHD's node_modules. Mirrors token-parser.js +// but flattened for inline use. +// - Brace-counted scan supports `@theme { ... }` AND `@theme inline { ... }` +// - Prefix order matters: longer prefixes (`font-weight-`) before shorter (`font-`). +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; } -`; -const INDEX_PAGE_TSX = `${MARKER_COMMENT}import fs from "node:fs/promises"; -import path from "node:path"; -import Link from "next/link"; +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"; + // Order matters: longer prefixes first. + 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; +}`; -async function readConfig() { +const READ_CONFIG_SRC = `async function readConfig() { try { const src = await fs.readFile(path.resolve(process.cwd(), "adhd.config.ts"), "utf8"); - const components: Record = {}; + 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[m[1]] = true; - } + while ((m = re.exec(inner)) !== null) components.push(m[1]); } const cssEntryMatch = /cssEntry\\s*:\\s*"([^"]+)"/.exec(src); const cssEntry = cssEntryMatch ? cssEntryMatch[1] : "app/globals.css"; - return { components: Object.keys(components), cssEntry }; + return { components, cssEntry }; } catch { - return { components: [], cssEntry: "app/globals.css" }; + return { components: [] as string[], cssEntry: "app/globals.css" }; } } +function slugFor(p: string) { + return p.replace(/\\.tsx?$/, "").replace(/\\/index$/, "").split("/").pop()?.toLowerCase() ?? p; +}`; + +// Layout: sidebar lists token domains + components; main area renders children. +// The layout is async so it can read adhd.config.ts to populate the components list. +const LAYOUT_TSX = `${MARKER_COMMENT}import type { Metadata } from "next"; +import fs from "node:fs/promises"; +import path from "node:path"; +import Link from "next/link"; + +export const metadata: Metadata = { + title: "Design System Docs", + robots: { index: false, follow: false }, +}; + +${TOKEN_DOMAINS_SRC} + +${READ_CONFIG_SRC} + +export default async function DesignSystemDocsLayout({ children }: { children: React.ReactNode }) { + const cfg = await readConfig(); + const components = cfg.components.map(p => ({ raw: p, slug: slugFor(p) })); + + return ( +
+ + +
+
{children}
+
+
+ ); +} +`; + +// Landing page — minimal welcome message; the sidebar already shows everything. +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 from your + globals.css + @theme blocks. + Components are loaded from + adhd.config.ts. +

+
+ ); +} +`; + +// Tokens domain page — one route, one renderer per domain. Reads the consumer's +// globals.css at request time and renders whatever's declared. Empty states +// reference Tailwind v4's defaults rather than implying the system is broken. +const TOKENS_PAGE_TSX = `${MARKER_COMMENT}import fs from "node:fs/promises"; +import path from "node:path"; +import { notFound } from "next/navigation"; + +${TOKEN_DOMAINS_SRC} + +${READ_CONFIG_SRC} + async function readCss(cssEntry: string) { - try { - return await fs.readFile(path.resolve(process.cwd(), cssEntry), "utf8"); - } catch { - return null; - } + 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; +${PARSE_TOKENS_SRC} + +function EmptyState({ domain }: { domain: typeof TOKEN_DOMAINS[number] }) { + return ( +
+ No custom {domain.varPrefix}* tokens declared in your @theme. + Tailwind v4 ships sensible defaults — see the {domain.label} docs. +
+ ); } -export default async function DesignSystemIndex() { +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 cfg = await readConfig(); const css = await readCss(cfg.cssEntry); - const tokens = extractTokens(css); + const tokens = parseTokens(css); return ( -
-
-

Colors

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

No colors detected.

: ( -
+
+
+

{domain.label}

+

Variables prefixed with {domain.varPrefix}

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

Spacing

- {tokens.spacing.multiplier ?

Multiplier: {tokens.spacing.multiplier}

:

No spacing variable detected.

} -
+ {slug === "spacing" && ( + tokens.spacing.multiplier == null ? : ( +
+

Multiplier: {tokens.spacing.multiplier}

+

Tailwind v4 derives all spacing utilities from this single variable.

+
+ ) + )} -
-

Typography

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

No typography tokens detected.

: ( + {slug === "typography" && ( + tokens.typography.length === 0 ? : (
{tokens.typography.map(t => ( -
- text-{t.name} +
+ text-{t.name} The quick brown fox jumps over the lazy dog - {t.size}{t.lineHeight ? \` / \${t.lineHeight}\` : ""} + {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} +
+ ))} +
+ ) + )} -
-

Radius

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

No radius tokens detected.

: ( -
+ {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}
))}
- )} -
+ ) + )} -
-

Shadows

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

No shadow tokens detected.

: ( -
- {tokens.shadows.map(s => ( -
-
+ {slug === "shadows" && ( + tokens.shadows.length === 0 ? : ( +
+ {tokens.shadows.map((s, i) => ( +
+
shadow-{s.name} + {s.value}
))}
- )} -
+ ) + )} -
-

Components

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

No components tracked yet.

: ( -
- {cfg.components.map(p => { - const slug = p.replace(/\\.tsx?$/, "").replace(/\\/index$/, "").split("/").pop()?.toLowerCase() ?? p; - return ( - -
{slug}
-
{p}
- - ); - })} + {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 — moved to /components/[component]. Two levels deep, so the +// PropToggle import is now `../../PropToggle`. 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"; +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; -} +${READ_CONFIG_SRC} async function parseProps(componentPath: string) { try { @@ -258,8 +491,8 @@ export default async function ComponentPage({ }) { const { component: slug } = await params; const sp = await searchParams; - const paths = await readConfig(); - const componentPath = paths.find(p => slugFor(p) === slug); + const cfg = await readConfig(); + const componentPath = cfg.components.find(p => slugFor(p) === slug); if (!componentPath) notFound(); const { props } = await parseProps(componentPath); @@ -294,7 +527,7 @@ export default async function ComponentPage({ return (
-

{slug}

+

{slug}

Props

@@ -383,4 +616,11 @@ export function PropToggle(p: Props) { } `; -module.exports = { MARKER_COMMENT, LAYOUT_TSX, INDEX_PAGE_TSX, COMPONENT_PAGE_TSX, PROP_TOGGLE_TSX }; +module.exports = { + MARKER_COMMENT, + LAYOUT_TSX, + INDEX_PAGE_TSX, + TOKENS_PAGE_TSX, + COMPONENT_PAGE_TSX, + PROP_TOGGLE_TSX, +}; diff --git a/plugins/adhd/lib/install-design-system-docs-route/token-parser.js b/plugins/adhd/lib/install-design-system-docs-route/token-parser.js index 358e59b..6ec4851 100644 --- a/plugins/adhd/lib/install-design-system-docs-route/token-parser.js +++ b/plugins/adhd/lib/install-design-system-docs-route/token-parser.js @@ -43,13 +43,29 @@ const DECL_RE = /--([a-zA-Z0-9_-]+)\s*:\s*([^;]+);/g; // 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.startsWith('color-')) { - return { domain: 'colors', leaf: name.slice('color-'.length) }; - } 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)) { @@ -61,11 +77,10 @@ function classify(name) { } 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) }; + for (const [prefix, domain] of PREFIX_MAP) { + if (name.startsWith(prefix)) { + return { domain, leaf: name.slice(prefix.length) }; + } } return { domain: 'unknown' }; } @@ -75,8 +90,15 @@ function parseTokens(globalsCss) { 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 @@ -103,23 +125,23 @@ function parseTokens(globalsCss) { const value = m[2].trim(); const cls = classify(name); switch (cls.domain) { - case 'colors': - out.colors.push({ name: cls.leaf, value }); - break; case 'spacing': out.spacing.multiplier = value; break; case 'typography': upsertTypography(cls.leaf, cls.kind, value); break; - case 'radius': - out.radius.push({ name: cls.leaf, value }); - break; - case 'shadows': - out.shadows.push({ name: cls.leaf, value }); + case 'unknown': + out.unknown.push({ name: '--' + name, value }); break; default: - out.unknown.push({ name: '--' + name, value }); + // 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 }); + } } } } diff --git a/plugins/adhd/skills/install-design-system-docs-route/SKILL.md b/plugins/adhd/skills/install-design-system-docs-route/SKILL.md index 2774ce3..9add183 100644 --- a/plugins/adhd/skills/install-design-system-docs-route/SKILL.md +++ b/plugins/adhd/skills/install-design-system-docs-route/SKILL.md @@ -1,5 +1,5 @@ --- -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." +description: "Install a self-generating design-system documentation route into a Next.js consumer app. Sidebar + viewer layout: the 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. All token domains and components are read from globals.css and adhd.config.ts at request time. Component pages introspect props for URL-driven toggles. Optionally excluded from production builds via Next.js pageExtensions trick. Re-runnable: marker-comment detection drives updates, and stale marker-bearing files from older template layouts are cleaned up." disable-model-invocation: true argument-hint: "" allowed-tools: Read Write Edit Bash AskUserQuestion From 1d41637d54512a286cee1c6dc2ade775479d93a2 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 16:34:29 -0400 Subject: [PATCH 23/79] docs-route: diagnose globals.css gaps + error boundary at component route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why this matters: The component page uses a broad dynamic import (`import("@/" + path)`) so adding to adhd.config.ts is enough to add components. The cost is that Webpack/Turbopack can't statically resolve the path, so it creates a context module that pulls every file under `@/` into the route's bundle. Tailwind v4 then scans all of those files — a much wider surface than the consumer's normal routes — and surfaces latent v3-to-v4 migration gaps in their globals.css (e.g. shadcn's `ring-offset-background` not in @theme). The result is "Cannot apply unknown utility class ..." during CSS compile, which prevents the build manifest from being written, which surfaces as an opaque "ENOENT: app-build-manifest.json" in the browser. We can't catch that error at React level — it happens before render. But we can make the failure mode self-explanatory: 1. Layout pre-scans globals.css. If the @theme block looks shadcn-shaped (defines `--color-foreground`, `--color-background`, plus at least one `*-foreground` pair) AND `--color-ring-offset-background` is missing, render a diagnostic banner above the main pane with the exact @theme/:root lines to add. 2. Add `components/[component]/error.tsx` — a client error boundary that catches runtime failures (broken dynamic imports, components that throw on mount, etc.). It detects whether the error.message mentions the build-manifest path and gives a focused note pointing back at the diagnostic banner. 3. Landing page (`/`) now has a Troubleshooting section with three expandable items, including a detailed walkthrough of the dynamic import / Tailwind interaction and how to debug it from the dev log. The shared `readCss` helper and the new `DETECT_ISSUES_SRC` / `READ_CSS_SRC` snippets are factored out of the template literals so the layout and tokens page both use the same brace-counted @theme scanner (which correctly handles `@theme inline { ... }`). Tested detection against the real reactor-webapp globals.css — fires correctly on missing --color-ring-offset-background. README updated to document the trade-off. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 18 ++ .../__tests__/route-installer.test.js | 7 +- .../__tests__/templates.test.js | 47 +++- .../route-installer.js | 5 + .../templates.js | 204 ++++++++++++++++-- 5 files changed, 261 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 36798ca..948e4a8 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,24 @@ 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. +#### Caveat: broad dynamic import + Tailwind v4 + +The component page resolves its target via `import("@/" + componentPath)` so +adding to `adhd.config.ts` is enough — no re-install per component. The +trade-off: Webpack/Turbopack can't statically resolve the path, so it +creates a context module that pulls every `.ts`/`.tsx` under your project +root into this route's bundle. Tailwind v4 then scans all of them for +classes — a much wider surface than your other routes touch. + +If your codebase has shadcn-v3-era classes that you never migrated (most +commonly `ring-offset-background`, used by Button/Input focus styles), +they'll surface as `Cannot apply unknown utility class …` errors during +route compilation, and the page will 500 with an ENOENT on +`app-build-manifest.json`. The layout pre-scans your `globals.css` for the +shadcn shibboleth and shows a diagnostic banner with the exact `@theme` +addition you need to make. There's also an `error.tsx` at the route boundary +for any runtime failures, and a Troubleshooting section on the landing page. + You can also trigger the install at the end of `/adhd:config` if you're setting up ADHD for the first time. diff --git a/plugins/adhd/lib/install-design-system-docs-route/__tests__/route-installer.test.js b/plugins/adhd/lib/install-design-system-docs-route/__tests__/route-installer.test.js index ee9807f..7c6ab24 100644 --- a/plugins/adhd/lib/install-design-system-docs-route/__tests__/route-installer.test.js +++ b/plugins/adhd/lib/install-design-system-docs-route/__tests__/route-installer.test.js @@ -21,11 +21,12 @@ test('installRoute writes page/layout files with .design-system.tsx suffix when prodExcluded: true, }); const docsDir = path.join(root, 'app', '(design-system)', '-docs'); - // Route files (page/layout) get the suffix so pageExtensions filters them in prod. + // Route files (page/layout/error) 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'))); + assert.ok(fs.existsSync(path.join(docsDir, 'components', '[component]', 'error.design-system.tsx'))); // PropToggle is a module imported by the component page; it doesn't need the // suffix for prod-exclusion (the page that imports it IS suffix-excluded). assert.ok(fs.existsSync(path.join(docsDir, 'PropToggle.tsx'))); @@ -44,6 +45,7 @@ test('installRoute writes plain .tsx files when not prodExcluded', () => { 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, 'components', '[component]', 'error.tsx'))); assert.ok(fs.existsSync(path.join(docsDir, 'PropToggle.tsx'))); }); @@ -56,6 +58,7 @@ test('all written files start with the marker comment', () => { 'page.design-system.tsx', 'tokens/[domain]/page.design-system.tsx', 'components/[component]/page.design-system.tsx', + 'components/[component]/error.design-system.tsx', 'PropToggle.tsx', ]) { const content = fs.readFileSync(path.join(docsDir, f), 'utf8'); @@ -143,7 +146,7 @@ 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 >= 5); + assert.ok(found.length >= 6); assert.ok(found.every(p => p.includes('-docs'))); }); diff --git a/plugins/adhd/lib/install-design-system-docs-route/__tests__/templates.test.js b/plugins/adhd/lib/install-design-system-docs-route/__tests__/templates.test.js index d1bf90a..57ddae5 100644 --- a/plugins/adhd/lib/install-design-system-docs-route/__tests__/templates.test.js +++ b/plugins/adhd/lib/install-design-system-docs-route/__tests__/templates.test.js @@ -2,7 +2,7 @@ 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, PROP_TOGGLE_TSX } = require('../templates'); +const { MARKER_COMMENT, LAYOUT_TSX, INDEX_PAGE_TSX, TOKENS_PAGE_TSX, COMPONENT_PAGE_TSX, COMPONENT_ERROR_TSX, PROP_TOGGLE_TSX } = require('../templates'); test('MARKER_COMMENT is a stable, non-ADHD-referencing string', () => { assert.match(MARKER_COMMENT, /design-system-docs-route/); @@ -75,12 +75,55 @@ test('PROP_TOGGLE_TSX uses router.replace for snappy URL updates', () => { assert.match(PROP_TOGGLE_TSX, /router\.replace/); }); +test('LAYOUT_TSX renders a DiagnosticBanner that surfaces detected globals.css issues', () => { + // The layout's whole reason for reading globals.css is to surface diagnostic issues + // pre-emptively. The banner component must be in the template and wired into render. + assert.match(LAYOUT_TSX, /DiagnosticBanner/); + assert.match(LAYOUT_TSX, //); +}); + +test('LAYOUT_TSX detection flags missing --color-ring-offset-background on shadcn-like themes', () => { + // The detection heuristic should mention the ring-offset-background token explicitly + // (the most common shadcn v3-to-v4 migration gap that surfaces on broad dynamic imports). + assert.match(LAYOUT_TSX, /--color-ring-offset-background/); + assert.match(LAYOUT_TSX, /looksShadcn/); +}); + +test('LAYOUT_TSX gracefully handles a missing globals.css (detectIssues returns [])', () => { + // detectIssues must return [] when css is null — the layout still needs to render + // for projects without globals.css. + assert.match(LAYOUT_TSX, /if \(!css\) return issues;/); +}); + +test('INDEX_PAGE_TSX has a Troubleshooting section explaining the ENOENT failure mode', () => { + assert.match(INDEX_PAGE_TSX, /Troubleshooting/); + assert.match(INDEX_PAGE_TSX, /app-build-manifest/); + assert.match(INDEX_PAGE_TSX, /broad dynamic import/i); +}); + +test('COMPONENT_ERROR_TSX is a client component error boundary', () => { + const afterMarker = COMPONENT_ERROR_TSX.replace(MARKER_COMMENT, ''); + assert.match(afterMarker, /^["']use client["']/); + // The error boundary props signature Next.js passes in + assert.match(COMPONENT_ERROR_TSX, /error.*reset/); + // Re-render mechanism + assert.match(COMPONENT_ERROR_TSX, /reset\(\)/); +}); + +test('COMPONENT_ERROR_TSX distinguishes the build-manifest ENOENT from other runtime errors', () => { + // The boundary inspects the error message and shows a focused note for the + // bundler-level ENOENT (which it technically can't recover, but the user + // should be told what's happening if it bubbles up). + assert.match(COMPONENT_ERROR_TSX, /app-build-manifest/); + assert.match(COMPONENT_ERROR_TSX, /isBuildManifestError/); +}); + test('none of the templates contain "ADHD" outside the marker', () => { // The literal filename `adhd.config.ts` is the consumer's own config artifact // (per the install spec) and is not a reference to the ADHD plugin/brand — // it's an unavoidable filename the generated pages must read at runtime. // Strip it before applying the "no ADHD references" rule. - for (const [name, content] of Object.entries({ LAYOUT_TSX, INDEX_PAGE_TSX, TOKENS_PAGE_TSX, COMPONENT_PAGE_TSX, PROP_TOGGLE_TSX })) { + for (const [name, content] of Object.entries({ LAYOUT_TSX, INDEX_PAGE_TSX, TOKENS_PAGE_TSX, COMPONENT_PAGE_TSX, COMPONENT_ERROR_TSX, PROP_TOGGLE_TSX })) { const body = content.replace(MARKER_COMMENT, '').replace(/adhd\.config\.ts/g, ''); assert.equal(/adhd/i.test(body), false, `${name} must not reference ADHD outside marker`); } diff --git a/plugins/adhd/lib/install-design-system-docs-route/route-installer.js b/plugins/adhd/lib/install-design-system-docs-route/route-installer.js index 1c1a538..e922dc7 100644 --- a/plugins/adhd/lib/install-design-system-docs-route/route-installer.js +++ b/plugins/adhd/lib/install-design-system-docs-route/route-installer.js @@ -7,6 +7,7 @@ const { INDEX_PAGE_TSX, TOKENS_PAGE_TSX, COMPONENT_PAGE_TSX, + COMPONENT_ERROR_TSX, PROP_TOGGLE_TSX, } = require('./templates'); @@ -49,6 +50,10 @@ function installRoute(projectRoot, opts) { { 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 }, + // error.tsx must be a client component, and Next.js handles it like any + // route file — it goes through pageExtensions. The plain `.tsx` variant + // is used when prod-exclusion is off (mirrors layout/page). + { abs: path.join(componentsDir, `error${pageExt}`), body: COMPONENT_ERROR_TSX }, { abs: path.join(docsDir, `PropToggle${moduleExt}`), body: PROP_TOGGLE_TSX }, ]; diff --git a/plugins/adhd/lib/install-design-system-docs-route/templates.js b/plugins/adhd/lib/install-design-system-docs-route/templates.js index 850dd9d..f957371 100644 --- a/plugins/adhd/lib/install-design-system-docs-route/templates.js +++ b/plugins/adhd/lib/install-design-system-docs-route/templates.js @@ -138,8 +138,67 @@ function slugFor(p: string) { return p.replace(/\\.tsx?$/, "").replace(/\\/index$/, "").split("/").pop()?.toLowerCase() ?? p; }`; +// Shared globals.css reader. Returns the file contents or null if missing. +const READ_CSS_SRC = `async function readCss(cssEntry: string) { + try { return await fs.readFile(path.resolve(process.cwd(), cssEntry), "utf8"); } + catch { return null; } +}`; + +// Diagnostic banner detection: scans the consumer's @theme block for token-name +// shibboleths that indicate a shadcn-style v3-to-v4 migration, and flags common +// tokens that are missing. The dynamic-import pattern used by the component page +// pulls in more files than the consumer's normal routes do, which surfaces stale +// `@apply` directives in transitively-bundled CSS — these missing tokens are the +// usual cause. +const DETECT_ISSUES_SRC = `type DetectedIssue = { + token: string; + why: string; + themeLine: string; + rootLine?: string; +}; + +function detectIssues(css: string | null): DetectedIssue[] { + const issues: DetectedIssue[] = []; + if (!css) return issues; + // Extract bodies of every @theme block (supports \`@theme inline\` modifier). + 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, 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; + } + const themeText = bodies.join("\\n"); + const has = (token: string) => new RegExp("(?:^|\\\\s)" + token.replace(/[-]/g, "\\\\-") + "\\\\s*:").test(themeText); + + // Shadcn shibboleth: foreground+background plus at least one *-foreground pair. + const looksShadcn = has("--color-foreground") && has("--color-background") && + (has("--color-card-foreground") || has("--color-popover-foreground")); + + if (looksShadcn && !has("--color-ring-offset-background")) { + issues.push({ + token: "--color-ring-offset-background", + why: "Shadcn components use \`ring-offset-background\` for focus styles. Without this in @theme, any \`@apply ring-offset-background\` in transitively-bundled CSS (from a UI library or stale components in your project) will fail with \\"Cannot apply unknown utility class ring-offset-background\\" during route compilation, and the component page will 500 with an ENOENT on the build manifest.", + themeLine: "--color-ring-offset-background: hsl(var(--ring-offset-background));", + rootLine: "--ring-offset-background: 0 0% 100%;", + }); + } + + return issues; +}`; + // Layout: sidebar lists token domains + components; main area renders children. -// The layout is async so it can read adhd.config.ts to populate the components list. +// The layout is async so it can read adhd.config.ts and globals.css for diagnostics. const LAYOUT_TSX = `${MARKER_COMMENT}import type { Metadata } from "next"; import fs from "node:fs/promises"; import path from "node:path"; @@ -154,8 +213,37 @@ ${TOKEN_DOMAINS_SRC} ${READ_CONFIG_SRC} +${READ_CSS_SRC} + +${DETECT_ISSUES_SRC} + +function DiagnosticBanner({ issues }: { issues: DetectedIssue[] }) { + if (issues.length === 0) return null; + return ( + + ); +} + export default async function DesignSystemDocsLayout({ children }: { children: React.ReactNode }) { const cfg = await readConfig(); + const css = await readCss(cfg.cssEntry); + const issues = detectIssues(css); const components = cfg.components.map(p => ({ raw: p, slug: slugFor(p) })); return ( @@ -200,25 +288,70 @@ export default async function DesignSystemDocsLayout({ children }: { children: R
-
{children}
+
+ + {children} +
); } `; -// Landing page — minimal welcome message; the sidebar already shows everything. +// Landing page — welcome + troubleshooting. The sidebar already shows tokens/components, +// so the body focuses on what the layout's diagnostic banner can't pre-emptively flag. 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 from your - globals.css - @theme blocks. - Components are loaded from - adhd.config.ts. -

+
+
+

Design System

+

+ Pick a token domain or a component from the sidebar. Tokens are read from your + globals.css + @theme blocks. + Components are loaded from + adhd.config.ts. +

+
+ +
+

Troubleshooting

+
+
+ Component page 500s with ENOENT: ... app-build-manifest.json +
+

+ The component page uses a broad dynamic import keyed off adhd.config.ts, so adding components is just a config edit. The trade-off: Webpack/Turbopack can't statically resolve the path, so it creates a context module that pulls every file under @/ into this route's bundle, and Tailwind v4 then scans all of them for classes. +

+

+ Your other routes only bundle what they statically import, so latent issues never surface there. On this route, Tailwind hits classes referenced in transitively-bundled CSS (often a UI lib like shadcn or @reactor-team/ui) that your @theme doesn't define yet. Tailwind throws, the CSS chunk never emits, and the manifest write fails — hence ENOENT. +

+

Fix:

+
    +
  1. Run npm run dev in a terminal and watch the output when you navigate to /components/<X>.
  2. +
  3. Look for Cannot apply unknown utility class <name> or Cannot use @variant with unknown variant: <name>.
  4. +
  5. For utility class names, add --color-<name> (or appropriate prefix) to your @theme block.
  6. +
  7. For variant names, add --breakpoint-<name> to your @theme block.
  8. +
+

If the layout's diagnostic banner is showing above this content, it has detected a likely candidate already.

+
+
+ +
+ Sidebar shows the component but the page fails to load it +

+ Check the path in adhd.config.ts resolves from your project root, and that the file exports a function (default export or a named function). If the dynamic import fails at runtime (not at compile), the error boundary at components/[component]/error.tsx will catch it and show the message. +

+
+ +
+ Token domain shows “no custom tokens” but you have some +

+ The parser supports @theme {"{ ... }"} and @theme inline {"{ ... }"}. If your tokens are in a different syntax (e.g. :root), they won't be picked up — Tailwind v4 only treats @theme declarations as design tokens. +

+
+
+
); } @@ -235,10 +368,7 @@ ${TOKEN_DOMAINS_SRC} ${READ_CONFIG_SRC} -async function readCss(cssEntry: string) { - try { return await fs.readFile(path.resolve(process.cwd(), cssEntry), "utf8"); } - catch { return null; } -} +${READ_CSS_SRC} ${PARSE_TOKENS_SRC} @@ -578,6 +708,47 @@ export default async function ComponentPage({ } `; +// Error boundary for the component route. Catches RUNTIME errors thrown during +// rendering — broken dynamic imports, components that throw on mount, prop-parse +// failures. Does NOT catch bundler-level Tailwind/PostCSS failures (those happen +// before React runs); the layout's diagnostic banner handles that case. +const COMPONENT_ERROR_TSX = `${MARKER_COMMENT}"use client"; + +export default function ComponentPageError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + const isBuildManifestError = /app-build-manifest\\.json/.test(error.message ?? ""); + return ( +
+
+

Couldn't render this component

+

+ {isBuildManifestError + ? "Next.js failed to load this route's build manifest. This usually means Tailwind v4 couldn't compile CSS for the route — see the diagnostic banner above (or the Troubleshooting section on the docs landing page) for the likely cause." + : "Something went wrong while loading or rendering this component. Common causes:"} +

+ {!isBuildManifestError && ( +
    +
  • The path in adhd.config.ts doesn't resolve from the project root.
  • +
  • The component throws on mount when no props are provided.
  • +
  • The component's prop interface uses types the docs route can't introspect.
  • +
+ )} +
+ Show error details +
{error.message}{error.digest ? \`\\n\\nDigest: \${error.digest}\` : ""}
+
+ +
+
+ ); +} +`; + const PROP_TOGGLE_TSX = `${MARKER_COMMENT}"use client"; import { useRouter, useSearchParams, usePathname } from "next/navigation"; @@ -622,5 +793,6 @@ module.exports = { INDEX_PAGE_TSX, TOKENS_PAGE_TSX, COMPONENT_PAGE_TSX, + COMPONENT_ERROR_TSX, PROP_TOGGLE_TSX, }; From 999fda036547e7ffb13302c18562416cc8d815a3 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 16:38:53 -0400 Subject: [PATCH 24/79] install-docs-route SKILL: batch Phase 3 questions into one wizard prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three install choices (route URL, route group, prod exclusion) are independent — no answer affects the next question — so they can be passed to AskUserQuestion as a single multi-question call. Renders as one wizard-style prompt instead of three sequential round-trips. Also note the fallback for failed Other-text validation (re-ask just that question in a follow-up call). Audited /adhd:config for the same opportunity but its phases branch on each other (Phase 2's keep/replace/abort gates Phase 1; Phase 6 only fires after Phase 5 reports a successful write), so batching would force wasteful questions. Left as-is. Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/adhd/skills/install-design-system-docs-route/SKILL.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/adhd/skills/install-design-system-docs-route/SKILL.md b/plugins/adhd/skills/install-design-system-docs-route/SKILL.md index 9add183..98f7efa 100644 --- a/plugins/adhd/skills/install-design-system-docs-route/SKILL.md +++ b/plugins/adhd/skills/install-design-system-docs-route/SKILL.md @@ -45,12 +45,14 @@ If user chose "Update in place": derive `groupName` and `routeSegment` from the ## Phase 3: Ask installation choices -Use `AskUserQuestion` three times: +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. **Exclude from production builds?** — default `Yes`. +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 From f149df2b0591d441a1df086ef8e9b07641f70b05 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 16:53:30 -0400 Subject: [PATCH 25/79] setup-design-system-docs-route: rename + flip to static imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames `/adhd:install-design-system-docs-route` to `/adhd:setup-design-system-docs-route` to reflect the actual lifecycle: this is something you re-run, not a one-shot install. Rename touches the skill folder, the lib folder, all test fixtures, the CI workflow, README, marketplace docs, and the config skill's cross-reference. The bigger change is the architecture flip. The previous component page did `import("@/" + componentPath)` so adding to adhd.config.ts was enough to surface a new component. The cost was that Webpack/Turbopack can't statically resolve a template-literal import, so it generated a context module covering every .ts/.tsx under the project root. Tailwind v4 then scanned all of those files for classes — a far wider surface than the consumer's normal routes — and surfaced legacy shadcn classes (`ring-offset-background`, custom `xs:` variants, etc.) that weren't in the consumer's @theme. The route 500'd with an opaque ENOENT on `app-build-manifest.json` because Tailwind's PostCSS pipeline crashed and no manifest was written. New flow: the installer parses adhd.config.ts at install time and generates `componentMap.tsx` per project. Each tracked component gets an explicit `import * as $cmpN from "@/"` statement, so the bundler resolves exactly the modules in the components map — no context module, no broad bundle, no Tailwind blast radius. The component page calls `getComponent(slug)` from the generated map and renders. To add, rename, or remove a component, edit adhd.config.ts and re-run the setup skill. The diagnostic banner introduced in the previous PR is gone — it existed specifically to flag the missing-token failure mode that broad dynamic imports surfaced, and that mode no longer exists. error.tsx stays for runtime render failures (component throws on mount, etc.). The landing page's troubleshooting section is rewritten around the new model: the main failure mode is "you added a component to adhd.config.ts and forgot to re-run." New module: `config-parser.js` reads adhd.config.ts server-side. The components-map parser uses a brace-counted scan (not the previous non-greedy regex) so nested `{ figma: { url: "..." } }` values don't truncate the parse — previously a fixture with two components revealed that bug, but the lone test fixture only ever had one. componentMap.tsx resolution: prefer `default` export, fall back to the first named function. Mirrors the runtime resolution behavior the dynamic import did, so existing user components keep working without changes. Tests: full plugin suite, 377/377. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 4 +- README.md | 53 ++- .../__tests__/route-installer.test.js | 214 ------------ .../__tests__/templates.test.js | 130 ------- .../README.md | 13 +- .../__fixtures__/avatar.tsx | 0 .../__fixtures__/globals.css | 0 .../__tests__/cli.test.js | 30 +- .../__tests__/config-parser.test.js | 93 +++++ .../__tests__/next-config-patcher.test.js | 0 .../__tests__/prop-parser.test.js | 0 .../__tests__/robots-patcher.test.js | 0 .../__tests__/route-installer.test.js | 330 ++++++++++++++++++ .../__tests__/slug.test.js | 0 .../__tests__/templates.test.js | 151 ++++++++ .../__tests__/token-parser.test.js | 0 .../cli.js | 19 +- .../config-parser.js | 93 +++++ .../next-config-patcher.js | 0 .../prop-parser.js | 0 .../robots-patcher.js | 0 .../route-installer.js | 74 ++-- .../slug.js | 0 .../templates.js | 326 +++++++---------- .../token-parser.js | 0 plugins/adhd/skills/config/SKILL.md | 6 +- .../SKILL.md | 42 ++- 27 files changed, 930 insertions(+), 648 deletions(-) delete mode 100644 plugins/adhd/lib/install-design-system-docs-route/__tests__/route-installer.test.js delete mode 100644 plugins/adhd/lib/install-design-system-docs-route/__tests__/templates.test.js rename plugins/adhd/lib/{install-design-system-docs-route => setup-design-system-docs-route}/README.md (51%) rename plugins/adhd/lib/{install-design-system-docs-route => setup-design-system-docs-route}/__fixtures__/avatar.tsx (100%) rename plugins/adhd/lib/{install-design-system-docs-route => setup-design-system-docs-route}/__fixtures__/globals.css (100%) rename plugins/adhd/lib/{install-design-system-docs-route => setup-design-system-docs-route}/__tests__/cli.test.js (76%) create mode 100644 plugins/adhd/lib/setup-design-system-docs-route/__tests__/config-parser.test.js rename plugins/adhd/lib/{install-design-system-docs-route => setup-design-system-docs-route}/__tests__/next-config-patcher.test.js (100%) rename plugins/adhd/lib/{install-design-system-docs-route => setup-design-system-docs-route}/__tests__/prop-parser.test.js (100%) rename plugins/adhd/lib/{install-design-system-docs-route => setup-design-system-docs-route}/__tests__/robots-patcher.test.js (100%) create mode 100644 plugins/adhd/lib/setup-design-system-docs-route/__tests__/route-installer.test.js rename plugins/adhd/lib/{install-design-system-docs-route => setup-design-system-docs-route}/__tests__/slug.test.js (100%) create mode 100644 plugins/adhd/lib/setup-design-system-docs-route/__tests__/templates.test.js rename plugins/adhd/lib/{install-design-system-docs-route => setup-design-system-docs-route}/__tests__/token-parser.test.js (100%) rename plugins/adhd/lib/{install-design-system-docs-route => setup-design-system-docs-route}/cli.js (82%) create mode 100644 plugins/adhd/lib/setup-design-system-docs-route/config-parser.js rename plugins/adhd/lib/{install-design-system-docs-route => setup-design-system-docs-route}/next-config-patcher.js (100%) rename plugins/adhd/lib/{install-design-system-docs-route => setup-design-system-docs-route}/prop-parser.js (100%) rename plugins/adhd/lib/{install-design-system-docs-route => setup-design-system-docs-route}/robots-patcher.js (100%) rename plugins/adhd/lib/{install-design-system-docs-route => setup-design-system-docs-route}/route-installer.js (59%) rename plugins/adhd/lib/{install-design-system-docs-route => setup-design-system-docs-route}/slug.js (100%) rename plugins/adhd/lib/{install-design-system-docs-route => setup-design-system-docs-route}/templates.js (67%) rename plugins/adhd/lib/{install-design-system-docs-route => setup-design-system-docs-route}/token-parser.js (100%) rename plugins/adhd/skills/{install-design-system-docs-route => setup-design-system-docs-route}/SKILL.md (61%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79c021f..b72bdd5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,8 +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 install-design-system-docs-route tests - run: node --test plugins/adhd/lib/install-design-system-docs-route/__tests__/ + - name: Run setup-design-system-docs-route tests + run: node --test plugins/adhd/lib/setup-design-system-docs-route/__tests__/ hygiene: name: project hygiene diff --git a/README.md b/README.md index 948e4a8..5049656 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ After install, seven slash commands are available: | `/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: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. | +| `/adhd:setup-design-system-docs-route` | — | 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. @@ -116,14 +116,14 @@ The skill reads the Figma Component Set, diffs it against the React file's `Reco Run once in your consumer repo: ``` -/adhd:install-design-system-docs-route +/adhd:setup-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 is a -sidebar-and-viewer layout: +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, @@ -137,31 +137,26 @@ sidebar-and-viewer layout: 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 +the production build literally doesn't see them. You can opt out at setup 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. - -#### Caveat: broad dynamic import + Tailwind v4 - -The component page resolves its target via `import("@/" + componentPath)` so -adding to `adhd.config.ts` is enough — no re-install per component. The -trade-off: Webpack/Turbopack can't statically resolve the path, so it -creates a context module that pulls every `.ts`/`.tsx` under your project -root into this route's bundle. Tailwind v4 then scans all of them for -classes — a much wider surface than your other routes touch. - -If your codebase has shadcn-v3-era classes that you never migrated (most -commonly `ring-offset-background`, used by Button/Input focus styles), -they'll surface as `Cannot apply unknown utility class …` errors during -route compilation, and the page will 500 with an ENOENT on -`app-build-manifest.json`. The layout pre-scans your `globals.css` for the -shadcn shibboleth and shows a diagnostic banner with the exact `@theme` -addition you need to make. There's also an `error.tsx` at the route boundary -for any runtime failures, and a Troubleshooting section on the landing page. +#### 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:setup-design-system-docs-route` 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. diff --git a/plugins/adhd/lib/install-design-system-docs-route/__tests__/route-installer.test.js b/plugins/adhd/lib/install-design-system-docs-route/__tests__/route-installer.test.js deleted file mode 100644 index 7c6ab24..0000000 --- a/plugins/adhd/lib/install-design-system-docs-route/__tests__/route-installer.test.js +++ /dev/null @@ -1,214 +0,0 @@ -'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 page/layout files with .design-system.tsx suffix when prodExcluded, but PropToggle is always plain .tsx', () => { - const root = makeTempProject(); - installRoute(root, { - groupName: '(design-system)', - routeSegment: '-docs', - prodExcluded: true, - }); - const docsDir = path.join(root, 'app', '(design-system)', '-docs'); - // Route files (page/layout/error) 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'))); - assert.ok(fs.existsSync(path.join(docsDir, 'components', '[component]', 'error.design-system.tsx'))); - // PropToggle is a module imported by the component page; it doesn't need the - // suffix for prod-exclusion (the page that imports it IS suffix-excluded). - assert.ok(fs.existsSync(path.join(docsDir, 'PropToggle.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, 'tokens', '[domain]', 'page.tsx'))); - assert.ok(fs.existsSync(path.join(docsDir, 'components', '[component]', 'page.tsx'))); - assert.ok(fs.existsSync(path.join(docsDir, 'components', '[component]', 'error.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', - 'tokens/[domain]/page.design-system.tsx', - 'components/[component]/page.design-system.tsx', - 'components/[component]/error.design-system.tsx', - 'PropToggle.tsx', - ]) { - const content = fs.readFileSync(path.join(docsDir, f), 'utf8'); - assert.match(content, /design-system-docs-route/); - } -}); - -test('layout sidebar links use absolute hrefs derived from the route segment', () => { - // The sidebar lives in the layout, so its links must use absolute hrefs - // (`/-docs/tokens/colors`, not `./tokens/colors`) — otherwise nested routes - // resolve from the current pathname instead of the docs root. - const root = makeTempProject(); - installRoute(root, { groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true }); - 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\}`\}/); - // The placeholder should be fully substituted — no `__ROUTE_PATH__` left over. - assert.doesNotMatch(layout, /__ROUTE_PATH__/); -}); - -test('route URL substitution honors a custom route segment', () => { - const root = makeTempProject(); - installRoute(root, { groupName: '(design-system)', routeSegment: 'design-system', prodExcluded: true }); - const layout = fs.readFileSync( - path.join(root, 'app', '(design-system)', 'design-system', 'layout.design-system.tsx'), - 'utf8', - ); - assert.match(layout, /href=\{`\/design-system\/tokens\/\$\{d\.slug\}`\}/); -}); - -test('COMPONENT_PAGE_TSX imports PropToggle from "../../PropToggle" (now two levels deep)', () => { - const root = makeTempProject(); - installRoute(root, { groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true }); - const componentPage = fs.readFileSync( - path.join(root, 'app', '(design-system)', '-docs', 'components', '[component]', 'page.design-system.tsx'), - 'utf8', - ); - assert.match(componentPage, /from "\.\.\/\.\.\/PropToggle"/); -}); - -test('TOKENS_PAGE_TSX uses parser that handles `@theme inline { ... }` modifier syntax', () => { - const root = makeTempProject(); - installRoute(root, { groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true }); - const tokensPage = fs.readFileSync( - path.join(root, 'app', '(design-system)', '-docs', 'tokens', '[domain]', 'page.design-system.tsx'), - 'utf8', - ); - // The inline parser must use the brace-counted scan (NOT the old - // `/@theme\s*\{...\}/` regex that misses `@theme inline { ... }`). - assert.match(tokensPage, /extractThemeBodies/); - // No naïve `@theme\s*\{` regex anywhere. - assert.doesNotMatch(tokensPage, /@theme\\s\*\\\{/); -}); - -test('TOKENS_PAGE_TSX renders all expected token domains', () => { - const root = makeTempProject(); - installRoute(root, { groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true }); - const tokensPage = fs.readFileSync( - path.join(root, 'app', '(design-system)', '-docs', 'tokens', '[domain]', 'page.design-system.tsx'), - 'utf8', - ); - for (const slug of ['colors', 'spacing', 'typography', 'font', 'font-weight', - 'tracking', 'leading', 'radius', 'shadows', 'breakpoint', - 'ease', 'animate']) { - assert.match(tokensPage, new RegExp(`slug === "${slug}"`), `missing renderer for ${slug}`); - } -}); - -test('empty-state messaging references Tailwind defaults, not "no X detected"', () => { - const root = makeTempProject(); - installRoute(root, { groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true }); - const tokensPage = fs.readFileSync( - path.join(root, 'app', '(design-system)', '-docs', 'tokens', '[domain]', 'page.design-system.tsx'), - 'utf8', - ); - assert.match(tokensPage, /Tailwind v4 ships sensible defaults/); - // The misleading "No X detected" phrasing is gone. - assert.doesNotMatch(tokensPage, /No (colors|typography|radius|shadow) (tokens? )?detected/); -}); - -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 >= 6); - 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 overwrites files cleanly', () => { - const root = makeTempProject(); - installRoute(root, { groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true }); - const layoutPath = path.join(root, 'app', '(design-system)', '-docs', 'layout.design-system.tsx'); - fs.writeFileSync(layoutPath, 'corrupted'); - 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 removes stale marker-bearing files from a previous install layout', () => { - // Simulate an older install where the component page lived at `[component]/page.*` - // directly under docsDir (the structure before the tokens/[domain] + components/[component] split). - const root = makeTempProject(); - const docsDir = path.join(root, 'app', '(design-system)', '-docs'); - const oldComponentDir = path.join(docsDir, '[component]'); - fs.mkdirSync(oldComponentDir, { recursive: true }); - const oldPath = path.join(oldComponentDir, 'page.design-system.tsx'); - fs.writeFileSync(oldPath, '// design-system-docs-route — stale\nexport default function Old() { return null; }\n'); - - const result = installRoute(root, { groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true }); - - // Stale file removed, reported in `removed`, and its now-empty parent dir pruned. - assert.ok(!fs.existsSync(oldPath), 'stale file should be deleted'); - assert.ok(result.removed.includes(oldPath)); - assert.ok(!fs.existsSync(oldComponentDir), 'empty `[component]` directory should be pruned'); -}); - -test('installRoute does NOT delete unrelated files (only marker-bearing ones)', () => { - const root = makeTempProject(); - const docsDir = path.join(root, 'app', '(design-system)', '-docs'); - // Pre-install a user-authored file under docsDir without the marker. - fs.mkdirSync(docsDir, { recursive: true }); - const userFile = path.join(docsDir, 'user-notes.tsx'); - fs.writeFileSync(userFile, '// user wrote this\nexport const NOTE = "keep me";\n'); - - installRoute(root, { groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true }); - - assert.ok(fs.existsSync(userFile), 'user file without marker must be preserved'); -}); - -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'))); - assert.ok(fs.existsSync(path.join(docsDir, 'PropToggle.tsx'))); - assert.ok(fs.existsSync(path.join(docsDir, 'tokens', '[domain]', 'page.design-system.tsx'))); -}); diff --git a/plugins/adhd/lib/install-design-system-docs-route/__tests__/templates.test.js b/plugins/adhd/lib/install-design-system-docs-route/__tests__/templates.test.js deleted file mode 100644 index 57ddae5..0000000 --- a/plugins/adhd/lib/install-design-system-docs-route/__tests__/templates.test.js +++ /dev/null @@ -1,130 +0,0 @@ -'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_ERROR_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 — the filename `adhd.config.ts` is the spec-allowed exception - // (the layout reads it to populate the Components sidebar list). - const body = LAYOUT_TSX.replace(MARKER_COMMENT, '').replace(/adhd\.config\.ts/g, ''); - assert.equal(/adhd/i.test(body), false); -}); - -test('LAYOUT_TSX renders sidebar nav linking every token domain', () => { - // The sidebar replaces the old single-page sections — every token domain - // gets its own entry in the layout's nav. - 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), `missing sidebar label: ${label}`); - } -}); - -test('LAYOUT_TSX reads adhd.config.ts to populate the Components sidebar list', () => { - // The sidebar lists tracked components below the token domains — the layout - // must read adhd.config.ts at request time to know what to show. - assert.match(LAYOUT_TSX, /adhd\.config\.ts/); - assert.match(LAYOUT_TSX, /Components/); -}); - -test('INDEX_PAGE_TSX is a minimal landing page (sections moved to TOKENS_PAGE_TSX)', () => { - // The landing page now just welcomes the user; per-domain renderers live in - // the tokens page that the sidebar links to. - assert.match(INDEX_PAGE_TSX, /Design System/); - assert.match(INDEX_PAGE_TSX, /Pick a token domain|Pick a/); -}); - -test('TOKENS_PAGE_TSX reads globals.css to render tokens at request time', () => { - assert.match(TOKENS_PAGE_TSX, /globals\.css|cssEntry/); - assert.match(TOKENS_PAGE_TSX, /parseTokens/); -}); - -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', () => { - // The marker comment is allowed to precede the directive — Next.js strips - // leading comments and treats `"use client"` as the first real statement. - // Required so the marker-detection contract (Task 8) still applies. - const afterMarker = PROP_TOGGLE_TSX.replace(MARKER_COMMENT, ''); - assert.match(afterMarker, /^["']use client["']/); -}); - -test('PROP_TOGGLE_TSX uses router.replace for snappy URL updates', () => { - assert.match(PROP_TOGGLE_TSX, /router\.replace/); -}); - -test('LAYOUT_TSX renders a DiagnosticBanner that surfaces detected globals.css issues', () => { - // The layout's whole reason for reading globals.css is to surface diagnostic issues - // pre-emptively. The banner component must be in the template and wired into render. - assert.match(LAYOUT_TSX, /DiagnosticBanner/); - assert.match(LAYOUT_TSX, //); -}); - -test('LAYOUT_TSX detection flags missing --color-ring-offset-background on shadcn-like themes', () => { - // The detection heuristic should mention the ring-offset-background token explicitly - // (the most common shadcn v3-to-v4 migration gap that surfaces on broad dynamic imports). - assert.match(LAYOUT_TSX, /--color-ring-offset-background/); - assert.match(LAYOUT_TSX, /looksShadcn/); -}); - -test('LAYOUT_TSX gracefully handles a missing globals.css (detectIssues returns [])', () => { - // detectIssues must return [] when css is null — the layout still needs to render - // for projects without globals.css. - assert.match(LAYOUT_TSX, /if \(!css\) return issues;/); -}); - -test('INDEX_PAGE_TSX has a Troubleshooting section explaining the ENOENT failure mode', () => { - assert.match(INDEX_PAGE_TSX, /Troubleshooting/); - assert.match(INDEX_PAGE_TSX, /app-build-manifest/); - assert.match(INDEX_PAGE_TSX, /broad dynamic import/i); -}); - -test('COMPONENT_ERROR_TSX is a client component error boundary', () => { - const afterMarker = COMPONENT_ERROR_TSX.replace(MARKER_COMMENT, ''); - assert.match(afterMarker, /^["']use client["']/); - // The error boundary props signature Next.js passes in - assert.match(COMPONENT_ERROR_TSX, /error.*reset/); - // Re-render mechanism - assert.match(COMPONENT_ERROR_TSX, /reset\(\)/); -}); - -test('COMPONENT_ERROR_TSX distinguishes the build-manifest ENOENT from other runtime errors', () => { - // The boundary inspects the error message and shows a focused note for the - // bundler-level ENOENT (which it technically can't recover, but the user - // should be told what's happening if it bubbles up). - assert.match(COMPONENT_ERROR_TSX, /app-build-manifest/); - assert.match(COMPONENT_ERROR_TSX, /isBuildManifestError/); -}); - -test('none of the templates contain "ADHD" outside the marker', () => { - // The literal filename `adhd.config.ts` is the consumer's own config artifact - // (per the install spec) and is not a reference to the ADHD plugin/brand — - // it's an unavoidable filename the generated pages must read at runtime. - // Strip it before applying the "no ADHD references" rule. - for (const [name, content] of Object.entries({ LAYOUT_TSX, INDEX_PAGE_TSX, TOKENS_PAGE_TSX, COMPONENT_PAGE_TSX, COMPONENT_ERROR_TSX, PROP_TOGGLE_TSX })) { - const body = content.replace(MARKER_COMMENT, '').replace(/adhd\.config\.ts/g, ''); - assert.equal(/adhd/i.test(body), false, `${name} must not reference ADHD outside marker`); - } -}); diff --git a/plugins/adhd/lib/install-design-system-docs-route/README.md b/plugins/adhd/lib/setup-design-system-docs-route/README.md similarity index 51% rename from plugins/adhd/lib/install-design-system-docs-route/README.md rename to plugins/adhd/lib/setup-design-system-docs-route/README.md index 24a96cb..3b69566 100644 --- a/plugins/adhd/lib/install-design-system-docs-route/README.md +++ b/plugins/adhd/lib/setup-design-system-docs-route/README.md @@ -1,18 +1,19 @@ -# lib/install-design-system-docs-route +# lib/setup-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`) +Deterministic helpers for `/adhd:setup-design-system-docs-route`. The +skill (at `plugins/adhd/skills/setup-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 +- `config-parser.js` — parse `adhd.config.ts` at install 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 4 generated files at the target path -- `templates.js` — page template strings +- `route-installer.js` — write the seven generated files at the target path, including a per-install `componentMap.tsx` with static imports +- `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 authoritative spec. +for the historical spec. diff --git a/plugins/adhd/lib/install-design-system-docs-route/__fixtures__/avatar.tsx b/plugins/adhd/lib/setup-design-system-docs-route/__fixtures__/avatar.tsx similarity index 100% rename from plugins/adhd/lib/install-design-system-docs-route/__fixtures__/avatar.tsx rename to plugins/adhd/lib/setup-design-system-docs-route/__fixtures__/avatar.tsx diff --git a/plugins/adhd/lib/install-design-system-docs-route/__fixtures__/globals.css b/plugins/adhd/lib/setup-design-system-docs-route/__fixtures__/globals.css similarity index 100% rename from plugins/adhd/lib/install-design-system-docs-route/__fixtures__/globals.css rename to plugins/adhd/lib/setup-design-system-docs-route/__fixtures__/globals.css diff --git a/plugins/adhd/lib/install-design-system-docs-route/__tests__/cli.test.js b/plugins/adhd/lib/setup-design-system-docs-route/__tests__/cli.test.js similarity index 76% rename from plugins/adhd/lib/install-design-system-docs-route/__tests__/cli.test.js rename to plugins/adhd/lib/setup-design-system-docs-route/__tests__/cli.test.js index 1b11092..98c624c 100644 --- a/plugins/adhd/lib/install-design-system-docs-route/__tests__/cli.test.js +++ b/plugins/adhd/lib/setup-design-system-docs-route/__tests__/cli.test.js @@ -94,13 +94,39 @@ test('detect-install subcommand prints existing install paths to stdout', () => assert.match(r.stdout, /-docs\/layout\.tsx/); }); -test('install subcommand writes files based on choices JSON', () => { +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', 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'))); + 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', prodExcluded: true, + })); + 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/setup-design-system-docs-route/__tests__/config-parser.test.js b/plugins/adhd/lib/setup-design-system-docs-route/__tests__/config-parser.test.js new file mode 100644 index 0000000..eb44025 --- /dev/null +++ b/plugins/adhd/lib/setup-design-system-docs-route/__tests__/config-parser.test.js @@ -0,0 +1,93 @@ +'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', + }]); + assert.equal(r.cssEntry, 'app/globals.css'); +}); + +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/install-design-system-docs-route/__tests__/next-config-patcher.test.js b/plugins/adhd/lib/setup-design-system-docs-route/__tests__/next-config-patcher.test.js similarity index 100% rename from plugins/adhd/lib/install-design-system-docs-route/__tests__/next-config-patcher.test.js rename to plugins/adhd/lib/setup-design-system-docs-route/__tests__/next-config-patcher.test.js diff --git a/plugins/adhd/lib/install-design-system-docs-route/__tests__/prop-parser.test.js b/plugins/adhd/lib/setup-design-system-docs-route/__tests__/prop-parser.test.js similarity index 100% rename from plugins/adhd/lib/install-design-system-docs-route/__tests__/prop-parser.test.js rename to plugins/adhd/lib/setup-design-system-docs-route/__tests__/prop-parser.test.js diff --git a/plugins/adhd/lib/install-design-system-docs-route/__tests__/robots-patcher.test.js b/plugins/adhd/lib/setup-design-system-docs-route/__tests__/robots-patcher.test.js similarity index 100% rename from plugins/adhd/lib/install-design-system-docs-route/__tests__/robots-patcher.test.js rename to plugins/adhd/lib/setup-design-system-docs-route/__tests__/robots-patcher.test.js diff --git a/plugins/adhd/lib/setup-design-system-docs-route/__tests__/route-installer.test.js b/plugins/adhd/lib/setup-design-system-docs-route/__tests__/route-installer.test.js new file mode 100644 index 0000000..50ac1ba --- /dev/null +++ b/plugins/adhd/lib/setup-design-system-docs-route/__tests__/route-installer.test.js @@ -0,0 +1,330 @@ +'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-setup-')); + fs.mkdirSync(path.join(root, 'app'), { recursive: true }); + return root; +} + +const SAMPLE_COMPONENTS = [ + { slug: 'logo', rawPath: 'components/design-system/logo/index.tsx', importPath: '@/components/design-system/logo' }, +]; + +test('installRoute writes the seven generated files with .design-system suffix when prodExcluded', () => { + const root = makeTempProject(); + installRoute(root, { + groupName: '(design-system)', + routeSegment: '-docs', + prodExcluded: true, + 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'))); + assert.ok(fs.existsSync(path.join(docsDir, 'components', '[component]', 'error.design-system.tsx'))); + // componentMap + PropToggle are plain .tsx modules so TS module resolution finds them. + assert.ok(fs.existsSync(path.join(docsDir, 'componentMap.tsx'))); + assert.ok(fs.existsSync(path.join(docsDir, 'PropToggle.tsx'))); + assert.ok(!fs.existsSync(path.join(docsDir, 'componentMap.design-system.tsx'))); + assert.ok(!fs.existsSync(path.join(docsDir, 'PropToggle.design-system.tsx'))); +}); + +test('installRoute writes plain .tsx files for route files when not prodExcluded', () => { + const root = makeTempProject(); + installRoute(root, { + groupName: '(design-system)', + routeSegment: '-docs', + prodExcluded: false, + 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, 'components', '[component]', 'error.tsx'))); + assert.ok(fs.existsSync(path.join(docsDir, 'componentMap.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, + 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', + 'components/[component]/error.design-system.tsx', + 'componentMap.tsx', + 'PropToggle.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(); + installRoute(root, { + groupName: '(design-system)', + routeSegment: '-docs', + prodExcluded: true, + components: SAMPLE_COMPONENTS, + cssEntry: 'app/globals.css', + }); + const mapPath = path.join(root, 'app', '(design-system)', '-docs', 'componentMap.tsx'); + const body = fs.readFileSync(mapPath, 'utf8'); + // Explicit import for the logo component + assert.match(body, /import \* as \$cmp0 from "@\/components\/design-system\/logo"/); + // Entry with matching slug and rawPath + assert.match(body, /slug: "logo"/); + assert.match(body, /rawPath: "components\/design-system\/logo\/index\.tsx"/); + assert.match(body, /module: \$cmp0/); + // No dynamic import — that's the whole point of this rewrite + assert.doesNotMatch(body, /await\s+import\(`/); +}); + +test('componentMap.tsx handles an empty components list (no tracked components yet)', () => { + const root = makeTempProject(); + installRoute(root, { + groupName: '(design-system)', + routeSegment: '-docs', + prodExcluded: true, + components: [], + cssEntry: 'app/globals.css', + }); + const body = fs.readFileSync( + path.join(root, 'app', '(design-system)', '-docs', 'componentMap.tsx'), + 'utf8', + ); + // No import lines for components — placeholder substituted with empty string + assert.doesNotMatch(body, /import \* as \$cmp/); + // ENTRIES is an empty array literal + assert.match(body, /const ENTRIES.*=\s*\[\]/); +}); + +test('componentMap.tsx renders multiple components with distinct import bindings', () => { + const root = makeTempProject(); + installRoute(root, { + groupName: '(design-system)', + routeSegment: '-docs', + prodExcluded: true, + components: [ + { slug: 'logo', rawPath: 'src/components/Logo.tsx', importPath: '@/src/components/Logo' }, + { slug: 'button', rawPath: 'src/components/Button/index.tsx', importPath: '@/src/components/Button' }, + ], + 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 "@\/src\/components\/Logo"/); + assert.match(body, /import \* as \$cmp1 from "@\/src\/components\/Button"/); + assert.match(body, /slug: "logo".*module: \$cmp0/s); + assert.match(body, /slug: "button".*module: \$cmp1/s); +}); + +test('layout sidebar links use absolute hrefs derived from the route segment', () => { + const root = makeTempProject(); + installRoute(root, { + groupName: '(design-system)', + routeSegment: '-docs', + prodExcluded: true, + 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 componentEntries from componentMap (the sidebar list source)', () => { + const root = makeTempProject(); + installRoute(root, { + groupName: '(design-system)', + routeSegment: '-docs', + prodExcluded: true, + 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, /from "\.\/componentMap"/); + assert.match(layout, /componentEntries/); + // No fs/path imports — the components list is baked at install time so the + // layout doesn't need to read adhd.config.ts at request time. + assert.doesNotMatch(layout, /from "node:fs|from "node:path/); +}); + +test('tokens page bakes the configured cssEntry path as a constant', () => { + const root = makeTempProject(); + installRoute(root, { + groupName: '(design-system)', + routeSegment: '-docs', + prodExcluded: true, + 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"/); + // No runtime read of adhd.config.ts + assert.doesNotMatch(tokensPage, /adhd\.config\.ts/); +}); + +test('component page imports getComponent from componentMap, not a dynamic import', () => { + const root = makeTempProject(); + installRoute(root, { + groupName: '(design-system)', + routeSegment: '-docs', + prodExcluded: true, + 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, /import \{ getComponent \} from "\.\.\/\.\.\/componentMap"/); + assert.match(componentPage, /import \{ PropToggle \} from "\.\.\/\.\.\/PropToggle"/); + // No broad dynamic import — that's what the rewrite eliminates + assert.doesNotMatch(componentPage, /await\s+import\(`@\//); +}); + +test('component page shows a "not in static map" message when slug is missing', () => { + // This is the new UX for "user added to adhd.config.ts but didn't re-run setup." + const root = makeTempProject(); + installRoute(root, { + groupName: '(design-system)', + routeSegment: '-docs', + prodExcluded: true, + 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:setup-design-system-docs-route/); +}); + +test('detectExistingInstall returns marker-bearing files', () => { + const root = makeTempProject(); + installRoute(root, { + groupName: '(design-system)', + routeSegment: '-docs', + prodExcluded: true, + components: SAMPLE_COMPONENTS, + cssEntry: 'app/globals.css', + }); + const found = detectExistingInstall(root); + assert.ok(found.length >= 7); + 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(); + installRoute(root, { + groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + 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', prodExcluded: true, + 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('installRoute removes stale marker-bearing files from a previous layout', () => { + // Simulate an older install where the dynamic-import-era component page + // lived at `[component]/page` directly under docsDir. + const root = makeTempProject(); + const docsDir = path.join(root, 'app', '(design-system)', '-docs'); + const oldCompDir = path.join(docsDir, '[component]'); + fs.mkdirSync(oldCompDir, { recursive: true }); + const oldPath = path.join(oldCompDir, 'page.design-system.tsx'); + fs.writeFileSync(oldPath, '// design-system-docs-route — stale\nexport {};\n'); + + const r = installRoute(root, { + groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', + }); + + assert.ok(!fs.existsSync(oldPath)); + assert.ok(r.removed.includes(oldPath)); + assert.ok(!fs.existsSync(oldCompDir)); +}); + +test('installRoute preserves user files that lack the marker', () => { + const root = makeTempProject(); + 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', prodExcluded: true, + components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', + }); + + assert.ok(fs.existsSync(userFile)); +}); + +test('installRoute supports an empty groupName (no route group)', () => { + const root = makeTempProject(); + installRoute(root, { + groupName: '', routeSegment: '-docs', prodExcluded: true, + 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 of the map source)', () => { + const body = renderComponentMap([ + { 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"/); +}); diff --git a/plugins/adhd/lib/install-design-system-docs-route/__tests__/slug.test.js b/plugins/adhd/lib/setup-design-system-docs-route/__tests__/slug.test.js similarity index 100% rename from plugins/adhd/lib/install-design-system-docs-route/__tests__/slug.test.js rename to plugins/adhd/lib/setup-design-system-docs-route/__tests__/slug.test.js diff --git a/plugins/adhd/lib/setup-design-system-docs-route/__tests__/templates.test.js b/plugins/adhd/lib/setup-design-system-docs-route/__tests__/templates.test.js new file mode 100644 index 0000000..4b39ca3 --- /dev/null +++ b/plugins/adhd/lib/setup-design-system-docs-route/__tests__/templates.test.js @@ -0,0 +1,151 @@ +'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_ERROR_TSX, + COMPONENT_MAP_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 renders sidebar nav linking every token domain', () => { + 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), `missing sidebar label: ${label}`); + } +}); + +test('LAYOUT_TSX imports componentEntries from componentMap (no runtime config read)', () => { + // Static architecture: the layout doesn't read adhd.config.ts at request time. + // Instead, the installer generates componentMap.tsx with the components baked in, + // and the layout imports componentEntries from it. + assert.match(LAYOUT_TSX, /from "\.\/componentMap"/); + assert.match(LAYOUT_TSX, /componentEntries/); + // No fs/path imports — the layout is a pure render now + assert.doesNotMatch(LAYOUT_TSX, /from "node:fs|from "node:path|readConfig\(/); +}); + +test('LAYOUT_TSX is a sync (non-async) server component now', () => { + // No fs reads anywhere in the layout; it's a pure render. + assert.doesNotMatch(LAYOUT_TSX, /export default async function/); + assert.match(LAYOUT_TSX, /export default function DesignSystemDocsLayout/); +}); + +test('LAYOUT_TSX has no diagnostic banner (removed with the dynamic-import architecture)', () => { + // The DiagnosticBanner existed to flag missing tokens that surfaced under the + // broad dynamic import. Static imports eliminate that failure mode entirely. + assert.doesNotMatch(LAYOUT_TSX, /DiagnosticBanner|detectIssues|ring-offset-background/); +}); + +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 a Troubleshooting section keyed to the new failure modes', () => { + assert.match(INDEX_PAGE_TSX, /Troubleshooting/); + assert.match(INDEX_PAGE_TSX, /not in the static map/i); + // Old broad-dynamic-import troubleshooting is gone + assert.doesNotMatch(INDEX_PAGE_TSX, /app-build-manifest|broad dynamic/i); +}); + +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/); + // Tokens page no longer reads adhd.config.ts at request time + assert.doesNotMatch(TOKENS_PAGE_TSX, /readConfig|adhd\.config\.ts/); +}); + +test('COMPONENT_PAGE_TSX uses getComponent from the static componentMap (no dynamic import)', () => { + assert.match(COMPONENT_PAGE_TSX, /import \{ getComponent \} from "\.\.\/\.\.\/componentMap"/); + // No template-literal dynamic import + assert.doesNotMatch(COMPONENT_PAGE_TSX, /await\s+import\(`/); +}); + +test('COMPONENT_PAGE_TSX reads searchParams for prop toggles', () => { + assert.match(COMPONENT_PAGE_TSX, /searchParams/); +}); + +test('COMPONENT_PAGE_TSX shows a "Not in the static map" branch for unknown slugs', () => { + // Replaces notFound() with an actionable message about re-running setup. + assert.match(COMPONENT_PAGE_TSX, /Not in the static map/); + assert.match(COMPONENT_PAGE_TSX, /re-run.*\/adhd:setup-design-system-docs-route/i); +}); + +test('COMPONENT_MAP_TSX has the substitution placeholders the installer needs', () => { + // The template is a per-install-generated file. These placeholders are + // filled in by route-installer.js's renderComponentMap. + 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 componentEntries/); +}); + +test('COMPONENT_MAP_TSX resolves a renderable function via default-then-named fallback', () => { + // Mirrors the runtime behavior of the previous dynamic-import resolution: + // prefer default export, fall back to first named function. This keeps + // existing user components working without changes. + assert.match(COMPONENT_MAP_TSX, /function resolveComponent/); + assert.match(COMPONENT_MAP_TSX, /mod\.default/); +}); + +test('PROP_TOGGLE_TSX is a client component', () => { + const afterMarker = PROP_TOGGLE_TSX.replace(MARKER_COMMENT, ''); + assert.match(afterMarker, /^["']use client["']/); +}); + +test('PROP_TOGGLE_TSX uses router.replace for snappy URL updates', () => { + assert.match(PROP_TOGGLE_TSX, /router\.replace/); +}); + +test('COMPONENT_ERROR_TSX is a client component error boundary', () => { + const afterMarker = COMPONENT_ERROR_TSX.replace(MARKER_COMMENT, ''); + assert.match(afterMarker, /^["']use client["']/); + assert.match(COMPONENT_ERROR_TSX, /error.*reset/); + assert.match(COMPONENT_ERROR_TSX, /reset\(\)/); +}); + +test('COMPONENT_ERROR_TSX no longer has the build-manifest-specific copy', () => { + // With static imports, the build-manifest ENOENT failure mode is gone, so + // the error boundary no longer needs to special-case it. + assert.doesNotMatch(COMPONENT_ERROR_TSX, /app-build-manifest|isBuildManifestError/); +}); + +test('none of the templates contain "ADHD" outside the marker', () => { + // Two filename-style exceptions are allowed (they're how the user actually + // interacts with the tool, and ejecting from ADHD doesn't break the file — + // it just means those references become vestigial guidance the user can edit): + // 1. `adhd.config.ts` — the consumer's own config artifact. + // 2. `/adhd:setup-design-system-docs-route` — the slash command name, + // referenced in troubleshooting copy so the user knows what to re-run. + const all = { LAYOUT_TSX, INDEX_PAGE_TSX, TOKENS_PAGE_TSX, COMPONENT_PAGE_TSX, COMPONENT_ERROR_TSX, COMPONENT_MAP_TSX, PROP_TOGGLE_TSX }; + for (const [name, content] of Object.entries(all)) { + const body = content + .replace(MARKER_COMMENT, '') + .replace(/adhd\.config\.ts/g, '') + .replace(/\/adhd:setup-design-system-docs-route/g, ''); + assert.equal(/adhd/i.test(body), false, `${name} must not reference ADHD outside marker / allowed exceptions`); + } +}); diff --git a/plugins/adhd/lib/install-design-system-docs-route/__tests__/token-parser.test.js b/plugins/adhd/lib/setup-design-system-docs-route/__tests__/token-parser.test.js similarity index 100% rename from plugins/adhd/lib/install-design-system-docs-route/__tests__/token-parser.test.js rename to plugins/adhd/lib/setup-design-system-docs-route/__tests__/token-parser.test.js diff --git a/plugins/adhd/lib/install-design-system-docs-route/cli.js b/plugins/adhd/lib/setup-design-system-docs-route/cli.js similarity index 82% rename from plugins/adhd/lib/install-design-system-docs-route/cli.js rename to plugins/adhd/lib/setup-design-system-docs-route/cli.js index 6c15bd9..1fd27c0 100644 --- a/plugins/adhd/lib/install-design-system-docs-route/cli.js +++ b/plugins/adhd/lib/setup-design-system-docs-route/cli.js @@ -9,6 +9,7 @@ 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 = { _: [] }; @@ -91,8 +92,22 @@ function main() { 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'); + // 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); } diff --git a/plugins/adhd/lib/setup-design-system-docs-route/config-parser.js b/plugins/adhd/lib/setup-design-system-docs-route/config-parser.js new file mode 100644 index 0000000..e840e07 --- /dev/null +++ b/plugins/adhd/lib/setup-design-system-docs-route/config-parser.js @@ -0,0 +1,93 @@ +'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'; +} + +// 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$/, ''); +} + +// 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), + })); + return { + components, + cssEntry: parseCssEntry(src), + }; +} + +module.exports = { readConfig, parseComponents, parseCssEntry, slugFor, importPathFor }; diff --git a/plugins/adhd/lib/install-design-system-docs-route/next-config-patcher.js b/plugins/adhd/lib/setup-design-system-docs-route/next-config-patcher.js similarity index 100% rename from plugins/adhd/lib/install-design-system-docs-route/next-config-patcher.js rename to plugins/adhd/lib/setup-design-system-docs-route/next-config-patcher.js diff --git a/plugins/adhd/lib/install-design-system-docs-route/prop-parser.js b/plugins/adhd/lib/setup-design-system-docs-route/prop-parser.js similarity index 100% rename from plugins/adhd/lib/install-design-system-docs-route/prop-parser.js rename to plugins/adhd/lib/setup-design-system-docs-route/prop-parser.js diff --git a/plugins/adhd/lib/install-design-system-docs-route/robots-patcher.js b/plugins/adhd/lib/setup-design-system-docs-route/robots-patcher.js similarity index 100% rename from plugins/adhd/lib/install-design-system-docs-route/robots-patcher.js rename to plugins/adhd/lib/setup-design-system-docs-route/robots-patcher.js diff --git a/plugins/adhd/lib/install-design-system-docs-route/route-installer.js b/plugins/adhd/lib/setup-design-system-docs-route/route-installer.js similarity index 59% rename from plugins/adhd/lib/install-design-system-docs-route/route-installer.js rename to plugins/adhd/lib/setup-design-system-docs-route/route-installer.js index e922dc7..bb61524 100644 --- a/plugins/adhd/lib/install-design-system-docs-route/route-installer.js +++ b/plugins/adhd/lib/setup-design-system-docs-route/route-installer.js @@ -8,6 +8,7 @@ const { TOKENS_PAGE_TSX, COMPONENT_PAGE_TSX, COMPONENT_ERROR_TSX, + COMPONENT_MAP_TSX, PROP_TOGGLE_TSX, } = require('./templates'); @@ -17,17 +18,37 @@ function mkdirpSync(p) { fs.mkdirSync(p, { recursive: true }); } +// Build the import + entries source for componentMap.tsx from the parsed +// adhd.config.ts components list. Empty list is fine — the map exports an +// empty array and the layout's sidebar shows a friendly "none tracked" message. +function renderComponentMap(components) { + const imports = components + .map((c, i) => `import * as $cmp${i} from "${c.importPath}";`) + .join('\n'); + const entries = components + .map((c, i) => ` { slug: ${JSON.stringify(c.slug)}, rawPath: ${JSON.stringify(c.rawPath)}, module: $cmp${i} },`) + .join('\n'); + return COMPONENT_MAP_TSX + .replace('__COMPONENT_IMPORTS__', imports) + .replace('__COMPONENT_ENTRIES__', entries.length === 0 ? '[]' : `[\n${entries}\n]`); +} + function installRoute(projectRoot, opts) { - const { groupName = '', routeSegment, prodExcluded } = opts; + const { + groupName = '', + routeSegment, + prodExcluded, + components = [], + cssEntry = 'app/globals.css', + } = opts; if (!routeSegment) throw new Error('routeSegment is required'); - // Page/layout files get the `.design-system.tsx` extension only when prod-excluded - // so Next.js's `pageExtensions` conditional filters them out of production builds. + // Page/layout/error files get the `.design-system.tsx` suffix only when + // prod-excluded so Next.js's `pageExtensions` filters them out of production + // builds. componentMap and PropToggle are regular modules — they're 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 them). const pageExt = prodExcluded ? '.design-system.tsx' : '.tsx'; - // PropToggle is a regular module (not a route file by name), so it doesn't need - // the `.design-system` suffix to be excluded — it's only bundled if its importing - // page is in the build, and the page IS suffix-excluded. Using a plain `.tsx` - // keeps the `import "../../PropToggle"` in COMPONENT_PAGE_TSX resolvable. const moduleExt = '.tsx'; const segments = ['app']; if (groupName) segments.push(groupName); @@ -36,49 +57,40 @@ function installRoute(projectRoot, opts) { const tokensDir = path.join(docsDir, 'tokens', '[domain]'); const componentsDir = path.join(docsDir, 'components', '[component]'); - // The runtime route URL (route groups like `(design-system)` are invisible in URLs, - // so the URL is just `/`). Templates use `__ROUTE_PATH__` as a - // placeholder so absolute hrefs in the sidebar/landing resolve correctly. + // 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; - // Files we're about to write. Anything else with our marker comment under - // `docsDir` is leftover from a previous installer version and gets removed - // below — that's how re-installs pick up structural changes (e.g. moving - // `[component]/` to `components/[component]/`). 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 }, - // error.tsx must be a client component, and Next.js handles it like any - // route file — it goes through pageExtensions. The plain `.tsx` variant - // is used when prod-exclusion is off (mirrors layout/page). { abs: path.join(componentsDir, `error${pageExt}`), body: COMPONENT_ERROR_TSX }, + { abs: path.join(docsDir, `componentMap${moduleExt}`), body: renderComponentMap(components) }, { abs: path.join(docsDir, `PropToggle${moduleExt}`), body: PROP_TOGGLE_TSX }, ]; - // Substitute the `__ROUTE_PATH__` placeholder in every body that needs it - // (the layout sidebar links and the landing-page references). It's a no-op - // for bodies that don't contain the placeholder. + // Per-template placeholder substitution. for (const t of targets) { - t.body = t.body.replace(/__ROUTE_PATH__/g, routeUrl); + t.body = t.body + .replace(/__ROUTE_PATH__/g, routeUrl) + .replace(/__CSS_ENTRY__/g, cssEntry); } - // Remove old marker-bearing files that aren't in the new target set. This - // lets users re-run the installer after structural changes (e.g. older - // versions put the component page at `[component]/page.*` directly under - // docsDir; new versions put it under `components/[component]/page.*`). + // 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); - // Now write the new files. Directories are created on demand. for (const t of targets) { mkdirpSync(path.dirname(t.abs)); fs.writeFileSync(t.abs, t.body); } - // Best-effort cleanup of now-empty directories left behind by removed files - // (e.g. the old `app/.../-docs/[component]/` directory). pruneEmptyDirs(docsDir); return { @@ -87,8 +99,6 @@ function installRoute(projectRoot, opts) { }; } -// Walk `docsDir`, find every `.tsx` file containing the marker comment, and -// delete the ones that aren't in `keep`. Returns the list of removed paths. function removeStaleMarkerFiles(docsDir, keep) { const removed = []; function walk(dir) { @@ -113,8 +123,6 @@ function removeStaleMarkerFiles(docsDir, keep) { return removed; } -// Recursively delete empty directories under (and including) `dir`. Skips `dir` -// itself if it's non-empty after the recursion. function pruneEmptyDirs(dir) { let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } @@ -151,4 +159,4 @@ function detectExistingInstall(projectRoot) { return found; } -module.exports = { installRoute, detectExistingInstall }; +module.exports = { installRoute, detectExistingInstall, renderComponentMap }; diff --git a/plugins/adhd/lib/install-design-system-docs-route/slug.js b/plugins/adhd/lib/setup-design-system-docs-route/slug.js similarity index 100% rename from plugins/adhd/lib/install-design-system-docs-route/slug.js rename to plugins/adhd/lib/setup-design-system-docs-route/slug.js diff --git a/plugins/adhd/lib/install-design-system-docs-route/templates.js b/plugins/adhd/lib/setup-design-system-docs-route/templates.js similarity index 67% rename from plugins/adhd/lib/install-design-system-docs-route/templates.js rename to plugins/adhd/lib/setup-design-system-docs-route/templates.js index f957371..09c8ba1 100644 --- a/plugins/adhd/lib/install-design-system-docs-route/templates.js +++ b/plugins/adhd/lib/setup-design-system-docs-route/templates.js @@ -5,10 +5,9 @@ const MARKER_COMMENT = `// design-system-docs-route — auto-generated installer `; // The list of token domains is shared verbatim between the sidebar (layout) and -// the token page (so the page can look up the right renderer by slug). Both -// copies use the same source string here, embedded into the templates below. -// The `tailwindDocs` field is the URL to Tailwind v4's relevant theme section, -// used in empty-state messaging. +// the tokens page (so the page can look up the right renderer by slug). Both +// copies embed the same source string from here. The `tailwindDocs` field is +// the URL to Tailwind v4's relevant theme section, used in empty-state messaging. const TOKEN_DOMAINS_SRC = `const TOKEN_DOMAINS = [ { 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" }, @@ -24,12 +23,17 @@ const TOKEN_DOMAINS_SRC = `const TOKEN_DOMAINS = [ { slug: "animate", label: "Animation", varPrefix: "--animate-", tailwindDocs: "https://tailwindcss.com/docs/animation" }, ];`; -// The CSS @theme parser shared between landing/tokens pages. Kept inline (not -// imported from the lib) because these are runtime server components in the -// consumer's app, with no access to ADHD's node_modules. Mirrors token-parser.js -// but flattened for inline use. -// - Brace-counted scan supports `@theme { ... }` AND `@theme inline { ... }` -// - Prefix order matters: longer prefixes (`font-weight-`) before shorter (`font-`). +// 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; @@ -73,7 +77,6 @@ function parseTokens(css: string | null) { if (!css) return out; const typoByName = new Map(); const LINE_HEIGHT_SUFFIX = "--line-height"; - // Order matters: longer prefixes first. const PREFIX_MAP: Array<[string, keyof typeof out]> = [ ["color-", "colors"], ["font-weight-", "fontWeights"], @@ -115,94 +118,55 @@ function parseTokens(css: string | null) { return out; }`; -const READ_CONFIG_SRC = `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]); - } - const cssEntryMatch = /cssEntry\\s*:\\s*"([^"]+)"/.exec(src); - const cssEntry = cssEntryMatch ? cssEntryMatch[1] : "app/globals.css"; - return { components, cssEntry }; - } catch { - return { components: [] as string[], cssEntry: "app/globals.css" }; +// 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:setup-design-system-docs-route`. +// +// 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__ + +type ModuleShape = Record; + +// Resolve the renderable function from a module: prefer the default export, +// fall back to the first exported function. Mirrors the previous runtime +// resolution behavior so existing user components keep working. +function resolveComponent(mod: ModuleShape): 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; } -function slugFor(p: string) { - return p.replace(/\\.tsx?$/, "").replace(/\\/index$/, "").split("/").pop()?.toLowerCase() ?? p; -}`; - -// Shared globals.css reader. Returns the file contents or null if missing. -const READ_CSS_SRC = `async function readCss(cssEntry: string) { - try { return await fs.readFile(path.resolve(process.cwd(), cssEntry), "utf8"); } - catch { return null; } -}`; - -// Diagnostic banner detection: scans the consumer's @theme block for token-name -// shibboleths that indicate a shadcn-style v3-to-v4 migration, and flags common -// tokens that are missing. The dynamic-import pattern used by the component page -// pulls in more files than the consumer's normal routes do, which surfaces stale -// `@apply` directives in transitively-bundled CSS — these missing tokens are the -// usual cause. -const DETECT_ISSUES_SRC = `type DetectedIssue = { - token: string; - why: string; - themeLine: string; - rootLine?: string; +export type ComponentEntry = { + slug: string; + rawPath: string; + Component: React.ComponentType | null; }; -function detectIssues(css: string | null): DetectedIssue[] { - const issues: DetectedIssue[] = []; - if (!css) return issues; - // Extract bodies of every @theme block (supports \`@theme inline\` modifier). - 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, 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; - } - const themeText = bodies.join("\\n"); - const has = (token: string) => new RegExp("(?:^|\\\\s)" + token.replace(/[-]/g, "\\\\-") + "\\\\s*:").test(themeText); - - // Shadcn shibboleth: foreground+background plus at least one *-foreground pair. - const looksShadcn = has("--color-foreground") && has("--color-background") && - (has("--color-card-foreground") || has("--color-popover-foreground")); - - if (looksShadcn && !has("--color-ring-offset-background")) { - issues.push({ - token: "--color-ring-offset-background", - why: "Shadcn components use \`ring-offset-background\` for focus styles. Without this in @theme, any \`@apply ring-offset-background\` in transitively-bundled CSS (from a UI library or stale components in your project) will fail with \\"Cannot apply unknown utility class ring-offset-background\\" during route compilation, and the component page will 500 with an ENOENT on the build manifest.", - themeLine: "--color-ring-offset-background: hsl(var(--ring-offset-background));", - rootLine: "--ring-offset-background: 0 0% 100%;", - }); - } +const ENTRIES: Array<{ slug: string; rawPath: string; module: ModuleShape }> = __COMPONENT_ENTRIES__; - return issues; -}`; +export const componentEntries: Array<{ slug: string; rawPath: string }> = + ENTRIES.map(e => ({ slug: e.slug, rawPath: e.rawPath })); + +export function getComponent(slug: string): ComponentEntry | null { + const entry = ENTRIES.find(e => e.slug === slug); + if (!entry) return null; + return { slug: entry.slug, rawPath: entry.rawPath, Component: resolveComponent(entry.module) }; +} +`; -// Layout: sidebar lists token domains + components; main area renders children. -// The layout is async so it can read adhd.config.ts and globals.css for diagnostics. +// Layout: sidebar (token domains baked-in, component list baked-in via the +// generated componentMap). No fs reads, no async — pure server component. const LAYOUT_TSX = `${MARKER_COMMENT}import type { Metadata } from "next"; -import fs from "node:fs/promises"; -import path from "node:path"; import Link from "next/link"; +import { componentEntries } from "./componentMap"; export const metadata: Metadata = { title: "Design System Docs", @@ -211,41 +175,7 @@ export const metadata: Metadata = { ${TOKEN_DOMAINS_SRC} -${READ_CONFIG_SRC} - -${READ_CSS_SRC} - -${DETECT_ISSUES_SRC} - -function DiagnosticBanner({ issues }: { issues: DetectedIssue[] }) { - if (issues.length === 0) return null; - return ( - - ); -} - -export default async function DesignSystemDocsLayout({ children }: { children: React.ReactNode }) { - const cfg = await readConfig(); - const css = await readCss(cfg.cssEntry); - const issues = detectIssues(css); - const components = cfg.components.map(p => ({ raw: p, slug: slugFor(p) })); - +export default function DesignSystemDocsLayout({ children }: { children: React.ReactNode }) { return (
-
- - {children} -
+
{children}
); } `; -// Landing page — welcome + troubleshooting. The sidebar already shows tokens/components, -// so the body focuses on what the layout's diagnostic banner can't pre-emptively flag. +// Landing page — welcome + brief troubleshooting. Static. No fs reads. 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 from your + Pick a token domain or a component from the sidebar. Tokens are read live from your globals.css - @theme blocks. - Components are loaded from - adhd.config.ts. + @theme blocks. Components are statically imported from adhd.config.ts. +

+

+ After editing adhd.config.ts (adding, renaming, or removing components), re-run /adhd:setup-design-system-docs-route in this project to regenerate the static component map.

@@ -318,36 +245,23 @@ const INDEX_PAGE_TSX = `${MARKER_COMMENT}export default function DesignSystemInd

Troubleshooting

- Component page 500s with ENOENT: ... app-build-manifest.json -
-

- The component page uses a broad dynamic import keyed off adhd.config.ts, so adding components is just a config edit. The trade-off: Webpack/Turbopack can't statically resolve the path, so it creates a context module that pulls every file under @/ into this route's bundle, and Tailwind v4 then scans all of them for classes. -

-

- Your other routes only bundle what they statically import, so latent issues never surface there. On this route, Tailwind hits classes referenced in transitively-bundled CSS (often a UI lib like shadcn or @reactor-team/ui) that your @theme doesn't define yet. Tailwind throws, the CSS chunk never emits, and the manifest write fails — hence ENOENT. -

-

Fix:

-
    -
  1. Run npm run dev in a terminal and watch the output when you navigate to /components/<X>.
  2. -
  3. Look for Cannot apply unknown utility class <name> or Cannot use @variant with unknown variant: <name>.
  4. -
  5. For utility class names, add --color-<name> (or appropriate prefix) to your @theme block.
  6. -
  7. For variant names, add --breakpoint-<name> to your @theme block.
  8. -
-

If the layout's diagnostic banner is showing above this content, it has detected a likely candidate already.

-
+ Component shows in the sidebar but the page says “not in the static map” +

+ The static map is generated at setup time. If the sidebar lists a component but its page reports it's not in the map, the layout is out of sync with componentMap.tsx. Re-run /adhd:setup-design-system-docs-route; both files will be regenerated together. +

- Sidebar shows the component but the page fails to load it + Component fails to render at runtime

- Check the path in adhd.config.ts resolves from your project root, and that the file exports a function (default export or a named function). If the dynamic import fails at runtime (not at compile), the error boundary at components/[component]/error.tsx will catch it and show the message. + The error boundary at components/[component]/error.tsx will catch it and show the message + a Try Again button. Most often: the component throws on mount without required props, or it expects context (theme provider, router) that the docs route doesn't provide.

Token domain shows “no custom tokens” but you have some

- The parser supports @theme {"{ ... }"} and @theme inline {"{ ... }"}. If your tokens are in a different syntax (e.g. :root), they won't be picked up — Tailwind v4 only treats @theme declarations as design tokens. + The parser supports @theme {"{ ... }"} and @theme inline {"{ ... }"}. If your tokens are in a different syntax (e.g. plain :root), they won't be picked up — Tailwind v4 only treats @theme declarations as design tokens.

@@ -357,21 +271,20 @@ const INDEX_PAGE_TSX = `${MARKER_COMMENT}export default function DesignSystemInd } `; -// Tokens domain page — one route, one renderer per domain. Reads the consumer's -// globals.css at request time and renders whatever's declared. Empty states -// reference Tailwind v4's defaults rather than implying the system is broken. +// Tokens domain page — reads globals.css at request time, renders whatever's +// declared. cssEntry is baked at install time (substituted from adhd.config.ts). const TOKENS_PAGE_TSX = `${MARKER_COMMENT}import fs from "node:fs/promises"; import path from "node:path"; import { notFound } from "next/navigation"; ${TOKEN_DOMAINS_SRC} -${READ_CONFIG_SRC} - ${READ_CSS_SRC} ${PARSE_TOKENS_SRC} +const CSS_ENTRY = "__CSS_ENTRY__"; + function EmptyState({ domain }: { domain: typeof TOKEN_DOMAINS[number] }) { return (
@@ -386,8 +299,7 @@ export default async function TokensDomainPage({ params }: { params: Promise<{ d const domain = TOKEN_DOMAINS.find(d => d.slug === slug); if (!domain) notFound(); - const cfg = await readConfig(); - const css = await readCss(cfg.cssEntry); + const css = await readCss(CSS_ENTRY); const tokens = parseTokens(css); return ( @@ -564,14 +476,14 @@ export default async function TokensDomainPage({ params }: { params: Promise<{ d } `; -// Component page — moved to /components/[component]. Two levels deep, so the -// PropToggle import is now `../../PropToggle`. +// Component page — uses the statically generated componentMap. No fs reads of +// adhd.config.ts at request time; the rawPath comes from the map. The page +// still reads the component's source via fs to introspect prop interfaces +// (that's a one-file read per request, not a bundle). 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"; - -${READ_CONFIG_SRC} +import { getComponent } from "../../componentMap"; async function parseProps(componentPath: string) { try { @@ -621,11 +533,24 @@ export default async function ComponentPage({ }) { const { component: slug } = await params; const sp = await searchParams; - const cfg = await readConfig(); - const componentPath = cfg.components.find(p => slugFor(p) === slug); - if (!componentPath) notFound(); + const entry = getComponent(slug); + + if (!entry) { + return ( +
+

Not in the static map

+

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

+

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

+
+ ); + } - const { props } = await parseProps(componentPath); + const { rawPath, Component } = entry; + const { props } = await parseProps(rawPath); // Resolve current prop values from searchParams const current: Record = {}; @@ -638,19 +563,8 @@ export default async function ComponentPage({ 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 importPath = "@/" + rawPath.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; @@ -690,11 +604,9 @@ export default async function ComponentPage({
- {importError ? ( -
{importError}
- ) : Component ? ( - - ) : null} + {Component ? : ( +

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

+ )}
{importStmt && jsxSnippet && ( @@ -708,30 +620,25 @@ export default async function ComponentPage({ } `; -// Error boundary for the component route. Catches RUNTIME errors thrown during -// rendering — broken dynamic imports, components that throw on mount, prop-parse -// failures. Does NOT catch bundler-level Tailwind/PostCSS failures (those happen -// before React runs); the layout's diagnostic banner handles that case. +// Error boundary for the component route. Catches runtime errors thrown during +// rendering — components that throw on mount, prop-parse failures, etc. With +// static imports there's no broad-bundle Tailwind blast radius anymore, so this +// is purely a runtime safety net. const COMPONENT_ERROR_TSX = `${MARKER_COMMENT}"use client"; export default function ComponentPageError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { - const isBuildManifestError = /app-build-manifest\\.json/.test(error.message ?? ""); return (
-

Couldn't render this component

+

Couldn't render this component

- {isBuildManifestError - ? "Next.js failed to load this route's build manifest. This usually means Tailwind v4 couldn't compile CSS for the route — see the diagnostic banner above (or the Troubleshooting section on the docs landing page) for the likely cause." - : "Something went wrong while loading or rendering this component. Common causes:"} + Something went wrong while rendering this component. Common causes:

- {!isBuildManifestError && ( -
    -
  • The path in adhd.config.ts doesn't resolve from the project root.
  • -
  • The component throws on mount when no props are provided.
  • -
  • The component's prop interface uses types the docs route can't introspect.
  • -
- )} +
    +
  • The component throws on mount when no props are provided.
  • +
  • The component expects context (theme provider, router, query client) that the docs route doesn't set up.
  • +
  • The component's prop interface uses types the docs route can't introspect.
  • +
Show error details
{error.message}{error.digest ? \`\\n\\nDigest: \${error.digest}\` : ""}
@@ -794,5 +701,6 @@ module.exports = { TOKENS_PAGE_TSX, COMPONENT_PAGE_TSX, COMPONENT_ERROR_TSX, + COMPONENT_MAP_TSX, PROP_TOGGLE_TSX, }; diff --git a/plugins/adhd/lib/install-design-system-docs-route/token-parser.js b/plugins/adhd/lib/setup-design-system-docs-route/token-parser.js similarity index 100% rename from plugins/adhd/lib/install-design-system-docs-route/token-parser.js rename to plugins/adhd/lib/setup-design-system-docs-route/token-parser.js diff --git a/plugins/adhd/skills/config/SKILL.md b/plugins/adhd/skills/config/SKILL.md index c8fd0fd..1c73e02 100644 --- a/plugins/adhd/skills/config/SKILL.md +++ b/plugins/adhd/skills/config/SKILL.md @@ -249,13 +249,13 @@ Options: - "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 +On "Yes": execute the phases of `/adhd:setup-design-system-docs-route` inline. +See `plugins/adhd/skills/setup-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.` +On "No": print `Run /adhd:setup-design-system-docs-route later to set it up.` Exit normally. ## Reference: Common errors and fix-up guidance diff --git a/plugins/adhd/skills/install-design-system-docs-route/SKILL.md b/plugins/adhd/skills/setup-design-system-docs-route/SKILL.md similarity index 61% rename from plugins/adhd/skills/install-design-system-docs-route/SKILL.md rename to plugins/adhd/skills/setup-design-system-docs-route/SKILL.md index 98f7efa..ce96404 100644 --- a/plugins/adhd/skills/install-design-system-docs-route/SKILL.md +++ b/plugins/adhd/skills/setup-design-system-docs-route/SKILL.md @@ -1,21 +1,22 @@ --- -description: "Install a self-generating design-system documentation route into a Next.js consumer app. Sidebar + viewer layout: the 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. All token domains and components are read from globals.css and adhd.config.ts at request time. Component pages introspect props for URL-driven toggles. Optionally excluded from production builds via Next.js pageExtensions trick. Re-runnable: marker-comment detection drives updates, and stale marker-bearing files from older template layouts are cleaned up." +description: "Generate a design-system documentation 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 install 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 Install Design System Docs Route +# ADHD Setup 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. +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 regenerate the static imports. -**Authoritative spec:** `docs/superpowers/specs/2026-05-11-adhd-install-design-system-docs-route.md` +**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 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. +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:setup-design-system-docs-route` referenced in troubleshooting 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 installer 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 @@ -30,7 +31,7 @@ Read `package.json` and confirm `next` is in `dependencies` or `devDependencies` ## Phase 2: Detect existing install ```bash -node plugins/adhd/lib/install-design-system-docs-route/cli.js detect-install --app-dir . +node plugins/adhd/lib/setup-design-system-docs-route/cli.js detect-install --app-dir . ``` Output is newline-separated paths of files containing the marker comment. @@ -77,7 +78,7 @@ If `EXISTS` and Phase 2 didn't already mark this as an existing install: prompt ## 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 \ +node plugins/adhd/lib/setup-design-system-docs-route/cli.js patch-next-config \ --config "" \ --route-url "" ``` @@ -102,7 +103,7 @@ pageExtensions: process.env.NODE_ENV === 'production' ## Phase 7: Write the page files ```bash -node plugins/adhd/lib/install-design-system-docs-route/cli.js install \ +node plugins/adhd/lib/setup-design-system-docs-route/cli.js install \ --config ``` @@ -116,12 +117,14 @@ Where `` is a temp file with shape: } ``` -The CLI prints the list of files it wrote. +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/install-design-system-docs-route/cli.js patch-robots \ +node plugins/adhd/lib/setup-design-system-docs-route/cli.js patch-robots \ --robots public/robots.txt \ --route-url "" ``` @@ -135,20 +138,23 @@ mkdir -p public Print: ``` -✓ Design system docs route installed. +✓ Design system docs route set up. 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. -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. +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:setup-design-system-docs-route to regenerate the static imports. -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. +Files where you've removed the marker comment are left alone. ``` ## Common errors From b7443947fc33bb1f1565abc93c882a6f3cddf96a Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 18:27:17 -0400 Subject: [PATCH 26/79] setup-design-system-docs-route: extract tokenDomains, prune landing page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cleanups noticed on review: 1. TOKEN_DOMAINS was duplicated: the same `const TOKEN_DOMAINS = [...]` block was being injected into both layout.tsx and the tokens-page template. Extracted to its own per-install `tokenDomains.tsx` module. Layout imports `TOKEN_DOMAINS` from `./tokenDomains`; the tokens page imports `TOKEN_DOMAINS` and the new `TokenDomain` type from `../../tokenDomains`. Adding a new domain is now a one-file edit. 2. Landing page troubleshooting was stale. The first item ("sidebar shows component but page says not in static map") was actually impossible under the new architecture — the sidebar list IS componentMap, so any slug visible in the sidebar is by definition in the map. Pruned the whole troubleshooting block: each route already surfaces its own failure mode (the component page has the "not in static map" message with re-run instructions, error.tsx catches runtime crashes, token pages link to Tailwind docs for empty domains). Landing is now just a brief orientation paragraph. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/route-installer.test.js | 9 +- .../__tests__/templates.test.js | 35 +++++-- .../route-installer.js | 4 + .../templates.js | 92 ++++++++----------- 4 files changed, 79 insertions(+), 61 deletions(-) diff --git a/plugins/adhd/lib/setup-design-system-docs-route/__tests__/route-installer.test.js b/plugins/adhd/lib/setup-design-system-docs-route/__tests__/route-installer.test.js index 50ac1ba..47f1e1a 100644 --- a/plugins/adhd/lib/setup-design-system-docs-route/__tests__/route-installer.test.js +++ b/plugins/adhd/lib/setup-design-system-docs-route/__tests__/route-installer.test.js @@ -17,7 +17,7 @@ const SAMPLE_COMPONENTS = [ { slug: 'logo', rawPath: 'components/design-system/logo/index.tsx', importPath: '@/components/design-system/logo' }, ]; -test('installRoute writes the seven generated files with .design-system suffix when prodExcluded', () => { +test('installRoute writes the full generated file set with .design-system suffix when prodExcluded', () => { const root = makeTempProject(); installRoute(root, { groupName: '(design-system)', @@ -33,10 +33,12 @@ test('installRoute writes the seven generated files with .design-system suffix w 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'))); assert.ok(fs.existsSync(path.join(docsDir, 'components', '[component]', 'error.design-system.tsx'))); - // componentMap + PropToggle are plain .tsx modules so TS module resolution finds them. + // Shared modules (imported by the route files) are plain .tsx so TS resolves them. assert.ok(fs.existsSync(path.join(docsDir, 'componentMap.tsx'))); + assert.ok(fs.existsSync(path.join(docsDir, 'tokenDomains.tsx'))); assert.ok(fs.existsSync(path.join(docsDir, 'PropToggle.tsx'))); assert.ok(!fs.existsSync(path.join(docsDir, 'componentMap.design-system.tsx'))); + assert.ok(!fs.existsSync(path.join(docsDir, 'tokenDomains.design-system.tsx'))); assert.ok(!fs.existsSync(path.join(docsDir, 'PropToggle.design-system.tsx'))); }); @@ -76,6 +78,7 @@ test('all written files start with the marker comment', () => { 'components/[component]/page.design-system.tsx', 'components/[component]/error.design-system.tsx', 'componentMap.tsx', + 'tokenDomains.tsx', 'PropToggle.tsx', ]) { const content = fs.readFileSync(path.join(docsDir, f), 'utf8'); @@ -248,7 +251,7 @@ test('detectExistingInstall returns marker-bearing files', () => { cssEntry: 'app/globals.css', }); const found = detectExistingInstall(root); - assert.ok(found.length >= 7); + assert.ok(found.length >= 8); assert.ok(found.every(p => p.includes('-docs'))); }); diff --git a/plugins/adhd/lib/setup-design-system-docs-route/__tests__/templates.test.js b/plugins/adhd/lib/setup-design-system-docs-route/__tests__/templates.test.js index 4b39ca3..97590e9 100644 --- a/plugins/adhd/lib/setup-design-system-docs-route/__tests__/templates.test.js +++ b/plugins/adhd/lib/setup-design-system-docs-route/__tests__/templates.test.js @@ -10,6 +10,7 @@ const { COMPONENT_PAGE_TSX, COMPONENT_ERROR_TSX, COMPONENT_MAP_TSX, + TOKEN_DOMAINS_TSX, PROP_TOGGLE_TSX, } = require('../templates'); @@ -27,13 +28,26 @@ test('LAYOUT_TSX sets robots: noindex / nofollow', () => { assert.match(LAYOUT_TSX, /robots:\s*\{[^}]*index:\s*false[^}]*follow:\s*false/); }); -test('LAYOUT_TSX renders sidebar nav linking every token domain', () => { +test('LAYOUT_TSX imports the TOKEN_DOMAINS catalog from the shared tokenDomains module', () => { + // Single source of truth lives in tokenDomains.tsx; the layout just imports it + // and iterates. Inlining the labels here would duplicate the catalog (the + // duplication the rewrite was meant to remove). + assert.match(LAYOUT_TSX, /import \{ TOKEN_DOMAINS \} from "\.\/tokenDomains"/); +}); + +test('TOKEN_DOMAINS_TSX exports the full Tailwind v4 token-domain catalog', () => { + // The catalog is THE source of truth — every token domain rendered by the + // tokens page must be listed here with its varPrefix and Tailwind docs link. 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), `missing sidebar label: ${label}`); + assert.match(TOKEN_DOMAINS_TSX, new RegExp(`label: "${label}"`), `missing domain label: ${label}`); } + // Shape: each entry has slug + label + varPrefix + tailwindDocs. + assert.match(TOKEN_DOMAINS_TSX, /slug:\s*"colors".*varPrefix:\s*"--color-".*tailwindDocs:/s); + assert.match(TOKEN_DOMAINS_TSX, /export const TOKEN_DOMAINS:/); + assert.match(TOKEN_DOMAINS_TSX, /export type TokenDomain/); }); test('LAYOUT_TSX imports componentEntries from componentMap (no runtime config read)', () => { @@ -64,11 +78,14 @@ test('INDEX_PAGE_TSX is a landing page describing the static-import flow', () => assert.match(INDEX_PAGE_TSX, /re-run/); }); -test('INDEX_PAGE_TSX has a Troubleshooting section keyed to the new failure modes', () => { - assert.match(INDEX_PAGE_TSX, /Troubleshooting/); - assert.match(INDEX_PAGE_TSX, /not in the static map/i); - // Old broad-dynamic-import troubleshooting is gone +test('INDEX_PAGE_TSX has no Troubleshooting section (each route handles its own failure modes)', () => { + // The component page surfaces "not in static map" itself; error.tsx catches + // runtime crashes; token pages link to Tailwind docs for empty domains. + // The landing page just orients the user — no duplicated troubleshooting copy. + assert.doesNotMatch(INDEX_PAGE_TSX, /Troubleshooting/); assert.doesNotMatch(INDEX_PAGE_TSX, /app-build-manifest|broad dynamic/i); + // It still mentions the re-run command so the user knows how to refresh the map. + assert.match(INDEX_PAGE_TSX, /\/adhd:setup-design-system-docs-route/); }); test('TOKENS_PAGE_TSX reads globals.css from a baked CSS_ENTRY constant', () => { @@ -78,6 +95,12 @@ test('TOKENS_PAGE_TSX reads globals.css from a baked CSS_ENTRY constant', () => assert.doesNotMatch(TOKENS_PAGE_TSX, /readConfig|adhd\.config\.ts/); }); +test('TOKENS_PAGE_TSX imports TOKEN_DOMAINS from the shared catalog (no inlined list)', () => { + assert.match(TOKENS_PAGE_TSX, /import \{ TOKEN_DOMAINS, type TokenDomain \} from "\.\.\/\.\.\/tokenDomains"/); + // The inline `const TOKEN_DOMAINS = [...]` block from earlier versions is gone. + assert.doesNotMatch(TOKENS_PAGE_TSX, /const TOKEN_DOMAINS = \[/); +}); + test('COMPONENT_PAGE_TSX uses getComponent from the static componentMap (no dynamic import)', () => { assert.match(COMPONENT_PAGE_TSX, /import \{ getComponent \} from "\.\.\/\.\.\/componentMap"/); // No template-literal dynamic import diff --git a/plugins/adhd/lib/setup-design-system-docs-route/route-installer.js b/plugins/adhd/lib/setup-design-system-docs-route/route-installer.js index bb61524..2133594 100644 --- a/plugins/adhd/lib/setup-design-system-docs-route/route-installer.js +++ b/plugins/adhd/lib/setup-design-system-docs-route/route-installer.js @@ -9,6 +9,7 @@ const { COMPONENT_PAGE_TSX, COMPONENT_ERROR_TSX, COMPONENT_MAP_TSX, + TOKEN_DOMAINS_TSX, PROP_TOGGLE_TSX, } = require('./templates'); @@ -69,6 +70,9 @@ function installRoute(projectRoot, opts) { { abs: path.join(componentsDir, `page${pageExt}`), body: COMPONENT_PAGE_TSX }, { abs: path.join(componentsDir, `error${pageExt}`), body: COMPONENT_ERROR_TSX }, { abs: path.join(docsDir, `componentMap${moduleExt}`), body: renderComponentMap(components) }, + // Shared token-domain catalog: imported by layout (sidebar nav) and the + // tokens page (renderer keys + empty-state link targets). One source of truth. + { abs: path.join(docsDir, `tokenDomains${moduleExt}`), body: TOKEN_DOMAINS_TSX }, { abs: path.join(docsDir, `PropToggle${moduleExt}`), body: PROP_TOGGLE_TSX }, ]; diff --git a/plugins/adhd/lib/setup-design-system-docs-route/templates.js b/plugins/adhd/lib/setup-design-system-docs-route/templates.js index 09c8ba1..573d88d 100644 --- a/plugins/adhd/lib/setup-design-system-docs-route/templates.js +++ b/plugins/adhd/lib/setup-design-system-docs-route/templates.js @@ -4,11 +4,19 @@ const MARKER_COMMENT = `// design-system-docs-route — auto-generated installer // Remove this comment to disable future overwrites from re-running the installer. `; -// The list of token domains is shared verbatim between the sidebar (layout) and -// the tokens page (so the page can look up the right renderer by slug). Both -// copies embed the same source string from here. The `tailwindDocs` field is -// the URL to Tailwind v4's relevant theme section, used in empty-state messaging. -const TOKEN_DOMAINS_SRC = `const TOKEN_DOMAINS = [ +// tokenDomains.tsx — the single source of truth for the token-domain catalog +// (sidebar entries + per-domain renderer keys). Generated once per install and +// imported by both layout.* and tokens/[domain]/page.*. The `tailwindDocs` +// field is the URL to Tailwind v4's relevant theme section, used in +// empty-state messaging on each domain page. +const TOKEN_DOMAINS_TSX = `${MARKER_COMMENT}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" }, @@ -21,7 +29,8 @@ const TOKEN_DOMAINS_SRC = `const TOKEN_DOMAINS = [ { 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" }, -];`; +]; +`; // 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. @@ -162,10 +171,11 @@ export function getComponent(slug: string): ComponentEntry | null { } `; -// Layout: sidebar (token domains baked-in, component list baked-in via the -// generated componentMap). No fs reads, no async — pure server component. +// Layout: sidebar links into the token-domain catalog 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 { TOKEN_DOMAINS } from "./tokenDomains"; import { componentEntries } from "./componentMap"; export const metadata: Metadata = { @@ -173,8 +183,6 @@ export const metadata: Metadata = { robots: { index: false, follow: false }, }; -${TOKEN_DOMAINS_SRC} - export default function DesignSystemDocsLayout({ children }: { children: React.ReactNode }) { return (
@@ -225,47 +233,25 @@ export default function DesignSystemDocsLayout({ children }: { children: React.R } `; -// Landing page — welcome + brief troubleshooting. Static. No fs reads. +// 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 adhd.config.ts (adding, renaming, or removing components), re-run /adhd:setup-design-system-docs-route in this project to regenerate the static component map. -

-
- -
-

Troubleshooting

-
-
- Component shows in the sidebar but the page says “not in the static map” -

- The static map is generated at setup time. If the sidebar lists a component but its page reports it's not in the map, the layout is out of sync with componentMap.tsx. Re-run /adhd:setup-design-system-docs-route; both files will be regenerated together. -

-
- -
- Component fails to render at runtime -

- The error boundary at components/[component]/error.tsx will catch it and show the message + a Try Again button. Most often: the component throws on mount without required props, or it expects context (theme provider, router) that the docs route doesn't provide. -

-
- -
- Token domain shows “no custom tokens” but you have some -

- The parser supports @theme {"{ ... }"} and @theme inline {"{ ... }"}. If your tokens are in a different syntax (e.g. plain :root), they won't be picked up — Tailwind v4 only treats @theme declarations as design tokens. -

-
-
-
+
+

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:setup-design-system-docs-route to regenerate the static imports. +

+

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

); } @@ -273,11 +259,12 @@ const INDEX_PAGE_TSX = `${MARKER_COMMENT}export default function DesignSystemInd // 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"; - -${TOKEN_DOMAINS_SRC} +import { TOKEN_DOMAINS, type TokenDomain } from "../../tokenDomains"; ${READ_CSS_SRC} @@ -285,7 +272,7 @@ ${PARSE_TOKENS_SRC} const CSS_ENTRY = "__CSS_ENTRY__"; -function EmptyState({ domain }: { domain: typeof TOKEN_DOMAINS[number] }) { +function EmptyState({ domain }: { domain: TokenDomain }) { return (
No custom {domain.varPrefix}* tokens declared in your @theme. @@ -702,5 +689,6 @@ module.exports = { COMPONENT_PAGE_TSX, COMPONENT_ERROR_TSX, COMPONENT_MAP_TSX, + TOKEN_DOMAINS_TSX, PROP_TOGGLE_TSX, }; From 6acd374c0a48ddca9aea661cd418f61f2d470358 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 18:38:03 -0400 Subject: [PATCH 27/79] sync-docs: rename from setup-design-system-docs-route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slash command, skill folder, lib folder, README headings, SKILL.md title/description/copy, final report verb, CI step name. The marker comment stays as `design-system-docs-route` (it's the file-detection contract, intentionally decoupled from command names). User-facing strings flipped from setup/install vocabulary to sync vocabulary in the appropriate places — the action is generating + regenerating the docs route from adhd.config.ts, which "sync" describes better. Internal CLI subcommand names (`install`, `detect-install`, etc.) stay — they're file-IO verbs, not user-facing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 4 ++-- README.md | 6 ++--- .../README.md | 12 +++++----- .../__fixtures__/avatar.tsx | 0 .../__fixtures__/globals.css | 0 .../__tests__/cli.test.js | 0 .../__tests__/config-parser.test.js | 0 .../__tests__/next-config-patcher.test.js | 0 .../__tests__/prop-parser.test.js | 0 .../__tests__/robots-patcher.test.js | 0 .../__tests__/route-installer.test.js | 2 +- .../__tests__/slug.test.js | 0 .../__tests__/templates.test.js | 8 +++---- .../__tests__/token-parser.test.js | 0 .../cli.js | 0 .../config-parser.js | 0 .../next-config-patcher.js | 0 .../prop-parser.js | 0 .../robots-patcher.js | 0 .../route-installer.js | 0 .../slug.js | 0 .../templates.js | 8 +++---- .../token-parser.js | 0 plugins/adhd/skills/config/SKILL.md | 16 +++++++------- .../SKILL.md | 22 +++++++++---------- 25 files changed, 39 insertions(+), 39 deletions(-) rename plugins/adhd/lib/{setup-design-system-docs-route => sync-docs}/README.md (55%) rename plugins/adhd/lib/{setup-design-system-docs-route => sync-docs}/__fixtures__/avatar.tsx (100%) rename plugins/adhd/lib/{setup-design-system-docs-route => sync-docs}/__fixtures__/globals.css (100%) rename plugins/adhd/lib/{setup-design-system-docs-route => sync-docs}/__tests__/cli.test.js (100%) rename plugins/adhd/lib/{setup-design-system-docs-route => sync-docs}/__tests__/config-parser.test.js (100%) rename plugins/adhd/lib/{setup-design-system-docs-route => sync-docs}/__tests__/next-config-patcher.test.js (100%) rename plugins/adhd/lib/{setup-design-system-docs-route => sync-docs}/__tests__/prop-parser.test.js (100%) rename plugins/adhd/lib/{setup-design-system-docs-route => sync-docs}/__tests__/robots-patcher.test.js (100%) rename plugins/adhd/lib/{setup-design-system-docs-route => sync-docs}/__tests__/route-installer.test.js (99%) rename plugins/adhd/lib/{setup-design-system-docs-route => sync-docs}/__tests__/slug.test.js (100%) rename plugins/adhd/lib/{setup-design-system-docs-route => sync-docs}/__tests__/templates.test.js (96%) rename plugins/adhd/lib/{setup-design-system-docs-route => sync-docs}/__tests__/token-parser.test.js (100%) rename plugins/adhd/lib/{setup-design-system-docs-route => sync-docs}/cli.js (100%) rename plugins/adhd/lib/{setup-design-system-docs-route => sync-docs}/config-parser.js (100%) rename plugins/adhd/lib/{setup-design-system-docs-route => sync-docs}/next-config-patcher.js (100%) rename plugins/adhd/lib/{setup-design-system-docs-route => sync-docs}/prop-parser.js (100%) rename plugins/adhd/lib/{setup-design-system-docs-route => sync-docs}/robots-patcher.js (100%) rename plugins/adhd/lib/{setup-design-system-docs-route => sync-docs}/route-installer.js (100%) rename plugins/adhd/lib/{setup-design-system-docs-route => sync-docs}/slug.js (100%) rename plugins/adhd/lib/{setup-design-system-docs-route => sync-docs}/templates.js (98%) rename plugins/adhd/lib/{setup-design-system-docs-route => sync-docs}/token-parser.js (100%) rename plugins/adhd/skills/{setup-design-system-docs-route => sync-docs}/SKILL.md (77%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b72bdd5..1fd5a07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,8 +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 setup-design-system-docs-route tests - run: node --test plugins/adhd/lib/setup-design-system-docs-route/__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 5049656..ded5847 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ After install, seven slash commands are available: | `/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:setup-design-system-docs-route` | — | 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. | +| `/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. @@ -116,7 +116,7 @@ The skill reads the Figma Component Set, diffs it against the React file's `Reco Run once in your consumer repo: ``` -/adhd:setup-design-system-docs-route +/adhd:sync-docs ``` This generates a documentation page that reads your `globals.css` live at @@ -146,7 +146,7 @@ content="noindex, nofollow" />` either way). 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:setup-design-system-docs-route` to regenerate the static imports. +`/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 diff --git a/plugins/adhd/lib/setup-design-system-docs-route/README.md b/plugins/adhd/lib/sync-docs/README.md similarity index 55% rename from plugins/adhd/lib/setup-design-system-docs-route/README.md rename to plugins/adhd/lib/sync-docs/README.md index 3b69566..a027f17 100644 --- a/plugins/adhd/lib/setup-design-system-docs-route/README.md +++ b/plugins/adhd/lib/sync-docs/README.md @@ -1,17 +1,17 @@ -# lib/setup-design-system-docs-route +# lib/sync-docs -Deterministic helpers for `/adhd:setup-design-system-docs-route`. The -skill (at `plugins/adhd/skills/setup-design-system-docs-route/SKILL.md`) -is the orchestrator; this library is the testable engine. +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 install time (components + cssEntry) +- `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, including a per-install `componentMap.tsx` with static imports +- `route-installer.js` — write the generated files at the target path, including per-sync `componentMap.tsx` and `tokenDomains.tsx` modules - `templates.js` — page template strings (with substitution placeholders) - `cli.js` — orchestrator surface invoked by SKILL.md diff --git a/plugins/adhd/lib/setup-design-system-docs-route/__fixtures__/avatar.tsx b/plugins/adhd/lib/sync-docs/__fixtures__/avatar.tsx similarity index 100% rename from plugins/adhd/lib/setup-design-system-docs-route/__fixtures__/avatar.tsx rename to plugins/adhd/lib/sync-docs/__fixtures__/avatar.tsx diff --git a/plugins/adhd/lib/setup-design-system-docs-route/__fixtures__/globals.css b/plugins/adhd/lib/sync-docs/__fixtures__/globals.css similarity index 100% rename from plugins/adhd/lib/setup-design-system-docs-route/__fixtures__/globals.css rename to plugins/adhd/lib/sync-docs/__fixtures__/globals.css diff --git a/plugins/adhd/lib/setup-design-system-docs-route/__tests__/cli.test.js b/plugins/adhd/lib/sync-docs/__tests__/cli.test.js similarity index 100% rename from plugins/adhd/lib/setup-design-system-docs-route/__tests__/cli.test.js rename to plugins/adhd/lib/sync-docs/__tests__/cli.test.js diff --git a/plugins/adhd/lib/setup-design-system-docs-route/__tests__/config-parser.test.js b/plugins/adhd/lib/sync-docs/__tests__/config-parser.test.js similarity index 100% rename from plugins/adhd/lib/setup-design-system-docs-route/__tests__/config-parser.test.js rename to plugins/adhd/lib/sync-docs/__tests__/config-parser.test.js diff --git a/plugins/adhd/lib/setup-design-system-docs-route/__tests__/next-config-patcher.test.js b/plugins/adhd/lib/sync-docs/__tests__/next-config-patcher.test.js similarity index 100% rename from plugins/adhd/lib/setup-design-system-docs-route/__tests__/next-config-patcher.test.js rename to plugins/adhd/lib/sync-docs/__tests__/next-config-patcher.test.js diff --git a/plugins/adhd/lib/setup-design-system-docs-route/__tests__/prop-parser.test.js b/plugins/adhd/lib/sync-docs/__tests__/prop-parser.test.js similarity index 100% rename from plugins/adhd/lib/setup-design-system-docs-route/__tests__/prop-parser.test.js rename to plugins/adhd/lib/sync-docs/__tests__/prop-parser.test.js diff --git a/plugins/adhd/lib/setup-design-system-docs-route/__tests__/robots-patcher.test.js b/plugins/adhd/lib/sync-docs/__tests__/robots-patcher.test.js similarity index 100% rename from plugins/adhd/lib/setup-design-system-docs-route/__tests__/robots-patcher.test.js rename to plugins/adhd/lib/sync-docs/__tests__/robots-patcher.test.js diff --git a/plugins/adhd/lib/setup-design-system-docs-route/__tests__/route-installer.test.js b/plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js similarity index 99% rename from plugins/adhd/lib/setup-design-system-docs-route/__tests__/route-installer.test.js rename to plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js index 47f1e1a..a068599 100644 --- a/plugins/adhd/lib/setup-design-system-docs-route/__tests__/route-installer.test.js +++ b/plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js @@ -238,7 +238,7 @@ test('component page shows a "not in static map" message when slug is missing', 'utf8', ); assert.match(componentPage, /Not in the static map/); - assert.match(componentPage, /\/adhd:setup-design-system-docs-route/); + assert.match(componentPage, /\/adhd:sync-docs/); }); test('detectExistingInstall returns marker-bearing files', () => { diff --git a/plugins/adhd/lib/setup-design-system-docs-route/__tests__/slug.test.js b/plugins/adhd/lib/sync-docs/__tests__/slug.test.js similarity index 100% rename from plugins/adhd/lib/setup-design-system-docs-route/__tests__/slug.test.js rename to plugins/adhd/lib/sync-docs/__tests__/slug.test.js diff --git a/plugins/adhd/lib/setup-design-system-docs-route/__tests__/templates.test.js b/plugins/adhd/lib/sync-docs/__tests__/templates.test.js similarity index 96% rename from plugins/adhd/lib/setup-design-system-docs-route/__tests__/templates.test.js rename to plugins/adhd/lib/sync-docs/__tests__/templates.test.js index 97590e9..4bdb2b6 100644 --- a/plugins/adhd/lib/setup-design-system-docs-route/__tests__/templates.test.js +++ b/plugins/adhd/lib/sync-docs/__tests__/templates.test.js @@ -85,7 +85,7 @@ test('INDEX_PAGE_TSX has no Troubleshooting section (each route handles its own assert.doesNotMatch(INDEX_PAGE_TSX, /Troubleshooting/); assert.doesNotMatch(INDEX_PAGE_TSX, /app-build-manifest|broad dynamic/i); // It still mentions the re-run command so the user knows how to refresh the map. - assert.match(INDEX_PAGE_TSX, /\/adhd:setup-design-system-docs-route/); + assert.match(INDEX_PAGE_TSX, /\/adhd:sync-docs/); }); test('TOKENS_PAGE_TSX reads globals.css from a baked CSS_ENTRY constant', () => { @@ -114,7 +114,7 @@ test('COMPONENT_PAGE_TSX reads searchParams for prop toggles', () => { test('COMPONENT_PAGE_TSX shows a "Not in the static map" branch for unknown slugs', () => { // Replaces notFound() with an actionable message about re-running setup. assert.match(COMPONENT_PAGE_TSX, /Not in the static map/); - assert.match(COMPONENT_PAGE_TSX, /re-run.*\/adhd:setup-design-system-docs-route/i); + assert.match(COMPONENT_PAGE_TSX, /re-run.*\/adhd:sync-docs/i); }); test('COMPONENT_MAP_TSX has the substitution placeholders the installer needs', () => { @@ -161,14 +161,14 @@ test('none of the templates contain "ADHD" outside the marker', () => { // interacts with the tool, and ejecting from ADHD doesn't break the file — // it just means those references become vestigial guidance the user can edit): // 1. `adhd.config.ts` — the consumer's own config artifact. - // 2. `/adhd:setup-design-system-docs-route` — the slash command name, + // 2. `/adhd:sync-docs` — the slash command name, // referenced in troubleshooting copy so the user knows what to re-run. const all = { LAYOUT_TSX, INDEX_PAGE_TSX, TOKENS_PAGE_TSX, COMPONENT_PAGE_TSX, COMPONENT_ERROR_TSX, COMPONENT_MAP_TSX, PROP_TOGGLE_TSX }; for (const [name, content] of Object.entries(all)) { const body = content .replace(MARKER_COMMENT, '') .replace(/adhd\.config\.ts/g, '') - .replace(/\/adhd:setup-design-system-docs-route/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/setup-design-system-docs-route/__tests__/token-parser.test.js b/plugins/adhd/lib/sync-docs/__tests__/token-parser.test.js similarity index 100% rename from plugins/adhd/lib/setup-design-system-docs-route/__tests__/token-parser.test.js rename to plugins/adhd/lib/sync-docs/__tests__/token-parser.test.js diff --git a/plugins/adhd/lib/setup-design-system-docs-route/cli.js b/plugins/adhd/lib/sync-docs/cli.js similarity index 100% rename from plugins/adhd/lib/setup-design-system-docs-route/cli.js rename to plugins/adhd/lib/sync-docs/cli.js diff --git a/plugins/adhd/lib/setup-design-system-docs-route/config-parser.js b/plugins/adhd/lib/sync-docs/config-parser.js similarity index 100% rename from plugins/adhd/lib/setup-design-system-docs-route/config-parser.js rename to plugins/adhd/lib/sync-docs/config-parser.js diff --git a/plugins/adhd/lib/setup-design-system-docs-route/next-config-patcher.js b/plugins/adhd/lib/sync-docs/next-config-patcher.js similarity index 100% rename from plugins/adhd/lib/setup-design-system-docs-route/next-config-patcher.js rename to plugins/adhd/lib/sync-docs/next-config-patcher.js diff --git a/plugins/adhd/lib/setup-design-system-docs-route/prop-parser.js b/plugins/adhd/lib/sync-docs/prop-parser.js similarity index 100% rename from plugins/adhd/lib/setup-design-system-docs-route/prop-parser.js rename to plugins/adhd/lib/sync-docs/prop-parser.js diff --git a/plugins/adhd/lib/setup-design-system-docs-route/robots-patcher.js b/plugins/adhd/lib/sync-docs/robots-patcher.js similarity index 100% rename from plugins/adhd/lib/setup-design-system-docs-route/robots-patcher.js rename to plugins/adhd/lib/sync-docs/robots-patcher.js diff --git a/plugins/adhd/lib/setup-design-system-docs-route/route-installer.js b/plugins/adhd/lib/sync-docs/route-installer.js similarity index 100% rename from plugins/adhd/lib/setup-design-system-docs-route/route-installer.js rename to plugins/adhd/lib/sync-docs/route-installer.js diff --git a/plugins/adhd/lib/setup-design-system-docs-route/slug.js b/plugins/adhd/lib/sync-docs/slug.js similarity index 100% rename from plugins/adhd/lib/setup-design-system-docs-route/slug.js rename to plugins/adhd/lib/sync-docs/slug.js diff --git a/plugins/adhd/lib/setup-design-system-docs-route/templates.js b/plugins/adhd/lib/sync-docs/templates.js similarity index 98% rename from plugins/adhd/lib/setup-design-system-docs-route/templates.js rename to plugins/adhd/lib/sync-docs/templates.js index 573d88d..bd2cf9e 100644 --- a/plugins/adhd/lib/setup-design-system-docs-route/templates.js +++ b/plugins/adhd/lib/sync-docs/templates.js @@ -132,7 +132,7 @@ function parseTokens(css: string | null) { // `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:setup-design-system-docs-route`. +// `adhd.config.ts`, then re-run `/adhd:sync-docs`. // // Placeholders substituted by route-installer.js: // __COMPONENT_IMPORTS__ — one `import * as $cmpN from "";` per component @@ -209,7 +209,7 @@ export default function DesignSystemDocsLayout({ children }: { children: React.R

Components

{componentEntries.length === 0 ? ( -

None tracked. Add to adhd.config.ts and re-run the setup command.

+

None tracked. Add to adhd.config.ts and re-run /adhd:sync-docs.

) : (
    {componentEntries.map(c => ( @@ -247,7 +247,7 @@ const INDEX_PAGE_TSX = `${MARKER_COMMENT}export default function DesignSystemInd globals.css @theme blocks. Components are statically imported from adhd.config.ts — after editing the components map, re-run - /adhd:setup-design-system-docs-route to regenerate the static imports. + /adhd:sync-docs to regenerate the static imports.

    Only @theme {"{ ... }"} and @theme inline {"{ ... }"} declarations are picked up — plain :root variables aren't. @@ -530,7 +530,7 @@ export default async function ComponentPage({ The slug {slug} isn't present in the generated componentMap.tsx.

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

); diff --git a/plugins/adhd/lib/setup-design-system-docs-route/token-parser.js b/plugins/adhd/lib/sync-docs/token-parser.js similarity index 100% rename from plugins/adhd/lib/setup-design-system-docs-route/token-parser.js rename to plugins/adhd/lib/sync-docs/token-parser.js diff --git a/plugins/adhd/skills/config/SKILL.md b/plugins/adhd/skills/config/SKILL.md index 1c73e02..5fa5928 100644 --- a/plugins/adhd/skills/config/SKILL.md +++ b/plugins/adhd/skills/config/SKILL.md @@ -235,27 +235,27 @@ 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): Set up the design-system docs route +## Phase 6 (optional): Sync 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." +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, install it now" + - "Yes, generate it now" - "No, maybe later" ``` -On "Yes": execute the phases of `/adhd:setup-design-system-docs-route` inline. -See `plugins/adhd/skills/setup-design-system-docs-route/SKILL.md` for the +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:setup-design-system-docs-route later to set it up.` +On "No": print `Run /adhd:sync-docs later to generate it.` Exit normally. ## Reference: Common errors and fix-up guidance diff --git a/plugins/adhd/skills/setup-design-system-docs-route/SKILL.md b/plugins/adhd/skills/sync-docs/SKILL.md similarity index 77% rename from plugins/adhd/skills/setup-design-system-docs-route/SKILL.md rename to plugins/adhd/skills/sync-docs/SKILL.md index ce96404..23c0317 100644 --- a/plugins/adhd/skills/setup-design-system-docs-route/SKILL.md +++ b/plugins/adhd/skills/sync-docs/SKILL.md @@ -1,22 +1,22 @@ --- -description: "Generate a design-system documentation 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 install 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." +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 Setup Design System Docs Route +# ADHD Sync Docs -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 regenerate the static imports. +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:setup-design-system-docs-route` referenced in troubleshooting copy. +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 installer 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. +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 @@ -31,7 +31,7 @@ Read `package.json` and confirm `next` is in `dependencies` or `devDependencies` ## Phase 2: Detect existing install ```bash -node plugins/adhd/lib/setup-design-system-docs-route/cli.js detect-install --app-dir . +node plugins/adhd/lib/sync-docs/cli.js detect-install --app-dir . ``` Output is newline-separated paths of files containing the marker comment. @@ -78,7 +78,7 @@ If `EXISTS` and Phase 2 didn't already mark this as an existing install: prompt ## Phase 6: Patch next.config.ts (only if prod-exclusion: yes) ```bash -node plugins/adhd/lib/setup-design-system-docs-route/cli.js patch-next-config \ +node plugins/adhd/lib/sync-docs/cli.js patch-next-config \ --config "" \ --route-url "" ``` @@ -103,7 +103,7 @@ pageExtensions: process.env.NODE_ENV === 'production' ## Phase 7: Write the page files ```bash -node plugins/adhd/lib/setup-design-system-docs-route/cli.js install \ +node plugins/adhd/lib/sync-docs/cli.js install \ --config ``` @@ -124,7 +124,7 @@ If `adhd.config.ts` is missing, the CLI aborts with `install: failed to read adh ## Phase 8: Patch robots.txt ```bash -node plugins/adhd/lib/setup-design-system-docs-route/cli.js patch-robots \ +node plugins/adhd/lib/sync-docs/cli.js patch-robots \ --robots public/robots.txt \ --route-url "" ``` @@ -138,7 +138,7 @@ mkdir -p public Print: ``` -✓ Design system docs route set up. +✓ Design system docs synced. URL: http://localhost:3000 Filesystem: app/// @@ -152,7 +152,7 @@ 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:setup-design-system-docs-route to regenerate the static imports. +/adhd:sync-docs to regenerate the static imports. Files where you've removed the marker comment are left alone. ``` From 44fb0c6191f880c6f3ff94b5745b7e03f53d1bd1 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 18:52:00 -0400 Subject: [PATCH 28/79] sync-docs: collapse tokenDomains.tsx into the layout User feedback: minimize the committed file surface so future syncs only churn one file (componentMap.tsx). The token-domain catalog was a separate tokenDomains.tsx solely so the layout and tokens page could share it without duplication. Moving the catalog onto the layout as a named export removes that file without re-introducing duplication: the tokens page now imports `{ TOKEN_DOMAINS, type TokenDomain }` from the layout module. Cross-import detail: the layout's basename depends on prod-exclusion (`layout` vs `layout.design-system`). The component-page template uses a `__LAYOUT_MODULE__` placeholder; the installer substitutes the right basename at sync time. TypeScript's bundler module resolution then adds `.tsx` and finds the file in both cases. Verified with an isolated strict-mode tsc test before wiring up. Generated file count drops from 8 to 7. Of those 7, six are committed-once boilerplate (layout, landing, tokens page, component page, error boundary, PropToggle) and one (componentMap.tsx) regenerates per sync. Stale cleanup handles the upgrade path: a previous-version tokenDomains.tsx gets removed on the next sync because it carries the marker comment and isn't in the new target set. Tests + strict tsc on the generated output both clean (99/99 lib tests, 382/382 plugin-wide). Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/adhd/lib/sync-docs/README.md | 2 +- .../__tests__/route-installer.test.js | 57 ++++++++++++++-- .../lib/sync-docs/__tests__/templates.test.js | 35 +++++----- plugins/adhd/lib/sync-docs/route-installer.js | 14 ++-- plugins/adhd/lib/sync-docs/templates.js | 68 ++++++++++--------- 5 files changed, 115 insertions(+), 61 deletions(-) diff --git a/plugins/adhd/lib/sync-docs/README.md b/plugins/adhd/lib/sync-docs/README.md index a027f17..8e85892 100644 --- a/plugins/adhd/lib/sync-docs/README.md +++ b/plugins/adhd/lib/sync-docs/README.md @@ -11,7 +11,7 @@ Modules: - `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 generated files at the target path, including per-sync `componentMap.tsx` and `tokenDomains.tsx` modules +- `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 diff --git a/plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js b/plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js index a068599..0482484 100644 --- a/plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js +++ b/plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js @@ -35,11 +35,12 @@ test('installRoute writes the full generated file set with .design-system suffix assert.ok(fs.existsSync(path.join(docsDir, 'components', '[component]', 'error.design-system.tsx'))); // Shared modules (imported by the route files) are plain .tsx so TS resolves them. assert.ok(fs.existsSync(path.join(docsDir, 'componentMap.tsx'))); - assert.ok(fs.existsSync(path.join(docsDir, 'tokenDomains.tsx'))); assert.ok(fs.existsSync(path.join(docsDir, 'PropToggle.tsx'))); assert.ok(!fs.existsSync(path.join(docsDir, 'componentMap.design-system.tsx'))); - assert.ok(!fs.existsSync(path.join(docsDir, 'tokenDomains.design-system.tsx'))); assert.ok(!fs.existsSync(path.join(docsDir, 'PropToggle.design-system.tsx'))); + // tokenDomains.tsx no longer exists — the catalog is named-exported from the + // layout, and the tokens page imports it from there. + assert.ok(!fs.existsSync(path.join(docsDir, 'tokenDomains.tsx'))); }); test('installRoute writes plain .tsx files for route files when not prodExcluded', () => { @@ -78,7 +79,6 @@ test('all written files start with the marker comment', () => { 'components/[component]/page.design-system.tsx', 'components/[component]/error.design-system.tsx', 'componentMap.tsx', - 'tokenDomains.tsx', 'PropToggle.tsx', ]) { const content = fs.readFileSync(path.join(docsDir, f), 'utf8'); @@ -186,6 +186,55 @@ test('layout imports componentEntries from componentMap (the sidebar list source assert.doesNotMatch(layout, /from "node:fs|from "node:path/); }); +test('tokens page imports TOKEN_DOMAINS from layout.design-system when prodExcluded', () => { + // The layout file's basename has `.design-system` when prod-excluded, and the + // tokens page must use that basename in its import so TS's bundler resolution + // adds `.tsx` and finds it. + const root = makeTempProject(); + installRoute(root, { + groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + 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) when not prodExcluded', () => { + const root = makeTempProject(); + installRoute(root, { + groupName: '(design-system)', routeSegment: '-docs', prodExcluded: false, + 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('re-sync removes stale tokenDomains.tsx from a previous template layout', () => { + // Mirrors the actual upgrade path: previous installer versions wrote + // tokenDomains.tsx alongside componentMap.tsx. Re-syncing should clean it up. + const root = makeTempProject(); + const docsDir = path.join(root, 'app', '(design-system)', '-docs'); + fs.mkdirSync(docsDir, { recursive: true }); + const stalePath = path.join(docsDir, 'tokenDomains.tsx'); + fs.writeFileSync(stalePath, '// design-system-docs-route — stale\nexport const TOKEN_DOMAINS = [];\n'); + + const r = installRoute(root, { + groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', + }); + + assert.ok(!fs.existsSync(stalePath), 'stale tokenDomains.tsx should be removed'); + assert.ok(r.removed.includes(stalePath)); +}); + test('tokens page bakes the configured cssEntry path as a constant', () => { const root = makeTempProject(); installRoute(root, { @@ -251,7 +300,7 @@ test('detectExistingInstall returns marker-bearing files', () => { cssEntry: 'app/globals.css', }); const found = detectExistingInstall(root); - assert.ok(found.length >= 8); + assert.ok(found.length >= 7); assert.ok(found.every(p => p.includes('-docs'))); }); diff --git a/plugins/adhd/lib/sync-docs/__tests__/templates.test.js b/plugins/adhd/lib/sync-docs/__tests__/templates.test.js index 4bdb2b6..e8eba0f 100644 --- a/plugins/adhd/lib/sync-docs/__tests__/templates.test.js +++ b/plugins/adhd/lib/sync-docs/__tests__/templates.test.js @@ -10,7 +10,6 @@ const { COMPONENT_PAGE_TSX, COMPONENT_ERROR_TSX, COMPONENT_MAP_TSX, - TOKEN_DOMAINS_TSX, PROP_TOGGLE_TSX, } = require('../templates'); @@ -28,26 +27,25 @@ test('LAYOUT_TSX sets robots: noindex / nofollow', () => { assert.match(LAYOUT_TSX, /robots:\s*\{[^}]*index:\s*false[^}]*follow:\s*false/); }); -test('LAYOUT_TSX imports the TOKEN_DOMAINS catalog from the shared tokenDomains module', () => { - // Single source of truth lives in tokenDomains.tsx; the layout just imports it - // and iterates. Inlining the labels here would duplicate the catalog (the - // duplication the rewrite was meant to remove). - assert.match(LAYOUT_TSX, /import \{ TOKEN_DOMAINS \} from "\.\/tokenDomains"/); -}); - -test('TOKEN_DOMAINS_TSX exports the full Tailwind v4 token-domain catalog', () => { - // The catalog is THE source of truth — every token domain rendered by the - // tokens page must be listed here with its varPrefix and Tailwind docs link. +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. + // The catalog covers every Tailwind v4 token domain with its varPrefix and + // Tailwind docs link (used for empty-state messaging on each domain page). + 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(TOKEN_DOMAINS_TSX, new RegExp(`label: "${label}"`), `missing domain label: ${label}`); + assert.match(LAYOUT_TSX, new RegExp(`label: "${label}"`), `missing domain label: ${label}`); } - // Shape: each entry has slug + label + varPrefix + tailwindDocs. - assert.match(TOKEN_DOMAINS_TSX, /slug:\s*"colors".*varPrefix:\s*"--color-".*tailwindDocs:/s); - assert.match(TOKEN_DOMAINS_TSX, /export const TOKEN_DOMAINS:/); - assert.match(TOKEN_DOMAINS_TSX, /export type TokenDomain/); + 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 componentEntries from componentMap (no runtime config read)', () => { @@ -95,9 +93,8 @@ test('TOKENS_PAGE_TSX reads globals.css from a baked CSS_ENTRY constant', () => assert.doesNotMatch(TOKENS_PAGE_TSX, /readConfig|adhd\.config\.ts/); }); -test('TOKENS_PAGE_TSX imports TOKEN_DOMAINS from the shared catalog (no inlined list)', () => { - assert.match(TOKENS_PAGE_TSX, /import \{ TOKEN_DOMAINS, type TokenDomain \} from "\.\.\/\.\.\/tokenDomains"/); - // The inline `const TOKEN_DOMAINS = [...]` block from earlier versions is gone. +test('TOKENS_PAGE_TSX does not inline the TOKEN_DOMAINS list', () => { + // The inline catalog from earlier versions is gone — the page imports from the layout. assert.doesNotMatch(TOKENS_PAGE_TSX, /const TOKEN_DOMAINS = \[/); }); diff --git a/plugins/adhd/lib/sync-docs/route-installer.js b/plugins/adhd/lib/sync-docs/route-installer.js index 2133594..37f3d92 100644 --- a/plugins/adhd/lib/sync-docs/route-installer.js +++ b/plugins/adhd/lib/sync-docs/route-installer.js @@ -9,7 +9,6 @@ const { COMPONENT_PAGE_TSX, COMPONENT_ERROR_TSX, COMPONENT_MAP_TSX, - TOKEN_DOMAINS_TSX, PROP_TOGGLE_TSX, } = require('./templates'); @@ -70,17 +69,22 @@ function installRoute(projectRoot, opts) { { abs: path.join(componentsDir, `page${pageExt}`), body: COMPONENT_PAGE_TSX }, { abs: path.join(componentsDir, `error${pageExt}`), body: COMPONENT_ERROR_TSX }, { abs: path.join(docsDir, `componentMap${moduleExt}`), body: renderComponentMap(components) }, - // Shared token-domain catalog: imported by layout (sidebar nav) and the - // tokens page (renderer keys + empty-state link targets). One source of truth. - { abs: path.join(docsDir, `tokenDomains${moduleExt}`), body: TOKEN_DOMAINS_TSX }, { abs: path.join(docsDir, `PropToggle${moduleExt}`), body: PROP_TOGGLE_TSX }, ]; + // 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'; + // Per-template placeholder substitution. for (const t of targets) { t.body = t.body .replace(/__ROUTE_PATH__/g, routeUrl) - .replace(/__CSS_ENTRY__/g, cssEntry); + .replace(/__CSS_ENTRY__/g, cssEntry) + .replace(/__LAYOUT_MODULE__/g, layoutModule); } // Remove stale marker-bearing files from previous template layouts (e.g. the diff --git a/plugins/adhd/lib/sync-docs/templates.js b/plugins/adhd/lib/sync-docs/templates.js index bd2cf9e..bb93828 100644 --- a/plugins/adhd/lib/sync-docs/templates.js +++ b/plugins/adhd/lib/sync-docs/templates.js @@ -4,33 +4,12 @@ const MARKER_COMMENT = `// design-system-docs-route — auto-generated installer // Remove this comment to disable future overwrites from re-running the installer. `; -// tokenDomains.tsx — the single source of truth for the token-domain catalog -// (sidebar entries + per-domain renderer keys). Generated once per install and -// imported by both layout.* and tokens/[domain]/page.*. The `tailwindDocs` -// field is the URL to Tailwind v4's relevant theme section, used in -// empty-state messaging on each domain page. -const TOKEN_DOMAINS_TSX = `${MARKER_COMMENT}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" }, -]; -`; +// 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. @@ -171,11 +150,11 @@ export function getComponent(slug: string): ComponentEntry | null { } `; -// Layout: sidebar links into the token-domain catalog and the static component -// map. No fs reads, no async — pure server component. +// 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 { TOKEN_DOMAINS } from "./tokenDomains"; import { componentEntries } from "./componentMap"; export const metadata: Metadata = { @@ -183,6 +162,32 @@ export const metadata: Metadata = { 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 (
@@ -264,7 +269,7 @@ const INDEX_PAGE_TSX = `${MARKER_COMMENT}export default function DesignSystemInd 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 "../../tokenDomains"; +import { TOKEN_DOMAINS, type TokenDomain } from "__LAYOUT_MODULE__"; ${READ_CSS_SRC} @@ -689,6 +694,5 @@ module.exports = { COMPONENT_PAGE_TSX, COMPONENT_ERROR_TSX, COMPONENT_MAP_TSX, - TOKEN_DOMAINS_TSX, PROP_TOGGLE_TSX, }; From 1d84ab06cb60c0d16170367a4bb35cf0baa107b5 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 19:22:50 -0400 Subject: [PATCH 29/79] sync-docs: inline PropToggle, drop error.tsx, bake prop schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7 files → 5. Of the 5, only componentMap.tsx churns per sync; the other 4 are committed once. - error.tsx is gone. The user's call — they don't want a custom error surface there. Next.js's dev overlay shows runtime errors in dev; prod excludes the route. The "Not in static map" branch in the component page itself stays (it's not an error boundary, just a render path). - PropToggle.tsx is inlined into the component page. To make that work, the component page becomes a client component. To make THAT work without losing prop introspection, parseProps moves to sync time: the installer reads each component's source via the lib's existing prop-parser.js and bakes the resulting `props` schema into each componentMap entry. The page reads schemas from componentMap — no runtime fs reads. - componentMap.tsx's exports collapse to one (`components`) + the `getComponent(slug)` helper. The old `componentEntries` was redundant since `components` carries the same fields plus `Component` and `props`. Layout imports `components` directly. Trade-off: editing a component's prop interface now requires a re-sync to refresh the toggles. Consistent with the rest of the architecture — adhd.config.ts changes already require re-sync, and now prop interfaces do too. The installer's stale-cleanup naturally removes PropToggle.tsx, error.design-system.tsx, and tokenDomains.tsx on the next sync because they carry the marker comment and aren't in the new target set. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/route-installer.test.js | 269 ++++++++------- .../lib/sync-docs/__tests__/templates.test.js | 104 ++---- plugins/adhd/lib/sync-docs/route-installer.js | 45 ++- plugins/adhd/lib/sync-docs/templates.js | 311 +++++++----------- 4 files changed, 328 insertions(+), 401 deletions(-) diff --git a/plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js b/plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js index 0482484..dea5ec9 100644 --- a/plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js +++ b/plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js @@ -8,23 +8,40 @@ const os = require('node:os'); const { installRoute, detectExistingInstall, renderComponentMap } = require('../route-installer'); function makeTempProject() { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'adhd-setup-')); + 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 full generated file set with .design-system suffix when prodExcluded', () => { +test('installRoute writes the five generated files when prodExcluded', () => { const root = makeTempProject(); + writeLogoFixture(root); installRoute(root, { - groupName: '(design-system)', - routeSegment: '-docs', - prodExcluded: true, - components: SAMPLE_COMPONENTS, - cssEntry: 'app/globals.css', + groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + 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. @@ -32,44 +49,36 @@ test('installRoute writes the full generated file set with .design-system suffix 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'))); - assert.ok(fs.existsSync(path.join(docsDir, 'components', '[component]', 'error.design-system.tsx'))); - // Shared modules (imported by the route files) are plain .tsx so TS resolves them. + // componentMap is a plain .tsx module so TS module resolution finds it. assert.ok(fs.existsSync(path.join(docsDir, 'componentMap.tsx'))); - assert.ok(fs.existsSync(path.join(docsDir, 'PropToggle.tsx'))); - assert.ok(!fs.existsSync(path.join(docsDir, 'componentMap.design-system.tsx'))); - assert.ok(!fs.existsSync(path.join(docsDir, 'PropToggle.design-system.tsx'))); - // tokenDomains.tsx no longer exists — the catalog is named-exported from the - // layout, and the tokens page imports it from there. + // 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 when not prodExcluded', () => { const root = makeTempProject(); + writeLogoFixture(root); installRoute(root, { - groupName: '(design-system)', - routeSegment: '-docs', - prodExcluded: false, - components: SAMPLE_COMPONENTS, - cssEntry: 'app/globals.css', + groupName: '(design-system)', routeSegment: '-docs', prodExcluded: false, + 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, 'components', '[component]', 'error.tsx'))); assert.ok(fs.existsSync(path.join(docsDir, 'componentMap.tsx'))); - assert.ok(fs.existsSync(path.join(docsDir, 'PropToggle.tsx'))); }); test('all written files start with the marker comment', () => { const root = makeTempProject(); + writeLogoFixture(root); installRoute(root, { - groupName: '(design-system)', - routeSegment: '-docs', - prodExcluded: true, - components: SAMPLE_COMPONENTS, - cssEntry: 'app/globals.css', + groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', }); const docsDir = path.join(root, 'app', '(design-system)', '-docs'); for (const f of [ @@ -77,9 +86,7 @@ test('all written files start with the marker comment', () => { 'page.design-system.tsx', 'tokens/[domain]/page.design-system.tsx', 'components/[component]/page.design-system.tsx', - 'components/[component]/error.design-system.tsx', 'componentMap.tsx', - 'PropToggle.tsx', ]) { const content = fs.readFileSync(path.join(docsDir, f), 'utf8'); assert.match(content, /design-system-docs-route/, `${f} missing marker`); @@ -88,74 +95,79 @@ test('all written files start with the marker comment', () => { test('componentMap.tsx has explicit static imports per registered component', () => { const root = makeTempProject(); + writeLogoFixture(root); installRoute(root, { - groupName: '(design-system)', - routeSegment: '-docs', - prodExcluded: true, - components: SAMPLE_COMPONENTS, - cssEntry: 'app/globals.css', + groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', }); - const mapPath = path.join(root, 'app', '(design-system)', '-docs', 'componentMap.tsx'); - const body = fs.readFileSync(mapPath, 'utf8'); - // Explicit import for the logo component + 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"/); - // Entry with matching slug and rawPath assert.match(body, /slug: "logo"/); assert.match(body, /rawPath: "components\/design-system\/logo\/index\.tsx"/); assert.match(body, /module: \$cmp0/); - // No dynamic import — that's the whole point of this rewrite assert.doesNotMatch(body, /await\s+import\(`/); }); -test('componentMap.tsx handles an empty components list (no tracked components yet)', () => { +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', - prodExcluded: true, - components: [], - cssEntry: 'app/globals.css', + groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + 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', prodExcluded: true, + components: [], cssEntry: 'app/globals.css', }); const body = fs.readFileSync( path.join(root, 'app', '(design-system)', '-docs', 'componentMap.tsx'), 'utf8', ); - // No import lines for components — placeholder substituted with empty string assert.doesNotMatch(body, /import \* as \$cmp/); - // ENTRIES is an empty array literal assert.match(body, /const ENTRIES.*=\s*\[\]/); }); -test('componentMap.tsx renders multiple components with distinct import bindings', () => { +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', - prodExcluded: true, - components: [ - { slug: 'logo', rawPath: 'src/components/Logo.tsx', importPath: '@/src/components/Logo' }, - { slug: 'button', rawPath: 'src/components/Button/index.tsx', importPath: '@/src/components/Button' }, - ], - cssEntry: 'app/globals.css', + groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + 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 "@\/src\/components\/Logo"/); - assert.match(body, /import \* as \$cmp1 from "@\/src\/components\/Button"/); - assert.match(body, /slug: "logo".*module: \$cmp0/s); - assert.match(body, /slug: "button".*module: \$cmp1/s); + assert.match(body, /props: \{\}/); }); test('layout sidebar links use absolute hrefs derived from the route segment', () => { const root = makeTempProject(); + writeLogoFixture(root); installRoute(root, { - groupName: '(design-system)', - routeSegment: '-docs', - prodExcluded: true, - components: SAMPLE_COMPONENTS, - cssEntry: 'app/globals.css', + groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', }); const layout = fs.readFileSync( path.join(root, 'app', '(design-system)', '-docs', 'layout.design-system.tsx'), @@ -166,31 +178,24 @@ test('layout sidebar links use absolute hrefs derived from the route segment', ( assert.doesNotMatch(layout, /__ROUTE_PATH__/); }); -test('layout imports componentEntries from componentMap (the sidebar list source)', () => { +test('layout imports the static components array from componentMap', () => { const root = makeTempProject(); + writeLogoFixture(root); installRoute(root, { - groupName: '(design-system)', - routeSegment: '-docs', - prodExcluded: true, - components: SAMPLE_COMPONENTS, - cssEntry: 'app/globals.css', + groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + 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, /from "\.\/componentMap"/); - assert.match(layout, /componentEntries/); - // No fs/path imports — the components list is baked at install time so the - // layout doesn't need to read adhd.config.ts at request time. + 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 when prodExcluded', () => { - // The layout file's basename has `.design-system` when prod-excluded, and the - // tokens page must use that basename in its import so TS's bundler resolution - // adds `.tsx` and finds it. const root = makeTempProject(); + writeLogoFixture(root); installRoute(root, { groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', @@ -205,6 +210,7 @@ test('tokens page imports TOKEN_DOMAINS from layout.design-system when prodExclu test('tokens page imports TOKEN_DOMAINS from layout (no suffix) when not prodExcluded', () => { const root = makeTempProject(); + writeLogoFixture(root); installRoute(root, { groupName: '(design-system)', routeSegment: '-docs', prodExcluded: false, components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', @@ -217,70 +223,52 @@ test('tokens page imports TOKEN_DOMAINS from layout (no suffix) when not prodExc assert.doesNotMatch(tokensPage, /__LAYOUT_MODULE__/); }); -test('re-sync removes stale tokenDomains.tsx from a previous template layout', () => { - // Mirrors the actual upgrade path: previous installer versions wrote - // tokenDomains.tsx alongside componentMap.tsx. Re-syncing should clean it up. - const root = makeTempProject(); - const docsDir = path.join(root, 'app', '(design-system)', '-docs'); - fs.mkdirSync(docsDir, { recursive: true }); - const stalePath = path.join(docsDir, 'tokenDomains.tsx'); - fs.writeFileSync(stalePath, '// design-system-docs-route — stale\nexport const TOKEN_DOMAINS = [];\n'); - - const r = installRoute(root, { - groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, - components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', - }); - - assert.ok(!fs.existsSync(stalePath), 'stale tokenDomains.tsx should be removed'); - assert.ok(r.removed.includes(stalePath)); -}); - test('tokens page bakes the configured cssEntry path as a constant', () => { const root = makeTempProject(); + writeLogoFixture(root); installRoute(root, { - groupName: '(design-system)', - routeSegment: '-docs', - prodExcluded: true, - components: SAMPLE_COMPONENTS, - cssEntry: 'src/app/globals.css', + groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + 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"/); - // No runtime read of adhd.config.ts assert.doesNotMatch(tokensPage, /adhd\.config\.ts/); }); -test('component page imports getComponent from componentMap, not a dynamic import', () => { +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', - prodExcluded: true, - components: SAMPLE_COMPONENTS, - cssEntry: 'app/globals.css', + groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + 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, /import \{ getComponent \} from "\.\.\/\.\.\/componentMap"/); - assert.match(componentPage, /import \{ PropToggle \} from "\.\.\/\.\.\/PropToggle"/); - // No broad dynamic import — that's what the rewrite eliminates - assert.doesNotMatch(componentPage, /await\s+import\(`@\//); + // "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', () => { - // This is the new UX for "user added to adhd.config.ts but didn't re-run setup." const root = makeTempProject(); + writeLogoFixture(root); installRoute(root, { - groupName: '(design-system)', - routeSegment: '-docs', - prodExcluded: true, - components: SAMPLE_COMPONENTS, - cssEntry: 'app/globals.css', + groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', }); const componentPage = fs.readFileSync( path.join(root, 'app', '(design-system)', '-docs', 'components', '[component]', 'page.design-system.tsx'), @@ -292,15 +280,13 @@ test('component page shows a "not in static map" message when slug is missing', test('detectExistingInstall returns marker-bearing files', () => { const root = makeTempProject(); + writeLogoFixture(root); installRoute(root, { - groupName: '(design-system)', - routeSegment: '-docs', - prodExcluded: true, - components: SAMPLE_COMPONENTS, - cssEntry: 'app/globals.css', + groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', }); const found = detectExistingInstall(root); - assert.ok(found.length >= 7); + assert.ok(found.length >= 5); assert.ok(found.every(p => p.includes('-docs'))); }); @@ -311,6 +297,7 @@ test('detectExistingInstall returns [] when no marker is present', () => { test('re-running installRoute overwrites files cleanly', () => { const root = makeTempProject(); + writeLogoFixture(root); installRoute(root, { groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', @@ -326,28 +313,35 @@ test('re-running installRoute overwrites files cleanly', () => { assert.match(after, /DesignSystemDocsLayout/); }); -test('installRoute removes stale marker-bearing files from a previous layout', () => { - // Simulate an older install where the dynamic-import-era component page - // lived at `[component]/page` directly under docsDir. +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'); - const oldCompDir = path.join(docsDir, '[component]'); - fs.mkdirSync(oldCompDir, { recursive: true }); - const oldPath = path.join(oldCompDir, 'page.design-system.tsx'); - fs.writeFileSync(oldPath, '// design-system-docs-route — stale\nexport {};\n'); + 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', prodExcluded: true, components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', }); - assert.ok(!fs.existsSync(oldPath)); - assert.ok(r.removed.includes(oldPath)); - assert.ok(!fs.existsSync(oldCompDir)); + 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'); @@ -363,6 +357,7 @@ test('installRoute preserves user files that lack the marker', () => { test('installRoute supports an empty groupName (no route group)', () => { const root = makeTempProject(); + writeLogoFixture(root); installRoute(root, { groupName: '', routeSegment: '-docs', prodExcluded: true, components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', @@ -372,11 +367,15 @@ test('installRoute supports an empty groupName (no route group)', () => { assert.ok(fs.existsSync(path.join(docsDir, 'componentMap.tsx'))); }); -test('renderComponentMap is exposed (standalone snapshot of the map source)', () => { - const body = renderComponentMap([ +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__/templates.test.js b/plugins/adhd/lib/sync-docs/__tests__/templates.test.js index e8eba0f..f0aef54 100644 --- a/plugins/adhd/lib/sync-docs/__tests__/templates.test.js +++ b/plugins/adhd/lib/sync-docs/__tests__/templates.test.js @@ -8,9 +8,7 @@ const { INDEX_PAGE_TSX, TOKENS_PAGE_TSX, COMPONENT_PAGE_TSX, - COMPONENT_ERROR_TSX, COMPONENT_MAP_TSX, - PROP_TOGGLE_TSX, } = require('../templates'); test('MARKER_COMMENT is a stable, non-ADHD-referencing string', () => { @@ -29,8 +27,6 @@ test('LAYOUT_TSX sets robots: noindex / nofollow', () => { 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. - // The catalog covers every Tailwind v4 token domain with its varPrefix and - // Tailwind docs link (used for empty-state messaging on each domain page). assert.match(LAYOUT_TSX, /export const TOKEN_DOMAINS: TokenDomain\[\]/); assert.match(LAYOUT_TSX, /export type TokenDomain/); for (const label of [ @@ -48,119 +44,87 @@ test('TOKENS_PAGE_TSX imports the catalog from the layout via a __LAYOUT_MODULE_ assert.match(TOKENS_PAGE_TSX, /import \{ TOKEN_DOMAINS, type TokenDomain \} from "__LAYOUT_MODULE__"/); }); -test('LAYOUT_TSX imports componentEntries from componentMap (no runtime config read)', () => { - // Static architecture: the layout doesn't read adhd.config.ts at request time. - // Instead, the installer generates componentMap.tsx with the components baked in, - // and the layout imports componentEntries from it. - assert.match(LAYOUT_TSX, /from "\.\/componentMap"/); - assert.match(LAYOUT_TSX, /componentEntries/); - // No fs/path imports — the layout is a pure render now - assert.doesNotMatch(LAYOUT_TSX, /from "node:fs|from "node:path|readConfig\(/); +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 now', () => { - // No fs reads anywhere in the layout; it's a pure render. +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('LAYOUT_TSX has no diagnostic banner (removed with the dynamic-import architecture)', () => { - // The DiagnosticBanner existed to flag missing tokens that surfaced under the - // broad dynamic import. Static imports eliminate that failure mode entirely. - assert.doesNotMatch(LAYOUT_TSX, /DiagnosticBanner|detectIssues|ring-offset-background/); -}); - 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 (each route handles its own failure modes)', () => { - // The component page surfaces "not in static map" itself; error.tsx catches - // runtime crashes; token pages link to Tailwind docs for empty domains. - // The landing page just orients the user — no duplicated troubleshooting copy. +test('INDEX_PAGE_TSX has no Troubleshooting section', () => { assert.doesNotMatch(INDEX_PAGE_TSX, /Troubleshooting/); - assert.doesNotMatch(INDEX_PAGE_TSX, /app-build-manifest|broad dynamic/i); - // It still mentions the re-run command so the user knows how to refresh the map. 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/); - // Tokens page no longer reads adhd.config.ts at request time - assert.doesNotMatch(TOKENS_PAGE_TSX, /readConfig|adhd\.config\.ts/); + assert.doesNotMatch(TOKENS_PAGE_TSX, /adhd\.config\.ts/); }); test('TOKENS_PAGE_TSX does not inline the TOKEN_DOMAINS list', () => { - // The inline catalog from earlier versions is gone — the page imports from the layout. assert.doesNotMatch(TOKENS_PAGE_TSX, /const TOKEN_DOMAINS = \[/); }); -test('COMPONENT_PAGE_TSX uses getComponent from the static componentMap (no dynamic import)', () => { - assert.match(COMPONENT_PAGE_TSX, /import \{ getComponent \} from "\.\.\/\.\.\/componentMap"/); - // No template-literal dynamic import +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 reads searchParams for prop toggles', () => { - assert.match(COMPONENT_PAGE_TSX, /searchParams/); +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', () => { - // Replaces notFound() with an actionable message about re-running setup. 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 needs', () => { - // The template is a per-install-generated file. These placeholders are - // filled in by route-installer.js's renderComponentMap. +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 componentEntries/); + assert.match(COMPONENT_MAP_TSX, /export const components/); + assert.match(COMPONENT_MAP_TSX, /export type PropSchema/); }); test('COMPONENT_MAP_TSX resolves a renderable function via default-then-named fallback', () => { - // Mirrors the runtime behavior of the previous dynamic-import resolution: - // prefer default export, fall back to first named function. This keeps - // existing user components working without changes. assert.match(COMPONENT_MAP_TSX, /function resolveComponent/); assert.match(COMPONENT_MAP_TSX, /mod\.default/); }); -test('PROP_TOGGLE_TSX is a client component', () => { - const afterMarker = PROP_TOGGLE_TSX.replace(MARKER_COMMENT, ''); - assert.match(afterMarker, /^["']use client["']/); -}); - -test('PROP_TOGGLE_TSX uses router.replace for snappy URL updates', () => { - assert.match(PROP_TOGGLE_TSX, /router\.replace/); -}); - -test('COMPONENT_ERROR_TSX is a client component error boundary', () => { - const afterMarker = COMPONENT_ERROR_TSX.replace(MARKER_COMMENT, ''); - assert.match(afterMarker, /^["']use client["']/); - assert.match(COMPONENT_ERROR_TSX, /error.*reset/); - assert.match(COMPONENT_ERROR_TSX, /reset\(\)/); -}); - -test('COMPONENT_ERROR_TSX no longer has the build-manifest-specific copy', () => { - // With static imports, the build-manifest ENOENT failure mode is gone, so - // the error boundary no longer needs to special-case it. - assert.doesNotMatch(COMPONENT_ERROR_TSX, /app-build-manifest|isBuildManifestError/); -}); - test('none of the templates contain "ADHD" outside the marker', () => { - // Two filename-style exceptions are allowed (they're how the user actually - // interacts with the tool, and ejecting from ADHD doesn't break the file — - // it just means those references become vestigial guidance the user can edit): + // 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 troubleshooting copy so the user knows what to re-run. - const all = { LAYOUT_TSX, INDEX_PAGE_TSX, TOKENS_PAGE_TSX, COMPONENT_PAGE_TSX, COMPONENT_ERROR_TSX, COMPONENT_MAP_TSX, PROP_TOGGLE_TSX }; + // 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, '') diff --git a/plugins/adhd/lib/sync-docs/route-installer.js b/plugins/adhd/lib/sync-docs/route-installer.js index 37f3d92..fac463d 100644 --- a/plugins/adhd/lib/sync-docs/route-installer.js +++ b/plugins/adhd/lib/sync-docs/route-installer.js @@ -7,10 +7,9 @@ const { INDEX_PAGE_TSX, TOKENS_PAGE_TSX, COMPONENT_PAGE_TSX, - COMPONENT_ERROR_TSX, COMPONENT_MAP_TSX, - PROP_TOGGLE_TSX, } = require('./templates'); +const { parseProps } = require('./prop-parser'); const MARKER_STR = 'design-system-docs-route'; @@ -18,15 +17,45 @@ function mkdirpSync(p) { fs.mkdirSync(p, { recursive: true }); } +// Reads a component's source file and returns a sync-time-baked prop schema +// suitable for embedding in componentMap.tsx. The schema mirrors the runtime +// PropSchema type in the template — only the shape the page actually uses +// (type + optional values for unions + the `optional` flag). +function bakedPropsFor(projectRoot, rawPath) { + const fullPath = path.join(projectRoot, rawPath); + let src; + try { src = fs.readFileSync(fullPath, 'utf8'); } + catch { return {}; } + const { props } = parseProps(src); + const out = {}; + for (const [name, def] of Object.entries(props)) { + // The page only renders toggles for these four shapes; everything else + // shows up as "toggle unavailable" so collapsing to "unknown" is fine. + if (def.type === 'union' && Array.isArray(def.values)) { + out[name] = { type: 'union', values: def.values, optional: !!def.optional }; + } else if (def.type === 'boolean' || def.type === 'string' || def.type === 'number') { + out[name] = { type: def.type, optional: !!def.optional }; + } else { + out[name] = { type: 'unknown', optional: !!def.optional }; + } + } + return out; +} + // Build the import + entries source for componentMap.tsx from the parsed -// adhd.config.ts components list. Empty list is fine — the map exports an -// empty array and the layout's sidebar shows a friendly "none tracked" message. -function renderComponentMap(components) { +// adhd.config.ts components list. Each entry includes baked prop schemas so +// the component page doesn't need to do any fs reads at request time. Empty +// list is fine — the map exports an empty `components` array and the layout's +// sidebar shows a friendly "none tracked" message. +function renderComponentMap(projectRoot, components) { const imports = components .map((c, i) => `import * as $cmp${i} from "${c.importPath}";`) .join('\n'); const entries = components - .map((c, i) => ` { slug: ${JSON.stringify(c.slug)}, rawPath: ${JSON.stringify(c.rawPath)}, module: $cmp${i} },`) + .map((c, i) => { + const props = JSON.stringify(bakedPropsFor(projectRoot, c.rawPath)); + return ` { slug: ${JSON.stringify(c.slug)}, rawPath: ${JSON.stringify(c.rawPath)}, module: $cmp${i}, props: ${props} },`; + }) .join('\n'); return COMPONENT_MAP_TSX .replace('__COMPONENT_IMPORTS__', imports) @@ -67,9 +96,7 @@ function installRoute(projectRoot, opts) { { 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(componentsDir, `error${pageExt}`), body: COMPONENT_ERROR_TSX }, - { abs: path.join(docsDir, `componentMap${moduleExt}`), body: renderComponentMap(components) }, - { abs: path.join(docsDir, `PropToggle${moduleExt}`), body: PROP_TOGGLE_TSX }, + { abs: path.join(docsDir, `componentMap${moduleExt}`), body: renderComponentMap(projectRoot, components) }, ]; // The tokens page imports TOKEN_DOMAINS from the layout. The layout file's diff --git a/plugins/adhd/lib/sync-docs/templates.js b/plugins/adhd/lib/sync-docs/templates.js index bb93828..b94ece7 100644 --- a/plugins/adhd/lib/sync-docs/templates.js +++ b/plugins/adhd/lib/sync-docs/templates.js @@ -119,12 +119,33 @@ function parseTokens(css: string | null) { const COMPONENT_MAP_TSX = `${MARKER_COMMENT}import type React from "react"; __COMPONENT_IMPORTS__ -type ModuleShape = Record; +// 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; + Component: React.ComponentType | null; + props: Record; +}; + +type RawEntry = { + slug: string; + rawPath: string; + module: Record; + props: Record; +}; -// Resolve the renderable function from a module: prefer the default export, -// fall back to the first exported function. Mirrors the previous runtime -// resolution behavior so existing user components keep working. -function resolveComponent(mod: ModuleShape): React.ComponentType | null { +// 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; @@ -132,21 +153,17 @@ function resolveComponent(mod: ModuleShape): React.ComponentType | null { return null; } -export type ComponentEntry = { - slug: string; - rawPath: string; - Component: React.ComponentType | null; -}; - -const ENTRIES: Array<{ slug: string; rawPath: string; module: ModuleShape }> = __COMPONENT_ENTRIES__; +const ENTRIES: RawEntry[] = __COMPONENT_ENTRIES__; -export const componentEntries: Array<{ slug: string; rawPath: string }> = - ENTRIES.map(e => ({ slug: e.slug, rawPath: e.rawPath })); +export const components: ComponentEntry[] = ENTRIES.map(e => ({ + slug: e.slug, + rawPath: e.rawPath, + Component: resolveComponent(e.module), + props: e.props, +})); -export function getComponent(slug: string): ComponentEntry | null { - const entry = ENTRIES.find(e => e.slug === slug); - if (!entry) return null; - return { slug: entry.slug, rawPath: entry.rawPath, Component: resolveComponent(entry.module) }; +export function getComponent(slug: string): ComponentEntry | undefined { + return components.find(c => c.slug === slug); } `; @@ -155,7 +172,7 @@ export function getComponent(slug: string): ComponentEntry | null { // No fs reads, no async — pure server component. const LAYOUT_TSX = `${MARKER_COMMENT}import type { Metadata } from "next"; import Link from "next/link"; -import { componentEntries } from "./componentMap"; +import { components } from "./componentMap"; export const metadata: Metadata = { title: "Design System Docs", @@ -213,11 +230,11 @@ export default function DesignSystemDocsLayout({ children }: { children: React.R

Components

- {componentEntries.length === 0 ? ( + {components.length === 0 ? (

None tracked. Add to adhd.config.ts and re-run /adhd:sync-docs.

) : (
    - {componentEntries.map(c => ( + {components.map(c => (
  • {c.slug} @@ -468,97 +485,93 @@ export default async function TokensDomainPage({ params }: { params: Promise<{ d } `; -// Component page — uses the statically generated componentMap. No fs reads of -// adhd.config.ts at request time; the rawPath comes from the map. The page -// still reads the component's source via fs to introspect prop interfaces -// (that's a one-file read per request, not a bundle). -const COMPONENT_PAGE_TSX = `${MARKER_COMMENT}import fs from "node:fs/promises"; -import path from "node:path"; -import { PropToggle } from "../../PropToggle"; -import { getComponent } from "../../componentMap"; - -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: {} }; +// 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 ( + + ); } -export default async function ComponentPage({ - params, - searchParams, -}: { - params: Promise<{ component: string }>; - searchParams: Promise>; -}) { - const { component: slug } = await params; - const sp = await searchParams; +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 ( -
    -

    Not in the static map

    -

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

    -

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

    -
    - ); - } + if (!entry) return ; - const { rawPath, Component } = entry; - const { props } = await parseProps(rawPath); + const { rawPath, Component, props } = entry; - // Resolve current prop values from searchParams + // Resolve current prop values from the URL. 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; + 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); } const importPath = "@/" + rawPath.replace(/\\.tsx?$/, "").replace(/\\/index$/, ""); - const importStmt = Component ? \`import \${Component.name ?? slug} from "\${importPath}";\` : null; + const componentName = Component?.name || slug; + const importStmt = Component ? \`import \${componentName} from "\${importPath}";\` : null; const jsxSnippet = Component - ? \`<\${Component.name ?? slug}\${Object.entries(current).map(([k,v]) => \` \${k}={\${JSON.stringify(v)}}\`).join("")} />\` + ? \`<\${componentName}\${Object.entries(current).map(([k, v]) => \` \${k}={\${JSON.stringify(v)}}\`).join("")} />\` : null; return ( @@ -567,23 +580,19 @@ export default async function ComponentPage({

    Props

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

    No prop introspection available.

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

    No prop interface detected at sync time.

    + ) : (
    - {Object.entries(props).map(([name, def]: [string, any]) => { - if (def.type === "union") { - return ( - - ); + {Object.entries(props).map(([name, def]) => { + if (def.type === "union" && def.values) { + return ; } if (def.type === "boolean") { - return ( - - ); + return ; } if (def.type === "string" || def.type === "number") { - return ( - - ); + return ; } return (
    @@ -596,8 +605,12 @@ export default async function ComponentPage({
    - {Component ? : ( -

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

    + {Component ? ( + + ) : ( +

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

    )}
    @@ -612,87 +625,11 @@ export default async function ComponentPage({ } `; -// Error boundary for the component route. Catches runtime errors thrown during -// rendering — components that throw on mount, prop-parse failures, etc. With -// static imports there's no broad-bundle Tailwind blast radius anymore, so this -// is purely a runtime safety net. -const COMPONENT_ERROR_TSX = `${MARKER_COMMENT}"use client"; - -export default function ComponentPageError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { - return ( -
    -
    -

    Couldn't render this component

    -

    - Something went wrong while rendering this component. Common causes: -

    -
      -
    • The component throws on mount when no props are provided.
    • -
    • The component expects context (theme provider, router, query client) that the docs route doesn't set up.
    • -
    • The component's prop interface uses types the docs route can't introspect.
    • -
    -
    - Show error details -
    {error.message}{error.digest ? \`\\n\\nDigest: \${error.digest}\` : ""}
    -
    - -
    -
    - ); -} -`; - -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, TOKENS_PAGE_TSX, COMPONENT_PAGE_TSX, - COMPONENT_ERROR_TSX, COMPONENT_MAP_TSX, - PROP_TOGGLE_TSX, }; From 728652e9f3edf1eb9d9c5b4fdb53c79bd0098af3 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 19:37:11 -0400 Subject: [PATCH 30/79] sync-docs: render-where wizard option adds Vercel-preview mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old "Exclude from production builds? Yes/No" Phase 3 question becomes a 3-option choice: Dev only / Dev + Vercel preview / Everywhere. The choice flows through as a single `renderMode` field replacing the binary `prodExcluded` shape. next-config-patcher generates a different conditional per mode: - "dev-only" (default, current behavior): pageExtensions: process.env.NODE_ENV === 'production' ? ['ts', 'tsx'] : ['ts', 'tsx', 'design-system.ts', 'design-system.tsx'] - "vercel-preview" (compound condition gates only on the Vercel choice): pageExtensions: process.env.VERCEL_ENV === 'production' || (!process.env.VERCEL && process.env.NODE_ENV === 'production') ? ['ts', 'tsx'] : ['ts', 'tsx', 'design-system.ts', 'design-system.tsx'] - "everywhere" — skip the patch entirely; files use plain .tsx and ship to production. Layout's `robots: { index: false, follow: false }` metadata still prevents indexing. The compound condition for vercel-preview excludes on Vercel production AND on any non-Vercel production deploy. Vercel preview deploys keep their route because VERCEL_ENV='preview' doesn't satisfy either disjunct. `isPatched` widened to recognize EITHER conditional shape so re-running sync-docs is a no-op regardless of which mode was originally chosen. Switching modes requires removing the marker comment from next.config (same eject-by-deleting-the-marker pattern as the generated route files). UX call: kept this as a single multi-option question in the existing Phase 3 wizard, not a stepped "Are you on Vercel?" flow. Three labeled options stay in one prompt — no extra round-trips, descriptions name the exact env vars baked in. 103 sync-docs tests + 386 plugin-wide, strict tsc on the generated output clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 19 ++-- .../adhd/lib/sync-docs/__tests__/cli.test.js | 4 +- .../__tests__/next-config-patcher.test.js | 38 ++++++++ .../__tests__/route-installer.test.js | 89 ++++++++++++++----- plugins/adhd/lib/sync-docs/cli.js | 5 +- .../adhd/lib/sync-docs/next-config-patcher.js | 35 ++++++-- plugins/adhd/lib/sync-docs/route-installer.js | 29 ++++-- plugins/adhd/skills/sync-docs/SKILL.md | 38 ++++++-- 8 files changed, 204 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index ded5847..834d4ff 100644 --- a/README.md +++ b/README.md @@ -135,11 +135,20 @@ request time and statically imports the components listed in - Component pages: each component gets its own route with URL-driven prop toggles, derived from the component's TypeScript prop interface. -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 setup -time if you'd rather ship the route (it still has `` either way). +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 diff --git a/plugins/adhd/lib/sync-docs/__tests__/cli.test.js b/plugins/adhd/lib/sync-docs/__tests__/cli.test.js index 98c624c..33ae960 100644 --- a/plugins/adhd/lib/sync-docs/__tests__/cli.test.js +++ b/plugins/adhd/lib/sync-docs/__tests__/cli.test.js @@ -108,7 +108,7 @@ const config = { export default config; `); const choices = tmp('choices.json', JSON.stringify({ - projectRoot: root, groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + 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); @@ -124,7 +124,7 @@ test('install subcommand aborts with a clear error when adhd.config.ts is missin 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', prodExcluded: true, + 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); 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 index a994874..ff23891 100644 --- a/plugins/adhd/lib/sync-docs/__tests__/next-config-patcher.test.js +++ b/plugins/adhd/lib/sync-docs/__tests__/next-config-patcher.test.js @@ -66,3 +66,41 @@ test('patchNextConfig refuses to silently overwrite an existing different pageEx 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/, + ); +}); diff --git a/plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js b/plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js index dea5ec9..a41aa11 100644 --- a/plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js +++ b/plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js @@ -36,11 +36,11 @@ const SAMPLE_COMPONENTS = [ { slug: 'logo', rawPath: 'components/design-system/logo/index.tsx', importPath: '@/components/design-system/logo' }, ]; -test('installRoute writes the five generated files when prodExcluded', () => { +test('installRoute writes the five generated files with renderMode: dev-only', () => { const root = makeTempProject(); writeLogoFixture(root); installRoute(root, { - groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only', components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', }); const docsDir = path.join(root, 'app', '(design-system)', '-docs'); @@ -58,11 +58,11 @@ test('installRoute writes the five generated files when prodExcluded', () => { assert.ok(!fs.existsSync(path.join(docsDir, 'tokenDomains.tsx'))); }); -test('installRoute writes plain .tsx files for route files when not prodExcluded', () => { +test('installRoute writes plain .tsx files for route files with renderMode: "everywhere"', () => { const root = makeTempProject(); writeLogoFixture(root); installRoute(root, { - groupName: '(design-system)', routeSegment: '-docs', prodExcluded: false, + groupName: '(design-system)', routeSegment: '-docs', renderMode: 'everywhere', components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', }); const docsDir = path.join(root, 'app', '(design-system)', '-docs'); @@ -73,11 +73,52 @@ test('installRoute writes plain .tsx files for route files when not prodExcluded 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', prodExcluded: true, + groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only', components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', }); const docsDir = path.join(root, 'app', '(design-system)', '-docs'); @@ -97,7 +138,7 @@ test('componentMap.tsx has explicit static imports per registered component', () const root = makeTempProject(); writeLogoFixture(root); installRoute(root, { - groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only', components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', }); const body = fs.readFileSync( @@ -118,7 +159,7 @@ test('componentMap.tsx bakes prop schemas read from each component source at syn const root = makeTempProject(); writeLogoFixture(root); installRoute(root, { - groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only', components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', }); const body = fs.readFileSync( @@ -134,7 +175,7 @@ test('componentMap.tsx bakes prop schemas read from each component source at syn test('componentMap.tsx handles an empty components list', () => { const root = makeTempProject(); installRoute(root, { - groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only', components: [], cssEntry: 'app/globals.css', }); const body = fs.readFileSync( @@ -152,7 +193,7 @@ test('componentMap.tsx handles a missing component source file (empty props bake const root = makeTempProject(); // Note: we DON'T call writeLogoFixture — the file is missing on purpose. installRoute(root, { - groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only', components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', }); const body = fs.readFileSync( @@ -166,7 +207,7 @@ test('layout sidebar links use absolute hrefs derived from the route segment', ( const root = makeTempProject(); writeLogoFixture(root); installRoute(root, { - groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only', components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', }); const layout = fs.readFileSync( @@ -182,7 +223,7 @@ test('layout imports the static components array from componentMap', () => { const root = makeTempProject(); writeLogoFixture(root); installRoute(root, { - groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only', components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', }); const layout = fs.readFileSync( @@ -193,11 +234,11 @@ test('layout imports the static components array from componentMap', () => { assert.doesNotMatch(layout, /from "node:fs|from "node:path/); }); -test('tokens page imports TOKEN_DOMAINS from layout.design-system when prodExcluded', () => { +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', prodExcluded: true, + groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only', components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', }); const tokensPage = fs.readFileSync( @@ -208,11 +249,11 @@ test('tokens page imports TOKEN_DOMAINS from layout.design-system when prodExclu assert.doesNotMatch(tokensPage, /__LAYOUT_MODULE__/); }); -test('tokens page imports TOKEN_DOMAINS from layout (no suffix) when not prodExcluded', () => { +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', prodExcluded: false, + groupName: '(design-system)', routeSegment: '-docs', renderMode: 'everywhere', components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', }); const tokensPage = fs.readFileSync( @@ -227,7 +268,7 @@ test('tokens page bakes the configured cssEntry path as a constant', () => { const root = makeTempProject(); writeLogoFixture(root); installRoute(root, { - groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only', components: SAMPLE_COMPONENTS, cssEntry: 'src/app/globals.css', }); const tokensPage = fs.readFileSync( @@ -242,7 +283,7 @@ test('component page is a client component with inline PropToggle and no fs read const root = makeTempProject(); writeLogoFixture(root); installRoute(root, { - groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only', components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', }); const componentPage = fs.readFileSync( @@ -267,7 +308,7 @@ 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', prodExcluded: true, + groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only', components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', }); const componentPage = fs.readFileSync( @@ -282,7 +323,7 @@ test('detectExistingInstall returns marker-bearing files', () => { const root = makeTempProject(); writeLogoFixture(root); installRoute(root, { - groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only', components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', }); const found = detectExistingInstall(root); @@ -299,13 +340,13 @@ test('re-running installRoute overwrites files cleanly', () => { const root = makeTempProject(); writeLogoFixture(root); installRoute(root, { - groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + 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', prodExcluded: true, + groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only', components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', }); const after = fs.readFileSync(layoutPath, 'utf8'); @@ -329,7 +370,7 @@ test('re-sync removes stale files from previous template layouts', () => { for (const p of stale) fs.writeFileSync(p, '// design-system-docs-route — stale\nexport {};\n'); const r = installRoute(root, { - groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only', components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', }); @@ -348,7 +389,7 @@ test('installRoute preserves user files that lack the marker', () => { fs.writeFileSync(userFile, '// user wrote this\nexport const NOTE = "keep";\n'); installRoute(root, { - groupName: '(design-system)', routeSegment: '-docs', prodExcluded: true, + groupName: '(design-system)', routeSegment: '-docs', renderMode: 'dev-only', components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', }); @@ -359,7 +400,7 @@ test('installRoute supports an empty groupName (no route group)', () => { const root = makeTempProject(); writeLogoFixture(root); installRoute(root, { - groupName: '', routeSegment: '-docs', prodExcluded: true, + groupName: '', routeSegment: '-docs', renderMode: 'dev-only', components: SAMPLE_COMPONENTS, cssEntry: 'app/globals.css', }); const docsDir = path.join(root, 'app', '-docs'); diff --git a/plugins/adhd/lib/sync-docs/cli.js b/plugins/adhd/lib/sync-docs/cli.js index 1fd27c0..bf5bdae 100644 --- a/plugins/adhd/lib/sync-docs/cli.js +++ b/plugins/adhd/lib/sync-docs/cli.js @@ -61,14 +61,15 @@ function main() { } if (cmd === 'patch-next-config') { - if (!args.config || !args['route-url']) { console.error('Usage: patch-next-config --config --route-url '); process.exit(2); } + if (!args.config || !args['route-url']) { console.error('Usage: patch-next-config --config --route-url [--render-mode ]'); process.exit(2); } + const renderMode = args['render-mode'] || 'dev-only'; 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); + const out = patchNextConfig(src, { renderMode }); fs.writeFileSync(args.config, out); process.exit(0); } diff --git a/plugins/adhd/lib/sync-docs/next-config-patcher.js b/plugins/adhd/lib/sync-docs/next-config-patcher.js index e3d9ee2..a4bba2d 100644 --- a/plugins/adhd/lib/sync-docs/next-config-patcher.js +++ b/plugins/adhd/lib/sync-docs/next-config-patcher.js @@ -1,8 +1,9 @@ '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_RE = /pageExtensions:\s*process\.env\.NODE_ENV\s*===\s*['"]production['"][\s\S]*?'design-system\.tsx'/; +// Detection: look for any pageExtensions entry that mentions our sentinel +// "design-system.tsx" — that's the unique fingerprint of OUR patch, regardless +// of which env-var condition guards it ('NODE_ENV' vs 'VERCEL_ENV'). +const PATCHED_SENTINEL_RE = /pageExtensions:[\s\S]*?'design-system\.tsx'/; // Detection: any other pageExtensions definition (array form). const EXISTING_PAGE_EXTENSIONS_RE = /pageExtensions:\s*\[/; @@ -10,9 +11,23 @@ const EXISTING_PAGE_EXTENSIONS_RE = /pageExtensions:\s*\[/; // Captures the full `pageExtensions: ...,` declaration for conflict reporting. const EXISTING_PAGE_EXTENSIONS_VALUE_RE = /pageExtensions:[^,\n]+,?/; -const PATCH_BLOCK = ` pageExtensions: process.env.NODE_ENV === 'production' +// Render-mode → conditional source. The "everywhere" mode is handled by NOT +// calling the patcher at all (files use plain `.tsx` extensions and ship to +// production). The two excluding modes only differ in which env vars they read. +const PATCH_BLOCKS = { + 'dev-only': ` pageExtensions: process.env.NODE_ENV === 'production' ? ['ts', 'tsx'] - : ['ts', 'tsx', 'design-system.ts', 'design-system.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'],`, +}; function isPatched(source) { return PATCHED_SENTINEL_RE.test(source); @@ -49,6 +64,12 @@ function patchNextConfig(source, options = {}) { throw new Error('next.config already sets pageExtensions to a different value. Run with detectOnly: true to inspect and prompt the user.'); } + const renderMode = options.renderMode || 'dev-only'; + const block = PATCH_BLOCKS[renderMode]; + if (!block) { + throw new Error(`Unknown renderMode: ${renderMode}. Expected one of: ${Object.keys(PATCH_BLOCKS).join(', ')}.`); + } + const insertAt = findConfigObjectStart(source); if (insertAt === -1) { throw new Error('Could not locate the config object in next.config. Manual edit required.'); @@ -58,9 +79,9 @@ function patchNextConfig(source, options = {}) { // properties. This puts it at the top of the config for visibility. const before = source.slice(0, insertAt); // Strip any leading newline from the tail so it isn't duplicated; we always - // emit exactly one `\n` on each side of PATCH_BLOCK for clean formatting. + // emit exactly one `\n` on each side of the block for clean formatting. const after = source.slice(insertAt).replace(/^\n/, ''); - return before + '\n' + PATCH_BLOCK + '\n' + after; + return before + '\n' + block + '\n' + after; } module.exports = { patchNextConfig, isPatched }; diff --git a/plugins/adhd/lib/sync-docs/route-installer.js b/plugins/adhd/lib/sync-docs/route-installer.js index fac463d..97b15d9 100644 --- a/plugins/adhd/lib/sync-docs/route-installer.js +++ b/plugins/adhd/lib/sync-docs/route-installer.js @@ -62,21 +62,36 @@ function renderComponentMap(projectRoot, components) { .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, - prodExcluded, + renderMode = 'dev-only', components = [], cssEntry = 'app/globals.css', } = opts; if (!routeSegment) throw new Error('routeSegment is required'); - - // Page/layout/error files get the `.design-system.tsx` suffix only when - // prod-excluded so Next.js's `pageExtensions` filters them out of production - // builds. componentMap and PropToggle are regular modules — they're 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 them). + 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']; diff --git a/plugins/adhd/skills/sync-docs/SKILL.md b/plugins/adhd/skills/sync-docs/SKILL.md index 23c0317..2a9ba4e 100644 --- a/plugins/adhd/skills/sync-docs/SKILL.md +++ b/plugins/adhd/skills/sync-docs/SKILL.md @@ -50,7 +50,18 @@ Ask all three questions in a **single** `AskUserQuestion` call so the user sees 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`. +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. @@ -75,27 +86,40 @@ 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) +## Phase 6: Patch next.config.ts (only when `renderMode !== "everywhere"`) + +Skip this phase entirely if `renderMode` is `"everywhere"` — those files use plain `.tsx` and ship to prod, so no `pageExtensions` conditional is needed. + +For the two excluding modes, the patcher generates a different conditional based on `--render-mode`: ```bash node plugins/adhd/lib/sync-docs/cli.js patch-next-config \ --config "" \ - --route-url "" + --route-url "" \ + --render-mode "" ``` 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 (see below) and continue with file installs. +- 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 this block and continue with Phase 7: +Automatic merging is NOT supported in v1. On "Show me the manual patch and continue," print the appropriate block for the chosen `renderMode` and continue with Phase 7: ```ts +// renderMode: "dev-only" pageExtensions: process.env.NODE_ENV === 'production' ? ['ts', 'tsx'] : ['ts', 'tsx', 'design-system.ts', 'design-system.tsx'], + +// renderMode: "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'], ``` …and tell the user to merge it with their existing `pageExtensions` value by hand. On "Abort," exit with no further changes. @@ -113,10 +137,12 @@ Where `` is a temp file with shape: "projectRoot": ".", "groupName": "(design-system)", "routeSegment": "-docs", - "prodExcluded": true + "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. From 7e065883978a5f34be220ca6a0d44dc48ba2ac2a Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 19:52:26 -0400 Subject: [PATCH 31/79] pull-component + pull-design-system: offer to sync docs route on success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a successful pull, detect whether the user has the docs route installed (via the sync-docs detect-install subcommand). If so, ask whether to re-sync now; if not, skip silently. - pull-component: new Phase 12 after cleanup. Pulling a component changes its props, which means componentMap.tsx's baked prop schemas are now stale. Re-syncing refreshes them. - pull-design-system: new Phase 10 after the final report. Tokens propagate live via globals.css so a re-sync isn't strictly needed for the tokens themselves, but the prompt copy explains that and offers re-sync anyway for users who've also been editing components. Both prompts use AskUserQuestion with Yes/No options. The existing install choices (route URL, group, render mode) are preserved because sync-docs' Phase 2 detects existing installs and offers "Update in place" — the user doesn't get re-asked for them. Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/adhd/skills/pull-component/SKILL.md | 23 +++++++++++++++++++ .../adhd/skills/pull-design-system/SKILL.md | 23 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/plugins/adhd/skills/pull-component/SKILL.md b/plugins/adhd/skills/pull-component/SKILL.md index 6ba5773..c3d5093 100644 --- a/plugins/adhd/skills/pull-component/SKILL.md +++ b/plugins/adhd/skills/pull-component/SKILL.md @@ -503,6 +503,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-design-system/SKILL.md index 2157c7b..66b0e21 100644 --- a/plugins/adhd/skills/pull-design-system/SKILL.md +++ b/plugins/adhd/skills/pull-design-system/SKILL.md @@ -102,6 +102,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:) From d10704f559aa00e2b236909dc7514970b27dda2b Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 20:04:18 -0400 Subject: [PATCH 32/79] lint + push/pull-component: add --annotate flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pushes lint violations directly to Figma as node-bound annotations, giving designers visibility into structural/variable issues without leaving Figma. Mechanism: ADHD owns a dedicated annotation category named "ADHD lint" (red). On every run, the use_figma script: 1. Ensures the category exists (idempotent create-if-missing). 2. Groups violations by nodeId. 3. Walks every page to find nodes with prior ADHD-category annotations. 4. For the union of "previously annotated" and "currently violated" nodes, replaces ADHD-category annotations with the current set — so fixed violations get their annotations cleared automatically. Designer-authored annotations and other categories are never touched. The category persists after ADHD is removed; the annotations become plain Figma annotations the designer can edit/delete. Surfaces: - /adhd:lint --annotate — standalone (new Phase 6) - /adhd:push-component ... --annotate — preflight (new Phase 10.5) - /adhd:pull-component ... --annotate — preflight (extends Phase 2.5) Implementation detail: push-component's preflight CLI wrapper used to inherit lint-engine's stdio, so the structured output (with per-violation nodeIds) wasn't capturable. Changed to pipe stdout, write a JSON sidecar next to the markdown report, and still echo to the parent process so existing behavior is preserved. Pull-component already invoked the lint engine directly; just added a stdout redirect. The annotation script body lives in /adhd:lint Phase 6. The other two skills reference that section by name to avoid 50 lines of duplicate inline JS in each SKILL. README command table + new section documenting --annotate. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 17 ++- .../lib/push-component/__tests__/cli.test.js | 9 ++ plugins/adhd/lib/push-component/cli.js | 10 +- plugins/adhd/skills/lint/SKILL.md | 104 +++++++++++++++++- plugins/adhd/skills/pull-component/SKILL.md | 17 ++- plugins/adhd/skills/push-component/SKILL.md | 21 +++- 6 files changed, 167 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 834d4ff..c8134b8 100644 --- a/README.md +++ b/README.md @@ -29,11 +29,11 @@ After install, seven 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:lint` | `[] [--annotate]` | read-only by default | Validates the Figma file (whole file or scoped) against the local design system + structure best-practices. With `--annotate`, also writes Figma annotations on each offending node in an "ADHD lint" category. | | `/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:push-component` | ` [--max-variants ] [--annotate]` | code → Figma | Pushes a React component to Figma as a structured Component Set with variant properties + variable bindings, plus a preflight lint check. `--annotate` annotates preflight violations on Figma nodes. | +| `/adhd:pull-component` | ` [--allow-unbound] [--annotate]` | Figma → code | Pulls a Figma Component Set into a React source file; updates lookup tables and union types only (function body untouched). `--annotate` annotates preflight violations on Figma nodes. | | `/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. @@ -86,6 +86,17 @@ Pass any Figma URL that includes a `node-id` query parameter — `/adhd:lint` wi 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. +### Annotate violations in Figma (`--annotate`) + +By default `/adhd:lint` (and the preflight inside `/adhd:push-component` / `/adhd:pull-component`) is read-only — it writes a local markdown report and exits. Pass `--annotate` to also push each violation to Figma as a node-bound annotation in a dedicated **"ADHD lint"** category (red). Designers see them on the layers panel, and a re-run with `--annotate` cleans up stale ADHD-category annotations automatically (designer-authored annotations and other categories are never touched). + +``` +/adhd:lint --annotate # whole file +/adhd:lint https://www.figma.com/design/?node-id=91-18 --annotate # scoped +/adhd:push-component app/components/avatar/index.tsx --annotate # preflight +/adhd:pull-component https://www.figma.com/design/?node-id=91-18 --annotate # preflight +``` + ### Push a component ``` diff --git a/plugins/adhd/lib/push-component/__tests__/cli.test.js b/plugins/adhd/lib/push-component/__tests__/cli.test.js index 08f9fb2..574dede 100644 --- a/plugins/adhd/lib/push-component/__tests__/cli.test.js +++ b/plugins/adhd/lib/push-component/__tests__/cli.test.js @@ -203,4 +203,13 @@ 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)); }); diff --git a/plugins/adhd/lib/push-component/cli.js b/plugins/adhd/lib/push-component/cli.js index 1948f15..9c4a60c 100644 --- a/plugins/adhd/lib/push-component/cli.js +++ b/plugins/adhd/lib/push-component/cli.js @@ -413,6 +413,9 @@ 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'); + // 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 result = spawnSync('node', [ lintCli, '--design-context', args['design-context'], @@ -422,7 +425,12 @@ function main() { '--target', 'PushComponent Preflight', '--target-url', 'about:blank', '--output', args.output, - ], { encoding: 'utf8', stdio: 'inherit' }); + ], { 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/skills/lint/SKILL.md b/plugins/adhd/skills/lint/SKILL.md index 633c10c..062d47a 100644 --- a/plugins/adhd/skills/lint/SKILL.md +++ b/plugins/adhd/skills/lint/SKILL.md @@ -1,7 +1,7 @@ --- -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. Read-only by default; with --annotate, also writes Figma annotations on each offending node in an 'ADHD lint' category. Optional argument: a Figma URL with node-id (scoped lint). With no argument, lints the whole file." disable-model-invocation: true -argument-hint: "[]" +argument-hint: "[] [--annotate]" allowed-tools: Read Write Bash AskUserQuestion mcp__plugin_figma_figma__use_figma --- @@ -65,7 +65,7 @@ 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 Phase 6 can re-use it for `--annotate`: ```bash node plugins/adhd/lib/lint-engine/cli.js \ @@ -75,7 +75,8 @@ node plugins/adhd/lib/lint-engine/cli.js \ --config adhd.config.ts \ --target "" \ --target-url "" \ - --output adhd-lint-report.md + --output 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). @@ -97,6 +98,101 @@ Read `adhd-lint-report.md` with the `Read` tool and echo it to the user verbatim Mention the report file path: "Full report: `adhd-lint-report.md` (paste-ready for Figma comments / Slack)." +## Phase 6: Optional — annotate offending nodes in Figma (`--annotate`) + +If the user passed `--annotate` (the only flag this skill accepts), push each violation to Figma as an annotation on its `nodeId`. ADHD owns a dedicated annotation category named **"ADHD lint"** (red); designer-authored annotations and any other categories are left untouched. + +If `--annotate` was NOT passed, skip this phase. + +### Inputs + +The lint engine's stdout (captured to `/tmp/adhd-lint/stdout.json` during Phase 4) is a JSON object with `variable` and `structure` arrays. Combine them into a flat violation list keeping only items with a `nodeId`: + +```bash +node -e ' +const r = JSON.parse(require("fs").readFileSync("/tmp/adhd-lint/stdout.json", "utf8")); +const all = [...(r.structure ?? []), ...(r.variable ?? [])].filter(v => v.nodeId); +const out = all.map(v => ({ nodeId: v.nodeId, code: v.code, message: v.message, severity: v.severity ?? "error" })); +require("fs").writeFileSync("/tmp/adhd-lint/violations.json", JSON.stringify(out)); +console.log(out.length); +' +``` + +### The use_figma script + +Pass the violations array to `mcp__plugin_figma_figma__use_figma` with `skillNames: "figma-use"`. The script ensures the category exists, applies current violations, and clears stale ADHD annotations file-wide (so a re-run reflects the current state — fixed violations get their annotations cleared automatically). + +```js +const VIOLATIONS = /* substituted: contents of /tmp/adhd-lint/violations.json */; +const CATEGORY_LABEL = "ADHD lint"; +const CATEGORY_COLOR = "red"; + +// 1) Ensure the ADHD lint category exists (idempotent across runs). +const cats = await figma.annotations.getAnnotationCategoriesAsync(); +let cat = cats.find(c => c.label === CATEGORY_LABEL); +if (!cat) { + cat = await figma.annotations.addAnnotationCategoryAsync({ label: CATEGORY_LABEL, color: CATEGORY_COLOR }); +} + +// 2) Group violations by nodeId. +const byNode = new Map(); +for (const v of VIOLATIONS) { + if (!byNode.has(v.nodeId)) byNode.set(v.nodeId, []); + byNode.get(v.nodeId).push(v); +} + +// 3) Walk every page to find nodes with prior ADHD annotations + apply updates. +// Pages load incrementally — use `setCurrentPageAsync` so `findAll` sees their content. +let updated = 0, cleared = 0; +const touchedIds = new Set(); + +for (const page of figma.root.children) { + await figma.setCurrentPageAsync(page); + // Previously-annotated nodes under this page. + const prior = page.findAll(n => + "annotations" in n && (n.annotations ?? []).some(a => a.categoryId === cat.id) + ); + for (const n of prior) touchedIds.add(n.id); +} + +// Union of "previously annotated" and "currently violated" — every node that needs a write. +const allTargetIds = new Set([...touchedIds, ...byNode.keys()]); + +for (const id of allTargetIds) { + const node = await figma.getNodeByIdAsync(id); + if (!node || !("annotations" in node)) continue; + const keep = (node.annotations ?? []).filter(a => a.categoryId !== cat.id); + const fresh = (byNode.get(id) ?? []).map(v => ({ + label: `${v.code}: ${v.message}`, + categoryId: cat.id, + })); + const hadAdhd = touchedIds.has(id); + node.annotations = [...keep, ...fresh]; + if (fresh.length > 0) updated++; + else if (hadAdhd) cleared++; +} + +return { categoryId: cat.id, categoryLabel: cat.label, updated, cleared, totalViolations: VIOLATIONS.length }; +``` + +### Report the result + +After the script returns, print one line: + +``` +✓ Annotated Figma node(s) in the "ADHD lint" category. Cleared stale annotation(s). +``` + +If `updated === 0 && cleared === 0`, print: + +``` +No node-bound violations to annotate (whole-file violations like pageGrouping are reported but not annotated). +``` + +### Why a dedicated category + +The "ADHD lint" category gives designers a one-click filter in Figma's annotations panel and lets us cleanly own/replace our own annotations without touching designer-authored ones. The category persists in the file — even after the user uninstalls ADHD, the annotations remain as plain Figma annotations the designer can edit or delete. + ## Common errors | Error | Fix-up guidance | diff --git a/plugins/adhd/skills/pull-component/SKILL.md b/plugins/adhd/skills/pull-component/SKILL.md index c3d5093..dc43801 100644 --- a/plugins/adhd/skills/pull-component/SKILL.md +++ b/plugins/adhd/skills/pull-component/SKILL.md @@ -1,7 +1,7 @@ --- description: "Pull a Figma Component Set into a React component source file. Inverse of /adhd:push-component. Updates only design-token lookup tables and union type members — function body, JSX, hooks, handlers, and imports are never modified. Reads adhd.config.ts and uses the mapping at components..figma.url. Pre-flight validates the Figma source using the same lint engine /adhd:lint uses; structural violations abort the pull." disable-model-invocation: true -argument-hint: " [--allow-unbound]" +argument-hint: " [--allow-unbound] [--annotate]" allowed-tools: Read Write Edit Bash AskUserQuestion mcp__plugin_figma_figma__use_figma --- @@ -84,13 +84,26 @@ 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 Phase 2.6's optional `--annotate` step. + 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. +### Optional — annotate offending nodes in Figma (`--annotate`) + +If the user passed `--annotate` to `/adhd:pull-component`, push the preflight violations to Figma as annotations using the **same flow described in `/adhd:lint` Phase 6**: + +- Engine stdout: `/tmp/adhd-pull-component/stdout.json` +- Distill to `/tmp/adhd-pull-component/violations.json` (filter for `nodeId`-bearing entries) using the same `node -e` snippet as lint Phase 6, with the pull-component paths substituted. +- Run the same `use_figma` script (the ADHD lint category + per-node replace logic). + +Annotate BEFORE evaluating the abort condition below, so the designer sees the annotations even when the pull aborts. Without `--annotate`, skip silently. + **If variable-binding errors exist:** Check whether the escape is active: diff --git a/plugins/adhd/skills/push-component/SKILL.md b/plugins/adhd/skills/push-component/SKILL.md index aaf850c..a98c744 100644 --- a/plugins/adhd/skills/push-component/SKILL.md +++ b/plugins/adhd/skills/push-component/SKILL.md @@ -1,7 +1,7 @@ --- description: "Push a React component to the configured Figma file as a structured Component Set. Reads adhd.config.ts. Parses the component's variant axes from its TypeScript prop unions, generates a temp Next.js preview route, auto-starts the dev server if needed (and tears it down after), captures via generate_figma_design, wraps the captured frames into a Component Set with variant properties, rebinds raw values to existing design-system variables, and runs the same lint engine /adhd:lint uses as a preflight check before finalizing." disable-model-invocation: true -argument-hint: " [--max-variants ]" +argument-hint: " [--max-variants ] [--annotate]" allowed-tools: Read Write Edit Bash AskUserQuestion mcp__plugin_figma_figma__use_figma mcp__plugin_figma_figma__generate_figma_design --- @@ -178,6 +178,25 @@ 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.5 uses this when `--annotate` is set. + +## Phase 10.5: Optional — annotate offending nodes in Figma (`--annotate`) + +If the user passed `--annotate` to `/adhd:push-component` AND the preflight surfaced any node-bound violations, push them to Figma as annotations using the **same flow described in `/adhd:lint` Phase 6**. + +Inputs: +- Engine stdout: `/tmp/adhd-push-component/preflight-report.json` +- Distill to `/tmp/adhd-push-component/violations.json` (filter for `nodeId`-bearing entries) using the same `node -e` snippet as lint Phase 6, just with the push-component paths substituted. +- Run the same `use_figma` script (the ADHD lint category + per-node replace logic). + +After it returns, print: + +``` +✓ Annotated Figma node(s) in the "ADHD lint" category. Cleared stale annotation(s). +``` + +Annotating happens AFTER the preflight CLI exits but BEFORE Phase 11's decide-or-rollback. That way the designer sees the annotations even when push aborts on errors. Without `--annotate`, skip silently. + ## Phase 11: Decide and finalize OR roll back If preflight has zero errors: print "✓ Preflight clean" plus warning summary, then proceed to Phase 12. From 07043c275085697a3fb8e393b491218dd3bb4457 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 20:18:58 -0400 Subject: [PATCH 33/79] lint + push/pull-component: offer to annotate when --annotate wasn't passed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX improvement. When a lint produces violations or a push/pull preflight fails on node-bound errors AND the user didn't pass --annotate, offer it retroactively via AskUserQuestion. Saves the user from having to re-run with the flag once they've seen the report. - /adhd:lint: new Phase 7 fires whenever any violation has a nodeId (works for both errors and warnings, both whole-file and scoped modes). Lint is a passive command — surfacing violations is its main action, so it's natural to prompt every time. - /adhd:push-component: Phase 10.5 now has two paths (--annotate set OR prompt-on-blocking-errors). Push proceeds via Phase 11 even if the user says no to the annotate prompt; the prompt is purely additive. - /adhd:pull-component: Phase 2.5 same shape. Prompt fires only when STRUCT003/004/005 errors would abort or trigger the --allow-unbound escape — non-blocking violations don't interrupt the flow. Skip the prompt entirely if zero violations have nodeIds (nothing to annotate). The annotation work itself is unchanged — same use_figma script described in /adhd:lint Phase 6, single source of truth. Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/adhd/skills/lint/SKILL.md | 20 +++++++++++++ plugins/adhd/skills/pull-component/SKILL.md | 26 +++++++++++++---- plugins/adhd/skills/push-component/SKILL.md | 32 +++++++++++++++------ 3 files changed, 64 insertions(+), 14 deletions(-) diff --git a/plugins/adhd/skills/lint/SKILL.md b/plugins/adhd/skills/lint/SKILL.md index 062d47a..d8961e5 100644 --- a/plugins/adhd/skills/lint/SKILL.md +++ b/plugins/adhd/skills/lint/SKILL.md @@ -193,6 +193,26 @@ No node-bound violations to annotate (whole-file violations like pageGrouping ar The "ADHD lint" category gives designers a one-click filter in Figma's annotations panel and lets us cleanly own/replace our own annotations without touching designer-authored ones. The category persists in the file — even after the user uninstalls ADHD, the annotations remain as plain Figma annotations the designer can edit or delete. +## Phase 7: Offer to annotate when `--annotate` wasn't passed + +If `--annotate` was passed, this phase is a no-op (Phase 6 already ran). + +If `--annotate` was NOT passed AND the lint produced at least one violation with a `nodeId` (count it from `/tmp/adhd-lint/stdout.json` using the same `node -e` snippet as Phase 6 — count of items in the distilled violations array), use `AskUserQuestion`: + +``` +Question: "Push these violation(s) to Figma as annotations? They'll appear on the offending nodes in an 'ADHD lint' category that designers can filter on." +Header: "Annotate?" +Options: + - "Yes, annotate them in Figma" + - "No, skip" +``` + +On "Yes": run Phase 6 inline (the distill step + the `use_figma` script) and print the result line. + +On "No": exit normally with no annotation work done. + +If there are zero `nodeId`-bearing violations (e.g. only whole-file violations like `pageGrouping`), skip the prompt — there's nothing to annotate. + ## Common errors | Error | Fix-up guidance | diff --git a/plugins/adhd/skills/pull-component/SKILL.md b/plugins/adhd/skills/pull-component/SKILL.md index dc43801..1524809 100644 --- a/plugins/adhd/skills/pull-component/SKILL.md +++ b/plugins/adhd/skills/pull-component/SKILL.md @@ -94,15 +94,31 @@ Use the globals.css path from `config.cssEntry` if set, otherwise auto-detect: ` 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. -### Optional — annotate offending nodes in Figma (`--annotate`) +### Annotate offending nodes in Figma -If the user passed `--annotate` to `/adhd:pull-component`, push the preflight violations to Figma as annotations using the **same flow described in `/adhd:lint` Phase 6**: +Two paths, both producing the same `use_figma` annotation work described in `/adhd:lint` Phase 6: +**Path A — `--annotate` was passed.** Run the annotation script unconditionally. + +**Path B — `--annotate` was NOT passed, AND there are variable-binding errors (STRUCT003/004/005) that will block the pull.** Use `AskUserQuestion` to offer it retroactively: + +``` +Question: "Push these preflight violation(s) to Figma as annotations before aborting? Designers can see them in the 'ADHD lint' category to fix in-context." +Header: "Annotate?" +Options: + - "Yes, annotate them in Figma" + - "No, skip" +``` + +On "Yes": run the annotation script. On "No": skip silently. + +For Path B, skip the prompt if no violation has a `nodeId` (no actionable annotations to write). + +Inputs (both paths): - Engine stdout: `/tmp/adhd-pull-component/stdout.json` -- Distill to `/tmp/adhd-pull-component/violations.json` (filter for `nodeId`-bearing entries) using the same `node -e` snippet as lint Phase 6, with the pull-component paths substituted. -- Run the same `use_figma` script (the ADHD lint category + per-node replace logic). +- Distill to `/tmp/adhd-pull-component/violations.json` using the same `node -e` snippet as lint Phase 6. -Annotate BEFORE evaluating the abort condition below, so the designer sees the annotations even when the pull aborts. Without `--annotate`, skip silently. +Either path runs BEFORE the abort/escape evaluation below, so the designer sees the annotations regardless of whether the pull continues. **If variable-binding errors exist:** diff --git a/plugins/adhd/skills/push-component/SKILL.md b/plugins/adhd/skills/push-component/SKILL.md index a98c744..da8ac49 100644 --- a/plugins/adhd/skills/push-component/SKILL.md +++ b/plugins/adhd/skills/push-component/SKILL.md @@ -180,22 +180,36 @@ 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.5 uses this when `--annotate` is set. -## Phase 10.5: Optional — annotate offending nodes in Figma (`--annotate`) +## Phase 10.5: Annotate offending nodes in Figma -If the user passed `--annotate` to `/adhd:push-component` AND the preflight surfaced any node-bound violations, push them to Figma as annotations using the **same flow described in `/adhd:lint` Phase 6**. +Two paths into this phase, both producing the same `use_figma` annotation work described in `/adhd:lint` Phase 6: -Inputs: -- Engine stdout: `/tmp/adhd-push-component/preflight-report.json` -- Distill to `/tmp/adhd-push-component/violations.json` (filter for `nodeId`-bearing entries) using the same `node -e` snippet as lint Phase 6, just with the push-component paths substituted. -- Run the same `use_figma` script (the ADHD lint category + per-node replace logic). - -After it returns, print: +**Path A — `--annotate` was passed.** Run the annotation script unconditionally. After it returns, print: ``` ✓ Annotated Figma node(s) in the "ADHD lint" category. Cleared stale annotation(s). ``` -Annotating happens AFTER the preflight CLI exits but BEFORE Phase 11's decide-or-rollback. That way the designer sees the annotations even when push aborts on errors. Without `--annotate`, skip silently. +**Path B — `--annotate` was NOT passed, AND preflight produced node-bound errors that will abort the push.** Use `AskUserQuestion` to offer it retroactively: + +``` +Question: "Push these preflight violation(s) to Figma as annotations before aborting? Designers can see them in the 'ADHD lint' category to fix in-context." +Header: "Annotate?" +Options: + - "Yes, annotate them in Figma" + - "No, skip" +``` + +On "Yes": run the same annotation script and print the result line. +On "No": skip silently. + +For Path B, skip the prompt if (a) preflight is clean (no errors blocking the push), or (b) no violation has a `nodeId`. + +Inputs (both paths): +- Engine stdout: `/tmp/adhd-push-component/preflight-report.json` +- Distill to `/tmp/adhd-push-component/violations.json` using the same `node -e` snippet as lint Phase 6. + +Phase 10.5 runs AFTER the preflight CLI exits but BEFORE Phase 11's decide-or-rollback — designers see annotations even when push aborts. ## Phase 11: Decide and finalize OR roll back From 156f54f94dee71ae9f9f5bb542b809dca3004340 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 20:26:19 -0400 Subject: [PATCH 34/79] lint STRUCT001: widen exemption to multi-child shape-only frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User hit a false positive on a Logo Component Set variant: a frame whose children were multiple vector paths composing one shape. The rule exempted single-child shape wrappers (icon/logo containers) but fired on multi-child compositions even when every child was a shape primitive. Reality: a frame whose children are ALL shape primitives (VECTOR, BOOLEAN_OPERATION, ELLIPSE, RECTANGLE, STAR, POLYGON, LINE) rasterizes to a single SVG. Flexbox doesn't apply. Multi-path wordmarks, composite icons, illustrations with multiple paths, and decorative shape stacks are the canonical cases. Mixed-content frames still fire — if even one child is text, a frame, or any non-shape, the auto-layout requirement holds because that child needs padding/alignment control. The SINGLE_CHILD_SHAPE_EXEMPT set is renamed to SHAPE_PRIMITIVE_TYPES since it's no longer single-child-specific. The set itself is unchanged. Tests: flipped the "still flags a frame with 2+ VECTOR children" test to assert the opposite (no STRUCT001 for all-shape multi-child) and added a positive test for mixed shapes + non-shapes to guard the new exemption from over-widening. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/structure-checker.test.js | 26 ++++++++++++++++--- .../adhd/lib/lint-engine/structure-checker.js | 21 ++++++++------- 2 files changed, 35 insertions(+), 12 deletions(-) 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..9a380f0 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,24 @@ test('STRUCT001: does NOT flag a frame holding a single shape primitive (icon/lo } }); +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,12 +87,14 @@ 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' }); diff --git a/plugins/adhd/lib/lint-engine/structure-checker.js b/plugins/adhd/lib/lint-engine/structure-checker.js index 0e156d2..90f9111 100644 --- a/plugins/adhd/lib/lint-engine/structure-checker.js +++ b/plugins/adhd/lib/lint-engine/structure-checker.js @@ -2,9 +2,11 @@ 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. A frame whose +// children are ALL of these types is exempt from STRUCT001 — the frame is +// going to be rasterized to a single SVG (multi-path icons, logos, +// illustrations, decorative shapes), so flexbox doesn't apply. +const SHAPE_PRIMITIVE_TYPES = new Set([ 'VECTOR', 'BOOLEAN_OPERATION', 'ELLIPSE', 'RECTANGLE', 'STAR', 'POLYGON', 'LINE', ]); @@ -40,15 +42,16 @@ 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 children are ALL shape primitives. Covers the icon / + // logo / illustration case — single Vector, multi-path Vector compositions, + // boolean operations, decorative rectangles, etc. These rasterize to a single + // SVG; flexbox doesn't apply. Mixed-content frames (text + shape, frame + + // shape, etc.) still fire — those want auto-layout for padding and alignment. 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(c => SHAPE_PRIMITIVE_TYPES.has(c.type)); + if (!allShapes) { push('STRUCT001', 'error', 'Frame has children but auto-layout is not enabled.'); } } From 47bcbd8259bb01cf200c9c56df2df3d478a9a11e Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 20:30:49 -0400 Subject: [PATCH 35/79] lint STRUCT007 + STRUCT010: stronger diagnostic copy for variant-intent cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User report: a frame with light/dark sub-components fired with terse copy ("2 sibling components named 'Logo/...' should be wrapped in a Component Set"). The designer's intent — variants of one component — isn't acknowledged, and the codegen consequence isn't called out, so the warning is easy to dismiss. Worse, if it IS dismissed, code gen will silently import each sibling as a separate component instead of one component with prop axes. STRUCT007 rewrite: - Names the specific siblings ("light", "dark", ...) capped at 4 with "+N more" overflow. - Calls them out as suspected variants of the shared prefix. - Tells the designer the exact Figma action ("select all → right-click → 'Combine as Variants'"). - Spells out the codegen consequence: "otherwise code generation imports them as N separate components instead of one with a prop axis." Drives home why this matters, not just what's wrong. STRUCT010 rewrite: - Counts the existing variants in the message. - Suggests realistic property examples (theme = light | dark, size = ...). - Same codegen-consequence framing so the designer connects the dot between "no property declared" and "N separate components in code". Tests: existing STRUCT007/STRUCT010 tests strengthened to assert the diagnostic content (named siblings, prefix, Figma action, consequence). Added a truncation test for STRUCT007 (4 shown + "+N more" suffix). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/structure-checker.test.js | 40 ++++++++++++++++--- .../adhd/lib/lint-engine/structure-checker.js | 27 +++++++++++-- 2 files changed, 58 insertions(+), 9 deletions(-) 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 9a380f0..0733eaa 100644 --- a/plugins/adhd/lib/lint-engine/__tests__/structure-checker.test.js +++ b/plugins/adhd/lib/lint-engine/__tests__/structure-checker.test.js @@ -235,7 +235,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', @@ -247,7 +247,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', () => { @@ -407,19 +413,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/structure-checker.js b/plugins/adhd/lib/lint-engine/structure-checker.js index 90f9111..22080f7 100644 --- a/plugins/adhd/lib/lint-engine/structure-checker.js +++ b/plugins/adhd/lib/lint-engine/structure-checker.js @@ -162,7 +162,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 = {}; @@ -173,8 +180,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; } } @@ -189,7 +208,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.`); } } From 254e60d2f870deb4b1b267af3a5226cbf35be1ec Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 20:39:42 -0400 Subject: [PATCH 36/79] lint: recursive STRUCT001 exemption + softer annotation category + /tmp report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes in one commit, all surfaced from the user's reactor-webapp session annotating the Logo Component Set: 1. STRUCT001 still fired on a frame containing light/dark sub-frames that themselves held vector paths. The previous fix exempted frames with direct shape-primitive children, but not the nested case. Replaced the predicate with isShapeOnlySubtree() — recurses through FRAME/GROUP/COMPONENT/INSTANCE containers and only exits at shape primitives. Mixed-content subtrees (text, instances of layout components, anything non-shape) still fire. Empty containers are NOT shape-only (they're placeholders, not shapes) — preserves the existing "single empty FRAME child" failure case. 2. Annotation category was "ADHD lint" (red). Renamed to plain "lint" and switched the color to "orange" — softer, friendlier, doesn't read as alarming on every layer it touches. The label-based category lookup means re-runs against an already-installed file create the new "lint" category alongside any stale "ADHD lint" category from earlier sessions; the designer can delete the old one manually. 3. /adhd:lint was writing adhd-lint-report.md to the repo root (with a gitignore noteasked to be removed). Moved the output to /tmp/adhd-lint/report.md — same place as the engine's stdout JSON. Repo root stays clean; nothing to ignore. Tests cover the recursive exemption (nested shape-only OK; deep TEXT leaf still flags) and the existing STRUCT001 baseline cases still hold. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 4 +- .../__tests__/structure-checker.test.js | 48 +++++++++++++++++++ .../adhd/lib/lint-engine/structure-checker.js | 40 ++++++++++++---- plugins/adhd/skills/lint/SKILL.md | 24 +++++----- plugins/adhd/skills/pull-component/SKILL.md | 2 +- plugins/adhd/skills/push-component/SKILL.md | 4 +- 6 files changed, 95 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index c8134b8..cfab9fa 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ After install, seven 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` | `[] [--annotate]` | read-only by default | Validates the Figma file (whole file or scoped) against the local design system + structure best-practices. With `--annotate`, also writes Figma annotations on each offending node in an "ADHD lint" category. | +| `/adhd:lint` | `[] [--annotate]` | read-only by default | Validates the Figma file (whole file or scoped) against the local design system + structure best-practices. With `--annotate`, also writes Figma annotations on each offending node in a "lint" category. | | `/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 ] [--annotate]` | code → Figma | Pushes a React component to Figma as a structured Component Set with variant properties + variable bindings, plus a preflight lint check. `--annotate` annotates preflight violations on Figma nodes. | @@ -88,7 +88,7 @@ The scoped report covers the same rules (STRUCT001–010 + variable mismatches), ### Annotate violations in Figma (`--annotate`) -By default `/adhd:lint` (and the preflight inside `/adhd:push-component` / `/adhd:pull-component`) is read-only — it writes a local markdown report and exits. Pass `--annotate` to also push each violation to Figma as a node-bound annotation in a dedicated **"ADHD lint"** category (red). Designers see them on the layers panel, and a re-run with `--annotate` cleans up stale ADHD-category annotations automatically (designer-authored annotations and other categories are never touched). +By default `/adhd:lint` (and the preflight inside `/adhd:push-component` / `/adhd:pull-component`) is read-only — it echoes a markdown report to the terminal and exits. Pass `--annotate` to also push each violation to Figma as a node-bound annotation in a dedicated **"lint"** category (orange). Designers see them on the layers panel, and a re-run with `--annotate` cleans up stale "lint"-category annotations automatically (designer-authored annotations and other categories are never touched). ``` /adhd:lint --annotate # whole file 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 0733eaa..c928894 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,54 @@ 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 diff --git a/plugins/adhd/lib/lint-engine/structure-checker.js b/plugins/adhd/lib/lint-engine/structure-checker.js index 22080f7..3440acd 100644 --- a/plugins/adhd/lib/lint-engine/structure-checker.js +++ b/plugins/adhd/lib/lint-engine/structure-checker.js @@ -2,14 +2,33 @@ const AUTO_NAME_RE = /^(Frame|Group|Rectangle|Ellipse|Vector|Line|Star|Polygon)\s+\d+$/; -// Shape primitives that don't benefit from auto-layout. A frame whose -// children are ALL of these types is exempt from STRUCT001 — the frame is -// going to be rasterized to a single SVG (multi-path icons, logos, -// illustrations, decorative shapes), so flexbox doesn't apply. +// 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; @@ -42,15 +61,16 @@ function visit(node, ctx, parentPath, parent) { }; // STRUCT001: auto-layout required. - // Exempt: a frame whose children are ALL shape primitives. Covers the icon / - // logo / illustration case — single Vector, multi-path Vector compositions, - // boolean operations, decorative rectangles, etc. These rasterize to a single - // SVG; flexbox doesn't apply. Mixed-content frames (text + shape, frame + - // shape, etc.) still fire — those 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 allShapes = node.children.every(c => SHAPE_PRIMITIVE_TYPES.has(c.type)); + const allShapes = node.children.every(isShapeOnlySubtree); if (!allShapes) { push('STRUCT001', 'error', 'Frame has children but auto-layout is not enabled.'); } diff --git a/plugins/adhd/skills/lint/SKILL.md b/plugins/adhd/skills/lint/SKILL.md index d8961e5..4276a2e 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 by default; with --annotate, also writes Figma annotations on each offending node in an 'ADHD lint' category. 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. Read-only by default; with --annotate, also writes Figma annotations on each offending node in a 'lint' category. Optional argument: a Figma URL with node-id (scoped lint). With no argument, lints the whole file." disable-model-invocation: true argument-hint: "[] [--annotate]" allowed-tools: Read Write Bash AskUserQuestion mcp__plugin_figma_figma__use_figma @@ -12,7 +12,7 @@ Validate that a Figma file (or a single frame/component/page) is ready for code - **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.). -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. 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` @@ -75,7 +75,7 @@ node plugins/adhd/lib/lint-engine/cli.js \ --config adhd.config.ts \ --target "" \ --target-url "" \ - --output adhd-lint-report.md \ + --output /tmp/adhd-lint/report.md \ > /tmp/adhd-lint/stdout.json ``` @@ -85,7 +85,7 @@ 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." @@ -96,11 +96,11 @@ Read `adhd-lint-report.md` with the `Read` tool and echo it to the user verbatim - 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." -Mention the report file path: "Full report: `adhd-lint-report.md` (paste-ready for Figma comments / Slack)." +Mention the report file path: "Full report: `/tmp/adhd-lint/report.md` (paste-ready for Figma comments / Slack)." ## Phase 6: Optional — annotate offending nodes in Figma (`--annotate`) -If the user passed `--annotate` (the only flag this skill accepts), push each violation to Figma as an annotation on its `nodeId`. ADHD owns a dedicated annotation category named **"ADHD lint"** (red); designer-authored annotations and any other categories are left untouched. +If the user passed `--annotate` (the only flag this skill accepts), push each violation to Figma as an annotation on its `nodeId`. ADHD owns a dedicated annotation category named **"lint"** (orange); designer-authored annotations and any other categories are left untouched. If `--annotate` was NOT passed, skip this phase. @@ -124,10 +124,10 @@ Pass the violations array to `mcp__plugin_figma_figma__use_figma` with `skillNam ```js const VIOLATIONS = /* substituted: contents of /tmp/adhd-lint/violations.json */; -const CATEGORY_LABEL = "ADHD lint"; -const CATEGORY_COLOR = "red"; +const CATEGORY_LABEL = "lint"; +const CATEGORY_COLOR = "orange"; -// 1) Ensure the ADHD lint category exists (idempotent across runs). +// 1) Ensure the lint category exists (idempotent across runs). const cats = await figma.annotations.getAnnotationCategoriesAsync(); let cat = cats.find(c => c.label === CATEGORY_LABEL); if (!cat) { @@ -180,7 +180,7 @@ return { categoryId: cat.id, categoryLabel: cat.label, updated, cleared, totalVi After the script returns, print one line: ``` -✓ Annotated Figma node(s) in the "ADHD lint" category. Cleared stale annotation(s). +✓ Annotated Figma node(s) in the "lint" category. Cleared stale annotation(s). ``` If `updated === 0 && cleared === 0`, print: @@ -191,7 +191,7 @@ No node-bound violations to annotate (whole-file violations like pageGrouping ar ### Why a dedicated category -The "ADHD lint" category gives designers a one-click filter in Figma's annotations panel and lets us cleanly own/replace our own annotations without touching designer-authored ones. The category persists in the file — even after the user uninstalls ADHD, the annotations remain as plain Figma annotations the designer can edit or delete. +The "lint" category gives designers a one-click filter in Figma's annotations panel and lets us cleanly own/replace our own annotations without touching designer-authored ones. The category persists in the file — even after the user uninstalls ADHD, the annotations remain as plain Figma annotations the designer can edit or delete. ## Phase 7: Offer to annotate when `--annotate` wasn't passed @@ -200,7 +200,7 @@ If `--annotate` was passed, this phase is a no-op (Phase 6 already ran). If `--annotate` was NOT passed AND the lint produced at least one violation with a `nodeId` (count it from `/tmp/adhd-lint/stdout.json` using the same `node -e` snippet as Phase 6 — count of items in the distilled violations array), use `AskUserQuestion`: ``` -Question: "Push these violation(s) to Figma as annotations? They'll appear on the offending nodes in an 'ADHD lint' category that designers can filter on." +Question: "Push these violation(s) to Figma as annotations? They'll appear on the offending nodes in a 'lint' category that designers can filter on." Header: "Annotate?" Options: - "Yes, annotate them in Figma" diff --git a/plugins/adhd/skills/pull-component/SKILL.md b/plugins/adhd/skills/pull-component/SKILL.md index 1524809..af75178 100644 --- a/plugins/adhd/skills/pull-component/SKILL.md +++ b/plugins/adhd/skills/pull-component/SKILL.md @@ -103,7 +103,7 @@ Two paths, both producing the same `use_figma` annotation work described in `/ad **Path B — `--annotate` was NOT passed, AND there are variable-binding errors (STRUCT003/004/005) that will block the pull.** Use `AskUserQuestion` to offer it retroactively: ``` -Question: "Push these preflight violation(s) to Figma as annotations before aborting? Designers can see them in the 'ADHD lint' category to fix in-context." +Question: "Push these preflight violation(s) to Figma as annotations before aborting? Designers can see them in the 'lint' category to fix in-context." Header: "Annotate?" Options: - "Yes, annotate them in Figma" diff --git a/plugins/adhd/skills/push-component/SKILL.md b/plugins/adhd/skills/push-component/SKILL.md index da8ac49..2dedade 100644 --- a/plugins/adhd/skills/push-component/SKILL.md +++ b/plugins/adhd/skills/push-component/SKILL.md @@ -187,13 +187,13 @@ Two paths into this phase, both producing the same `use_figma` annotation work d **Path A — `--annotate` was passed.** Run the annotation script unconditionally. After it returns, print: ``` -✓ Annotated Figma node(s) in the "ADHD lint" category. Cleared stale annotation(s). +✓ Annotated Figma node(s) in the "lint" category. Cleared stale annotation(s). ``` **Path B — `--annotate` was NOT passed, AND preflight produced node-bound errors that will abort the push.** Use `AskUserQuestion` to offer it retroactively: ``` -Question: "Push these preflight violation(s) to Figma as annotations before aborting? Designers can see them in the 'ADHD lint' category to fix in-context." +Question: "Push these preflight violation(s) to Figma as annotations before aborting? Designers can see them in the 'lint' category to fix in-context." Header: "Annotate?" Options: - "Yes, annotate them in Figma" From e86214bee1b999a1a5a216fde3a0b2ab69ab4eae Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 21:14:57 -0400 Subject: [PATCH 37/79] sync-docs: eliminate explicit \`any\` from generated files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User's consumer build failed with 5 @typescript-eslint/no-explicit-any errors on the generated docs files. Replaced every \`any\` with a precise type: - React.ComponentType → React.ComponentType> (4 sites in componentMap.tsx — return type of resolveComponent, the ComponentEntry interface, and the two cast sites inside resolveComponent where we know `mod.default` / a named export is a function. The cast is safe because `mod[k]` starts as `unknown`, and React's typing accepts Record as a prop signature for the loose-bridge case the docs route fundamentally is.) - Record → Record for the `current` map in the component page (PropValue = string | boolean | number — the three shapes we actually set from searchParams). One narrowing cast added at the union-PropToggle render site: \`current[name]\` is typed PropValue but the union branch only ever stores strings (validated against def.values at load time). Explicit \`as string | undefined\` here keeps the spread type-correct without needing \`any\`. Strict tsc on the generated output: clean. The user's build should now pass with no-explicit-any enabled. Added a regression-guard test in templates.test.js that fails if any template re-introduces \`any\` — \\bany\\b word-boundary so legitimate identifiers like "Company" aren't false-positives. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/sync-docs/__tests__/templates.test.js | 12 +++++++++++ plugins/adhd/lib/sync-docs/templates.js | 20 ++++++++++++------- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/plugins/adhd/lib/sync-docs/__tests__/templates.test.js b/plugins/adhd/lib/sync-docs/__tests__/templates.test.js index f0aef54..3192e9a 100644 --- a/plugins/adhd/lib/sync-docs/__tests__/templates.test.js +++ b/plugins/adhd/lib/sync-docs/__tests__/templates.test.js @@ -120,6 +120,18 @@ test('COMPONENT_MAP_TSX resolves a renderable function via default-then-named fa 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. diff --git a/plugins/adhd/lib/sync-docs/templates.js b/plugins/adhd/lib/sync-docs/templates.js index b94ece7..aa6f0e2 100644 --- a/plugins/adhd/lib/sync-docs/templates.js +++ b/plugins/adhd/lib/sync-docs/templates.js @@ -131,7 +131,7 @@ export type PropSchema = { export type ComponentEntry = { slug: string; rawPath: string; - Component: React.ComponentType | null; + Component: React.ComponentType> | null; props: Record; }; @@ -145,10 +145,10 @@ type RawEntry = { // 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; +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; + if (typeof v === "function") return v as React.ComponentType>; } return null; } @@ -556,8 +556,11 @@ export default function ComponentPage() { const { rawPath, Component, props } = entry; - // Resolve current prop values from the URL. - const current: Record = {}; + // 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; @@ -586,7 +589,10 @@ export default function ComponentPage() {

    {Object.entries(props).map(([name, def]) => { if (def.type === "union" && def.values) { - return ; + // 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 ; From 5e9819abaec12b7f4ff7b43810f78770a2f74f6f Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 21:46:09 -0400 Subject: [PATCH 38/79] lint STRUCT003: surface raw hex, honor paint styles, catch mixed-paint silent misses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes triggered by a user report that lint missed a #FFFFFF fill on a layer inside a Logo Component Set. Investigation showed two real gaps in the rule plus one diagnostic improvement that would have made the issue easier to spot even when the rule did fire. 1) Surface the raw hex value in the message. Previous copy was generic ("Fill is a raw color; use a color variable.") so when many violations fired across one frame the specific offending color was hard to track down. New copy: "Fill is a raw color (#FFFFFF); bind it to a color variable or apply a paint style." 2) Honor `fillStyleId` / `strokeStyleId` (Figma's legacy paint-style bindings, distinct from variable bindings). Paint styles ARE valid design-system tokens — they pre-date variables but the design-system system itself still consumes them. The rule used to fire false positives on style-bound layers because the serializer didn't even capture the style id. Now: serializer captures both id fields, and the rule skips the raw-color check when a non-empty, non-mixed id is present. 3) Recognize the new `"__MIXED__"` sentinel and fire STRUCT003 on it. Figma returns `figma.mixed` (a Symbol) for `node.fills` on TEXT with per-character coloring, and JSON.stringify drops Symbols. The serializer used to assign the raw value, the Symbol disappeared silently during JSON serialization, and the rule had nothing to check. Result: a multi-color TEXT with a raw white range never surfaced as a violation. The user's exact scenario. The serializer spec now coerces every potentially-mixed field (`fills`, `strokes`, `fillStyleId`, `strokeStyleId`) to the string "__MIXED__" before JSON.stringify. The rule recognizes the sentinel, reports a mixed-paint variant of STRUCT003, and — when the style id is mixed but the fills are present — falls through to the per-paint check so unbound ranges still get caught. Tests cover all three: hex in message, paint-style binding skip, mixed sentinel firing, and mixed-style-id falling through to fills. SKILL.md updated with `fillStyleId` / `strokeStyleId` capture instructions and a detailed `figma.mixed` handling note so future serializer regenerations don't silently regress. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/structure-checker.test.js | 46 ++++++++++- .../adhd/lib/lint-engine/structure-checker.js | 78 ++++++++++++++----- plugins/adhd/skills/lint/SKILL.md | 3 + 3 files changed, 107 insertions(+), 20 deletions(-) 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 c928894..e3c3495 100644 --- a/plugins/adhd/lib/lint-engine/__tests__/structure-checker.test.js +++ b/plugins/adhd/lib/lint-engine/__tests__/structure-checker.test.js @@ -149,9 +149,51 @@ test('STRUCT001: still flags a frame with mixed shapes + non-shape children (nee 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: 0.37, g: 0.23, b: 0.93 } }], + 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({ + 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')); diff --git a/plugins/adhd/lib/lint-engine/structure-checker.js b/plugins/adhd/lib/lint-engine/structure-checker.js index 3440acd..002d1e8 100644 --- a/plugins/adhd/lib/lint-engine/structure-checker.js +++ b/plugins/adhd/lib/lint-engine/structure-checker.js @@ -34,6 +34,35 @@ 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(':', '-'); } @@ -90,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; + } } } } diff --git a/plugins/adhd/skills/lint/SKILL.md b/plugins/adhd/skills/lint/SKILL.md index 4276a2e..926f6e8 100644 --- a/plugins/adhd/skills/lint/SKILL.md +++ b/plugins/adhd/skills/lint/SKILL.md @@ -41,12 +41,15 @@ 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). From aa4cf1f1c1129e20c3b1842d6c83353c0224e4d9 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 21:55:45 -0400 Subject: [PATCH 39/79] sync-docs: trace globals.css for serverless tokens-page deploys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User report: tokens page shows colors locally but not on Vercel preview. Other Claude session correctly diagnosed: the tokens page reads globals.css via runtime fs.readFile, but Vercel's serverless runtime doesn't include CSS source files in the function bundle by default — so fs.readFile throws ENOENT, readCss swallows it as null, parseTokens(null) returns empty arrays, and every domain falls through to its empty state. Same root cause for any non-dev render mode. Fix is to add Next.js's outputFileTracingIncludes config so the CSS source ships alongside the tokens function. patchNextConfig now emits up to two blocks based on renderMode: dev-only → pageExtensions ONLY (page runs locally via next dev, project root is cwd, tracing irrelevant) vercel-preview → pageExtensions + tracing everywhere → tracing ONLY (no extension gate, but page still serves from a serverless function in prod) The tracing block keys on the consumer's actual routeUrl (e.g. /-docs/tokens/[domain] vs /design-system/tokens/[domain]) and points at their configured cssEntry — both threaded through the CLI now via --route-url (already accepted) and the new --css-entry flag. isPatched widened to recognize either sentinel ('design-system.tsx' OR the tracing comment), so re-runs in any mode are a no-op once patched. Switching modes still requires manually removing the patch. Tests cover all three modes' emitted blocks, the routeUrl substitution, idempotency on tracing-only patches, and the absence of tracing in dev-only mode. To pick this up: pull the branch, /reload-plugins, manually remove the existing patch comment from next.config.ts, re-run /adhd:sync-docs and choose the same render mode. The patcher will re-emit with the tracing block included. Next Vercel deploy ships globals.css with the function. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/next-config-patcher.test.js | 64 ++++++++++++++ plugins/adhd/lib/sync-docs/cli.js | 8 +- .../adhd/lib/sync-docs/next-config-patcher.js | 83 +++++++++++++------ plugins/adhd/skills/sync-docs/SKILL.md | 29 +++++-- 4 files changed, 148 insertions(+), 36 deletions(-) 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 index ff23891..cc23306 100644 --- a/plugins/adhd/lib/sync-docs/__tests__/next-config-patcher.test.js +++ b/plugins/adhd/lib/sync-docs/__tests__/next-config-patcher.test.js @@ -104,3 +104,67 @@ test('patchNextConfig throws on an unknown renderMode', () => { /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/cli.js b/plugins/adhd/lib/sync-docs/cli.js index bf5bdae..acce554 100644 --- a/plugins/adhd/lib/sync-docs/cli.js +++ b/plugins/adhd/lib/sync-docs/cli.js @@ -61,15 +61,17 @@ function main() { } if (cmd === 'patch-next-config') { - if (!args.config || !args['route-url']) { console.error('Usage: patch-next-config --config --route-url [--render-mode ]'); process.exit(2); } + 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 }); + 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 }); + const out = patchNextConfig(src, { renderMode, routeUrl, cssEntry }); fs.writeFileSync(args.config, out); process.exit(0); } diff --git a/plugins/adhd/lib/sync-docs/next-config-patcher.js b/plugins/adhd/lib/sync-docs/next-config-patcher.js index a4bba2d..d83731d 100644 --- a/plugins/adhd/lib/sync-docs/next-config-patcher.js +++ b/plugins/adhd/lib/sync-docs/next-config-patcher.js @@ -1,20 +1,20 @@ 'use strict'; -// Detection: look for any pageExtensions entry that mentions our sentinel -// "design-system.tsx" — that's the unique fingerprint of OUR patch, regardless -// of which env-var condition guards it ('NODE_ENV' vs 'VERCEL_ENV'). -const PATCHED_SENTINEL_RE = /pageExtensions:[\s\S]*?'design-system\.tsx'/; +// 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). +// 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 → conditional source. The "everywhere" mode is handled by NOT -// calling the patcher at all (files use plain `.tsx` extensions and ship to -// production). The two excluding modes only differ in which env vars they read. -const PATCH_BLOCKS = { +// 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'],`, @@ -29,6 +29,23 @@ const PATCH_BLOCKS = { : ['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); } @@ -54,20 +71,39 @@ function findConfigObjectStart(source) { function patchNextConfig(source, options = {}) { if (isPatched(source)) return source; - // Detect existing different pageExtensions - if (EXISTING_PAGE_EXTENSIONS_RE.test(source)) { - if (options.detectOnly) { - const existing = EXISTING_PAGE_EXTENSIONS_VALUE_RE.exec(source)[0]; - return { conflict: true, existing }; + 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.'); } - // 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 renderMode = options.renderMode || 'dev-only'; - const block = PATCH_BLOCKS[renderMode]; - if (!block) { - throw new Error(`Unknown renderMode: ${renderMode}. Expected one of: ${Object.keys(PATCH_BLOCKS).join(', ')}.`); + // 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); @@ -75,13 +111,10 @@ function patchNextConfig(source, options = {}) { 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 blocks = [pageExtensionsBlock, tracingBlock].filter(Boolean).join('\n'); const before = source.slice(0, insertAt); - // Strip any leading newline from the tail so it isn't duplicated; we always - // emit exactly one `\n` on each side of the block for clean formatting. const after = source.slice(insertAt).replace(/^\n/, ''); - return before + '\n' + block + '\n' + after; + return before + '\n' + blocks + '\n' + after; } module.exports = { patchNextConfig, isPatched }; diff --git a/plugins/adhd/skills/sync-docs/SKILL.md b/plugins/adhd/skills/sync-docs/SKILL.md index 2a9ba4e..6ffbeb8 100644 --- a/plugins/adhd/skills/sync-docs/SKILL.md +++ b/plugins/adhd/skills/sync-docs/SKILL.md @@ -86,17 +86,21 @@ 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 when `renderMode !== "everywhere"`) +## Phase 6: Patch next.config.ts -Skip this phase entirely if `renderMode` is `"everywhere"` — those files use plain `.tsx` and ship to prod, so no `pageExtensions` conditional is needed. +Always run. The patcher emits up to two blocks depending on `renderMode`: -For the two excluding modes, the patcher generates a different conditional based on `--render-mode`: +- 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 "" + --render-mode "" \ + --css-entry "" ``` Exit codes: @@ -106,23 +110,32 @@ Exit codes: **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 for the chosen `renderMode` and continue with Phase 7: +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" +// 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" +// 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]': ['./'], +}, ``` -…and tell the user to merge it with their existing `pageExtensions` value by hand. On "Abort," exit with no further changes. +Tell the user to merge into their existing config by hand. On "Abort," exit with no further changes. ## Phase 7: Write the page files From 5d76ff0daec304f8dac4295a5706e902305df2c2 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 22:52:46 -0400 Subject: [PATCH 40/79] lint STRUCT011: check Figma variable names against the project's convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When linting a frame, walk every variable referenced and check that its name path follows the project's namingConvention (kebab-case, PascalCase, camelCase). Aggregates into a SINGLE violation per lint run with all bad variables listed inline — so the resulting annotation on the scoped frame is one tidy block instead of N near-identical entries. Per-segment check: Figma var keys arrive as /. The collection name (e.g. "Primitives", "Semantic") is conventionally PascalCase in Figma regardless of the project's variable-naming convention, so we leave the first segment alone — same treatment that variable-categorizer.strippedToken applies. Every segment AFTER the collection must match the configured convention. Suggestions are best-effort transforms. "Primitives/color/BrandPrimary" in a kebab project becomes "Primitives/color/brand-primary". Acronym runs like "HTMLParser" split to ["html","parser"] and reassemble. Numerics stay attached to adjacent text in kebab ("Blue500" → "blue-500", not "blue-5-0-0"). Scope: - Scoped lint: violation carries the scoped target's nodeId, so the annotation lands on the frame. One annotation, multi-line markdown body listing every offending variable with its suggested rename. - Whole-file lint: nodeId omitted (no scope root to attach to). The violation still appears in the markdown report; it just doesn't get pushed to Figma as an annotation. Annotation flow updated to use labelMarkdown when the message contains a newline (the bulleted format) — label collapses newlines to spaces. 12 unit tests on variable-namer + 2 CLI integration tests covering both modes. 415/415 plugin-wide. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/lint-engine/__tests__/cli.test.js | 70 ++++++++++++ .../__tests__/variable-namer.test.js | 101 ++++++++++++++++++ plugins/adhd/lib/lint-engine/cli.js | 28 +++++ .../adhd/lib/lint-engine/variable-namer.js | 76 +++++++++++++ plugins/adhd/skills/lint/SKILL.md | 13 ++- 5 files changed, 284 insertions(+), 4 deletions(-) create mode 100644 plugins/adhd/lib/lint-engine/__tests__/variable-namer.test.js create mode 100644 plugins/adhd/lib/lint-engine/variable-namer.js diff --git a/plugins/adhd/lib/lint-engine/__tests__/cli.test.js b/plugins/adhd/lib/lint-engine/__tests__/cli.test.js index a40395d..a138c32 100644 --- a/plugins/adhd/lib/lint-engine/__tests__/cli.test.js +++ b/plugins/adhd/lib/lint-engine/__tests__/cli.test.js @@ -165,3 +165,73 @@ test('cli with structure errors but no variable issues exits 1', () => { assert.equal(summary.variable.length, 0); assert.ok(summary.errors >= 1); }); + +test('STRUCT011: flags Figma variables whose names violate the naming convention', () => { + // Design system uses kebab-case (set in adhd.config.ts), but Figma has + // `Primitives/color/BrandPrimary` and `Primitives/radius/MD`. The rule + // aggregates both into ONE STRUCT011 violation on the scoped target's + // nodeId so designers see a single annotation on the frame rather than + // two near-identical entries. The collection prefix ("Primitives") is + // left alone — Figma convention treats collection names as PascalCase. + const varDefs = tmp('vars.json', { + 'Primitives/color/BrandPrimary': '#000', + 'Primitives/radius/MD': '8px', + 'Primitives/color/text/default': '#222', // compliant — shouldn't appear in the message + }); + 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, 'expected a STRUCT011 violation'); + assert.equal(struct011.severity, 'warning'); + // Anchored to the scoped frame for annotation. + assert.equal(struct011.nodeId, '5:42'); + // Aggregated message lists both offenders + their suggested kebab forms. + assert.match(struct011.message, /2 Figma variable\(s\) don't match the kebab-case convention/); + assert.match(struct011.message, /Primitives\/color\/BrandPrimary +→ +Primitives\/color\/brand-primary/); + assert.match(struct011.message, /Primitives\/radius\/MD +→ +Primitives\/radius\/md/); + // Compliant variable is NOT listed. + assert.doesNotMatch(struct011.message, /text\/default/); +}); + +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__/variable-namer.test.js b/plugins/adhd/lib/lint-engine/__tests__/variable-namer.test.js new file mode 100644 index 0000000..c4c72bb --- /dev/null +++ b/plugins/adhd/lib/lint-engine/__tests__/variable-namer.test.js @@ -0,0 +1,101 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { checkVariableNames, caseMatchesSegment, suggestName, toCase } = 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'), []); +}); + +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/cli.js b/plugins/adhd/lib/lint-engine/cli.js index 0d83469..bd13dc0 100644 --- a/plugins/adhd/lib/lint-engine/cli.js +++ b/plugins/adhd/lib/lint-engine/cli.js @@ -21,6 +21,7 @@ const fs = require('node:fs'); const { parseTheme } = require('./theme-parser'); const { categorizeVariables } = require('./variable-categorizer'); const { checkStructure } = require('./structure-checker'); +const { checkVariableNames } = require('./variable-namer'); const { formatReport } = require('./report-formatter'); function parseArgs(argv) { @@ -102,6 +103,33 @@ function main() { structureViolations = checkStructure(designCtx, { fileKey, namingConvention }); } + // STRUCT011 — variable-name compliance. Aggregated into a SINGLE violation + // per lint run (rather than per-variable) so the annotation on the scoped + // frame is one tidy block instead of N near-identical entries. In whole-file + // mode there's no scope root, so we omit nodeId — the violation still + // appears in the report but doesn't annotate. + const badVarNames = checkVariableNames(Object.keys(varDefs || {}), namingConvention); + if (badVarNames.length > 0) { + const isScoped = designCtx && designCtx.mode !== 'whole-file' && designCtx.id; + const scopedNodeId = isScoped ? designCtx.id : undefined; + const shown = badVarNames.slice(0, 8); + const lines = shown.map(v => ` • ${v.name} → ${v.suggestion}`); + const more = badVarNames.length > 8 ? `\n +${badVarNames.length - 8} more` : ''; + structureViolations.push({ + rule: 'STRUCT011', + severity: 'warning', + nodeId: scopedNodeId, + nodePath: 'Variables', + message: + `${badVarNames.length} Figma variable(s) don't match the ${namingConvention} convention:\n` + + `${lines.join('\n')}${more}\n` + + `Rename them in Figma (right-click the variable → "Rename") to match.`, + deepLink: scopedNodeId + ? 'https://figma.com/design/' + fileKey + '?node-id=' + scopedNodeId.replace(':', '-') + : args['target-url'], + }); + } + const meta = { target: args.target, targetUrl: args['target-url'], 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..7f5e044 --- /dev/null +++ b/plugins/adhd/lib/lint-engine/variable-namer.js @@ -0,0 +1,76 @@ +'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; +} + +module.exports = { checkVariableNames, caseMatchesSegment, suggestName, toCase }; diff --git a/plugins/adhd/skills/lint/SKILL.md b/plugins/adhd/skills/lint/SKILL.md index 926f6e8..fb9cc93 100644 --- a/plugins/adhd/skills/lint/SKILL.md +++ b/plugins/adhd/skills/lint/SKILL.md @@ -165,10 +165,15 @@ for (const id of allTargetIds) { const node = await figma.getNodeByIdAsync(id); if (!node || !("annotations" in node)) continue; const keep = (node.annotations ?? []).filter(a => a.categoryId !== cat.id); - const fresh = (byNode.get(id) ?? []).map(v => ({ - label: `${v.code}: ${v.message}`, - categoryId: cat.id, - })); + const fresh = (byNode.get(id) ?? []).map(v => { + // labelMarkdown renders newlines and bullet lists (used by STRUCT011's + // aggregated variable-naming message); label collapses them to spaces. + // Prefer labelMarkdown when the message contains a newline. + const text = `${v.code}: ${v.message}`; + return text.includes("\n") + ? { labelMarkdown: text, categoryId: cat.id } + : { label: text, categoryId: cat.id }; + }); const hadAdhd = touchedIds.has(id); node.annotations = [...keep, ...fresh]; if (fresh.length > 0) updated++; From ad1e3402d15329ed46414524b740119fcdc49204 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 22:56:32 -0400 Subject: [PATCH 41/79] lint STRUCT011: add Tailwind-domain "did you mean?" hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User asked for a feature where lint hints at the right variable naming when a designer's choice doesn't map to a Tailwind v4 token domain — e.g. "did you mean 'color' instead of 'colur'?" Folded into STRUCT011 (same rule, second half) so a single annotation per frame covers everything related to "fix your variable names." Three detection paths: - Synonyms (high precision): a hand-curated table mapping common alternates to canonical names. `colors → color`, `space → spacing`, `screens → breakpoint`, `font-size → text`, `line-height → leading`, etc. - Typos (Levenshtein ≤ 2): catches `colur → color`, `radiu → radius`, etc. without producing false-positive matches for genuinely unrelated names. - Unknown (distance > 2): designer is using a prefix we can't guess at. Surface the canonical list so they can pick one. CLI now emits a single aggregated STRUCT011 violation that combines case and domain violations into "N variable-naming issue(s)" with separate sub-sections per concern. Whole-file mode still omits nodeId so the violation appears in the report without an annotation. Example annotation body on a mixed-issues frame: 4 variable-naming issue(s): Case (kebab-case): • Primitives/color/BrandPrimary → Primitives/color/brand-primary Tailwind v4 domain: • Primitives/colur/brand-500 — did you mean "color"? (looks like a typo) • Primitives/space/sm — did you mean "spacing"? (Tailwind v4 prefix) • Primitives/widget/foo — unknown domain "widget"; expected one of: color, spacing, text, font, font-weight, tracking, leading, radius, shadow, breakpoint, ease, animate Rename them in Figma (right-click the variable → "Rename") to match. 7 new unit tests covering classifyDomain across known/synonym/typo/ unknown cases + checkVariableDomains end-to-end. CLI integration test asserts the combined message format with all four flavours of issue. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/lint-engine/__tests__/cli.test.js | 31 +++-- .../__tests__/variable-namer.test.js | 71 +++++++++- plugins/adhd/lib/lint-engine/cli.js | 49 +++++-- .../adhd/lib/lint-engine/variable-namer.js | 127 +++++++++++++++++- 4 files changed, 249 insertions(+), 29 deletions(-) diff --git a/plugins/adhd/lib/lint-engine/__tests__/cli.test.js b/plugins/adhd/lib/lint-engine/__tests__/cli.test.js index a138c32..5f25e56 100644 --- a/plugins/adhd/lib/lint-engine/__tests__/cli.test.js +++ b/plugins/adhd/lib/lint-engine/__tests__/cli.test.js @@ -166,17 +166,15 @@ test('cli with structure errors but no variable issues exits 1', () => { assert.ok(summary.errors >= 1); }); -test('STRUCT011: flags Figma variables whose names violate the naming convention', () => { - // Design system uses kebab-case (set in adhd.config.ts), but Figma has - // `Primitives/color/BrandPrimary` and `Primitives/radius/MD`. The rule - // aggregates both into ONE STRUCT011 violation on the scoped target's - // nodeId so designers see a single annotation on the frame rather than - // two near-identical entries. The collection prefix ("Primitives") is - // left alone — Figma convention treats collection names as PascalCase. +test('STRUCT011: flags Figma variables whose names violate case OR Tailwind domain expectations', () => { + // Mix of three issue types in one frame — STRUCT011 aggregates them into a + // single annotation with separate "Case" and "Tailwind v4 domain" sections. const varDefs = tmp('vars.json', { - 'Primitives/color/BrandPrimary': '#000', - 'Primitives/radius/MD': '8px', - 'Primitives/color/text/default': '#222', // compliant — shouldn't appear in the message + 'Primitives/color/BrandPrimary': '#000', // case violation (kebab-case project) + 'Primitives/colur/brand-500': '#111', // domain typo (Levenshtein) + 'Primitives/space/sm': '0.5rem', // domain synonym + 'Primitives/widget/foo': '?', // unknown domain + 'Primitives/color/text/default': '#222', // compliant — shouldn't appear }); const ctx = tmp('ctx.json', { id: '5:42', name: 'Logo', type: 'FRAME', layoutMode: 'VERTICAL', @@ -200,12 +198,17 @@ test('STRUCT011: flags Figma variables whose names violate the naming convention const struct011 = summary.structure.find(v => v.rule === 'STRUCT011'); assert.ok(struct011, 'expected a STRUCT011 violation'); assert.equal(struct011.severity, 'warning'); - // Anchored to the scoped frame for annotation. assert.equal(struct011.nodeId, '5:42'); - // Aggregated message lists both offenders + their suggested kebab forms. - assert.match(struct011.message, /2 Figma variable\(s\) don't match the kebab-case convention/); + // Header counts ALL issues (1 case + 3 domain = 4). + assert.match(struct011.message, /4 variable-naming issue\(s\)/); + // Case section + assert.match(struct011.message, /Case \(kebab-case\):/); assert.match(struct011.message, /Primitives\/color\/BrandPrimary +→ +Primitives\/color\/brand-primary/); - assert.match(struct011.message, /Primitives\/radius\/MD +→ +Primitives\/radius\/md/); + // Domain section with all three "did you mean?" flavours + assert.match(struct011.message, /Tailwind v4 domain:/); + assert.match(struct011.message, /Primitives\/colur\/brand-500.*did you mean "color".*typo/); + assert.match(struct011.message, /Primitives\/space\/sm.*did you mean "spacing".*Tailwind v4 prefix/); + assert.match(struct011.message, /Primitives\/widget\/foo.*unknown domain "widget".*expected one of: color, spacing/); // Compliant variable is NOT listed. assert.doesNotMatch(struct011.message, /text\/default/); }); diff --git a/plugins/adhd/lib/lint-engine/__tests__/variable-namer.test.js b/plugins/adhd/lib/lint-engine/__tests__/variable-namer.test.js index c4c72bb..93a9aea 100644 --- a/plugins/adhd/lib/lint-engine/__tests__/variable-namer.test.js +++ b/plugins/adhd/lib/lint-engine/__tests__/variable-namer.test.js @@ -2,7 +2,10 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { checkVariableNames, caseMatchesSegment, suggestName, toCase } = require('../variable-namer'); +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 @@ -71,6 +74,72 @@ test('top-level vars without a collection prefix are skipped (no name to check)' 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('caseMatchesSegment: kebab accepts lowercase+digits+hyphens, rejects uppercase', () => { assert.equal(caseMatchesSegment('brand-primary', 'kebab-case'), true); assert.equal(caseMatchesSegment('blue500', 'kebab-case'), true); diff --git a/plugins/adhd/lib/lint-engine/cli.js b/plugins/adhd/lib/lint-engine/cli.js index bd13dc0..2dca8a6 100644 --- a/plugins/adhd/lib/lint-engine/cli.js +++ b/plugins/adhd/lib/lint-engine/cli.js @@ -21,7 +21,7 @@ const fs = require('node:fs'); const { parseTheme } = require('./theme-parser'); const { categorizeVariables } = require('./variable-categorizer'); const { checkStructure } = require('./structure-checker'); -const { checkVariableNames } = require('./variable-namer'); +const { checkVariableNames, checkVariableDomains, TAILWIND_DOMAINS } = require('./variable-namer'); const { formatReport } = require('./report-formatter'); function parseArgs(argv) { @@ -103,26 +103,49 @@ function main() { structureViolations = checkStructure(designCtx, { fileKey, namingConvention }); } - // STRUCT011 — variable-name compliance. Aggregated into a SINGLE violation - // per lint run (rather than per-variable) so the annotation on the scoped - // frame is one tidy block instead of N near-identical entries. In whole-file - // mode there's no scope root, so we omit nodeId — the violation still - // appears in the report but doesn't annotate. - const badVarNames = checkVariableNames(Object.keys(varDefs || {}), namingConvention); - if (badVarNames.length > 0) { + // STRUCT011 — variable-name compliance. Combines TWO concerns into one + // aggregated violation so the designer sees a single "fix your variable + // names" block per lint run: + // - Case: name doesn't follow the project's namingConvention + // (kebab/Pascal/camel). + // - Domain: first segment after the collection doesn't map to a Tailwind v4 + // token-domain prefix (color/spacing/text/font/etc.). Suggests a synonym + // or typo correction via the "did you mean?" heuristic. + // In whole-file mode there's no scope root, so we omit nodeId — the + // violation still appears in the report but doesn't annotate. + const varKeys = Object.keys(varDefs || {}); + const badCase = checkVariableNames(varKeys, namingConvention); + const badDomain = checkVariableDomains(varKeys); + if (badCase.length > 0 || badDomain.length > 0) { const isScoped = designCtx && designCtx.mode !== 'whole-file' && designCtx.id; const scopedNodeId = isScoped ? designCtx.id : undefined; - const shown = badVarNames.slice(0, 8); - const lines = shown.map(v => ` • ${v.name} → ${v.suggestion}`); - const more = badVarNames.length > 8 ? `\n +${badVarNames.length - 8} more` : ''; + const sections = []; + if (badCase.length > 0) { + const shown = badCase.slice(0, 8); + const lines = shown.map(v => ` • ${v.name} → ${v.suggestion}`); + const more = badCase.length > 8 ? `\n +${badCase.length - 8} more` : ''; + sections.push(`Case (${namingConvention}):\n${lines.join('\n')}${more}`); + } + if (badDomain.length > 0) { + const shown = badDomain.slice(0, 8); + const lines = shown.map(v => { + const c = v.classification; + if (c.kind === 'synonym') return ` • ${v.name} — did you mean "${c.suggestion}"? (Tailwind v4 prefix)`; + if (c.kind === 'typo') return ` • ${v.name} — did you mean "${c.suggestion}"? (looks like a typo)`; + return ` • ${v.name} — unknown domain "${v.domainSegment}"; expected one of: ${TAILWIND_DOMAINS.join(', ')}`; + }); + const more = badDomain.length > 8 ? `\n +${badDomain.length - 8} more` : ''; + sections.push(`Tailwind v4 domain:\n${lines.join('\n')}${more}`); + } + const total = badCase.length + badDomain.length; structureViolations.push({ rule: 'STRUCT011', severity: 'warning', nodeId: scopedNodeId, nodePath: 'Variables', message: - `${badVarNames.length} Figma variable(s) don't match the ${namingConvention} convention:\n` + - `${lines.join('\n')}${more}\n` + + `${total} variable-naming issue(s):\n` + + `${sections.join('\n\n')}\n\n` + `Rename them in Figma (right-click the variable → "Rename") to match.`, deepLink: scopedNodeId ? 'https://figma.com/design/' + fileKey + '?node-id=' + scopedNodeId.replace(':', '-') diff --git a/plugins/adhd/lib/lint-engine/variable-namer.js b/plugins/adhd/lib/lint-engine/variable-namer.js index 7f5e044..febb617 100644 --- a/plugins/adhd/lib/lint-engine/variable-namer.js +++ b/plugins/adhd/lib/lint-engine/variable-namer.js @@ -73,4 +73,129 @@ function checkVariableNames(varNames, convention) { return out; } -module.exports = { checkVariableNames, caseMatchesSegment, suggestName, toCase }; +// --------------------------------------------------------------------------- +// 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' }; +} + +// Returns an array of `{ name, domainSegment, classification }` for vars whose +// post-collection first segment doesn't match a known Tailwind v4 domain. +// Names with no path segments after the collection are skipped (nothing to +// classify). +function checkVariableDomains(varNames) { + const out = []; + for (const name of varNames) { + const segments = name.split('/'); + if (segments.length <= 1) continue; + const domainSegment = segments[1]; // first segment AFTER collection + const classification = classifyDomain(domainSegment); + if (classification.kind !== 'known') { + out.push({ name, domainSegment, classification }); + } + } + return out; +} + +module.exports = { + checkVariableNames, caseMatchesSegment, suggestName, toCase, + checkVariableDomains, classifyDomain, TAILWIND_DOMAINS, +}; From 6fe405c4ddc2e26d901c8d7f4ae705f4adbfaf85 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 22:58:30 -0400 Subject: [PATCH 42/79] pull-component: STRUCT011 now blocks the pull (no escape) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User report: pulling a component with STRUCT011 violations was treated as ignorable. Promoted STRUCT011 to a blocking violation in the preflight, alongside the existing STRUCT003/004/005 binding errors. Rationale: STRUCT011 indicates Figma variables that won't bind cleanly to code's @theme (case mismatch, unrecognized Tailwind domain, or both). If we proceed, the pulled component's lookup tables either reference variables that don't exist in code, or land with the wrong name shape — either way the round-trip via /adhd:push-component will drift. No escape for STRUCT011 — the only valid fix is renaming in Figma. The existing --allow-unbound and components..allowUnboundFigma config flag still apply to binding errors only; they're for the "land it as an arbitrary Tailwind class" pragmatic short-term path. There's no analogous "land bad names anyway" path for STRUCT011, so adding a flag wouldn't be honest. When both blocker classes are present, STRUCT011 is surfaced first (more fundamental fix — bind-errors might be tolerable with the escape, but bad names always need fixing first). Annotate-before-aborting flow already picks STRUCT011 up via the existing annotation pipeline (it has nodeId in scoped mode). Updated the "annotate retroactively?" prompt copy to mention both blocker classes. Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/adhd/skills/pull-component/SKILL.md | 27 +++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/plugins/adhd/skills/pull-component/SKILL.md b/plugins/adhd/skills/pull-component/SKILL.md index af75178..76dcf2c 100644 --- a/plugins/adhd/skills/pull-component/SKILL.md +++ b/plugins/adhd/skills/pull-component/SKILL.md @@ -92,7 +92,12 @@ The stdout redirect captures the engine's JSON summary for Phase 2.6's optional 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`. Two 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. Rationale: if Figma variables have non-conventional names, the pulled code's lookup tables would either reference broken names or drift from the design system. Better to fix the source than carry forward bad names into generated code. + +Other rules' violations are noted for the final report but don't block. ### Annotate offending nodes in Figma @@ -100,7 +105,7 @@ Two paths, both producing the same `use_figma` annotation work described in `/ad **Path A — `--annotate` was passed.** Run the annotation script unconditionally. -**Path B — `--annotate` was NOT passed, AND there are variable-binding errors (STRUCT003/004/005) that will block the pull.** Use `AskUserQuestion` to offer it retroactively: +**Path B — `--annotate` was NOT passed, AND there are blocking violations (STRUCT003/004/005 binding errors, OR STRUCT011 variable-naming non-compliance).** Use `AskUserQuestion` to offer annotation retroactively: ``` Question: "Push these preflight violation(s) to Figma as annotations before aborting? Designers can see them in the 'lint' category to fix in-context." @@ -160,6 +165,24 @@ 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). + ## Phase 3: Read both sides **React side (update mode only):** use `Read` on `` (from Phase 2). Identify: From bc1dacd51b732f9d33c640a0c6a31bf2a1a949e5 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 23:06:57 -0400 Subject: [PATCH 43/79] lint STRUCT011: two bug fixes from real lint output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both reported by the user on a real reactor-webapp run: 1. Variable case check was honoring \`naming: "PascalCase"\` from adhd.config.ts and suggesting renames like \`Color/gold\` → \`Color/Gold\`. The \`naming\` config is for component identifiers (Logo vs logo); CSS custom properties are kebab-case lowercase by Tailwind v4 spec — there's no working "PascalCase variables" mode. Hardcoded the variable case check to kebab-case regardless of project config, and updated the section header to say so: "Case (kebab-case — Tailwind v4 requires lowercase CSS vars)" rather than "Case (PascalCase)". 2. \`Radius/sm\` was flagged as unknown domain. The rule assumed the first segment AFTER the collection was the domain prefix, but some teams organize collections BY domain ("Color", "Radius", "Spacing") instead of by tier ("Primitives", "Semantic"). When the collection name itself is a Tailwind domain (or a known synonym), the variable name doesn't need another domain prefix. New collectionIsDomain helper normalizes collection names (lowercases, collapses "+", spaces, hyphens) and runs them through classifyDomain. Match (known OR synonym) → skip domain check for that variable. Distinguishes: - "Color" → "color" → known → skip ✓ - "Colors" → "colors" → synonym → skip ✓ - "Radius" → "radius" → known → skip ✓ - "Type + Effects" → "type-effects" → unknown → still check first segment after collection (catches the user's Font-Size/Line-Height issues correctly) - "Primitives" → unknown → check first segment (preserves the standard tier-collection behavior) 5 new unit tests + 2 CLI integration tests covering PascalCase config ignored for vars + collection-is-domain skip. 428/428 plugin-wide. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/lint-engine/__tests__/cli.test.js | 74 ++++++++++++++++++- .../__tests__/variable-namer.test.js | 36 +++++++++ plugins/adhd/lib/lint-engine/cli.js | 11 ++- .../adhd/lib/lint-engine/variable-namer.js | 30 +++++++- 4 files changed, 144 insertions(+), 7 deletions(-) diff --git a/plugins/adhd/lib/lint-engine/__tests__/cli.test.js b/plugins/adhd/lib/lint-engine/__tests__/cli.test.js index 5f25e56..8363f61 100644 --- a/plugins/adhd/lib/lint-engine/__tests__/cli.test.js +++ b/plugins/adhd/lib/lint-engine/__tests__/cli.test.js @@ -201,8 +201,10 @@ test('STRUCT011: flags Figma variables whose names violate case OR Tailwind doma assert.equal(struct011.nodeId, '5:42'); // Header counts ALL issues (1 case + 3 domain = 4). assert.match(struct011.message, /4 variable-naming issue\(s\)/); - // Case section - assert.match(struct011.message, /Case \(kebab-case\):/); + // Case section. The header explains kebab-case is a Tailwind v4 requirement + // (not the project's naming convention) — important since users may have + // PascalCase configured for components. + assert.match(struct011.message, /Case \(kebab-case — Tailwind v4 requires lowercase CSS vars\)/); assert.match(struct011.message, /Primitives\/color\/BrandPrimary +→ +Primitives\/color\/brand-primary/); // Domain section with all three "did you mean?" flavours assert.match(struct011.message, /Tailwind v4 domain:/); @@ -213,6 +215,74 @@ test('STRUCT011: flags Figma variables whose names violate case OR Tailwind doma assert.doesNotMatch(struct011.message, /text\/default/); }); +test('STRUCT011: variable case is always kebab-case, regardless of project naming config', () => { + // The user's case: project config is `naming: "PascalCase"` (for COMPONENT + // identifiers like Logo vs logo). The rule must NOT apply PascalCase to + // variable names — CSS custom properties are kebab-lowercase by Tailwind + // v4 spec. So `Color/gold` should not be flagged as needing "Color/Gold". + const varDefs = tmp('vars.json', { + 'Color/gold': '#c5a572', // kebab-ok (collection-is-domain, lowercase value) + 'Color/BrandGold': '#c5a572', // case violation → should suggest brand-gold, NOT BrandGold + }); + 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"] {}`); + // PascalCase project — must be ignored for variable case checking. + 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, 'expected STRUCT011 to flag BrandGold'); + // Variable-case section advertises kebab-case (with the rationale), not PascalCase + assert.match(struct011.message, /Case \(kebab-case — Tailwind v4 requires lowercase CSS vars\)/); + // BrandGold suggestion is kebab, not PascalCase + assert.match(struct011.message, /Color\/BrandGold +→ +Color\/brand-gold/); + // Color/gold is NOT flagged (already kebab-compliant, even though "gold" + // would fail PascalCase if the config were honored). + assert.doesNotMatch(struct011.message, /Color\/gold +→/); +}); + +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('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: [] }); diff --git a/plugins/adhd/lib/lint-engine/__tests__/variable-namer.test.js b/plugins/adhd/lib/lint-engine/__tests__/variable-namer.test.js index 93a9aea..153a4e9 100644 --- a/plugins/adhd/lib/lint-engine/__tests__/variable-namer.test.js +++ b/plugins/adhd/lib/lint-engine/__tests__/variable-namer.test.js @@ -140,6 +140,42 @@ 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'); +}); + test('caseMatchesSegment: kebab accepts lowercase+digits+hyphens, rejects uppercase', () => { assert.equal(caseMatchesSegment('brand-primary', 'kebab-case'), true); assert.equal(caseMatchesSegment('blue500', 'kebab-case'), true); diff --git a/plugins/adhd/lib/lint-engine/cli.js b/plugins/adhd/lib/lint-engine/cli.js index 2dca8a6..eda43cf 100644 --- a/plugins/adhd/lib/lint-engine/cli.js +++ b/plugins/adhd/lib/lint-engine/cli.js @@ -113,8 +113,15 @@ function main() { // or typo correction via the "did you mean?" heuristic. // In whole-file mode there's no scope root, so we omit nodeId — the // violation still appears in the report but doesn't annotate. + // Variable names are ALWAYS checked against kebab-case, regardless of the + // project's `naming` config. That config is for component identifiers + // (`Logo` vs `logo`); CSS custom properties — what Figma variables ultimately + // become — are kebab-case-lowercase by Tailwind v4 spec. There's no honest + // way to honor `naming: "PascalCase"` for variables and still produce + // working utility classes. + const VARIABLE_CASE = 'kebab-case'; const varKeys = Object.keys(varDefs || {}); - const badCase = checkVariableNames(varKeys, namingConvention); + const badCase = checkVariableNames(varKeys, VARIABLE_CASE); const badDomain = checkVariableDomains(varKeys); if (badCase.length > 0 || badDomain.length > 0) { const isScoped = designCtx && designCtx.mode !== 'whole-file' && designCtx.id; @@ -124,7 +131,7 @@ function main() { const shown = badCase.slice(0, 8); const lines = shown.map(v => ` • ${v.name} → ${v.suggestion}`); const more = badCase.length > 8 ? `\n +${badCase.length - 8} more` : ''; - sections.push(`Case (${namingConvention}):\n${lines.join('\n')}${more}`); + sections.push(`Case (kebab-case — Tailwind v4 requires lowercase CSS vars):\n${lines.join('\n')}${more}`); } if (badDomain.length > 0) { const shown = badDomain.slice(0, 8); diff --git a/plugins/adhd/lib/lint-engine/variable-namer.js b/plugins/adhd/lib/lint-engine/variable-namer.js index febb617..1c26b2c 100644 --- a/plugins/adhd/lib/lint-engine/variable-namer.js +++ b/plugins/adhd/lib/lint-engine/variable-namer.js @@ -177,15 +177,38 @@ function classifyDomain(segment) { 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. -// Names with no path segments after the collection are skipped (nothing to -// classify). +// 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') { @@ -197,5 +220,6 @@ function checkVariableDomains(varNames) { module.exports = { checkVariableNames, caseMatchesSegment, suggestName, toCase, - checkVariableDomains, classifyDomain, TAILWIND_DOMAINS, + checkVariableDomains, classifyDomain, collectionIsDomain, + normalizeCollectionName, TAILWIND_DOMAINS, }; From 5fbf4a68bd7469e858573b2571923d998ca1c664 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 23:20:29 -0400 Subject: [PATCH 44/79] =?UTF-8?q?pull-component:=20Phase=202.7=20=E2=80=94?= =?UTF-8?q?=20opportunistic=20variable=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After preflight passes, scan the lint engine's variable mismatches for NEW Figma variables that aren't in the consumer's globals.css yet. Offer to add them additively as part of the pull, so users discover the design system incrementally as they pull components in. Three categories surfaced in a single prompt: Primitives (mode: undefined): auto-addable. Single @theme entry per variable. Yes-branch invokes applyToCss (the same helper pull-design-system uses for CSS edits) with set-primitive actions. Semantic tokens with light/dark modes: NOT auto-addable. They need three coordinated edits (:root, .dark, @theme inline). Surfaced in the prompt with a "run /adhd:pull-design-system" note instead. Value conflicts: not touched here at all. pull-design-system's interactive resolution is the right tool. Surfaced in the prompt for awareness; doesn't block the pull (conflicts are an existing not-a-blocker class for pull-component). UX decisions per user: - Always prompt with the list (no silent auto-add) - Warn about conflicts in the same prompt, allow continuing Three options on the prompt: Yes — add primitives and continue No — continue without adding (component pull may land raw values) Cancel — abort, run /adhd:pull-design-system first The phase is skipped entirely when there are zero new variables AND zero conflicts. SKILL-only change. The applyToCss helper already exists in lib/design-system/code-writer.js (pull-design-system uses it); we just invoke it from a node -e snippet with new set-primitive actions. Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/adhd/skills/pull-component/SKILL.md | 65 +++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/plugins/adhd/skills/pull-component/SKILL.md b/plugins/adhd/skills/pull-component/SKILL.md index 76dcf2c..c0b7c4d 100644 --- a/plugins/adhd/skills/pull-component/SKILL.md +++ b/plugins/adhd/skills/pull-component/SKILL.md @@ -183,6 +183,71 @@ 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). +## 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. +- **`status: "conflict"`** — both sides have the variable but values disagree. NOT touched by pull-component; this is `/adhd:pull-design-system`'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-design-system for these" note. + +If `missingPrimitives.length === 0 && missingSemantics.length === 0 && conflicts.length === 0`, skip this phase entirely. + +Otherwise, build a single `AskUserQuestion` prompt: + +``` +Found new variables referenced by this frame: + +Primitives (add to @theme): + color/brand/600 #5e3aee + radius/lg 12px + shadow/popover 0 4px 12px rgba(0,0,0,.08) + +[if missingSemantics > 0] +Semantic tokens with light/dark modes (not auto-addable): + color/text/primary (light: #111, dark: #fafafa) + color/surface (light: #fff, dark: #0a0a0a) +Run /adhd:pull-design-system to add these — they need coordinated +`:root`, `.dark`, and `@theme inline` edits. + +[if conflicts > 0] +Variables with value mismatches (existing in code, different in Figma): + color/brand/500 code=#5e3aee figma=#6a4cf2 +Run /adhd:pull-design-system to resolve. + +Options: + [Yes — add the primitives and continue] + [No — continue without adding (component pull may land raw values)] + [Cancel — abort, I'll run /adhd:pull-design-system first] +``` + +On **Yes**: build an actions array for each primitive and invoke `applyToCss` (the helper `pull-design-system` already uses for CSS edits). Save under `/tmp/adhd-pull-component/new-globals.css`, then write to the configured `globals.css` path: + +```bash +node -e ' + const fs = require("fs"); + const { applyToCss } = require("plugins/adhd/lib/design-system/code-writer.js"); + const cssPath = process.argv[1]; + const css = fs.readFileSync(cssPath, "utf8"); + const actions = JSON.parse(process.argv[2]); + fs.writeFileSync(cssPath, applyToCss(css, actions)); +' "" "$ACTIONS_JSON" +``` + +Where each action is shaped `{ kind: "set-primitive", cssVar: "--" + token.replace(/\//g, "-"), value: }`. The token comes from each missing entry's `token` field (already collection-stripped). + +Print a confirmation: `Added primitive(s) to globals.css: `. Then continue with Phase 3. + +On **No**: continue without writing. The component pull proceeds; un-tracked variables may land as raw values or `// adhd:off-system` markers in the lookup tables. + +On **Cancel**: abort with `Run /adhd:pull-design-system first to sync the design system, then re-run /adhd:pull-component.` + +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-design-system 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 3: Read both sides **React side (update mode only):** use `Read` on `` (from Phase 2). Identify: From ee8bcd863f64659179066ca9226d954ae4274c0c Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 23:25:55 -0400 Subject: [PATCH 45/79] lint STRUCT011: one concrete rename target per variable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User screenshot from their reactor file showed the previous emission was unactionable: Type + Effects/Font-Size/Body → Type + Effects/font-size/body (case half) Type + Effects/Font-Size/Body — did you mean "text"? (domain half) Two contradictory hints on the same variable. Following the case fix produced a name that still failed the domain check; following the domain hint required structural change the case fix didn't account for. Designers were stuck reconciling them on their own. Replaced the two-section emission with a single-target-per-variable format produced by a new suggestTargetName() helper. The helper picks ONE end-state name that satisfies both concerns at once. Algorithm (priority order): 1. Tier collection (Primitives/Semantic/Tokens/Base/Theme) — preserve the tier, kebab-case the leaves, normalize the domain segment inside (synonyms → canonical). Tier + unrecognized inner segment surfaces a no-mapping hint instead of guessing. 2. Domain-named collection (Color/Radius/Spacing/…) — preserve the collection, kebab-case the leaves. Synonym collections (Colors, Shadows) get renormalized to the canonical name. 3. Bundled / unknown collection — walk rest segments looking for a domain hint. If found, MOVE the variable to a dedicated domain-named collection in PascalCase form: Type + Effects/Font-Size/Body → Text/body Type + Effects/Line-Height/Line Height 28 → Leading/line-height-28 The domain-naming segment in the original path is dropped (the new collection name supplies it). 4. No domain hint anywhere → no-mapping with the canonical list, including a "consider whether this variable belongs or should be removed" hint. Sample new annotation body for the user's case: 5 variable(s) need renaming for Tailwind v4 alignment: • Type + Effects/Font-Size/Body → Text/body • Type + Effects/Line-Height/Line Height 28 → Leading/line-height-28 • Type + Effects/Effects/Opacity 100% ⚠ No Tailwind v4 domain found in path. Expected one of: color, spacing, text, font, font-weight, tracking, leading, radius, shadow, breakpoint, ease, animate. Consider whether this variable maps to one of those domains, or if it should be removed. • Primitives/color/BrandPrimary → Primitives/color/brand-primary 7 new unit tests + 2 CLI integration tests rewritten for the new format. 435/435 plugin-wide. The old checkVariableNames + checkVariableDomains exports stay for backwards compatibility but are no longer used by cli.js. Both still have their unit tests, so any future regression there is caught. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/lint-engine/__tests__/cli.test.js | 97 +++++++---------- .../__tests__/variable-namer.test.js | 76 +++++++++++++ plugins/adhd/lib/lint-engine/cli.js | 72 +++++------- .../adhd/lib/lint-engine/variable-namer.js | 103 ++++++++++++++++++ 4 files changed, 249 insertions(+), 99 deletions(-) diff --git a/plugins/adhd/lib/lint-engine/__tests__/cli.test.js b/plugins/adhd/lib/lint-engine/__tests__/cli.test.js index 8363f61..f9f2df3 100644 --- a/plugins/adhd/lib/lint-engine/__tests__/cli.test.js +++ b/plugins/adhd/lib/lint-engine/__tests__/cli.test.js @@ -166,91 +166,74 @@ test('cli with structure errors but no variable issues exits 1', () => { assert.ok(summary.errors >= 1); }); -test('STRUCT011: flags Figma variables whose names violate case OR Tailwind domain expectations', () => { - // Mix of three issue types in one frame — STRUCT011 aggregates them into a - // single annotation with separate "Case" and "Tailwind v4 domain" sections. +test('STRUCT011: emits one concrete rename target per variable (mixed case + domain issues)', () => { + // Real-world scenario from the user's reactor file: a bundled "Type + + // Effects" collection that conflates typography sizes, line-heights, and + // effects. Each variable gets a single target, not two contradictory + // hints. The designer can act on each line independently. const varDefs = tmp('vars.json', { - 'Primitives/color/BrandPrimary': '#000', // case violation (kebab-case project) - 'Primitives/colur/brand-500': '#111', // domain typo (Levenshtein) - 'Primitives/space/sm': '0.5rem', // domain synonym - 'Primitives/widget/foo': '?', // unknown domain - 'Primitives/color/text/default': '#222', // compliant — shouldn't appear - }); - const ctx = tmp('ctx.json', { - id: '5:42', name: 'Logo', type: 'FRAME', layoutMode: 'VERTICAL', + 'Type + Effects/Font-Size/Body': '16px', + 'Type + Effects/Line-Height/Line Height 28': '28px', + 'Type + Effects/Effects/Opacity 100%': '1', // no Tailwind v4 mapping + '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, + 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, 'expected a STRUCT011 violation'); + assert.ok(struct011); assert.equal(struct011.severity, 'warning'); assert.equal(struct011.nodeId, '5:42'); - // Header counts ALL issues (1 case + 3 domain = 4). - assert.match(struct011.message, /4 variable-naming issue\(s\)/); - // Case section. The header explains kebab-case is a Tailwind v4 requirement - // (not the project's naming convention) — important since users may have - // PascalCase configured for components. - assert.match(struct011.message, /Case \(kebab-case — Tailwind v4 requires lowercase CSS vars\)/); - assert.match(struct011.message, /Primitives\/color\/BrandPrimary +→ +Primitives\/color\/brand-primary/); - // Domain section with all three "did you mean?" flavours - assert.match(struct011.message, /Tailwind v4 domain:/); - assert.match(struct011.message, /Primitives\/colur\/brand-500.*did you mean "color".*typo/); - assert.match(struct011.message, /Primitives\/space\/sm.*did you mean "spacing".*Tailwind v4 prefix/); - assert.match(struct011.message, /Primitives\/widget\/foo.*unknown domain "widget".*expected one of: color, spacing/); - // Compliant variable is NOT listed. - assert.doesNotMatch(struct011.message, /text\/default/); + // Header counts ALL issues (4 — Color/gold is compliant and doesn't appear). + assert.match(struct011.message, /4 variable\(s\) need renaming for Tailwind v4 alignment/); + // Unknown-collection + domain-segment-hint → MOVE into domain-named collection + assert.match(struct011.message, /Type \+ Effects\/Font-Size\/Body[\s\S]*→ Text\/body/); + assert.match(struct011.message, /Type \+ Effects\/Line-Height\/Line Height 28[\s\S]*→ Leading\/line-height-28/); + // Tier collection + case-only issue → preserve tier, kebab the leaf + assert.match(struct011.message, /Primitives\/color\/BrandPrimary[\s\S]*→ Primitives\/color\/brand-primary/); + // Domain-less variable → no-mapping explanation + assert.match(struct011.message, /Type \+ Effects\/Effects\/Opacity 100%[\s\S]*⚠ No Tailwind v4 domain/); + // Already-correct variable doesn't appear + assert.doesNotMatch(struct011.message, /Color\/gold/); + // No leftover from the old format + assert.doesNotMatch(struct011.message, /Case \(kebab-case/); + assert.doesNotMatch(struct011.message, /did you mean/); }); test('STRUCT011: variable case is always kebab-case, regardless of project naming config', () => { - // The user's case: project config is `naming: "PascalCase"` (for COMPONENT - // identifiers like Logo vs logo). The rule must NOT apply PascalCase to - // variable names — CSS custom properties are kebab-lowercase by Tailwind - // v4 spec. So `Color/gold` should not be flagged as needing "Color/Gold". + // 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', // kebab-ok (collection-is-domain, lowercase value) - 'Color/BrandGold': '#c5a572', // case violation → should suggest brand-gold, NOT BrandGold + '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"] {}`); - // PascalCase project — must be ignored for variable case checking. 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, + 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, 'expected STRUCT011 to flag BrandGold'); - // Variable-case section advertises kebab-case (with the rationale), not PascalCase - assert.match(struct011.message, /Case \(kebab-case — Tailwind v4 requires lowercase CSS vars\)/); - // BrandGold suggestion is kebab, not PascalCase - assert.match(struct011.message, /Color\/BrandGold +→ +Color\/brand-gold/); - // Color/gold is NOT flagged (already kebab-compliant, even though "gold" - // would fail PascalCase if the config were honored). - assert.doesNotMatch(struct011.message, /Color\/gold +→/); + 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', () => { diff --git a/plugins/adhd/lib/lint-engine/__tests__/variable-namer.test.js b/plugins/adhd/lib/lint-engine/__tests__/variable-namer.test.js index 153a4e9..7ce075f 100644 --- a/plugins/adhd/lib/lint-engine/__tests__/variable-namer.test.js +++ b/plugins/adhd/lib/lint-engine/__tests__/variable-namer.test.js @@ -176,6 +176,82 @@ test('normalizeCollectionName collapses separators and lowercases', () => { 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: bundled collection with no domain hint anywhere → no-mapping', () => { + // "Type + Effects/Effects/Opacity 100%" — none of "Type+Effects", "Effects", + // or "Opacity 100%" maps to a Tailwind v4 domain. The engine surfaces the + // canonical list so the designer picks a destination. + const r = suggestTargetName('Type + Effects/Effects/Opacity 100%'); + 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); diff --git a/plugins/adhd/lib/lint-engine/cli.js b/plugins/adhd/lib/lint-engine/cli.js index eda43cf..172b5f7 100644 --- a/plugins/adhd/lib/lint-engine/cli.js +++ b/plugins/adhd/lib/lint-engine/cli.js @@ -21,7 +21,7 @@ const fs = require('node:fs'); const { parseTheme } = require('./theme-parser'); const { categorizeVariables } = require('./variable-categorizer'); const { checkStructure } = require('./structure-checker'); -const { checkVariableNames, checkVariableDomains, TAILWIND_DOMAINS } = require('./variable-namer'); +const { buildVariableSuggestions } = require('./variable-namer'); const { formatReport } = require('./report-formatter'); function parseArgs(argv) { @@ -103,57 +103,45 @@ function main() { structureViolations = checkStructure(designCtx, { fileKey, namingConvention }); } - // STRUCT011 — variable-name compliance. Combines TWO concerns into one - // aggregated violation so the designer sees a single "fix your variable - // names" block per lint run: - // - Case: name doesn't follow the project's namingConvention - // (kebab/Pascal/camel). - // - Domain: first segment after the collection doesn't map to a Tailwind v4 - // token-domain prefix (color/spacing/text/font/etc.). Suggests a synonym - // or typo correction via the "did you mean?" heuristic. - // In whole-file mode there's no scope root, so we omit nodeId — the + // STRUCT011 — variable-name compliance. For each variable, produce ONE + // concrete rename target that combines case + domain concerns into a + // single actionable suggestion. This is the upgrade from the old + // two-section emission, which forced designers to reconcile contradictory + // hints ("rename Font-Size to font-size" + "did you mean text?") on + // their own — too much cognitive load when 10+ variables are flagged. + // + // The new emission shows: "Type + Effects/Font-Size/Body → Text/body". + // One line, complete target. The designer creates the new variable in + // the right collection (or renames in place) and moves on. + // + // 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). + // + // In whole-file mode there's no scope root, so nodeId is omitted — // violation still appears in the report but doesn't annotate. - // Variable names are ALWAYS checked against kebab-case, regardless of the - // project's `naming` config. That config is for component identifiers - // (`Logo` vs `logo`); CSS custom properties — what Figma variables ultimately - // become — are kebab-case-lowercase by Tailwind v4 spec. There's no honest - // way to honor `naming: "PascalCase"` for variables and still produce - // working utility classes. - const VARIABLE_CASE = 'kebab-case'; const varKeys = Object.keys(varDefs || {}); - const badCase = checkVariableNames(varKeys, VARIABLE_CASE); - const badDomain = checkVariableDomains(varKeys); - if (badCase.length > 0 || badDomain.length > 0) { + const suggestions = buildVariableSuggestions(varKeys); + if (suggestions.length > 0) { const isScoped = designCtx && designCtx.mode !== 'whole-file' && designCtx.id; const scopedNodeId = isScoped ? designCtx.id : undefined; - const sections = []; - if (badCase.length > 0) { - const shown = badCase.slice(0, 8); - const lines = shown.map(v => ` • ${v.name} → ${v.suggestion}`); - const more = badCase.length > 8 ? `\n +${badCase.length - 8} more` : ''; - sections.push(`Case (kebab-case — Tailwind v4 requires lowercase CSS vars):\n${lines.join('\n')}${more}`); - } - if (badDomain.length > 0) { - const shown = badDomain.slice(0, 8); - const lines = shown.map(v => { - const c = v.classification; - if (c.kind === 'synonym') return ` • ${v.name} — did you mean "${c.suggestion}"? (Tailwind v4 prefix)`; - if (c.kind === 'typo') return ` • ${v.name} — did you mean "${c.suggestion}"? (looks like a typo)`; - return ` • ${v.name} — unknown domain "${v.domainSegment}"; expected one of: ${TAILWIND_DOMAINS.join(', ')}`; - }); - const more = badDomain.length > 8 ? `\n +${badDomain.length - 8} more` : ''; - sections.push(`Tailwind v4 domain:\n${lines.join('\n')}${more}`); - } - const total = badCase.length + badDomain.length; + const shown = suggestions.slice(0, 10); + const lines = shown.map(s => { + if (s.kind === 'rename') return ` • ${s.name}\n → ${s.target}`; + if (s.kind === 'no-mapping') return ` • ${s.name}\n ⚠ ${s.reason}`; + return ` • ${s.name}`; + }); + const more = suggestions.length > 10 ? `\n\n +${suggestions.length - 10} more` : ''; structureViolations.push({ rule: 'STRUCT011', severity: 'warning', nodeId: scopedNodeId, nodePath: 'Variables', message: - `${total} variable-naming issue(s):\n` + - `${sections.join('\n\n')}\n\n` + - `Rename them in Figma (right-click the variable → "Rename") to match.`, + `${suggestions.length} variable(s) need renaming for Tailwind v4 alignment:\n\n` + + `${lines.join('\n\n')}${more}\n\n` + + `For each rename: right-click the variable in Figma → "Rename".\n` + + `Tip: you can also create the variable in the suggested collection and point old references at it via aliasing.`, deepLink: scopedNodeId ? 'https://figma.com/design/' + fileKey + '?node-id=' + scopedNodeId.replace(':', '-') : args['target-url'], diff --git a/plugins/adhd/lib/lint-engine/variable-namer.js b/plugins/adhd/lib/lint-engine/variable-namer.js index 1c26b2c..36ece5f 100644 --- a/plugins/adhd/lib/lint-engine/variable-namer.js +++ b/plugins/adhd/lib/lint-engine/variable-namer.js @@ -218,8 +218,111 @@ function checkVariableDomains(varNames) { 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', +]); + +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) { + return { + name, kind: 'no-mapping', + reason: `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; + 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, }; From ff63be75e84c736026e1ccd7412653fbab97a531 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 23:29:34 -0400 Subject: [PATCH 46/79] lint annotate: scope-aware cleanup so scoped lints don't wipe other frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real bug surfaced in a session: a scoped lint on UserAvatar wiped a previously-annotated STRUCT010 on Logotype, even though Logotype wasn't in this run's violation set. The annotation script was walking the WHOLE file for stale-annotation cleanup regardless of whether the lint was scoped. Fixed by injecting a SCOPE_ROOT_ID into the script: - null → whole-file lint, walk every page (current behavior preserved) - → scoped lint, walk only that subtree (root + descendants) A scoped lint now affects ONLY annotations within its scope. Annotations on unrelated frames are left alone — which matches the user's mental model: "I'm linting UserAvatar, so don't touch annotations on Logotype." Cross-skill plumbing: - lint SKILL Phase 2 already resolves the scoped target's nodeId; pass it as SCOPE_ROOT_ID, null for whole-file mode. - push-component Phase 10.5: pass the freshly-pushed Component Set's nodeId (componentSetId from Phase 9). - pull-component Phase 2.5: pass the target Component Set's nodeId (from Phase 2's URL extraction). All three are scoped lints by definition; whole-file lints stay unscoped (SCOPE_ROOT_ID = null). The scope walk includes the root itself, since STRUCT011's aggregated variable-naming message attaches to the scope root rather than a descendant. Descendant walk uses `node.findAll`. SKILL-only change. No code changes; existing tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/adhd/skills/lint/SKILL.md | 47 ++++++++++++++++----- plugins/adhd/skills/pull-component/SKILL.md | 1 + plugins/adhd/skills/push-component/SKILL.md | 1 + 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/plugins/adhd/skills/lint/SKILL.md b/plugins/adhd/skills/lint/SKILL.md index fb9cc93..83e1167 100644 --- a/plugins/adhd/skills/lint/SKILL.md +++ b/plugins/adhd/skills/lint/SKILL.md @@ -123,10 +123,13 @@ console.log(out.length); ### The use_figma script -Pass the violations array to `mcp__plugin_figma_figma__use_figma` with `skillNames: "figma-use"`. The script ensures the category exists, applies current violations, and clears stale ADHD annotations file-wide (so a re-run reflects the current state — fixed violations get their annotations cleared automatically). +Pass the violations array to `mcp__plugin_figma_figma__use_figma` with `skillNames: "figma-use"`. The script ensures the category exists, applies current violations, and clears stale "lint"-category annotations — **scoped to whatever the current lint covered**. This is important: scoped lints (Phase 2 with a target nodeId) should ONLY touch annotations within the scoped subtree, never wipe annotations on unrelated frames that this run didn't lint. Whole-file lints walk every page. + +Inject `SCOPE_ROOT_ID` from Phase 2's resolved target: the nodeId for scoped mode, `null` for whole-file mode. The script branches on it. ```js const VIOLATIONS = /* substituted: contents of /tmp/adhd-lint/violations.json */; +const SCOPE_ROOT_ID = /* substituted: scoped target's nodeId, or null for whole-file */; const CATEGORY_LABEL = "lint"; const CATEGORY_COLOR = "orange"; @@ -144,18 +147,42 @@ for (const v of VIOLATIONS) { byNode.get(v.nodeId).push(v); } -// 3) Walk every page to find nodes with prior ADHD annotations + apply updates. -// Pages load incrementally — use `setCurrentPageAsync` so `findAll` sees their content. +// 3) Find previously-annotated nodes WITHIN THE LINT'S SCOPE. +// Pages load incrementally — use `setCurrentPageAsync` so `findAll` +// sees their content. The scope branch is critical: a scoped lint on +// UserAvatar must not wipe a STRUCT010 annotation that lives on Logotype +// — that frame wasn't part of this run. let updated = 0, cleared = 0; const touchedIds = new Set(); -for (const page of figma.root.children) { - await figma.setCurrentPageAsync(page); - // Previously-annotated nodes under this page. - const prior = page.findAll(n => - "annotations" in n && (n.annotations ?? []).some(a => a.categoryId === cat.id) - ); - for (const n of prior) touchedIds.add(n.id); +if (SCOPE_ROOT_ID) { + // Scoped: walk only the scope's subtree (root + descendants). + const scopeRoot = await figma.getNodeByIdAsync(SCOPE_ROOT_ID); + if (!scopeRoot) return { error: "Scope root not found", SCOPE_ROOT_ID }; + // Set page context so findAll sees descendants. + let page = scopeRoot; + while (page.parent && page.type !== "PAGE") page = page.parent; + if (page && page.type === "PAGE") await figma.setCurrentPageAsync(page); + // Include the scope root itself if it has prior annotations (STRUCT011's + // aggregated message attaches there). + if ("annotations" in scopeRoot && (scopeRoot.annotations ?? []).some(a => a.categoryId === cat.id)) { + touchedIds.add(scopeRoot.id); + } + if (typeof scopeRoot.findAll === "function") { + const prior = scopeRoot.findAll(n => + "annotations" in n && (n.annotations ?? []).some(a => a.categoryId === cat.id) + ); + for (const n of prior) touchedIds.add(n.id); + } +} else { + // Whole-file: walk every page. + for (const page of figma.root.children) { + await figma.setCurrentPageAsync(page); + const prior = page.findAll(n => + "annotations" in n && (n.annotations ?? []).some(a => a.categoryId === cat.id) + ); + for (const n of prior) touchedIds.add(n.id); + } } // Union of "previously annotated" and "currently violated" — every node that needs a write. diff --git a/plugins/adhd/skills/pull-component/SKILL.md b/plugins/adhd/skills/pull-component/SKILL.md index c0b7c4d..de2cec2 100644 --- a/plugins/adhd/skills/pull-component/SKILL.md +++ b/plugins/adhd/skills/pull-component/SKILL.md @@ -122,6 +122,7 @@ For Path B, skip the prompt if no violation has a `nodeId` (no actionable annota Inputs (both paths): - Engine stdout: `/tmp/adhd-pull-component/stdout.json` - Distill to `/tmp/adhd-pull-component/violations.json` using the same `node -e` snippet as lint Phase 6. +- **`SCOPE_ROOT_ID`** — pass the target Component Set's nodeId (from Phase 2's URL extraction). This bounds the annotation script's stale-annotation cleanup to this Component Set's subtree, so a pull of UserAvatar doesn't wipe a previously-annotated STRUCT010 on an unrelated Logo Component Set elsewhere in the file. Either path runs BEFORE the abort/escape evaluation below, so the designer sees the annotations regardless of whether the pull continues. diff --git a/plugins/adhd/skills/push-component/SKILL.md b/plugins/adhd/skills/push-component/SKILL.md index 2dedade..b1684a0 100644 --- a/plugins/adhd/skills/push-component/SKILL.md +++ b/plugins/adhd/skills/push-component/SKILL.md @@ -208,6 +208,7 @@ For Path B, skip the prompt if (a) preflight is clean (no errors blocking the pu Inputs (both paths): - Engine stdout: `/tmp/adhd-push-component/preflight-report.json` - Distill to `/tmp/adhd-push-component/violations.json` using the same `node -e` snippet as lint Phase 6. +- **`SCOPE_ROOT_ID`** — pass the freshly-pushed Component Set's nodeId (saved in Phase 9 as `componentSetId`). This bounds the annotation script's stale-annotation cleanup to this Component Set's subtree, so a push of UserAvatar doesn't wipe a previously-annotated STRUCT010 on an unrelated Logo Component Set elsewhere in the file. Phase 10.5 runs AFTER the preflight CLI exits but BEFORE Phase 11's decide-or-rollback — designers see annotations even when push aborts. From aa38f66eabfc4e7816b8f22f9b4076ab7afde7c2 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 23:35:49 -0400 Subject: [PATCH 47/79] lint engine: merge Tailwind v4 defaults so vars like \`white\` aren't "missing" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: pull-component Phase 2.7 prompted to add \`color/white\` (#ffffff) to globals.css. White is part of Tailwind v4's default theme — adding it locally is redundant clutter. The user's rule: "Never add something locally in code that is implicitly provided by tailwind please!" Saved as a feedback memory + fixed at the root in the lint engine: parseTheme on the user's globals.css now gets merged with the parsed contents of lib/design-system/tailwind-defaults.css (the canonical copy push/pull-design-system already use via parseCodeDesignSystem). User overrides win on key collision. After the merge, the variable comparator only reports \`status: "missing"\` for variables that are absent from BOTH the user's @theme block AND Tailwind v4's defaults. Downstream surfaces benefit for free: - Lint reports no longer list Tailwind-covered vars as actionable - pull-component Phase 2.7 discovery prompt doesn't suggest adding them - /adhd:lint --annotate doesn't annotate them Implementation detail: the defaults file uses \`@theme default {\` and \`@theme default inline {\` (Tailwind's canonical reference syntax). The lint engine's parseTheme only matches \`@theme {\` / \`@theme inline {\`, so loadTailwindDefaultPrimitives rewrites the headers before parsing. Only \`primitives\` is merged (exposure/light/dark stay user-owned). Test: 3-variable fixture with white + black + a custom brand color and an empty globals.css. Only the custom color surfaces as missing. White and black are silently covered by Tailwind. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/lint-engine/__tests__/cli.test.js | 34 ++++++++++++++++++ plugins/adhd/lib/lint-engine/cli.js | 35 ++++++++++++++++++- plugins/adhd/skills/pull-component/SKILL.md | 2 +- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/plugins/adhd/lib/lint-engine/__tests__/cli.test.js b/plugins/adhd/lib/lint-engine/__tests__/cli.test.js index f9f2df3..ab4632b 100644 --- a/plugins/adhd/lib/lint-engine/__tests__/cli.test.js +++ b/plugins/adhd/lib/lint-engine/__tests__/cli.test.js @@ -266,6 +266,40 @@ test('STRUCT011: collection-name-is-domain (Color/, Radius/, Spacing/) — no do 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: 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: [] }); diff --git a/plugins/adhd/lib/lint-engine/cli.js b/plugins/adhd/lib/lint-engine/cli.js index 172b5f7..4482153 100644 --- a/plugins/adhd/lib/lint-engine/cli.js +++ b/plugins/adhd/lib/lint-engine/cli.js @@ -18,7 +18,34 @@ */ const fs = require('node:fs'); +const path = require('node:path'); const { parseTheme } = require('./theme-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-design-system 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 {'); + return parseTheme(normalized).primitives; +} const { categorizeVariables } = require('./variable-categorizer'); const { checkStructure } = require('./structure-checker'); const { buildVariableSuggestions } = require('./variable-namer'); @@ -79,7 +106,13 @@ function main() { const namingConvention = readNamingConvention(args['config']); const fileKey = extractFileKey(args['target-url']); - const theme = parseTheme(cssText); + 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 = []; diff --git a/plugins/adhd/skills/pull-component/SKILL.md b/plugins/adhd/skills/pull-component/SKILL.md index de2cec2..0860877 100644 --- a/plugins/adhd/skills/pull-component/SKILL.md +++ b/plugins/adhd/skills/pull-component/SKILL.md @@ -188,7 +188,7 @@ If BOTH STRUCT011 AND variable-binding errors are present, surface STRUCT011 fir 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. +- **`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-design-system`'s job. Split missing further by the categorizer's `mode` field: From c1576d6c3e2084314c51c99a6fec625bdaf16585 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 23:42:08 -0400 Subject: [PATCH 48/79] add /adhd:pull-all-components and /adhd:push-all-components bulk skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User asked for bulk pull/push to iterate the components map in adhd.config.ts without typing per-component commands. Both directions shipped per user's preference. Behavior: - Sequential iteration over every entry in adhd.config.ts's `components` map - Halt on first failure by default (user picked this over continue-by-default) - `--continue-on-error` opts into best-effort mode with end-of-run summary - Per-component flags (`--allow-unbound`, `--annotate`, `--max-variants`) pass through to each invocation - Each component's pull/push runs its FULL interactive flow — preflight blockers, escape questions, Phase 2.7 missing-var discovery, annotate offers, sync-docs prompt. No batch override of per-component decisions. End-of-run summary shows per-component status with ✓ / ✗ / ⏭ markers, counts, and a "to re-try just the failures" hint for the actual failed paths. Exit code 0 if all succeed, 1 if any abort. Dev-server handling in push-all: push-component's Phase 4 only starts the server if not already running. The first bulk push starts it (if needed); subsequent ones reuse the running instance. push-component's Phase 13 cleanup leaves the server alone when it didn't start it. The bulk skill's Phase 4 cleanup owns the final teardown — only if the bulk started it; if it was already up, leave alone. SKILL-only change; no lib code. Tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 4 +- .../adhd/skills/pull-all-components/SKILL.md | 151 ++++++++++++++++++ .../adhd/skills/push-all-components/SKILL.md | 109 +++++++++++++ 3 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 plugins/adhd/skills/pull-all-components/SKILL.md create mode 100644 plugins/adhd/skills/push-all-components/SKILL.md diff --git a/README.md b/README.md index cfab9fa..bb9756e 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ 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, seven slash commands are available: +After install, nine slash commands are available: | Command | Args | Direction | What it does | |---|---|---|---| @@ -33,7 +33,9 @@ After install, seven slash commands are available: | `/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 ] [--annotate]` | code → Figma | Pushes a React component to Figma as a structured Component Set with variant properties + variable bindings, plus a preflight lint check. `--annotate` annotates preflight violations on Figma nodes. | +| `/adhd:push-all-components` | `[--continue-on-error] [--max-variants ] [--annotate]` | 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] [--annotate]` | Figma → code | Pulls a Figma Component Set into a React source file; updates lookup tables and union types only (function body untouched). `--annotate` annotates preflight violations on Figma nodes. | +| `/adhd:pull-all-components` | `[--continue-on-error] [--allow-unbound] [--annotate]` | 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. | | `/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. 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..edce5ca --- /dev/null +++ b/plugins/adhd/skills/pull-all-components/SKILL.md @@ -0,0 +1,151 @@ +--- +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, Phase 2.7 missing-var discovery, annotate, 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] [--annotate]" +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, annotate offers). Each component's pull runs its full interactive flow. +- Apply decisions across all components ("annotate all", "add all missing vars", 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) + - `--annotate` (per-component preflight annotation) + + The per-component pull-component SKILL handles its own validation, preflight, abort/escape logic, opportunistic-variable discovery (Phase 2.7), final report, and the post-success sync-docs prompt. All of those still fire normally — `pull-all-components` doesn't interfere with their flow. + +3. Record the outcome for this component into `/tmp/adhd-pull-all/outcomes.json` (append-only). Outcome shape: + ```json + { "path": "", "status": "success" | "abort" | "cancel", "summary": "", "error": "" } + ``` + - `success`: the per-component pull completed Phase 10 (final report). + - `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` 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.) + - 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/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 succeeded, 1 failed, 2 skipped. +``` + +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 annotations were pushed** (i.e., `--annotate` was active): print `Annotations updated in the "lint" category in Figma.` + +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/push-all-components/SKILL.md b/plugins/adhd/skills/push-all-components/SKILL.md new file mode 100644 index 0000000..e2c6cac --- /dev/null +++ b/plugins/adhd/skills/push-all-components/SKILL.md @@ -0,0 +1,109 @@ +--- +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, --annotate prompts) 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 ] [--annotate]" +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, annotate offers). Each push runs its full interactive flow. +- Apply decisions across all components ("rollback all on any failure", "annotate all"). 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) + - `--annotate` (per-component preflight annotation) + + The per-component push-component SKILL handles its own validation, dev-server start/check, capture, consolidation, preflight, decide-or-rollback, final report, and the mapping write to `adhd.config.ts`. All of those still fire normally. + + **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 `. +- **`--annotate` was active:** `Preflight annotations updated in the "lint" category in Figma.` + +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. | From cbbb4b039f60583bcffe5678972b0c671853caf3 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 23:46:24 -0400 Subject: [PATCH 49/79] sync-docs: open-in-Figma link + proper PascalCase component name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues from the user's reactor docs route: 1) The import + JSX snippets rendered as \`import d from "..."\` and \`\` on the logotype page. Root cause: the template used Component?.name as the snippet's display name, and the user's exported Logotype function got wrapped/minified so its .name is a single letter. Fix: prefer Component.name ONLY when it looks like a real identifier (matches /^[A-Z][A-Za-z0-9]+$/), otherwise fall back to a PascalCase'd slug. "logotype" → "Logotype", so the snippet reads \`import Logotype from "..."\` + \`\` regardless of how the component was wrapped. 2) No way to jump from a component's docs page back to its Figma source. Added a ↗ link in the page header that opens the entry's figma.url in a new tab. Plumbing: - config-parser: new parseFigmaUrlForPath() extracts \`figma.url\` per component path from adhd.config.ts. readConfig now includes figmaUrl in each component entry (null when no figma block). - route-installer: threads figmaUrl into the componentMap.tsx entries. Renders as a string literal or null. - templates: ComponentEntry shape carries figmaUrl: string | null; component page conditionally renders with the ↗ glyph, target="_blank" + rel="noopener noreferrer". Both fixes apply to existing docs installs on re-sync — the marker-based cleanup replaces componentMap.tsx and the component page in place. No breaking changes; figmaUrl is opt-in (silent skip when absent). Tests: figmaUrl extraction in config-parser, figmaUrl threading in route-installer's componentMap output, template assertions for the Figma link + PascalCase fallback. 441/441 plugin-wide. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../sync-docs/__tests__/config-parser.test.js | 18 +++++++++++ .../__tests__/route-installer.test.js | 24 ++++++++++++++ .../lib/sync-docs/__tests__/templates.test.js | 28 +++++++++++++++++ plugins/adhd/lib/sync-docs/config-parser.js | 16 +++++++++- plugins/adhd/lib/sync-docs/route-installer.js | 3 +- plugins/adhd/lib/sync-docs/templates.js | 31 +++++++++++++++++-- 6 files changed, 115 insertions(+), 5 deletions(-) diff --git a/plugins/adhd/lib/sync-docs/__tests__/config-parser.test.js b/plugins/adhd/lib/sync-docs/__tests__/config-parser.test.js index eb44025..58c1b1f 100644 --- a/plugins/adhd/lib/sync-docs/__tests__/config-parser.test.js +++ b/plugins/adhd/lib/sync-docs/__tests__/config-parser.test.js @@ -71,10 +71,28 @@ export default config; slug: 'logo', rawPath: 'components/design-system/logo/index.tsx', importPath: '@/components/design-system/logo', + figmaUrl: 'x', }]); 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 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/); diff --git a/plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js b/plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js index a41aa11..1fca4f1 100644 --- a/plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js +++ b/plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js @@ -152,6 +152,30 @@ test('componentMap.tsx has explicit static imports per registered component', () 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, diff --git a/plugins/adhd/lib/sync-docs/__tests__/templates.test.js b/plugins/adhd/lib/sync-docs/__tests__/templates.test.js index 3192e9a..50bd62c 100644 --- a/plugins/adhd/lib/sync-docs/__tests__/templates.test.js +++ b/plugins/adhd/lib/sync-docs/__tests__/templates.test.js @@ -115,6 +115,34 @@ test('COMPONENT_MAP_TSX has the substitution placeholders the installer fills in 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/); diff --git a/plugins/adhd/lib/sync-docs/config-parser.js b/plugins/adhd/lib/sync-docs/config-parser.js index e840e07..ae91aac 100644 --- a/plugins/adhd/lib/sync-docs/config-parser.js +++ b/plugins/adhd/lib/sync-docs/config-parser.js @@ -57,6 +57,19 @@ function parseCssEntry(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" @@ -83,6 +96,7 @@ function readConfig(projectRoot) { slug: slugFor(rawPath), rawPath, importPath: importPathFor(rawPath), + figmaUrl: parseFigmaUrlForPath(src, rawPath), })); return { components, @@ -90,4 +104,4 @@ function readConfig(projectRoot) { }; } -module.exports = { readConfig, parseComponents, parseCssEntry, slugFor, importPathFor }; +module.exports = { readConfig, parseComponents, parseCssEntry, parseFigmaUrlForPath, slugFor, importPathFor }; diff --git a/plugins/adhd/lib/sync-docs/route-installer.js b/plugins/adhd/lib/sync-docs/route-installer.js index 97b15d9..8e33258 100644 --- a/plugins/adhd/lib/sync-docs/route-installer.js +++ b/plugins/adhd/lib/sync-docs/route-installer.js @@ -54,7 +54,8 @@ function renderComponentMap(projectRoot, components) { const entries = components .map((c, i) => { const props = JSON.stringify(bakedPropsFor(projectRoot, c.rawPath)); - return ` { slug: ${JSON.stringify(c.slug)}, rawPath: ${JSON.stringify(c.rawPath)}, module: $cmp${i}, props: ${props} },`; + const figmaUrl = c.figmaUrl ? JSON.stringify(c.figmaUrl) : 'null'; + return ` { slug: ${JSON.stringify(c.slug)}, rawPath: ${JSON.stringify(c.rawPath)}, figmaUrl: ${figmaUrl}, module: $cmp${i}, props: ${props} },`; }) .join('\n'); return COMPONENT_MAP_TSX diff --git a/plugins/adhd/lib/sync-docs/templates.js b/plugins/adhd/lib/sync-docs/templates.js index aa6f0e2..ca9398d 100644 --- a/plugins/adhd/lib/sync-docs/templates.js +++ b/plugins/adhd/lib/sync-docs/templates.js @@ -131,6 +131,7 @@ export type PropSchema = { export type ComponentEntry = { slug: string; rawPath: string; + figmaUrl: string | null; Component: React.ComponentType> | null; props: Record; }; @@ -138,6 +139,7 @@ export type ComponentEntry = { type RawEntry = { slug: string; rawPath: string; + figmaUrl: string | null; module: Record; props: Record; }; @@ -158,6 +160,7 @@ const ENTRIES: RawEntry[] = __COMPONENT_ENTRIES__; export const components: ComponentEntry[] = ENTRIES.map(e => ({ slug: e.slug, rawPath: e.rawPath, + figmaUrl: e.figmaUrl, Component: resolveComponent(e.module), props: e.props, })); @@ -554,7 +557,7 @@ export default function ComponentPage() { if (!entry) return ; - const { rawPath, Component, props } = entry; + const { rawPath, figmaUrl, 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 @@ -570,8 +573,16 @@ export default function ComponentPage() { 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 componentName = Component?.name || slug; const importStmt = Component ? \`import \${componentName} from "\${importPath}";\` : null; const jsxSnippet = Component ? \`<\${componentName}\${Object.entries(current).map(([k, v]) => \` \${k}={\${JSON.stringify(v)}}\`).join("")} />\` @@ -579,7 +590,21 @@ export default function ComponentPage() { return (
    -

    {slug}

    +

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

    Props

    From 364d889a9260ef3764ce88c8d8dbe9b1c57548c8 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Mon, 11 May 2026 23:51:50 -0400 Subject: [PATCH 50/79] sync-docs: "Last built" timestamp in sidebar header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Designers reading the docs route want a quick signal of "is this fresh?" Added a UTC build timestamp to the sidebar header, right under the "Internal — not indexed" line. Format: \`Last built 2026-05-11 03:14 UTC\` — UTC by default so no locale dependency, sortable, unambiguous. Baked at sync time via a new __SYNC_AT__ placeholder; installer accepts an optional \`opts.now\` Date override for deterministic tests. The timestamp reflects when the user last ran /adhd:sync-docs (when the static componentMap.tsx + layout were regenerated). Tokens are read live from globals.css so their freshness isn't gated on this — but if the designer wants to verify they're on the latest static map (component imports, baked prop schemas), the timestamp is the signal. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/route-installer.test.js | 19 +++++++++++++++++++ plugins/adhd/lib/sync-docs/route-installer.js | 14 +++++++++++++- plugins/adhd/lib/sync-docs/templates.js | 1 + 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js b/plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js index 1fca4f1..71671f0 100644 --- a/plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js +++ b/plugins/adhd/lib/sync-docs/__tests__/route-installer.test.js @@ -227,6 +227,25 @@ test('componentMap.tsx handles a missing component source file (empty props bake 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); diff --git a/plugins/adhd/lib/sync-docs/route-installer.js b/plugins/adhd/lib/sync-docs/route-installer.js index 8e33258..18442a1 100644 --- a/plugins/adhd/lib/sync-docs/route-installer.js +++ b/plugins/adhd/lib/sync-docs/route-installer.js @@ -122,12 +122,24 @@ function installRoute(projectRoot, opts) { // `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(/__LAYOUT_MODULE__/g, layoutModule) + .replace(/__SYNC_AT__/g, syncAt); } // Remove stale marker-bearing files from previous template layouts (e.g. the diff --git a/plugins/adhd/lib/sync-docs/templates.js b/plugins/adhd/lib/sync-docs/templates.js index ca9398d..79540e3 100644 --- a/plugins/adhd/lib/sync-docs/templates.js +++ b/plugins/adhd/lib/sync-docs/templates.js @@ -215,6 +215,7 @@ export default function DesignSystemDocsLayout({ children }: { children: React.R

    Design System

    Internal — not indexed

    +

    Last built __SYNC_AT__