@verndale/ui-design-library
v4.5.1
Published
Public React component library keyed by ui-design-brain canonical slugs.
Keywords
Readme
ui-design-library
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.
Why it exists
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 implementationThat 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.
Layout
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 one implementation — components/<slug>/, <slug> == kebab(canonical), matching the catalog. When a canonical needs more than one structurally distinct implementation (a plain nav bar and a mega menu, both Navigation), the default stays the bare components/<slug>/ and each alternate is a sibling components/<slug>--<variant>/. Both carry slug == kebab(canonical); variant (singular — the structural axis, not the prop-value variants array) distinguishes them, and one is the default. The key is (canonical, variant). Single-variant components need neither field. pnpm contracts enforces all of it.
Server-first component boundaries
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.
The story file is the API contract
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.
The direction toggle
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.
Accessibility is enforced
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 }] } } }The pnpm/aria-query workaround
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.
Tokens are the portability contract
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.
Consuming it
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.
When upgrading across the server-first architecture release, see MIGRATION.md for the Button, Alert, Badge, and Carousel API changes.
The package includes each implementation's source, story, and component.json for deterministic orchestration inspection. Package-level uiDesignLibrary.reuseContractVersion identifies the metadata contract. Each manifest names one primary AI candidate with exportName and its derived rendering boundary (server, hybrid, or client). 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.
Releases
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.
How a component gets here
- A
project-retrospectiverun produces a capture — a mature implementation, its evidence, 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. - It lands as
maturity: "candidate". Promotion tosupportedis a separate, deliberate decision.
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 a structurally distinct take on a canonical the library already ships — a mega menu where Navigation ships a plain bar — lands as a new components/<slug>--<variant>/ directory rather than overwriting the incumbent. Its component.json shares the canonical and slug and sets variant; the incumbent gains variant and default: true. This is the one case where a second capture of the same canonical does not replace the first.
Quality gates
pnpm test # typecheck + lint + contracts + SSR + Chromium/WebKit/mode/motion tests
pnpm typecheck # tsc --noEmit
pnpm lint # hooks, boundaries, and source linting
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
pnpm contracts:selftest # exercises the contract checker itself against fixtures
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 webkitEnvironment
- Node 24+, pnpm 10+ via Corepack.
pnpm install. - React 19, Tailwind v4, Storybook 10, Vite 7.
Context wiki
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 tableModelled 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.
Related
ui-design-brain— canonical vocabulary; this library implements itproject-retrospective— produces the capturesui-design-evidence— retrospective runs and the cross-project graph
