Sharp corners
The "someone will bleed on this" list. Shapes that trip newcomers and sometimes trip returnees. Each entry states the shape, the reason, and the defensive rule.
Storybook addon gotchas
Manager code cannot use JSX
Shape: the Storybook manager bundle runs React 18 and doesn't expose react/jsx-runtime's React-19 dispatcher. JSX in manager code crashes with Cannot read properties of undefined (reading 'recentlyCreatedOwnerStacks').
Rule: use React.createElement (or a local h alias) in every file under packages/addon/src/manager/. Preview code is fine with JSX — it runs on the consumer's React.
Register manager tools with render: () => h(Component), not render: Component
Shape: passing a component function directly as render invokes hooks outside React's render cycle.
Rule: always wrap in an element-returning arrow: render: () => h(ThemeToolbar).
MDX doc blocks cannot use Storybook preview hooks
Shape: useGlobals / useArgs / useChannel / useParameter from storybook/preview-api require the preview HooksContext, which only exists while a story is rendering. Called from an MDX doc block they throw "Storybook preview hooks can only be called inside decorators and story functions."
Rule: for cross-story reactivity in MDX, subscribe to addons.getChannel() directly (globalsUpdated is the event) and manage state with plain React hooks. That's what the provider-less path in useProject does.
Preview ↔ manager communication goes through the channel
Shape: the manager can't import preview-side Vite virtual modules. The preview can't import manager-side React either.
Rule: addons.getChannel() + emit/on. Named events only — see packages/addon/src/constants.ts for the roster.
HMR / file watching
Watch parent directories, not files
Shape: atomic-save editors unlink the old file inode and write a new one. A watcher on the original file either fires a one-shot 'rename' and goes deaf, or on some platforms loops on ghost events for the old inode.
Rule: watch dirname(file) with a filename filter. The dir inode is stable across the rename dance. Both the addon (virtual/plugin.ts) and the MCP bin (mcp/src/bin.ts) follow this.
Vite's watcher doesn't carry events across pnpm symlink boundaries
Shape: server.watcher.add(file) works fine for files inside the Vite root, but tokens often live in a sibling workspace package reached through a pnpm symlink. Vite's watcher silently drops events across the symlink boundary.
Rule: run your own fs.watch — don't try to bolt onto Vite's watcher for token files.
Debounce the reload
Shape: a single save in most editors emits 2–3 filesystem events (atomic rename + rewrite + metadata). Naively triggering a reload per event runs loadProject three times in a row.
Rule: 100 ms trailing debounce. Both the addon plugin and the MCP bin use the same number; keep them in lockstep.
React rules-of-hooks
Don't hoist hooks below empty-state early returns
Shape: if <Block /> has an empty-state early return (if (items.length === 0) return <Empty/>), all hooks must be called before that return. Adding a new useMemo after the guard works the first time the tree is non-empty, but the first time it flips to empty the hook is skipped. The next non-empty render sees one fewer hook than before and React throws Rendered fewer hooks than expected. This may be caused by an accidental early return statement.
Rule: all hooks at the top of the component body, guards at the bottom. We've hit this once in TokenNavigator; scan every block with an empty-state branch when adding a memo or effect.
Hook the channel, not the Storybook preview-api hooks, from MDX
See MDX doc blocks cannot use Storybook preview hooks above — same shape, different angle.
Virtual module + provider
Import block hooks from either -blocks or -addon
Shape: historically the hooks were only exported from @unpunnyfuns/swatchbook-blocks. Today the addon re-exports everything via export * from '@unpunnyfuns/swatchbook-blocks', so import { useSwatchbookData } from '@unpunnyfuns/swatchbook-addon' works too. Both import paths resolve to the same symbols.
Rule: either is fine. Pick whichever package the consumer already has.
Never import virtual:swatchbook/tokens from consumer code
Shape: the virtual module is addon-internal plumbing. Its shape, its event names, its field set are subject to change between minor versions without a changeset entry.
Rule: consumers go through SwatchbookProvider + hooks. Inside this repo, only the preview decorator and the blocks' internal reactivity plumbing touch the virtual module directly.
Import specifiers
Always carry the on-disk extension
Shape: every import in this repo names the file extension — .ts, .tsx, .css, .json, .svg. No inference, no "fake .js".
Rule: import { emitCss } from '#/css.ts' — not '#/css' or '../css'. Vite, Vitest, tsdown, and Node strip-types all resolve TS-extension specifiers natively.
Use #/* for internal paths
Shape: every package has "imports": { "#/*": "./src/*" } in package.json. That one entry handles every filetype.
Rule: #/foo.ts beats ../../foo.ts for any file within the package's own src/. Cross-package imports go through the published name.
Tests
Flat structure, no nested describes, no beforeEach for cosmetics
Shape: nested describes and cosmetic beforeEach make each it require scrolling up to reconstruct the world it's running in.
Rule: one top-level describe at most, inline setup() helpers when shared code is cheap, beforeAll only as a perf escape hatch with an annotated reason. See Avoid Nesting When You're Testing.
Storybook interaction tests via addon-vitest
Shape: play functions in .stories.tsx run through @storybook/addon-vitest, not directly as Vitest tests. pnpm turbo run test:storybook is the runner.
Rule: keep play functions short and deterministic. If a test needs elaborate setup, write a unit test instead.
Changesets
Pre-1.0 breaking changes bump minor, not major
Shape: in semver pre-1.0, 0.x treats minor as the major-ish position. Bumping major now is reserved for the 1.0 cut itself.
Rule: breaking changes → minor. Features → minor. Bug fixes → patch. Docs-only PRs → patch (so the snapshot rebuild picks them up on the next release).
One changeset per PR, fixed-version group bumps together
Shape: -core, -addon, -blocks, -switcher, -mcp are a fixed version group in .changeset/config.json. The bump level you pick applies to all of them.
Rule: don't try to bump them independently. Either the whole group moves or none of them do.
Styling
Blocks use colocated CSS files, not CSS-in-JS
Shape: every block has a sibling .css file with sb-<block>__<part>--<modifier> BEM-ish class names. Inline style={{...}} objects are gone.
Rule: new block styles → new .css file, colocated with the block. Compose classes with clsx at the JSX site. Don't import styled-components or Emotion; they're not in the dep tree.
Chrome variables read from a fixed --swatchbook-* namespace
Shape: blocks read ten chrome variables (surface colors, text colors, border roles) from a fixed --swatchbook-* namespace that's independent of the consumer's cssVarPrefix. The DEFAULT_CHROME_MAP provides light-dark() defaults so zero-config chrome auto-flips with OS color-scheme.
Rule: when adding a new chrome role, add it to CHROME_ROLES in packages/core/src/chrome.ts and provide a DEFAULT_CHROME_MAP entry. Consumers wire roles to their own tokens via config.chrome.
Releases
Docs-only PRs still need a patch changeset
Shape: scripts/snapshot-docs-version.mjs rebuilds the current minor's docs snapshot on every release. Docs fixes without a changeset only reach /next/, never /.
Rule: if your PR touches apps/docs/, file a patch changeset. Even if nothing functional changed.
Turbo's build task inputs include versioned snapshots
Shape: turbo.json's build inputs list versioned_docs/**, versioned_sidebars/**, versions.json. This is load-bearing for the remote cache — a fresh minor snapshot invalidates cache entries for downstream builds that depend on it.
Rule: don't prune those entries from the build inputs. If you add a new versioned docs artifact, include it too.
MCP
stderr for logs, stdout reserved for protocol frames
Shape: the stdio transport sends JSON-RPC frames over stdout. Any accidental console.log call there corrupts the channel and the MCP client reports malformed JSON.
Rule: use console.error for everything diagnostic. The reload notice, the --help text, error messages, all go to stderr. Only the transport writes to stdout.
Deferred tool schemas from MCP
Shape: MCP tools registered by a connected server don't have their schemas loaded into the agent's prompt by default. The agent sees tool names only and has to call ToolSearch to fetch schemas before invoking.
Rule: design tool descriptions to be self-sufficient — the description is what makes the agent decide to fetch the schema. Be specific, list the shape of the return value in prose.
Plan + issue governance
Every PR links an issue
Shape: project convention — every PR body has a Closes: #N line, milestone assignment, and Plan impact note.
Rule: file the issue first (gh issue create --milestone "Maintenance" --title "…"). Merging the PR auto-closes the issue.
PR titles: verb-first lowercase, Conventional Commits scope
Shape: imperative verb as first word; lowercase even for proper nouns; scope matches a package slug.
Rule: fix(blocks): hoist navigator hooks above empty-state early return — not Fix(Blocks): TokenNavigator hooks run before empty-state early return.