Implementation plan — One Library, Designer ⇄ Developer mode switch

Spec: specs/approved/library-mode-switch.md · status approved Plan date: 2026-06-10 · owner: jborozco

What we're building (one paragraph)

Collapse the separate /developer page into the existing Library surface. A ?mode=developer query param (default designer) flips every Properties + Components surface from editable affordances to a read-only, copy-first rendering. Same routes, same nav, same data — only the affordances change. Plus a self-contained pre-req: rename the third token tier shadcn → component everywhere (with a load-time migration).


Decisions (resolved 2026-06-10)

  1. Component detail unification — CONFIRMED. Keep ComponentDetailPage as the single detail; fold the editable SnippetEditor into its Code tab so "edit snippet → live preview" works in both modes; gate RulesEditor read-only in Developer mode. Retire developer/detail/ComponentDetail.tsx. (Highest-risk task — two preview/token-broadcast mechanisms to reconcile.)

  2. TopNav "Developer" item — REMOVE, no redirect. Alpha isn't live, so just delete the /developer route and the TopNav entry. No /developer/library?mode=developer redirect needed.

  3. No "shadcn" anywhere in our surface — CONFIRMED. Rename the route slug tokens/shadcntokens/component and rename our own identifiers/files:

    • TokensShadcnPage.tsxTokensComponentPage.tsx
    • src/lib/shadcnCss.tssrc/lib/componentCss.ts; buildShadcnCssbuildComponentCss
    • src/shadcn-manifest.jsonsrc/component-manifest.json (+ its importer components/manifestTypes.ts and the generator scripts/ that writes it)
    • all our code comments mentioning "shadcn" as the tier/concept

    Install command — KEEP as the real command. The Code panel's install line stays npx shadcn@latest add <name> — it's the actual working command for the real tool, not our naming. ("No shadcn" means nav + slugs + our identifiers, not the real CLI step.)

    Vendored source dir — RENAME too. preview-host/src/shadcn/* (the vendored shadcn/ui source the sync script writes) → preview-host/src/components/*. Internal-only but deep — see Phase 0b.


Phase 0 — Tier rename shadcn → component (land first, isolated)

Self-contained, independently testable, no UI-mode coupling. Do it first so the mode work builds on the final tier name.

Code edits (the tier value + the one export key):

  • src/tokens/types.ts:1TokenTier = 'primitive' | 'semantic' | 'component'.
  • src/tokens/defaults.ts — ~30 tokens tier: 'shadcn''component' (lines ~76–110) + the explanatory comments (lines 8–11, 72–75).
  • src/components/pages/properties/TokensShadcnPage.tsxt.tier === 'shadcn''component'; local shadcn/sh var names cosmetic (optional).
  • src/export/designSystemJson.ts:16output key shadcn:component: and filter t.tier === 'component'. (This is the intended export-schema value change, §3 exception / §11.)
  • src/export/tokensCss.ts:40 — filter === 'component'.
  • src/lib/shadcnCss.ts:40 — filter === 'component' (keep buildShadcnCss name + file — it targets the real shadcn lib; only the tier value changes).
  • src/lib/docs.ts:21,174 — filters; :155 internal DocsTokenGroups.shadcn key → component (used to build design-system.md).

Migration (load-time, both entry points):

  • src/store/index.ts initLibrary (line 370) and importLibrary (line 404): after JSON.parse, remap any token with tier === 'shadcn''component' before it lands in state. One shared helper migrateTiers(tokens).

Identifier / file / route renames (Decision 3 — "no shadcn in our surface"):

  • TokensShadcnPage.tsxTokensComponentPage.tsx; update PropertiesPage.tsx import + the route element.
  • Route slug tokens/shadcntokens/component (in PropertiesPage.tsx librarySections + the <Route>). v1 URL change; no live users yet.
  • src/lib/shadcnCss.tssrc/lib/componentCss.ts; buildShadcnCssbuildComponentCss; update importers (developer/detail/ComponentDetail.tsx is being retired anyway — update wherever it survives, e.g. the merged ComponentDetailPage Code tab).
  • src/shadcn-manifest.jsonsrc/component-manifest.json; update its importer components/manifestTypes.ts and the scripts/ sync generator that writes it.
  • All our code comments that use "shadcn" as the tier/concept → "component".

Keep (real third-party command): the literal npx shadcn@latest add <name> install line in the Code panel — it's the real CLI step, not our naming.

Tests to update: src/export/designSystemJson.test.ts (key component), src/export/tokensCss.test.ts ("primitive/semantic/component sections"), src/lib/shadcnCss.test.tscomponentCss.test.ts ("component-tier vars"), any docs test; add a migration unit test (persisted tier:'shadcn' loads as 'component').

Gate: tsc + vitest run src/export src/tokens src/lib + a manual export → confirm design-system.json emits "component": [...].


Phase 0b — Rename vendored source dir preview-host/src/shadcn/ → components/

Internal-only, but the deepest rename — touches the sync tooling, the manifest, preview routes, the live renderer scope, and the Spread skill doc. Land it with Phase 0 (both are the "purge shadcn from our surface" family), but track separately.

  • preview-host/src/shadcn/<name>.tsxpreview-host/src/components/<name>.tsx (the vendored source). Update every import + the preview-host route resolver that maps #<name> / #live to these modules + the LiveSnippetRenderer compile scope.
  • scripts/ sync generator (sync:shadcn, ~36 hits): output path → the new dir; manifest write path → src/component-manifest.json. The script may keep fetching from the real shadcn registry (ui.shadcn.com) — that's the upstream source, unchanged; only our output paths change.
  • .claude/skills/spread/SKILL.md — the line referencing preview-host/src/shadcn/<name>.tsx and src/shadcn-manifest.json → new paths.
  • Re-run npm run sync:shadcn (or equivalent) to regenerate into the new dir and confirm previews + the manifest still resolve.

Gate: preview-host builds; component previews render at #<name> and #live; manifest importer resolves; npm run build (CLI bundle reads the manifest) green.


Phase 1 — Mode plumbing (no surface changes yet)

  • src/lib/useMode.ts (new): useMode(): 'designer' | 'developer' — reads useSearchParams().get('mode'); unknown/empty/wrong-case → 'designer' (§8). Also export useSetMode() that sets/clears ?mode via setSearchParams(..., { replace: true }) so there's no navigation and no scroll reset (§4 Flow 1). Single RBAC chokepoint (documented comment).
  • src/lib/librarySections.ts (new): the one definition of the Properties sub-categories (currently hard-coded in PropertiesPage.tsx SECTIONS) + the tier display-name map (component → "Component"). PropertiesPage imports it instead of its inline SECTIONS.
  • src/components/.../ModeSwitch.tsx (new): segmented two-icon control (Designer glyph + </>), radiogroup with arrow-key nav + aria-checked + per-glyph aria-label; tooltips Designer mode / Dev mode. Reads useMode, writes via useSetMode. Renders the Export bundle button to its left when mode is developer.
  • Mount ModeSwitch in the Library sub-header that holds the Properties · Components tabs — that lives in LibraryPage.tsx (LibraryTabs).

Tests: useMode (param → mode, default, fallback); librarySections (tier label resolves; single source consumed by nav).


Phase 2 — Token tier lists: mode-aware (Developer = read-only + copy)

Branch the three token pages on mode; no component forks — same component, shallow affordance branch.

  • TokensPrimitivesPage.tsx, TokensSemanticPage.tsx, TokensShadcnPage.tsx:
    • Designer → today's editable rows (TokenRow cells, add/delete).
    • Developer → read-only rows: [swatch] name var(--token-id) [copy], with light/dark resolved value as secondary text. Reuse Swatch + useResolvedValue from TokenRow.tsx; add a ReadOnlyTokenList (new small component) or a readOnly branch in the existing row.
    • Copy handler: navigator.clipboard.writeText(\var(--${id})`)` → copied state (1.2s); clipboard-unavailable fallback (select text, §8).
    • Empty tier → No tokens in this tier yet.

Tests: Developer-mode read-only rendering; snippet text === var(--<id>); copy handler toggle/reset; Designer-mode still editable; empty-state.


Phase 3 — Layout principles & Navigation rules: read-only JSON

  • LayoutPrinciplesPageV2.tsx, DesignRulesPage.tsx: Developer mode renders a read-only list — one entry per principle / rule with a copyable JSON block (the entry's structured object). Source the per-entry JSON by JSON.stringify-ing the matching store-slice object (shells/layouts/overlays/navigation/designRules); shape mirrors designSystemJson. Copy + empty states as Phase 2.
  • Designer mode unchanged (existing editors).

Tests: Developer-mode JSON snippet renders + copies for one principle + the rules object.


Phase 4 — Components detail, Export/install, retire developer/ tree

  • Component detail (per Decision 1): in ComponentDetailPage.tsx, fold the editable SnippetEditor into the Code tab (live edit → preview) and gate RulesEditor read-only when useMode() === 'developer'. Keep ComponentControls. Reconcile token broadcast (useTokenBroadcast vs the playground's manual postMessage + per-preview dark toggle) — prefer the existing useTokenBroadcast.
  • Relocate shared playground modules out of the retired tree into components/: SnippetEditor.tsx, ComponentControls.tsx (+ their tests).
  • Export bundle: lift handleExport (buildBundle + zipForUi + downloadBlob) from DeveloperPage.tsx into the ModeSwitch/sub-header (Developer mode only).
  • Install guide: new small footer-link component (Implementing Tostada in your codebase →) appended to each Property/Component page → links to docs/ install guide. Replaces HowToInstallGuide as a header affordance.
  • Retire the developer/ tree: delete DeveloperPage, LibraryBrowser, useLibraryItems, detail/{TokenDetail,PrincipleDetail,ComponentDetail}, HowToInstallGuide (+ tests). useLibraryItems is removed (§9 Q2).
  • Routing (AppShell.tsx): delete the /developer/* route entirely (no redirect — Decision 2) and remove the Developer entry from TopNav.tsx.

Tests: component detail snippet edit (invalid JSX → inline error, store untouched); export still downloads a ZIP from the new location; /developer redirect; rules read-only in Developer mode.


Phase 5 — Docs, E2E, final gate

  • Docs (§11): CLAUDE.md "Developer export" section rewrite + token-tier table (component); README "hand-off surface" paragraph; TEST_PLAN merge Developer cases into the Library spec; REBUILD_PLAN milestone; docs/ user-facing page + screenshots incl. the mode switch; in-app empty-state copy.
  • E2E (Playwright, §10): the 8 scenarios — switch-in-place preserves route+scroll & sets ?mode=developer; copy var(--bg-accent); tier browse; component detail edit/copy; export ZIP; empty tier; deep-link ?mode=developer cold-load + refresh; invalid ?mode=banana → designer.
  • Final gate: tsc · npm run test · npm run build (app + CLI) · npm run e2e · manual QA (sub-header crowding at small viewport, real clipboard, CVA component live preview, footer link target).

Sequencing & rationale

Phase 0  (tier value + our identifiers/files/slug + migration)  ┐
Phase 0b (rename vendored preview-host/src/shadcn → components)  ┘ land together
        ──►  Phase 1 (mode plumbing)  ──►  Phase 2 (tokens)
                                      ──►  Phase 3 (principles/rules)
                                      ──►  Phase 4 (components + retire dev tree)
                                      ──►  Phase 5 (docs + e2e)
  • Phases 0 + 0b first & together: the whole "purge shadcn from our surface" + tier rename + migration is a pure refactor with its own tests; landing it as one reviewable unit keeps the migration's data-hazard (one-way shadcn → component, §13) and the deep preview-host dir rename auditable in isolation. 0b is the riskiest of the two (sync tooling + preview routes) — verify previews render before moving on.
  • Phase 1 before 2–4: every surface depends on useMode + librarySections.
  • Phases 2 / 3 / 4 are parallelizable once Phase 1 lands (independent surfaces), but 4 carries the retire-tree + routing churn, so do it last of the three.
  • Phase 5 last: docs + E2E assert the finished behavior.

Risks / watch-items

  • Component-detail merge (Decision 1) is the only deep change — two preview/token- broadcast mechanisms to reconcile. Budget the most time here.
  • Migration is effectively one-way (§13): once a saved library is rewritten to component, reverting needs code that tolerates both tier names. Keep a shadcn alias tolerated in any reverted read path, or treat as point-of-no-return.
  • ?mode must not reset scroll/route — use setSearchParams(replace:true), verify with the Flow 1 E2E.
  • Retiring developer/ tree: grep for stray imports (useLibraryItems, TokenDetail, PrincipleDetail, HowToInstallGuide) before deleting.