Skip to content

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.