Skip to content

Add /adhd:install-design-system-docs-route skill#9

Open
hhff wants to merge 79 commits into
mainfrom
adhd/install-design-system-docs-route
Open

Add /adhd:install-design-system-docs-route skill#9
hhff wants to merge 79 commits into
mainfrom
adhd/install-design-system-docs-route

Conversation

@hhff
Copy link
Copy Markdown
Member

@hhff hhff commented May 11, 2026

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), and provides 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) 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.
  • Production exclusion via Next.js pageExtensions conditional. Files use .design-system.tsx extension when prod-excluded; 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" — only the consumer's adhd.config.ts filename appears (which is unavoidable since the page reads it). Marker comment is generic: // design-system-docs-route — auto-generated installer artifact; safe to edit.
  • Re-runnable. Marker-bearing files get wholesale Write-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.
  • Per-layer-type Tailwind utility correctness. The templates know that an SVG path fill needs fill-* / text-* (with currentColor), not bg-*. Documented in Phase 3 / Phase 7 of the install SKILL.

What lives where

New library plugins/adhd/lib/install-design-system-docs-route/:

  • token-parser.js — extract @theme tokens from globals.css
  • prop-parser.js — extract a component's prop interface
  • slug.js — component path → URL slug + collision disambiguation
  • next-config-patcher.js — idempotent conditional pageExtensions patch
  • robots-patcher.js — idempotent Disallow entry
  • templates.js — page templates (layout, index, [component]/page, PropToggle) as string constants
  • route-installer.js — write the 4 files at the target path; marker-comment detection for re-runs
  • cli.js — orchestrator surface for the SKILL

New skill plugins/adhd/skills/install-design-system-docs-route/SKILL.md — 9-phase orchestrator.

Modified:

  • plugins/adhd/skills/config/SKILL.md — new optional Phase 6 offering the install at the end of /adhd:config
  • README.md — command table row + "Design system docs route" subsection
  • .claude-plugin/marketplace.json — description includes the new command
  • .github/workflows/ci.yml — new test step

Test plan

  • 344/344 lib tests pass (~58 new across token-parser, prop-parser, slug, next-config-patcher, robots-patcher, templates, route-installer, cli)
  • 7/7 SKILL frontmatters valid
  • Example app builds clean (no regression to existing routes)
  • Manual smoke test in example/: run /adhd:install-design-system-docs-route, pick defaults → npm run dev → visit /-docs → verify token catalog renders + click into a component → toggle props → npm run build → verify the route's chunks aren't in the production output → npm start → confirm 404 at /-docs

Notes from the implementation

Three spec/plan/test contradictions were caught and resolved during Task 7 (templates):

  • The plan's verbatim INDEX_PAGE_TSX included /adhd:push-component in an empty-state message; replaced with a neutral "No components tracked yet." (spec criterion #13 forbids ADHD references in generated files).
  • Test 11's /adhd/i regex would match the legitimate adhd.config.ts filename in the generated page bodies; test scoped to strip that filename before checking (spec invariant explicitly carves out adhd.config.ts as the one allowed reference).
  • Test 9's ^["']use client["'] anchor failed because PROP_TOGGLE_TSX must start with the marker comment; test scoped to strip the marker first (Next.js permits leading comments before the client directive).

All three resolutions are documented inline in the test file.

🤖 Generated with Claude Code

hhff and others added 30 commits May 11, 2026 14:33
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…, no ADHD refs)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… 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) <noreply@anthropic.com>
Bug 1: Index page Links navigated to the wrong URL.
  The index page used `<Link href={`./${slug}`}>` 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/<component>`.

  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) <noreply@anthropic.com>
…efault empty states

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)/<seg>/layout.design-system.tsx       — sidebar
     app/(group)/<seg>/page.design-system.tsx         — landing
     app/(group)/<seg>/tokens/[domain]/page.*         — per-domain renderer
     app/(group)/<seg>/components/[component]/page.*  — component viewer
     app/(group)/<seg>/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) <noreply@anthropic.com>
…oute

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 "@/<importPath>"` 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
hhff and others added 30 commits May 11, 2026 23:51
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) <noreply@anthropic.com>
…name"

User feedback: the previous message said "right-click → Rename" but most
suggestions were MOVES across collections (Type + Effects/Font-Size/Body
→ Text/body). Rename only works within the same collection — a designer
literally couldn't apply most of the suggestions as advertised.

Three improvements:

1. Group renames by TARGET COLLECTION. Designer sees the action pattern:
   "create Text, move 2 vars in; create Leading, move 1 var in." Instead
   of a flat list of 6 unrelated-looking rename lines.

2. Call the action what it actually is: a MOVE. The footer explains
   Figma's "Move to..." mechanic (right-click → Move to → pick target
   collection; Figma auto-rewires references) and explicitly warns
   "Use Move to (not Rename) — Rename only works within the same
   collection."

3. Detect ambiguity. Some leaf names hint at a different domain than
   their path. "Type + Effects/Line-Height/Letter Space 0" — path
   suggests leading, but the leaf is letter-spacing (tracking). The
   suggestion now surfaces both options:
     Primary:   → Leading/letter-space-0
     Alternate: → Tracking/0
   Designer picks based on actual usage.

4. Concept-specific guidance for opacity. Tailwind v4 has no opacity
   domain — opacity is applied via class modifiers (bg-white/50), not
   stored as variables. The no-mapping message now explains this
   instead of just listing the canonical domain set.

Sample output for the user's reactor file (6 issues):

  STRUCT011: 6 variable-naming issue(s). Suggested restructure:

  → Move to "Leading" collection (1 var):
    • Type + Effects/Line-Height/Line Height 28
        → Leading/line-height-28

  → Move to "Spacing" collection (1 var):
    • Space/space/0  → Spacing/space/0

  → Move to "Text" collection (2 vars):
    • Type + Effects/Font-Size/Body     → Text/body
    • Type + Effects/Font-Size/Body LG  → Text/body-lg

  ⚠ Ambiguous (1) — path and leaf disagree on the target domain:
    • Type + Effects/Line-Height/Letter Space 0
        ⚠ path suggests leading, leaf "Letter Space 0" suggests tracking
        Primary:    → Leading/letter-space-0
        Alternate:  → Tracking/0

  ⚠ No Tailwind v4 mapping (1):
    • Type + Effects/Effects/Opacity 100%
        ⚠ Tailwind v4 has no "opacity" domain — opacity is applied via
          class modifiers (e.g. `bg-white/50`), not stored as variables.

  How to apply each move in Figma:
    1. Open the Variables panel; create any missing target collections.
    2. Right-click each source variable → "Move to..." → pick the target.
    3. Inside the target, rename to drop redundant path segments.

  Use Figma's "Move to..." (not "Rename") — Rename only works within
  the same collection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
STRUCT012 catches cross-domain variable bindings — e.g. a designer binding
`Spacing/4` to letter-spacing. The variable's name is fine on its own (so
STRUCT011 stays quiet), but the binding crosses Tailwind's token-domain
boundary. Inferred from the variable's name via the same logic the namer
already uses for rename suggestions; ambiguous names produce no
STRUCT012 violation (we'd rather under-report than false-positive).

STRUCT011 now annotates per layer instead of clustering on the scope
root. Each layer that binds a bad-named variable gets its own annotation
so designers can walk the offending layers one by one. Requires the new
`varIdMap` (Figma VariableID → name) emitted by the lint SKILL's
serializer; without it, falls back gracefully to the old aggregated
emission.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renames the slash commands from /adhd:push-design-system and
/adhd:pull-design-system to /adhd:push-tokens and /adhd:pull-tokens.
"Tokens" is the precise term for what these skills move — variables and
named styles, not the whole design system (which also includes
components, conventions, and a docs route handled by other skills).

Adds a --dry-run flag to both. Dry run runs the comparator, formats a
preview (additions per mode, conflicts showing both values, untouched
count from the other side), and exits before any prompts or writes. It's
a pure discovery tool — you can run it any time to see the surface area
of a sync without committing to apply.

Preview shows both values for each conflict rather than pre-resolving;
resolution still belongs in the per-conflict prompt loop. Specs and
plans under docs/superpowers stay frozen (historical), but all live
references in skills + lib code now use the new names.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
STRUCT013 surfaces Figma variables that duplicate a Tailwind v4 default
— same canonical name, same value. After designers push the full
Tailwind palette to Figma via /adhd:push-tokens, any leftover custom
variables that match a default by both name and value are flagged for
consolidation.

Strict match only: both normalized name AND value have to align. A
semantically-named variable like `Color/MyZinc` that happens to equal
`--color-zinc-500` is intentionally NOT surfaced — the designer's
naming choice signals intent we should respect.

/adhd:lint --fix walks each candidate via AskUserQuestion. Approved
consolidations rebind every layer that uses the duplicate to the
canonical Tailwind variable, then delete the duplicate. The rebind
covers scalar bindings (letterSpacing, padding, *Radius, etc.) plus
per-paint fills/strokes bindings. Pre-flight check ensures the canonical
Tailwind variable exists in Figma — if not, the candidate is held back
with a clear message to run /adhd:push-tokens first.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Designers familiar with /adhd:push-tokens --dry-run may reflexively try
the same flag on lint. Failing loudly with a clear message is safer
than silently picking one interpretation. /adhd:lint doesn't have its
own --dry-run mode — the per-candidate AskUserQuestion in Phase 8
already provides preview-then-approve.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…inline calc/var

Two related bugs that made /adhd:push-tokens unusable on real Tailwind v4
projects, both surfacing as false-positive diffs:

1. Tailwind-defaults floor. The comparator was treating
   `includeTailwindDefaults: true` tokens as code-side state, then offering
   to push hundreds of canonical Tailwind variables into Figma every run.
   Now each token carries `fromTailwindDefault` provenance; the comparator
   filters origin-tagged tokens out of `codeOnly` while keeping them in
   `same` and `conflict` (real state on the Figma side). User-authored
   overrides at the same path clear the flag and still push.

2. @theme inline calc/var resolution. The parser ignored every inline
   entry that wasn't a pure `var(--X)` alias — meaning the shadcn pattern
   `--radius-sm: calc(var(--radius) - 4px)` silently fell back to
   Tailwind's `0.25rem` default and conflicted against Figma's resolved
   6px. The new resolver walks `var()`, `calc(var() ± Npx)`, and
   `calc(var() * N)` against a `rawVars` map of every `--name: value;`
   seen during parse, producing literal `<N>px` overrides. Expressions
   the resolver can't reduce (undefined refs, multi-op chains, percentage
   math) leave the Tailwind default in place — caller can decide what to
   do with the resulting conflict.

Net effect on the user's reactor-webapp dry run: the 493 spurious
additions and 4 spurious conflicts should both collapse to zero.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… preview

Previous commit filtered Tailwind defaults out of codeOnly so daily
pushes stayed focused, but designers want the seed-mode workflow too:
push the entire Tailwind palette to Figma so every utility is available
as a variable, no ad-hoc tokens required.

Adds --include-tailwind to /adhd:push-tokens. Without it, day-to-day
pushes see only authored tokens (focused diff, ~20 entries). With it,
the comparator surfaces every Tailwind default in codeOnly so the seed
push creates them all in Figma. Designed for one-time setup; subsequent
pushes use the default mode.

Also buckets the dry-run preview by domain when there are >25
additions, so the seed-mode flood (493 entries) becomes COLOR (350) /
SPACING (50) / RADIUS (4) / ... with a 6-row sample per bucket instead
of a 493-row unfurl. The full list still lives in diff.json for
detailed review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-target normalization

Three round-trip bugs surfacing on real Figma projects, all stemming
from CSS-var-name lossiness:

1. Path-tokenization canonicalization. Figma variable named `Color/gold-25`
   (single leaf with internal hyphen) pulls as `--color-gold-25`, but the
   parser tokenizes that back as path `gold/25` on push — so the same
   variable looks like a fresh codeOnly token and push creates a duplicate
   in Figma. The comparator now does a reclaim pass: any (codeOnly,
   figmaOnly) pair that canonicalizes to the same CSS-var name moves into
   `same`. Domain still separates collisions — color/gold-25 vs
   shadow/gold-25 stay distinct.

2. Alias target canonicalization. `var(--color-neutral-0)` in code
   tokenizes to alias-target `neutral/0`; Figma extract stores
   `target.name` verbatim ("neutral-0"). Without normalization the
   comparator saw aliases as conflicting even when they pointed at the
   same variable. valuesEqual now compares targets with slashes collapsed
   to hyphens.

3. Font families don't push as variables. `--font-aeonik` and friends
   belong in Figma's text-style system — pushing as STRING variables
   creates a competing channel. The action builder emits a
   `skip-font-family` action so the SKILL surfaces the reason in the
   report. Font weights, sizes, line-heights, and tracking still push
   (scalar values, variables are the right home).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Designers want a conscious per-push decision about what gets pushed to
Figma, with different domains warranting different choices: push all
colors or only my semantic tokens? Skip opacity (Tailwind's class-modifier
pattern) or push for documentation? Push z-index/animate/ease/etc that
Figma can't even consume?

Wizard runs at Phase 1.5 of /adhd:push-tokens on every invocation —
including --dry-run, so the dry-run preview reflects exactly the choices
the live push would make. No persistence: dispositions live in /tmp
for the run only.

Seven questions, ordered by impact:
  1. Color: all / semantic-only / skip
  2. Typography (sans family — always text-styles): all / sizes+weights / skip
  3. Spacing: all / authored-only / skip
  4. Radius + border-width: push / skip
  5. Shadow: effect-styles / skip
  6. Opacity: skip (recommended) / push
  7. Utility domains (z-index, animate, ease, aspect, perspective,
     container, breakpoint, blur): skip (recommended) / push

New module `dispositions.js` classifies each codeOnly token per the
wizard's answers. Action builder emits `skip-by-disposition` (with the
specific reason) for filtered tokens — preview groups skips by reason
so designers see exactly what's been filtered and why. Dry-run preview
splits into "Would add" vs "Would NOT add (filtered)" lanes. Final
report counts the skips so the policy is verifiable end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The wizard's "color: all" and "spacing: all" answers already cover the
seed-the-Tailwind-palette workflow, and "color: semantic-only" /
"spacing: authored-only" cover the focused workflow. The --include-tailwind
binary flag was a coarser version of the same axis — keeping both gave
users two confusing knobs for the same decision.

Comparator is now policy-free: every code-side token surfaces in
codeOnly with the `fromTailwindDefault` marker intact. Filtering moves
entirely to the action-builder layer, driven by the wizard's
dispositions. One source of truth for "what pushes" — the wizard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…STRING+FONT_SIZE)

Tailwind v4 ships paired line-height companions for every text size
(--text-xs--line-height: calc(1 / 0.75), etc. — eight of them in the
default theme). Two bugs combined to make these the only push failures
on a real Tailwind v4 project:

1. dimensionToPx rejected calc() expressions outright, so the action
   builder typed the line-height tokens as STRING (raw "calc(1 / 0.75)")
   instead of evaluating to a FLOAT ratio.

2. The figma-write script's tokenScopesFor narrowed `text/*` paths to
   the FONT_SIZE scope without checking for the companion `/line-height`
   suffix — so even if the type had been right, the scope would have
   been wrong.

Fix #1 extends dimensionToPx to evaluate a narrow window of calc shapes:
calc(<num> <op> <num>) where each operand re-enters the evaluator (so
rem→px conversion still applies). Covers the ratio case (`calc(1 / 0.75)`
→ 1.333) and the multiplier case (`calc(1rem * 2)` → 32). Anything more
elaborate (var refs, nested calls) still falls through to STRING.

Fix #2 adds a `text/*/line-height` branch BEFORE the broader text/
branch in tokenScopesFor. Exposes the function as a JS-side module
export (mirroring the inline copy in WRITE_SCRIPT) with a drift-guard
test ensuring the two stay in sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…color)

Designer's Figma file had collections named "Color", "Radius", "Borders",
"Space", "Type + Effects" — push was looking up the canonical names
("color", "radius", "border-width", "spacing", "typography")
case-sensitively against existing collection names, missing the
designer's collections every time and creating fresh ones alongside.
Designer ended up with both "Color" (their 25 vars) and "color" (ours)
in the variables panel — silly noise.

ensureCollection now consults an alias table before creating: case-
insensitive match against any known alias for the canonical domain
("Color"/"colors"/"colour" all → color; "Type + Effects"/"text styles"
→ typography; "Borders"/"strokes" → border-width). When a match is
found, push appends into the designer's existing collection — they
keep their naming preference, we stop polluting.

JS-side `findCollectionAlias` mirrors the inline copy in WRITE_SCRIPT;
drift-guard tests assert every action-builder domain has an alias list
and every alias string appears in the inlined script.

Pre-existing duplicates from prior pushes are left alone — designers
can consolidate manually, or we can add a /adhd:lint --fix-style rule
for that as a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Alias-aware push (the previous commit) prevents NEW duplicate
collections, but the pre-existing dupes from earlier pushes — Color +
color, Space + spacing, Borders + border-width, Type + Effects +
typography — stay parked in the file until something actively merges
them.

STRUCT014 surfaces every group where two or more Figma collections
alias to the same canonical domain. Detection works from the variable
key list (varDefs) alone — collection names are the first slash segment
of every key. Groups are ordered by var count descending so the most
populated collection is the natural "keep this one" suggestion.

/adhd:lint --fix gains a Phase 8b for STRUCT014. Per group:
  - AskUserQuestion picks the keeper (most-populated suggested first)
  - For each variable in the loser collections, create an equivalent
    in the keeper with the same name/type/scopes/description, copy
    values per mode (mapped by mode-name since mode IDs differ across
    collections), rebind every layer that references the old variable,
    delete the original.
  - When all variables in a loser are moved, delete the now-empty
    loser collection.

Name collisions (same var name exists in keeper with potentially
different value) skip the move and surface as warnings — designer
decides manually which side wins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…thing changed)

After a successful pull, /adhd:pull-component records an 8-char SHA-256
prefix and an ISO pulledAt timestamp in adhd.config.ts next to the
component's figma block:

  components: {
    'app/Button': {
      figma: { url: '...' },
      pulledAt: '2026-05-12T14:30:00.000Z',
      fingerprint: 'a1b2c3d4',
    },
  }

The fingerprint hashes the Figma extract (component context + resolved
vars map) plus the adhd.config.ts fields that affect generated code
(naming, cssEntry). Anything that changes the pull's output flips the
hash — false-positive bias is deliberate so a missed re-sync is never
silent.

Wired into pull-component's Phase 2.5 as a short-circuit after the
Figma extract: if `fingerprint-check` says match, the SKILL exits
before lint, diff, write, or commit. The 8-char form is Git-style;
collisions are astronomically unlikely for the dozens-to-hundreds of
components a project tracks, and lookup is by path anyway.

pull-all-components surfaces fingerprint matches as a separate
`unchanged` outcome in the bulk summary so designers see how many
re-synced vs how many were skipped.

The docs route (/adhd:sync-docs) now displays "Last pulled <UTC>" on
each component page, sourced from the same `pulledAt` field — gives
designers a visual cue for how fresh each piece of generated code is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…broken

Previously the lint engine surfaced "this variable is missing from
code" and "this variable's value conflicts" at the FILE level. The
component-level lint summary called the frame "ready for code
translation" because no structural errors fired, even when layers in
the frame were bound to phantom or drifting variables. Pulling such a
component would either generate code referencing a CSS variable that
globals.css doesn't declare (broken render) or render with code's value
that visibly drifts from Figma.

STRUCT015 fires per-layer when a binding references a variable that's
missing from code. STRUCT016 fires per-layer when a binding references
a variable whose value differs between code and Figma. Both are
severity=error and join STRUCT003/004/005/011 in the pull-component
blocking-error list — no escape. Designer must rebind in Figma OR run
/adhd:pull-tokens (to take Figma's values) or /adhd:push-tokens (to
send code's values back) before the pull can proceed.

Also fixes the long-tail "primary" false-conflict bug from the user's
session: normalizeColor now accepts Figma's raw {r,g,b,a} channel
object form (channels 0..1), so `#0a0a0a` and `{r: 0.039, ...}` resolve
to the same hex and the comparator stops falsely conflicting them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two stacked bugs were causing STRUCT016 to fire on color/primary even
though both sides resolved to the same #0a0a0a:

1. The variable-categorizer fed `strippedToken(figmaPath)` to
   inferDomain — so "color/primary" became "primary", which didn't
   match any of the `startsWith('color/')` checks. Domain came back
   "unknown" and valuesMatch fell to the strict-equality default
   branch — never reaching the per-domain color normalization. The
   normalizeColor fix from the previous commit DID handle the rgb
   object form, but never got a chance to run.

2. inferDomain was case-sensitive: "Color/primary" (capitalized
   collection — common Figma convention) also returned "unknown" for
   the same reason as #1.

Fix: pass the FULL figma path to inferDomain (lets the function see
the collection name), lowercase the input before checking, and
include "spacing/" as a synonym for the older "space/" prefix.

End-to-end: the user\'s annotation showing "code: #0a0a0a, figma:
#0a0a0a" now correctly resolves to no conflict — both forms
canonicalize to the same hex.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two more bugs surfaced when running the lint against the user's actual
reactor extract:

1. The lint engine crashed mid-categorization on the first numeric
   Figma value it encountered (radius/sm: 6, spacing/space/0: 0,
   text-size: 14, etc.). normalizeDimension threw on the number; the
   throw bubbled out of categorizeVariables and the cli wrote a stack
   trace to stdout instead of a JSON summary. The user's last few lint
   runs were all silently aborting partway through — the "STRUCT016
   color/primary drift" output they kept seeing was stale from a prior
   completed run. Fix: accept numbers in normalizeDimension, integer →
   '<N>px' (lengths), non-integer → '<N>' (ratios — line-heights mostly).

2. The "color/primary false positive" was the alias-vs-literal compare
   path. The shadcn pattern declares `:root { --primary: #0a0a0a; }`
   then exposes it via `@theme inline { --color-primary: var(--primary); }`.
   The categorizer found `var(--primary)` in the exposure layer,
   compared it against Figma's `{r,g,b,a}` object, normalizeColor threw
   on the alias string, valuesMatch caught and returned false, conflict
   fired. The normalizeColor fix from earlier never ran because the
   THROW killed valuesMatch before normalization could happen.

   Fix: when code-side is `var(--X)` and figma-side is a literal,
   resolve the chain through primitives/exposure/light/dark (bounded
   recursion depth 8 to guard against pathological aliases), collect
   every terminal value, accept the binding as equal if ANY terminal
   value matches the figma side. Real drift still surfaces — the
   resolved values just have to disagree with figma for the conflict
   to fire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… writes

Replaces the unconditional abort-on-blocking-variable-issues behavior
with per-variable AskUserQuestion prompts. STRUCT015 (missing in code)
offers two options — "Add to globals.css" or abort. STRUCT016 (value
conflict) offers three — "Take Figma's value", "Keep code's value", or
abort.

The interesting work is the write-target resolver. The shadcn pattern
declares semantic colors in :root and exposes them to Tailwind via
@theme inline, so the cssVar Figma references (--color-primary) is
just an alias re-export. Naively writing the Figma value to
--color-primary would replace the alias with a literal and break
dark-mode propagation. The resolver walks the alias chain (bounded
recursion, cycle-safe) until it reaches the literal source, then
returns set-primitive / set-semantic actions targeting the right cssVar
AND the right layer (primitives, :root light, :root dark).

Edge cases handled in tests:
  - Missing-everywhere → set-primitive at @theme
  - Simple primitive literal → set-primitive at @theme
  - shadcn exposure → light literal: write to :root light
  - Exposure → light + dark literals: conservative default to light
    only; bothModes flag writes both
  - Exposure → dark only: writes to dark
  - Two-hop and three-hop alias chains: bottoms out at the terminal
  - Alias cycles: detected, falls back to primitive write
  - Alias to undefined variable: STRUCT015-path primitive write
  - 20-hop pathological chain: depth-bounded, doesn't hang

End-to-end tests verify resolveWriteTarget → applyToCss → re-parse →
categorizer clean, for both the STRUCT015 add-missing case and the
STRUCT016 shadcn-alias-chain take-Figma case.

New CLI subcommand `resolve-actions` lets the SKILL shell out without
inlining a 60-line Node script.

Push-component variant (same prompts mirrored, but actions hit Figma
instead of globals.css) is intentionally a separate commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 10.7 walks the same per-unique-variable AskUserQuestion flow as
/adhd:pull-component, but the action lanes differ by direction:

  STRUCT015 (missing in code):
    - Add to globals.css   → alias-aware write via resolveWriteTarget
    - Skip                 → push lands with the broken binding
    - Abort                → Phase 11's rollback path

  STRUCT016 (value conflict):
    - Take code's value    → use_figma update-variable on the Figma side
    - Take Figma's value   → globals.css write via resolveWriteTarget
    - Skip                 → both sides stay divergent
    - Abort                → rollback

The "Take code's value" path needs the Figma variable id, so push-component's
preflight extraction now mirrors /adhd:lint by emitting varidmap.json
alongside ctx.json + vars.json. push-component's preflight CLI forwards
--var-id-map to the lint engine when provided; without it, the legacy
aggregated STRUCT011 behavior holds (no STRUCT012/015/016 per-layer
violations would fire either, since they all share the bridge).

The use_figma write script handles the type-aware value conversion
(hex → {r,g,b,a} 0..1 for COLOR, dimension → px-number for FLOAT,
string passthrough otherwise). Same conversion the push-tokens
WRITE_SCRIPT does, inlined for clarity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… variable"

The intent of the third option was always "leave the divergence in place
and surface it via the Figma annotation," but "Skip" undersold the
behavior — designers couldn't tell from the label that the annotation
stayed visible. Renaming makes the semantics explicit so the choice is
informed.

Also makes the option set symmetrical between pull and push for the
same rule:
  STRUCT015 (missing): Add to code / Annotate only / Abort
  STRUCT016 (conflict):
    pull → Take Figma / Annotate only / Abort
    push → Take code / Take Figma / Annotate only / Abort

Pull's STRUCT016 doesn't have a "Take code's value" option because that
would mean "push code to Figma," which is /adhd:push-component's
direction. "Annotate only" replaces what was a less-clear "Keep code's
value" — same behavior (no write, annotation persists), clearer label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…on't sync" now aborts

User's complaint: picking "Annotate only" on a STRUCT015 / STRUCT016
prompt let the pull/push proceed with broken state — a binding
referencing a missing variable rendered the component broken in
production, even though the designer had explicitly flagged the
violation. "Annotate only" was sold as a per-variable skip, but in
practice it shipped broken state.

Flip the semantics so the third option means what designers expect:
"Don't sync — leave the annotation in Figma and abort." Picking it on
ANY single variable aborts the whole pull/push. The lint annotation is
guaranteed to land in Figma before exit so the designer sees the
unresolved violation next time they open the file.

Side-effect: the --annotate flag is no longer needed on
pull-component / push-component (or the all-components orchestrators).
Annotation is automatic on any abort path and clearing on any success
path — single mechanic, no flag. /adhd:lint keeps --annotate because
it's read-only and the flag is its only way to write annotations
without a sync action.

Push-component Phase 11 (decide-or-rollback) collapses too. Previously
allowed "Keep with errors" as an option; now any unresolved blocking
error rolls back automatically with annotations pushed. The structured
resolution flow in Phase 10.7 is the only path forward — if the
designer doesn't resolve through the prompts, rollback is the only
safe outcome.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tives lookup

Tailwind v4 ships `--spacing: 0.25rem` (the base multiplier) but
generates `--spacing-0`, `--spacing-1`, … `--spacing-96` at build time
via its CSS plugin — they're never present as literal entries in the
@theme block. The design-system code-parser already synthesizes these
for push-tokens / pull-tokens (so `compareDesignSystems` sees the full
scale), but the lint engine's `loadTailwindDefaultPrimitives` was only
calling `parseTheme(tailwind-defaults.css)` — which returns just the
literal entries.

Result: a canonical Figma variable like `spacing/0` got flagged as
STRUCT015 ("doesn't exist in code's design system") even though the
CSS variable IS available at runtime via Tailwind's utility class
build step. False positive that surfaced on every component using a
zero-spacing token.

Fix: lint engine's loader now also calls `synthesizeTailwindUtilityScale`
(newly exported from code-parser) and merges its output. Same view of
the default theme as push/pull-tokens; STRUCT015 fires only on
genuinely-non-canonical paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…abeling

The STRUCT015 resolution flow now offers a fourth option when the
Figma value strictly equals a Tailwind canonical: rebind the layers to
the canonical variable in Figma. Designer was hitting the user-reported
case (Font-Size/Body = 14 → matches --text-sm = 14px) and the only
options were "Add the non-canonical name to code" or "Abort." Auto-fix
is now the recommended path for those: rename happens in Figma, code
stays clean, no visual change because the values are identical.

For variables whose Figma path looks semantic (brand, accent, surface,
background, primary, etc.), the "Add to globals.css" label flips to
"Add as semantic variable (recommended for brand / accent / surface
tokens — canonical match is coincidence)." Brand colors that happen
to match a Tailwind palette entry stay branded — the matcher reports
the match but the looksSemantic flag steers the prompt away from
auto-fix.

The matcher (lib/lint-engine/canonical-matcher.js) does:
  - Strict-equality value matching after normalization (hex / rgb /
    oklch all canonicalize through a single pipeline; px/rem/em get
    common-unit reduction)
  - Typography family disambiguation: a "Font-Size/Body" path only
    matches --text-* candidates, never --leading-* even when the px
    value overlaps
  - Returns null when ambiguous (multiple canonicals in the same
    family share the value)

Required side-fixes the matcher exposed:
  - normalizeColor now handles oklch() input via the existing
    design-system/oklch helper. Without this every Tailwind v4 color
    (which ships as oklch) failed cross-form comparison.
  - variable-categorizer's inferDomain now recognizes the
    "typography/" collection prefix the user actually uses, in
    addition to the "font/" / "text-" / "line-height" heuristics it
    had before.

The Figma rebind script mirrors STRUCT013 --fix: find the source
variable, find or create the canonical, copy values per mode (mapped
by mode-name), walk every layer rebinding source→canonical for both
scalar bindings and per-paint fills/strokes, then remove the source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s from Phase 2.5

Phase 2.7 was still using its original batch prompt ("Yes — add all 4
to @theme", "No — continue without adding", "Cancel"). When the
agent had already detected that the values matched Tailwind canonicals
(text-sm, leading-7, tracking-normal), the designer couldn't actually
pick "rebind in Figma to those canonicals" — only "add the
non-canonical names to code" or "skip everything."

Now Phase 2.7 walks each missing variable through the same prompt
shape as Phase 2.5's STRUCT015 resolution: Auto-fix (when a Tailwind
canonical strictly matches), Add (or Add-as-semantic when the name
looks semantic), Skip, or Cancel. Auto-fix and Add picks queue into
the same auto-fix-input / actions-input arrays Phase 2.5 builds;
they're applied together in the existing "Apply OR abort" step. No
new machinery — just routing.

The canonicalCandidate / looksSemantic fields are attached at lint
time for Phase 2.5 (via cli.js's missingVarMeta). For Phase 2.7 the
source list comes from variable-categorizer's raw output, which
doesn't carry those fields, so the SKILL computes them inline via a
small node -e against canonical-matcher.js — same matcher used in
Phase 2.5, same primitives map (Tailwind defaults + synthesized scale
+ user @theme).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…and abort"

User clarification: "Skip this one" doesn't push an annotation (Phase
2.7's missing variables typically aren't tied to a layer in the scope
— Phase 2.5's STRUCT015 catches the bound ones first), but the
prompt's third option should still make "annotate-then-abort" the
explicit named choice. The previous "Cancel — run /adhd:pull-tokens
first" wording was vague about whether an annotation lands.

Now the third option is "Skip this one — continue without adding (no
annotation; this component pull works fine without it)" and the
fourth is "Annotate and abort — push the lint annotation and stop
the pull." The annotate-and-abort path falls through Phase 2.5's
abort-time annotation helper, which lands on bound layers when they
exist and just exits cleanly when they don't.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User asked for clearer labels. The action verbs were buried in verbose
parenthetical explanations. Now each option leads with the verb:

  Auto-fix in Figma — rebind to `<canonical>` (same value, no visual change)
  Add in code as `--<cssVar>`
  Add as semantic — keep `<figmaName>` in code (recommended for brand / accent / surface tokens)
  Don't sync — annotate and abort
  Skip — continue without adding
  Abort the pull

"Add in code" and "Add as semantic" are the two labels for the same
underlying action (write the figma value to globals.css); the label
switches based on looksSemantic so the right choice reads obvious in
the moment. Other options were already clear.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n wizard

User reframe: /adhd:lint isn't a read-only command anymore; it's the
generalized resolver. Every violation goes through a per-rule wizard
with rule-appropriate options (auto-fix in Figma, add in code, take
Figma's value, take code's value, annotate only, skip). The previous
--annotate / --fix flags collapse into the per-violation prompts —
annotation is a choice on each prompt, fix candidates surface
inline when they exist.

Key shift from pull/push semantics: lint has NO abort option. There's
no sync to abort; the wizard's picks are the only outputs. The last
option on every prompt is "Skip" (record nothing, no annotation, no
fix), distinct from pull/push's "Abort the pull/roll back."

Per-rule option matrix:
  STRUCT001–010 structural   → Annotate-all / Skip-all (batched per rule code)
  STRUCT011 naming           → Annotate only / Skip (rename needs designer)
  STRUCT012 cross-domain     → Annotate only / Skip (rebind needs designer)
  STRUCT013 Tailwind dupe    → Auto-fix / Annotate / Skip
  STRUCT014 collection dupe  → Auto-fix / Annotate / Skip
  STRUCT015 missing-in-code  → Auto-fix (when canonical match)
                               Add in code / Add as semantic (label varies)
                               Annotate only / Skip
  STRUCT016 value conflict   → Take Figma / Take code / Annotate / Skip
                               (generalized resolver per user's call)

The "Take code" path on STRUCT016 means push the code value to Figma's
variable — same mechanic as push-tokens' update-variable action. The
"Take Figma" path writes to globals.css via the alias-aware resolver
from pull-component. Lint covers both directions per-violation; designers
who want bulk value sync still reach for push-tokens / pull-tokens.

Phase 7 applies the three queued action types in order:
  1. Figma-side actions in one use_figma call (rebinds, consolidations,
     variable-value updates)
  2. Code-side actions via applyToCss
  3. Annotation reconciliation (push annotateNodes, clear stale entries)

Removed Phases 6, 7, 8, 8b (flag-driven paths), Phase 1.5 (flag-combo
validation no longer needed). 587 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ate JSX

The prior spec was binary: vector-driven inlined SVG, layout-driven
stubbed `<span />`. The user pulled UserAvatar (root FRAME + 1 TEXT
child) and correctly pointed out that "layout-driven" was overshooting
— the Figma extract had every fact needed to render a real
component, but the SKILL routed it to the stub branch with the
"reconstructing JSX from Figma is unreliable" rationale.

Adds a middle branch — "simple-layout-driven" — between the two
existing ones. Triggered when ALL of:

  - Root is a single FRAME / COMPONENT
  - Root has layoutMode !== 'NONE' (auto-layout positions children
    unambiguously)
  - Tree depth ≤ 2 (root + one level of children)
  - Every direct child is TEXT, a shape primitive, or a
    simple-shape-only FRAME
  - No variant axis hides / shows / reparents children (variant
    differences only affect bound values: sizes, colors, weights)

When the rubric passes, the agent writes real JSX following an
explicit translation table:

  - layoutMode HORIZONTAL/VERTICAL → inline-flex / flex flex-col
  - padding* / itemSpacing → p-{var} / gap-{var}
  - primary+counter axis CENTER → items-center justify-center
  - fills bound to color var → bg-{strippedVarName}
  - cornerRadius bound → rounded-{strippedVarName}
  - opacity bound → opacity-{N} class modifier (not variable)
  - TEXT static character → <span>literal</span>
  - TEXT character varying per instance → expose as prop (initial?)
  - TEXT with bound fontSize → className={TABLE[variant]} from
    the existing lookup tables

Includes the UserAvatar concrete example so the rubric is grounded.
Notes the judgment-call cases (deriving box-size from variant
nominal sizes since Figma's hug-content layouts don't carry explicit
width/height) and marks scaffold-derived entries with
`// adhd: derived` so developers know which are designer-driven vs
inferred.

When the rubric fails — depth > 2, conditional rendering, anything
ambiguous — the existing stub branch still applies. Decision rubric
explicitly biases toward "stub when unsure" so the SKILL never
emits mis-reconstructed JSX a developer has to undo.

SKILL-doc-only change. No new modules, no tests, no engine work —
the agent's judgment handles per-case translation. 587 tests still
pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-flight

User asked how to handle component composition — a Card with an
Avatar instance, a Dashboard with multiple Cards. Per their pick of
option (a), tracked-instance handling is wired in as a fourth child
type for the simple-layout-driven rubric; un-tracked instances abort
the pull with a clear "pull this dependency first" instruction.

Wiring:

Phase 3 (serializer): captures `mainComponentId` + `componentProperties`
for INSTANCE nodes, alongside the structural data it already collects.

Phase 2.8 (NEW pre-flight): walks every INSTANCE in the extract,
collects unique `mainComponentId` values, resolves each against the
`components: { ... }` map in adhd.config.ts via a new
`lib/pull-component/cli.js resolve-instance` subcommand. The resolver
parses the figma URL in each config entry, converts the `A-B` URL
form to `A:B` Figma ID form, matches on equality, and returns
`{ matched, relPath, importPath, exportName, fileExists }`. exportName
is read from the existing file when present (handles
`export function Name`, `export default function Name`,
`export const Name = ...`, `export default Name`) or derived from the
file's slug otherwise.

If any instance is unmatched, Phase 2.8 aborts with the dependency
list. The user pulls each missing component (in any order — they're
independent), then re-runs the parent.

Phase 7 (simple-layout-driven branch): grows a fourth child type —
`INSTANCE-of-tracked-component`. The translation: convert
`componentProperties` keys to the target casing (kebab → camel etc.),
string-literal variants → JSX strings, boolean variants → boolean
attributes; emit `<ExportName prop1={...} prop2={...} />` + the
`import` statement at the top of the file. Visual overrides on the
instance drop with an `// adhd: dropped override` comment — designer
either lifts the override into a new variant in Figma or accepts the
unstyled instance.

Includes a concrete UserCard example (root FRAME + UserAvatar
INSTANCE + TEXT child) to show what the generated code looks like.

The depth metric counts JSX depth in the parent's output, so an
INSTANCE collapses to a single JSX node regardless of how complex
its resolved Figma source is — recursion across multiple tracked
components is fine.

12 tests for instance-resolver covering URL parsing, slug-to-PascalCase
fallback, export-name discovery from existing files in three export
forms, unmatched component-ids, partial config entries without figma
URLs, and the file-missing-but-matched case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant