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.
Amendment 1 Accepted 2026-10-26.
ui-formmerges fs-form and ui-inputs into oneui-*package: headless, with no default look, and a control takesinvalidwhile the field owns the message. Two points stay open: what "removed" means for the old packages (§ 5, decided atui-form's first publish), and mutation testing of the.vuelogic (before the form code lands). See Amendment 1.
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. (Corrected by Amendment 1 § 4: noui-*package ever declared this peer dependency.)
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.
Amendment 1 (Accepted, 2026-10-26): ui-form, fs-form and ui-inputs as one package
AcceptedStatus. The package author proposed this in fs-packages#323, a draft closed on 2026-10-23 as too large to review that stays the reference for the full shape. The war room integrated it here under ADR-0015 § Amendment Protocol. ui-form now lands one component per PR, starting with fs-packages#328 (the scaffold plus TextInput, published as private). The Commander accepted it on 2026-10-26, after the author answered each section. Two points stay open until each is settled on its own, as with ADR-0028 Amendment 2.
| Point | State |
|---|---|
§ 1 Merge into ui-form | Accepted 2026-10-26. |
§ 2 A control takes invalid; the field owns the message | Ruled in the Commander's review of #328 (2026-10-23). This replaces the proposal's "every control takes error". |
| § 3 Headless, no default look | Ruled 2026-10-26. The proposal's "themeable with a default, no longer headless" is rejected. |
§ 3 The ui-form cascade layer | Kept, provided the layer recipes are pinned before the first publish (WR-2715). |
§ 4 fs-theme is not a peer dependency | A correction of fact, measured. |
| § 5 Deprecation: the interim freeze | Accepted 2026-10-26. |
| § 5 Deprecation: when the old packages stop, and what "removed" means | Open. Decided at ui-form's first publish. |
| § 6 Scope | Accepted 2026-10-26, with the rule that a control comes in because a consumer needs it. |
| § 7 Typing as enforcement | Accepted 2026-10-26. It lands with the form code. |
Mutation testing of the .vue script logic | Open. Settled before the form code lands. |
Context
Every form needs both the form state (the 422 binding, the submit guard, client-side refusals) and the controls. The wiring between them sits exactly on the seam between the two packages: the field's id, its aria-describedby, its message by name, and scrollToError's query for [aria-invalid="true"]. Each consumer wires that seam by hand in every form. In the local checkouts read on 2026-10-26, 12 manifests depend on ui-inputs and 6 on fs-form. Five use both (BIO, emmie, isms, lokalekeuze and ublgenie), and town-crier uses fs-form only. Two packages carrying overlapping controls also mean every fix lands twice: the WR-1339 forced-colours fix had to be ported by hand.
The original decision rejected "fold the controls into fs-form" because fs-* names a service and a component kit is not one. ui-form goes the other way: it is a ui-* package that takes the form state in, so the naming argument still holds.
Decision (as proposed, with the rulings applied)
Merge. fs-form and ui-inputs merge into
@script-development/ui-form. It lives in the Armory, and the graduation trigger is unchanged.Errors. A control takes
invalidanddescribedby.invalidmarks the control witharia-invalidand, on text-like controls, an in-input icon. A control never takes the message. The field owns the message and its id, whether that field is aFormFieldor the bound field a form hands out. A form-bound layer sits on top:useFormhands out controls and aFormFieldbound by name and wired by the form's own ids.useFormtakes no HTTP service and registers nothing on one. A form takes only the 422 that its own submit action rejects with, in the shape fs-http (axios) produces:response.status422 withdata.errors. A territory on another HTTP client hands its errors over in that shape. The components still import no error service, and nothing is provided or injected.Styling: headless, with no default look. The
--ui-*contract stays, and every visual rule reads a token. The token defaults stay neutral, with the values ui-inputs 0.17 ships, and any new token gets a neutral default. No territory's palette becomes the package default. The proposal's defaults came from one consumer (#de7356focus border,#e1daceborder,12pxradius, a fixed0.875remfont size). They would have served that consumer and dressed every other consumer's unmapped tokens in its look. They are rejected. A territory maps its own tokens, as today: all 12 consumers already set between 11 and 41 of the 95 tokens.The cascade layer. Every component rule sits in
@layer ui-form, and the tokens stay unlayered. Unlayered consumer CSS always beats a package rule, whatever its specificity or load order. That includes an unlayered CSS reset, which the proposal understated. Measured in Chromium against #328's stylesheet:Setup Input border / padding / height Control: unlayered @unocss/reset/tailwind.css+ the ui-inputs 0.17 stylesheet1px / 8.8px / 44px The same reset + the ui-formstylesheet0px / 0px / 24px The reset in layer(base), with@layer base, ui-form;declared first1px / 8.8px / 44px Six of the twelve consumers import that reset from a
main.ts, where no layer can be attached. Tailwind v4 fails the other way: its utilities are layered, so aui-formstylesheet imported aftertailwindcssbeatsp-0(measured against Tailwind v4's layer order). The layer stays, but the docs and the migration table must give a layer order for each stack (UnoCSS with@unocss/reset, and Tailwind v4). Each order is pinned by a browser fixture with a positive control before the first publish (WR-2715). Bootstrap is out of scope: the fleet is moving away from it everywhere (Commander, 2026-10-26).fs-themeis not a peer dependency. The original § 1 says the package peer-depends on@script-development/fs-theme. Neither ui-inputs (0.17.2 peers onlyvue) norui-formever did. Light/dark is orthogonal: a consumer that wants it pairs fs-theme'sdata-themewith its token map.Deprecation. fs-form and ui-inputs are deprecated once
ui-formpublishes (npm deprecate, plus a notice in both READMEs and docs pages). Consumers pin their versions, so deprecation changes nothing for a consumer until it upgrades. Theui-formstylesheet also styles ui-inputs 0.17 markup, so a consumer can migrate one page at a time.The interim, from now on: ui-inputs is frozen. It takes fixes only, as 0.17.x patch releases, so a consumer on
^0.17gets them within its range, and each fix PR carries itsui-formport. A consumer pinned below 0.17 gets no fix until it bumps.Open, decided at
ui-form's first publish: when the fix lane stops, and whether "removed" means no further releases or deleted from the tree. Until that publishui-formis private and there is nothing to migrate to, so the lane runs regardless. Stopping it ends fixes for the ui-inputs-only consumers (crit, entreezuil, kendo, scripthub, wijs, zmuuzn-auth and a lab gadget) and for town-crier, which uses only fs-form and would take on a component package to keep its form state.Scope. The ui-inputs controls, plus
TimeInput,SearchInput,PasswordInputandInputGroup. The rule: a control comes in because a consumer needs it, and the PR that adds it names that consumer. All four answer an emmie need:TimeInput: the contact report, meeting start times, open registrations and messages.SearchInput: the document tab, the contact report and the planning toolbar.PasswordInput: login, changing and activating a password, and the user password modal.InputGroup: emmie builds the addon-on-a-field pattern by hand three times (AdditiveInput, and the € tariff field in two product drawers).
TextInputno longer offers thepasswordorsearchtypes (#328). The date picker stays deferred; a consumer wires its own through the boundFormField.Typing as enforcement. A bound control accepts only a path of the draft that it can read and whose type holds what the control writes, checked by vue-tsc. The package's typecheck also runs a declaration check, so a type that the public surface names but does not export fails inside the package rather than in a consumer. This lands with the form code, because the type tests exercise
field()and the draft paths.
Consequences
- Positive: there is one way to build a field. Messages land by the form's own ids, so scroll-to-error and
aria-describedbycannot drift from the markup. There is one package to maintain instead of two overlapping ones. - Negative: consumers of both packages face a breaking migration.
useFormtakes no HTTP service, the select family'slabelbecomesoptionLabel, and a form takes only its own action's 422. Code that catches a 422 itself, such as a store or a composable, must rethrow it or pass it totake(), or the form never sees it. Every consumer with an unlayered reset, or on Tailwind v4, must add a layer order. - Risks:
- Mixed fs-form and ui-form pages. While fs-form forms remain, fs-form's middleware on the shared HTTP service fills every mounted fs-form form on any 422, including one that a
ui-formsave received. Aui-formmodal over an fs-form page lights that page up, so a page has to migrate together with every dialog it opens. - Moving list rows. Messages are keyed by path, index included, so a list whose rows move must
withdraw(path)what was said about each row. - New API. The form-bound layer stays pre-1.0 until its build-tested consumer runs fully on it.
- Mixed fs-form and ui-form pages. While fs-form forms remain, fs-form's middleware on the shared HTTP service fills every mounted fs-form form on any 422, including one that a
Enforcement
| What | Mechanism | Scope |
|---|---|---|
| Coverage | 100 % root threshold | ui-form |
| Mutation | Stryker over the .ts code: the form code, the composables and the internals, with break: 90. The .vue script logic stays unmutated, the same gap ui-inputs carries. Open, and settled before the form code lands: Stryker cannot instrument <script setup> around defineProps(). | ui-form |
| Typing of the bound controls | vue-tsc type tests (tests/types) plus the declaration check | ui-form |
| Accessibility | axe audits in the browser suite | ui-form |
| Layer recipes | A browser fixture per stack, with a positive control (WR-2715) | ui-form, before the first publish |
| Neutral defaults | Review only. The token block landed unchanged from ui-inputs 0.17 in #328, so a diff against it shows any re-valued default. | ui-form |
| Migration done | An architecture test per migrated component asserting it imports nothing from fs-form or ui-inputs | Consumers |