Skip to content

Latest commit

 

History

141 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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 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.


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 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.

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 build

The addons that carry real weight:

  • @storybook/addon-docstags: ['autodocs'] in .storybook/preview.ts generates a Docs page per component: the description, a live preview, and a prop table built from the TypeScript types and argTypes. 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, :active and friends as static states from the toolbar. Most of this library's behaviour lives in those states, and :focus-visible cannot 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's maturity in the sidebar. Configured in .storybook/manager.ts, not preview.ts — the addon reads addons.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 below xl and Modal goes full-screen below lg; 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.

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.

Figma library

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 registration

See 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.

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

  1. A project-retrospective run 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.
  2. 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.
  3. 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.
  4. It remains maturity: "candidate" and unpublished. Promotion to supported and 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.


Quality gates

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 fixture

pnpm test:stories needs a browser binary. Once per machine:

pnpm exec playwright install chromium webkit

Environment

  • 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 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.

Related

  • ui-design-brain — canonical vocabulary; this library implements it
  • project-retrospective — produces the captures
  • ui-design-evidence — retrospective runs and the cross-project graph

About

Private React/Tailwind component library keyed to ui-design-brain canonical slugs — captured from consuming projects, de-cliented to semantic tokens, and consumed as a source-imported git submodule.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages