Implementation plan — One Library, Designer ⇄ Developer mode switch
Spec: specs/approved/library-mode-switch.md · status
approvedPlan 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)
Component detail unification — CONFIRMED. Keep
ComponentDetailPageas the single detail; fold the editableSnippetEditorinto its Code tab so "edit snippet → live preview" works in both modes; gateRulesEditorread-only in Developer mode. Retiredeveloper/detail/ComponentDetail.tsx. (Highest-risk task — two preview/token-broadcast mechanisms to reconcile.)TopNav "Developer" item — REMOVE, no redirect. Alpha isn't live, so just delete the
/developerroute and theTopNaventry. No/developer→/library?mode=developerredirect needed.No "shadcn" anywhere in our surface — CONFIRMED. Rename the route slug
tokens/shadcn→tokens/componentand rename our own identifiers/files:TokensShadcnPage.tsx→TokensComponentPage.tsxsrc/lib/shadcnCss.ts→src/lib/componentCss.ts;buildShadcnCss→buildComponentCsssrc/shadcn-manifest.json→src/component-manifest.json(+ its importercomponents/manifestTypes.tsand the generatorscripts/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:1—TokenTier = 'primitive' | 'semantic' | 'component'.src/tokens/defaults.ts— ~30 tokenstier: 'shadcn'→'component'(lines ~76–110) + the explanatory comments (lines 8–11, 72–75).src/components/pages/properties/TokensShadcnPage.tsx—t.tier === 'shadcn'→'component'; localshadcn/shvar names cosmetic (optional).src/export/designSystemJson.ts:16— output keyshadcn:→component:and filtert.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'(keepbuildShadcnCssname + file — it targets the real shadcn lib; only the tier value changes).src/lib/docs.ts:21,174— filters;:155internalDocsTokenGroups.shadcnkey →component(used to builddesign-system.md).
Migration (load-time, both entry points):
src/store/index.tsinitLibrary(line 370) andline 404): afterimportLibrary(JSON.parse, remap any token withtier === 'shadcn'→'component'before it lands in state. One shared helpermigrateTiers(tokens).
Identifier / file / route renames (Decision 3 — "no shadcn in our surface"):
TokensShadcnPage.tsx→TokensComponentPage.tsx; updatePropertiesPage.tsximport + the route element.- Route slug
tokens/shadcn→tokens/component(inPropertiesPage.tsxlibrarySections+ the<Route>). v1 URL change; no live users yet. src/lib/shadcnCss.ts→src/lib/componentCss.ts;buildShadcnCss→buildComponentCss; update importers (developer/detail/ComponentDetail.tsxis being retired anyway — update wherever it survives, e.g. the mergedComponentDetailPageCode tab).src/shadcn-manifest.json→src/component-manifest.json; update its importercomponents/manifestTypes.tsand thescripts/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.ts → componentCss.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>.tsx→preview-host/src/components/<name>.tsx(the vendored source). Update every import + the preview-host route resolver that maps#<name>/#liveto these modules + theLiveSnippetRenderercompile 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 referencingpreview-host/src/shadcn/<name>.tsxandsrc/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'— readsuseSearchParams().get('mode'); unknown/empty/wrong-case →'designer'(§8). Also exportuseSetMode()that sets/clears?modeviasetSearchParams(..., { 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 inPropertiesPage.tsxSECTIONS) + the tier display-name map (component → "Component").PropertiesPageimports it instead of its inlineSECTIONS.src/components/.../ModeSwitch.tsx(new): segmented two-icon control (Designer glyph +</>),radiogroupwith arrow-key nav +aria-checked+ per-glypharia-label; tooltipsDesigner mode/Dev mode. ReadsuseMode, writes viauseSetMode. Renders the Export bundle button to its left when mode isdeveloper.- Mount
ModeSwitchin the Library sub-header that holds the Properties · Components tabs — that lives inLibraryPage.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 (
TokenRowcells, add/delete). - Developer → read-only rows:
[swatch] name var(--token-id) [copy], with light/dark resolved value as secondary text. ReuseSwatch+useResolvedValuefromTokenRow.tsx; add aReadOnlyTokenList(new small component) or areadOnlybranch 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.
- Designer → today's editable rows (
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 byJSON.stringify-ing the matching store-slice object (shells/layouts/overlays/navigation/designRules); shape mirrorsdesignSystemJson. 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 editableSnippetEditorinto the Code tab (live edit → preview) and gateRulesEditorread-only whenuseMode() === 'developer'. KeepComponentControls. Reconcile token broadcast (useTokenBroadcastvs the playground's manual postMessage + per-preview dark toggle) — prefer the existinguseTokenBroadcast. - Relocate shared playground modules out of the retired tree into
components/:SnippetEditor.tsx,ComponentControls.tsx(+ their tests). - Export bundle: lift
handleExport(buildBundle + zipForUi + downloadBlob) fromDeveloperPage.tsxinto theModeSwitch/sub-header (Developer mode only). - Install guide: new small footer-link component (
Implementing Tostada in your codebase →) appended to each Property/Component page → links todocs/install guide. ReplacesHowToInstallGuideas a header affordance. - Retire the
developer/tree: deleteDeveloperPage,LibraryBrowser,useLibraryItems,detail/{TokenDetail,PrincipleDetail,ComponentDetail},HowToInstallGuide(+ tests).useLibraryItemsis removed (§9 Q2). - Routing (
AppShell.tsx): delete the/developer/*route entirely (no redirect — Decision 2) and remove the Developer entry fromTopNav.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; copyvar(--bg-accent); tier browse; component detail edit/copy; export ZIP; empty tier; deep-link?mode=developercold-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 ashadcnalias tolerated in any reverted read path, or treat as point-of-no-return. ?modemust not reset scroll/route — usesetSearchParams(replace:true), verify with the Flow 1 E2E.- Retiring
developer/tree: grep for stray imports (useLibraryItems,TokenDetail,PrincipleDetail,HowToInstallGuide) before deleting.