Public npm component library for Verndale's frontend platform. Components are keyed by ui-design-brain canonical slugs, so a resolved design label maps straight to an implementation.
The catalog says what a Modal is. This repo is the Modal.
Every project rebuilds the same dialog. Not because anyone wants to, but because the previous project's version is buried in a client repo, wired to that client's CMS and tokens, and nobody can tell from outside whether it was any good.
This library is where a component goes once it has earned reuse. Its contract is the catalog's vocabulary:
ui-design-brain resolves "dialog" → canonical Modal (slug: modal)
ui-design-library components/modal/ → the implementation
That is the whole mechanism. Because both sides key on the same slug, a build pipeline that has resolved a label can look up an implementation deterministically instead of generating one from scratch.
components/<slug>/ a canonical's default implementation
├── index.ts stable public facade for the package subpath
├── <Component>.types.ts public prop and supporting types
├── <Component>.tsx | server-compatible tree, or
│ <Component>.client.tsx client-only tree when wholly interactive
├── parts/ private branches, leaves, and narrow client islands
├── <Component>.stories.tsx the API contract — see below
└── component.json canonical, API slots, reuse fingerprint, variants,
tokens, provenance
components/<slug>--<variant>/ a structurally-distinct implementation of the same
canonical (optional) — the same internal contract
src/tokens/ the semantic token layer (the styling contract)
src/lib/ shared, dependency-free primitives (focus, scroll)
A canonical resolves to a directory. In the common case that is components/<slug>/, <slug> == kebab(canonical). When the same role, affordance, and interaction semantics need a separate structural import, the default stays bare and each alternate is components/<slug>--<variant>/. Different semantics require a different catalog canonical. Both carry the base slug; singular variant distinguishes structures, and one is default. Single-implementation components need neither field.
Every public subpath resolves through components/<slug>/index.ts. That facade is stable; the tree/branch/leaf files behind it can change without changing the consumer import. Stories also import only ./index, so Storybook exercises the same API consumers receive.
Components are server-compatible by default. Browser state, effects, portals, focus management, and DOM observers belong in files named *.client.tsx or *.client.ts, with 'use client' at the narrowest useful boundary. Every client module is limited to 120 physical lines. A hybrid component such as Accordion keeps its outer tree server-compatible and hydrates its disclosure leaves; a wholly interactive component such as Modal exposes a client facade.
The architecture gate also requires a separate types module and at least two meaningful non-story TSX modules per component. It rejects client hooks or browser globals in neutral modules, render-time browser access, misplaced client directives, and story imports that bypass the facade. pnpm test:ssr renders public components without browser globals.
The core stays React- and Tailwind-based rather than Next-specific. No component may import next/*. Next is a development-only consumer fixture exercised by pnpm test:next, not a runtime or peer dependency.
Not documentation — the contract. argTypes declares every prop with its control and description, so both a developer browsing Storybook and an agent reading the file get the same surface. A component without stories fails pnpm contracts, because an implementation nobody can inspect is not reusable.
pnpm storybook # browse at http://localhost:6006
pnpm build-storybook # static buildThe addons that carry real weight:
@storybook/addon-docs—tags: ['autodocs']in.storybook/preview.tsgenerates a Docs page per component: the description, a live preview, and a prop table built from the TypeScript types andargTypes. Without that tag the addon is installed and produces nothing, so leave it on.@storybook/addon-a11y— runs axe against every rendered story and reports violations, passes, and inconclusive results in the Accessibility panel. Accessibility is most of why a captured component is worth keeping.@storybook/addon-vitest— runs every story as a test in a real Chromium and reports in the sidebar Testing widget. It is also what makes the a11y check a gate rather than a suggestion.storybook-addon-pseudo-states— forces:hover,:focus-visible,:activeand friends as static states from the toolbar. Most of this library's behaviour lives in those states, and:focus-visiblecannot be inspected by hand at all: it deliberately does not match a click. It also makes the a11y panel usable on focus states, which is where focus-ring contrast problems actually are.storybook-addon-tag-badges— renders each component'smaturityin the sidebar. Configured in.storybook/manager.ts, notpreview.ts— the addon readsaddons.getConfig(), so a preview parameter is silently ignored.
Two toolbar controls are configured rather than installed, since Storybook 10 ships both in core:
- Viewport — includes the two library breakpoints (
lg,xl) alongside the device presets. Breadcrumbs collapses belowxland Modal goes full-screen belowlg; both are otherwise only checkable by dragging the window. - Backgrounds — named for the semantic tokens (
surface-base,surface-inverse, …) rather than colours, so a project overriding a token sees the override here too.
Every component uses logical properties (ps/pe, ms/me, border-s) so it works in both writing directions. That only holds if somebody can see the other direction — an incomplete conversion reads as perfectly correct in LTR. The Direction toolbar control flips the preview.
It is a local decorator (.storybook/withDirection.tsx), not a dependency, and it sets dir on documentElement rather than on a wrapper: Modal renders through a portal into document.body, so a wrapper would leave the component with the most to get wrong in RTL still rendering LTR.
This is not theoretical. Quote shipped with logical padding and a physical border-l, so in RTL the accent rule and the text sat on opposite sides.
preview.ts sets a11y: { test: 'error' }, so an axe violation fails the story test. That is backed by a real runner: pnpm test:stories renders every story in Chromium, runs its play function, and runs axe over the result.
Not every axe finding is a real defect. Where a rule is wrong for a specific story — a disabled control tripping color-contrast, which WCAG 1.4.3 explicitly exempts — scope the rule off on that story with a comment saying why, rather than loosening the global setting:
parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } }Wiring this up was previously abandoned as blocked: Vitest browser mode served the CJS aria-query raw and every story failed with does not provide an export named 'elementRoles'. The cause is pnpm's isolated node_modules — neither aria-query nor @testing-library/dom resolves from the project root, so naming either in optimizeDeps.include silently does nothing. Pre-bundling the resolvable ancestor works:
optimizeDeps: { include: ['storybook/test'] }vitest.config.ts also re-declares the Tailwind plugin. It replaces vite.config.ts for the test run rather than extending it, and without Tailwind every story renders unstyled — which quietly invalidates any computed-style or contrast assertion while still reporting green.
Components reference only semantic tokens — bg-surface-raised, text-text-primary, px-page-margin. Never a hex value, never a client's brand name. pnpm contracts fails on a raw colour in a component.
A consuming project re-themes the library by overriding the custom properties in src/tokens/semantic.css with its own values. It never edits a component. That is what makes the same Modal render in one client's palette and another's without a line changing.
The defaults here are deliberately unbranded. A component shipping a client's red is that client's component, not a library one.
Install one exact version. The exact dependency is the orchestration pipeline's package-reuse opt-in; ranges, tags, aliases, workspace links, and file: dependencies are deliberately not accepted.
pnpm add --save-exact @verndale/ui-design-library@<exact-version>Import Tailwind, the library's semantic layer, and one explicit source path in the application's global stylesheet. @source paths are relative to the stylesheet containing the directive, so adjust the example's ../ segments for the consuming project.
@import "tailwindcss";
@import "@verndale/ui-design-library/styles.css";
@source "../../../node_modules/@verndale/ui-design-library/dist";import { Modal } from '@verndale/ui-design-library/components/modal';The import path is the same for a server-compatible component:
import { Alert } from '@verndale/ui-design-library/components/alert';
import { Button } from '@verndale/ui-design-library/components/button';
export function SaveForm() {
return (
<form action="/save">
<Alert>Ready to save.</Alert>
<Button type="submit">Save</Button>
</form>
);
}Interactive callback APIs are used from a Client Component:
'use client';
import { DismissibleAlert } from '@verndale/ui-design-library/components/alert';
export function SavedNotice({ onClose }: { onClose: () => void }) {
return <DismissibleAlert onDismiss={onClose}>Saved.</DismissibleAlert>;
}A structural variant lives in its own directory; both satisfy the same catalog canonical — import the structure the design resolved to:
import { Navigation } from '@verndale/ui-design-library/components/navigation';
import { MegaMenu } from '@verndale/ui-design-library/components/navigation--mega-menu';There is no root barrel and no short alias such as @verndale/ui-design-library/modal. The directory-shaped subpath is the public identity. Override semantic tokens in the consuming project's own layer; never edit the installed package.
The package includes each implementation's source, story, and component.json for deterministic orchestration inspection. Package-level uiDesignLibrary.reuseContractVersion, realizationContractVersion, and sourceParityContractVersion identify the metadata contracts. Each manifest names one primary AI candidate with exportName and its derived rendering boundary (server, hybrid, or client). Its client-neutral sourceParity object links the implementation to an immutable private decision through an audited family key, stable decision IDs, a digest, decision-scoped implementation keys, and target surfaces; client identity, paths, and excerpts never enter this public package. Secondary developer exports remain public but are not separate candidates.
reuseFingerprint is separate from API-level slots: it describes only the primary export and uses the pipeline's governed structural slots + affordance + role triad. Governed other values are valid metadata but intentionally never auto-match. variant remains the singular structural implementation axis, while variants remains the primary export's list of prop/style values. Entries in variants are unique non-empty strings; an empty array means the primary export has no governed style values.
The UI Design Library is the governed Organization-tier Figma library. Stable master identity, canonical/variant names, family-page designation, Storybook/API/source-parity evidence, review evidence, and publication state live in figma/library.json. One canonical family page keeps the default Ready for Dev section first and qualified structural masters below. Resolution is canonical + optional variant → componentPath → publicImport → Figma node; Code Connect remains forbidden.
pnpm figma:validate # coverage + governance contracts + optional local live audit
pnpm figma:live # authenticated read-only audit of the registered Figma masters
pnpm test:code # complete code/story/browser gate that runs before Figma registrationSee figma/README.md for file organization, node migration, credential, review, and release rules. Every future promotion also follows the enforceable Figma component promotion checklist: the direct canonical instance is the component-only handoff target, annotations remain outside it, and responsive specimens use the governed 1440/1024/768/390 widths and code-token bindings. Figma library publication remains an explicit maintainer action.
Every merge to main runs the full test/build/pack gate and semantic-release. A breaking change publishes a major, feat publishes a minor, and every other permitted conventional-commit type publishes a patch. Tags and GitHub releases use v<version>; the source package.json stays 0.0.0-development.
Publishing uses npm trusted publishing for the verndale/ui-design-library repository and .github/workflows/release.yml. The workflow requests id-token: write and carries no long-lived npm token. Configure that trusted publisher before merging a release-producing PR, then remove any obsolete NPM_TOKEN repository secret.
GitHub squash merges must use the PR title as the commit subject and a blank commit description. Intentional breaking releases put ! in that title (feat(package)!: ...). The release preflight checks every commit after the latest reachable v<semver> tag and rejects BREAKING CHANGE text in any body, so a failed release followed by a clean merge cannot hide stale text that semantic-release would still analyze.
- A
project-retrospectiverun produces a capture plus one source-parity artifact — a mature implementation, pinned source facts, classified differences, and an exhaustive list of the client coupling that has to come out. - A human executes the capture: rewrites the component against the library's tokens and primitives, writes the stories, fills in
component.json, represents every accepted source-parity decision, and passes the code-only gates. - The same capture creates the unpublished Figma master and left-rail documentation, registers stable identity and typed decision-scoped source-parity representations, fixes source-parity/adversarial/design-review findings in place, records node-specific evidence, and passes the Figma and full repository gates.
- It remains
maturity: "candidate"and unpublished. Promotion tosupportedand Figma library publication are separate, deliberate maintainer decisions.
Captures usually come from labels that already resolve — a mature Card or Modal — not novel ones. Novel labels are typically the least settled code in a project.
Step 2 is a rewrite, not a copy. component.json's declienting array records exactly what was stripped, so the cost is visible rather than folklore.
A capture that is structurally distinct but semantically the same lands as components/<slug>--<variant>/ rather than overwriting the incumbent. Its manifest and Figma registration share the canonical/base slug, set the structural variant, and resolve to their own import/master; the incumbent becomes the default. A role, affordance, or interaction change is promoted as a new canonical instead.
pnpm test # typecheck + lint + contracts + SSR + Chromium/WebKit/mode/motion tests
pnpm test:fast # stable architecture/contract/export/SSR subset used before push
pnpm verify:push # side-effect-free typecheck + fast tests
pnpm verify:ci # full non-fixing PR gate, including graph freshness and packed consumer
pnpm typecheck # tsc --noEmit
pnpm lint # all maintained first-party JS/TS, with warnings treated as failures
pnpm lint:fix # explicit whole-repository autofix
pnpm architecture # index/types/parts shape, client naming + 120-line ceiling
pnpm test:ssr # public components render with no browser globals
pnpm build # native Node ESM + declaration build, then export/dist parity
pnpm exports:check # component directories and committed package exports agree
pnpm exports:sync # deliberately update the committed map after adding/removing a component
pnpm contracts # slug/canonical agreement, the variant axis, declared tokens exist,
# no raw colours, provenance/stories/maturity, reuse fingerprint,
# Figma registry identity/API/mapping/nesting parity
pnpm contracts:selftest # exercises the contract checker itself against fixtures
pnpm graph:check # deterministic curated-graph freshness and integrity
pnpm figma:validate # type-check, contract-check, locally parse optional templates, and audit live nodes when authenticated
pnpm figma:live # require FIGMA_REST_TOKEN and audit registered masters without mutation
pnpm accessibility # every story rendered in Chromium: play functions + WCAG 2.2 A/AA axe
pnpm test:a11y:webkit # the same stories in WebKit (a Safari-engine proxy)
pnpm test:a11y:modes # 320px, 200% text, WCAG text spacing, and forced colors
pnpm test:stories # compatibility alias for the Chromium accessibility suite
pnpm test:stories:watch # the same, in watch mode
pnpm test:motion # `motion`-tagged stories re-run under prefers-reduced-motion
pnpm verify # test/build + packed native-ESM imports + Next/Tailwind consumer fixturepnpm test:stories needs a browser binary. Once per machine:
pnpm exec playwright install chromium webkit- Node 24+, pnpm 10+ via Corepack.
pnpm install. - React 19, Tailwind v4, Storybook 10, Vite 7.
wiki/ records why the repo is the way it is: executed plans, the decisions behind them, and what was ruled out. Start at wiki/INDEX.md and open only the page it routes to.
wiki/
├── INDEX.md # read this first — routes to everything else
├── MECHANICS.md # the capture protocol
├── topics/ # per-subsystem design history
├── journal/ # one entry per substantive change
└── plans/ # executed plans + an audit table
Modelled on ui-design-brain's wiki, including its knowledge graph (pnpm graph:build / pnpm graph:view, scripts/graph/README.md) and wiki-sync bot automation, adapted to this repo's shape. What's still different is listed at the bottom of INDEX.md.
ui-design-brain— canonical vocabulary; this library implements itproject-retrospective— produces the capturesui-design-evidence— retrospective runs and the cross-project graph