ADR-0043: Headless + Themeable Shared Vue UI Components (ui-* family in the Armory)
Accepted Cross-Project UniversalAccepted 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 before1.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:
- 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. - 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
defineModelcontract. 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-linetheme-<territory>.css), then adopts every component. erroris a prop, never a service import. The consumer resolves the error string from its own error layer and passeserror?: string(+errorId?for aria wiring). The package is agnostic to how errors are produced.fs-themeis the light/dark bridge. The package peer-depends on@script-development/fs-themefor thedata-themeswitching 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-*, NOTfs-. 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-developmentscope, Dependabot, ADR-0027/0028 canonical settings — rather than re-paying all of it in a new repo, and it workspace-linksfs-themeas a peer. The monorepo hosts heterogeneous per-package builds (theui-*package adds a Vite library build + Vue-shaped oxlint; thefs-*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 viagit 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
| Option | Verdict | Reason |
|---|---|---|
| Adopt a third-party UI kit (PrimeVue / Radix-Vue / shadcn-vue) | Rejected | No 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-parameterised | Rejected | Excludes 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-prop | Accepted | Shares the expensive part (behaviour/a11y) while leaving brand styling per-territory; spike-proven across two radically different design languages; reaches every CSS engine. |
Home
| Option | Verdict | Reason |
|---|---|---|
Fold into fs-form or a new fs--prefixed package | Rejected | fs-* = frontend service; a UI component kit is a different category, and fs-form is state/validation, not UI. |
| Brand-new dedicated design-system repo | Rejected (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-theme | Accepted | Reuses 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 namedfs-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 publishing1.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
| What | Mechanism | Scope |
|---|---|---|
| No token vocabulary / colour literal in the shared components | Package CI lint rule rejecting non---ui-* colour/border/shadow values in ui-* component styles | ui-* packages |
| No territory-service import in the shared components | Package dependency/lint boundary (no @shared/** / services/** imports) | ui-* packages |
| Accessibility contract (roles, keyboard, aria wiring) | Component unit + a11y tests in-package | ui-* packages |
| Consumer adoption (later, opt-in per territory) | Territory arch test pinning input usage to the package once adopted | Consuming 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
| Territory | State | Notes |
|---|---|---|
| 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. |
| kendo | Not Started | Donor — reference implementation the core is extracted from; unlikely early consumer. |
| brick-inventory-orchestrator | Landed — 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. |
| emmie | Not Started | Donor-class (mature internal kit); consumer only if/when ROI justifies retrofit. |
| wijs / ublgenie / isms | Not Started | Primary ROI consumers (thin/duplicated/greenfield kits). |
| codebook / entreezuil / scripthub / town-crier | Not Started | Phase-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 || undefinedon 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
nullinto a non-nullnumberfield 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.