Sharp corners
This is the “someone will bleed on this” list: shapes that trip newcomers, and sometimes returnees. Each entry states the shape, the reason, and the defensive rule.
Storybook addon gotchas
Section titled “Storybook addon gotchas”Manager code cannot use JSX
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “HMR / file watching”Watch parent directories, not files
Section titled “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
Section titled “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
Section titled “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
Section titled “React rules-of-hooks”Don’t hoist hooks below empty-state early returns
Section titled “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.
Virtual module + provider
Section titled “Virtual module + provider”Import block hooks from either -blocks or -addon
Section titled “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
Section titled “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
Section titled “Import specifiers”Always carry the on-disk extension
Section titled “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 { loadProject } from '#/load.ts', not '#/load' or '../load'. Vite, Vitest, tsdown, and Node strip-types all resolve TS-extension specifiers natively.
Use #/* for internal paths
Section titled “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.
Flat structure, no nested describes, no beforeEach for cosmetics
Section titled “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
Section titled “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
Section titled “Changesets”Post-1.0 breaking changes bump major
Section titled “Post-1.0 breaking changes bump major”Shape: post-1.0, swatchbook treats major versions as deliberate stability commitments. Breaking changes must bump major to signal incompatibility to users.
Rule: breaking changes → major. Features → minor. Bug fixes → patch. Docs-only PRs don’t need a changeset; see Docs-only PRs don’t need a changeset below.
One changeset per PR, fixed-version group bumps together
Section titled “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
Section titled “Styling”Blocks use colocated CSS files, not CSS-in-JS
Section titled “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
Section titled “Chrome variables read from a fixed --swatchbook-* namespace”Shape: blocks read nine chrome variables (surface colors, text colors, border roles, plus the body font stack) from a fixed --swatchbook-* namespace that’s independent of the consumer’s cssVarPrefix. DEFAULT_CHROME_MAP provides one hard-coded light-scheme literal per role, not a light-dark() value: swatchbook has no intrinsic dark axis, so the zero-config default is a single committed appearance, and light-dark() / system colors would couple the chrome to the OS color-scheme, which is foreign to swatchbook’s axis model. config.chrome wires roles to consumer tokens for per-axis variation.
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
Section titled “Releases”Docs-only PRs don’t need a changeset
Section titled “Docs-only PRs don’t need a changeset”Shape: the docs site has no per-release snapshot. apps/docs/ serves a single current docs set and the deploy workflow (.github/workflows/docs.yml) rebuilds it from main on every push, independent of package releases.
Rule: skip the changeset for apps/docs/-only changes. There’s nothing for it to gate — the fix reaches the live site on the next push to main, not on the next package release.
Release publishes through a deployment environment that requires human approval
Section titled “Release publishes through a deployment environment that requires human approval”Shape: the release workflow (.github/workflows/release.yml) gates the release environment behind a conditional: environment: ${{ startsWith(github.event.head_commit.message, 'chore(release):') && 'release' || '' }}. The expression resolves to release only when the head commit’s message starts with chore(release):, i.e. the Version Packages PR’s squash-merge. Every other push to main (feature merges, changeset-only PR merges) runs without the gate, because those don’t publish; they only open or update the Version Packages PR. The approver list lives under repo Settings → Environments → release. Required reviewers ≥ 1.
Rule: when a release stalls after the Version Packages PR merges, check the Actions tab for a pending deployment review. A privileged org member needs to approve the deployment to release. This is intentional: the gate defends against the compromised-maintainer-account scenario where a malicious commit lands on main and a publish would otherwise proceed unattended. Routine merges (anything that isn’t a Version Packages PR) don’t fire the gate.
stderr for logs, stdout reserved for protocol frames
Section titled “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
Section titled “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
Section titled “Plan + issue governance”Every PR links an issue
Section titled “Every PR links an issue”Shape: project convention; every PR body has a Closes #N line (plain, one issue per 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
Section titled “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.