Skip to content

ADR-0043: Headless + Themeable Shared Vue UI Components (ui-* family in the Armory)

Accepted Cross-Project Universal

Accepted 2026-07-16 — the BIO pilot (WR-0431, PR #284) validated the --ui-* token contract and the error-as-prop contract end-to-end against a live, stylistically-divergent consumer. This satisfies the "one build-tested consumer before 1.0" precondition the ADR was held Proposed on. See the Implementation row and Pilot outcome below.

Date: 2026-07-14

Context

Every actively-developed Vue territory independently rebuilds the same form-input controls. A 10-territory recon (campaigns/war-room/2026-07-14-vue-input-component-library-recon.md) confirmed the duplication is real and concentrated in the complex controls: a bespoke <select> replacement (SingleSelect/MultiSelect/SearchableSelect over @floating-ui) has been hand-built 4+ separate times — kendo, emmie, ublgenie (three non-shared dropdown impls in one repo alone). No territory pulls a third-party UI kit; it is all hand-rolled native elements. The behavioural core — listbox a11y, keyboard nav, floating positioning, the generic defineModel<T['id']> contract — is what keeps getting rewritten.

The obvious response — "one shared component library" — runs into two hard facts the recon surfaced:

  1. Styling substrate is fragmented. 6 of 10 territories are UnoCSS, but each hardcodes its own token vocabulary (kendo-*, brick-*, sam-*/bb-*, ink/paper, …); the other four are Bootstrap 5 (entreezuil, codebook), Tailwind v4 (scripthub), and plain-CSS tokens (town-crier). A component that writes a literal token class serves exactly one territory.
  2. Error handling is territory-local. Every territory feeds validation through its own service (services/error / vue-services/error / getErrorByProperty / fs-form). A shared input that imports an error service couples to one territory.

A throwaway spike (kendo → BIO extraction) resolved the central risk. kendo's SingleSelect core was lifted with exactly two couplings severed — useFieldError → an error prop, and kendo-* token classes → --ui-* CSS custom properties — and the identical component + identical CSS then rendered faithfully under both kendo's soft-rounded token map and BIO's brutalist brick-* map (light, dark, error, open-listbox states), with zero component code between them. Load-bearing finding: a colours-only contract would have failed — brick's identity is structural (3px borders, square corners, hard offset shadows, uppercase labels). The contract that survives parameterises structure as well as palette (~25 --ui-* properties), but it is bounded and declarative — data, not logic.

This raises the question this ADR settles: what shape does the library take, and where does it live — noting that the existing Armory prefix fs-* means frontend service, which a UI component kit is not.

Decision

1. Architecture — headless behavioural core + themeable skin, error-as-prop

  • Behavioural core is shared; styling is not. The package owns the state machine, a11y, keyboard nav, floating positioning, and the defineModel contract. It writes no token vocabulary and no colour literal — every visual rule is keyed on a --ui-* custom property.
  • The theme contract parameterises structure, not just palette. Border width, radius, shadow (shape, not just colour), label transform/weight, and focus-ring are --ui-* variables alongside the colours (the spike proved a colours-only contract cannot express a brutalist territory). A consuming territory maps its own tokens onto the contract once (a ~25-line theme-<territory>.css), then adopts every component.
  • error is a prop, never a service import. The consumer resolves the error string from its own error layer and passes error?: string (+ errorId? for aria wiring). The package is agnostic to how errors are produced.
  • fs-theme is the light/dark bridge. The package peer-depends on @script-development/fs-theme for the data-theme switching the token maps key off — no reinvented dark-mode plumbing.

2. Home — ui-* family inside the fs-packages monorepo (the Armory)

  • New family prefix ui-*, NOT fs-. A UI component kit is not a frontend service; the prefix signals the category. Phase-1 package: @script-development/ui-inputs.
  • Hosted in the existing fs-packages monorepo. It reuses the Armory's paid-for machinery — Trusted Publishing (OIDC), the 8-gate CI, the @script-development scope, Dependabot, ADR-0027/0028 canonical settings — rather than re-paying all of it in a new repo, and it workspace-links fs-theme as a peer. The monorepo hosts heterogeneous per-package builds (the ui-* package adds a Vite library build + Vue-shaped oxlint; the fs-* TS-only posture is per-package, not repo-wide — town-crier already runs Vue-shaped oxlint on SFCs).
  • Graduation trigger (explicit). If the ui-* family grows into a full design system with its own gravity — Storybook, visual-regression, a divergent release cadence that thrashes the stable service packages — it graduates to a standalone repo via git subtree split (the town-crier / kendo-error-tracker graduation path). Start where the machinery is; split when gravity demands.

3. Phase-1 scope

  • In: the Select/Combobox family (the 4×-reinvented control) + the Field/Label/Error composition trio. Text/textarea/checkbox ride along cheaply once the field contract is set.
  • Deferred: date/time pickers. The fleet is split across v-calendar (kendo/emmie/codebook) and @vuepic/vue-datepicker (ublgenie/wijs) — a dependency-choice fight, not a duplication win. Revisit once the core lands.
  • First-cut consumers: the UnoCSS core (kendo, emmie, BIO, wijs, ublgenie, isms). Bootstrap/Tailwind/plain-CSS territories (codebook, entreezuil, scripthub, town-crier) are phase-2 — they validate that the CSS-var contract survives a full engine change.

Options Considered

Library shape

OptionVerdictReason
Adopt a third-party UI kit (PrimeVue / Radix-Vue / shadcn-vue)RejectedNo territory has one to migrate from; a heavy external dep displaces nothing and imposes its own styling model on 9 token systems.
Fully-styled UnoCSS-only kit, token-parameterisedRejectedExcludes the Bootstrap/Tailwind/plain-CSS territories outright, and the spike proved a colours-only contract cannot express a structurally-divergent territory.
Headless behavioural core + structural CSS-var theme contract, error-as-propAcceptedShares the expensive part (behaviour/a11y) while leaving brand styling per-territory; spike-proven across two radically different design languages; reaches every CSS engine.

Home

OptionVerdictReason
Fold into fs-form or a new fs--prefixed packageRejectedfs-* = frontend service; a UI component kit is a different category, and fs-form is state/validation, not UI.
Brand-new dedicated design-system repoRejected (for now)Re-pays all Armory machinery (CI, publishing, settings, CODEOWNERS) for a single control family; premature. Reserved as the graduation target if the family grows.
New ui-* family inside the fs-packages monorepo, peer on fs-themeAcceptedReuses the Armory's publishing/CI/settings; workspace-links the theme bridge; honours the metaphor ("equipment for all territories"); reversible via subtree-split.

Consequences

Positive

  • Kills the expensive, repeatedly-reinvented behavioural duplication (the select/combobox core) fleet-wide.
  • One accessibility/keyboard implementation to test and harden, not 4+.
  • Brand divergence is preserved by design — territories keep their visual identity; only behaviour converges.
  • Reaches every CSS engine (the structural CSS-var contract is engine-agnostic).
  • No new repo machinery to stand up; immediate reuse of Armory publishing + CI.

Negative

  • Adds .vue + a Vite library build + Vue-shaped oxlint to a previously TS-only monorepo (per-package, but real new surface).
  • A ~25-property theme contract is more onboarding than "swap the colours" — each consumer writes a token-map file once.
  • Mild identity blur: a ui-* family in a repo literally named fs-packages (cosmetic; rename available).

Risks

  • Value flows extract-from-strong → adopt-into-weak. kendo/emmie already solved this internally (arch-test-enforced, 100%-tested) and are donors, not eager consumers; ROI accrues to the greenfield/messy territories (isms, wijs, ublgenie). Mitigation: measure success as adoption-into-the-weak, not fleet-wide migration; do not expect kendo/emmie to consume it early.
  • Contract churn. Getting the --ui-* surface wrong forces breaking changes across every consumer. Mitigation: one build-tested consumer end-to-end before publishing 1.0; treat the token surface as the API and semver it strictly.
  • Maintenance surface. A UI family is ongoing work in a monorepo optimised for stable service packages. Mitigation: the graduation trigger — split it out the moment its gravity distorts the service packages' CI/cadence.

Enforcement

WhatMechanismScope
No token vocabulary / colour literal in the shared componentsPackage CI lint rule rejecting non---ui-* colour/border/shadow values in ui-* component stylesui-* packages
No territory-service import in the shared componentsPackage dependency/lint boundary (no @shared/** / services/** imports)ui-* packages
Accessibility contract (roles, keyboard, aria wiring)Component unit + a11y tests in-packageui-* packages
Consumer adoption (later, opt-in per territory)Territory arch test pinning input usage to the package once adoptedConsuming territories, phase-2+

Enforcement here is primarily Level 3 (package CI gates) rather than a fleet arch test — the package is the enforcement surface until territories adopt it, at which point per-territory arch tests (Level 1) pin the adoption.

Resolved Questions

Why not the fs- prefix, and why the service monorepo anyway?

Resolved 2026-07-14. fs-* denotes a frontend service (logic). A themeable component kit is a distinct category, so it takes a distinct ui-* prefix. But the repository question is separate from the naming question: the Armory monorepo already owns the publishing (OIDC), CI, settings, and scope machinery a new repo would have to rebuild, and the token contract's natural peer is fs-theme. Naming honours the category; location honours the machinery. The graduation trigger keeps the "eventually its own repo" option open without paying for it prematurely.

Is a colours-only theme contract enough?

Resolved 2026-07-14. No — proven by the spike. BIO's brick-* identity is structural (3px borders, 0 radius, hard 4px 4px 0 offset shadows, uppercase bold labels). A colours-only contract would render brick as a recoloured kendo. The contract must expose border-width, radius, shadow-shape, and label transform/weight as variables. The full surface is ~25 properties — bounded, and declarative data rather than logic.

Why defer the date picker?

Resolved 2026-07-14. The fleet is split between v-calendar and @vuepic/vue-datepicker. Unifying it forces a dependency migration on half the territories — a separate, larger decision than sharing the bespoke behavioural controls that everyone already hand-rolls identically. Deferred until the core lands.

Implementation

TerritoryStateNotes
fs-packages (Armory)Landed — 0.2.0 published@script-development/ui-inputs — 5 SFCs (SingleSelect/TextInput/FormField/FormLabel/FormError), sibling-aligned compound names (no Fs prefix), --ui-* theme tokens; logic inlined into the components (no premature internal/ split). PR #160 MERGED 2026-07-15 → 0.2.0 published to npm (Trusted Publisher OIDC via publish.yml; npm-publish env-approval gate; no 404 trap); 0.1.0 (old Fs* names) deprecated. Review pass tightened props to required-where-meaningful and added a11y required-state via aria-required (error-as-prop lib → not native required) + optionsLabel. Build is tsdown + unplugin-vue (not Vite) preserving the dual-format index.{mjs,cjs,d.mts,d.cts} contract; coverage 100% incl. SFCs (v8); test:mutation a documented no-op (SFC-only package — Stryker mutates .ts only). Follow-ups: SingleSelect combobox focus-exposure (aria-activedescendant + option ids + aria-selected semantics) = WR-0441; BIO pilot = WR-0431 (gate cleared); CLAUDE.md 12→13 = doc PR #161.
kendoNot StartedDonor — reference implementation the core is extracted from; unlikely early consumer.
brick-inventory-orchestratorLanded — pilot validated (PR #284)First live consumer (WR-0431). Full atom-at-call-site: the entire local form-molecule kit retired, 16 consumers migrated, ~30 specs rewritten, 8 molecules deleted (net −949 lines); all gates green (type-check · knip · coverage 100% · integration · 3-app build). The brick-*--ui-* token map (one :root block, 34 props, light+dark) carries full brutalist identity — the token contract holds against a design language maximally distant from the TextInput/SingleSelect defaults. error-prop is a clean fs-form match. See Pilot outcome.
emmieNot StartedDonor-class (mature internal kit); consumer only if/when ROI justifies retrofit.
wijs / ublgenie / ismsNot StartedPrimary ROI consumers (thin/duplicated/greenfield kits).
codebook / entreezuil / scripthub / town-crierNot StartedPhase-2 — validate the contract across Bootstrap / Tailwind / plain-CSS engines.

Pilot outcome (BIO / WR-0431, 2026-07-16)

What held. The central bet — one --ui-* contract survives stylistically-divergent territories — is confirmed against a live app, not just the spike. 34 props (palette and structure) render BIO's brutalist identity light+dark from a single :root map over the existing --brick-* vars. error-as-prop is a clean match for the fs-form errors.field pattern.

What the pilot surfaced (drives follow-ups, not blockers):

  • The --ui-* contract has no background-on-state hook. Brick fills the control yellow on focus and red-light on invalid — states the contract exposes border/shadow/text for, but not background. BIO rode two class overrides. → candidate new vars --ui-control-bg-focus / --ui-control-bg-invalid (tracked in WR-0460).
  • No Number / Date / Textarea atom is a real ergonomic tax. Full atom-at-call-site forced every such field to inline FormField + a native control + (for number) a per-page NaN-guard handler — the reinvention the package exists to kill. → WR-0460 (add the three atoms, 0.3.0).
  • required || undefined on a constant-true required field is a dead istanbul branch — broke 100% coverage on gated files; BIO standardised on bare :aria-required="required". The package should specify the aria-required binding or ship a native attribute-spreader (WR-0460).
  • required-marker polarity differs — BIO marked optional fields "(optional)"; the package marks required fields "*". Adoption flips the convention app-wide (accepted, worth calling out to future consumers).
  • The conversion caught a latent bug in the donor code (the old NumberInput wrote null into a non-null number field via v-model indirection) — a bonus of the atom's stricter typing.

Combobox AT-focus exposure (aria-activedescendant / option ids / aria-selected) remains WR-0441, now with a live consumer to pressure-test against.

Architecture documentation for contributors and collaborators.