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.

Amendment 1 Accepted 2026-10-26. ui-form merges fs-form and ui-inputs into one ui-* package: headless, with no default look, and a control takes invalid while the field owns the message. Two points stay open: what "removed" means for the old packages (§ 5, decided at ui-form's first publish), and mutation testing of the .vue logic (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:

  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. (Corrected by Amendment 1 § 4: no ui-* package ever declared this peer dependency.)

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.

Amendment 1 (Accepted, 2026-10-26): ui-form, fs-form and ui-inputs as one package ​

Accepted

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

PointState
§ 1 Merge into ui-formAccepted 2026-10-26.
§ 2 A control takes invalid; the field owns the messageRuled in the Commander's review of #328 (2026-10-23). This replaces the proposal's "every control takes error".
§ 3 Headless, no default lookRuled 2026-10-26. The proposal's "themeable with a default, no longer headless" is rejected.
§ 3 The ui-form cascade layerKept, provided the layer recipes are pinned before the first publish (WR-2715).
§ 4 fs-theme is not a peer dependencyA correction of fact, measured.
§ 5 Deprecation: the interim freezeAccepted 2026-10-26.
§ 5 Deprecation: when the old packages stop, and what "removed" meansOpen. Decided at ui-form's first publish.
§ 6 ScopeAccepted 2026-10-26, with the rule that a control comes in because a consumer needs it.
§ 7 Typing as enforcementAccepted 2026-10-26. It lands with the form code.
Mutation testing of the .vue script logicOpen. 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) ​

  1. Merge. fs-form and ui-inputs merge into @script-development/ui-form. It lives in the Armory, and the graduation trigger is unchanged.

  2. Errors. A control takes invalid and describedby. invalid marks the control with aria-invalid and, 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 a FormField or the bound field a form hands out. A form-bound layer sits on top: useForm hands out controls and a FormField bound by name and wired by the form's own ids. useForm takes 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.status 422 with data.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.

  3. 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 (#de7356 focus border, #e1dace border, 12px radius, a fixed 0.875rem font 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:

    SetupInput border / padding / height
    Control: unlayered @unocss/reset/tailwind.css + the ui-inputs 0.17 stylesheet1px / 8.8px / 44px
    The same reset + the ui-form stylesheet0px / 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 a ui-form stylesheet imported after tailwindcss beats p-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).

  4. fs-theme is not a peer dependency. The original § 1 says the package peer-depends on @script-development/fs-theme. Neither ui-inputs (0.17.2 peers only vue) nor ui-form ever did. Light/dark is orthogonal: a consumer that wants it pairs fs-theme's data-theme with its token map.

  5. Deprecation. fs-form and ui-inputs are deprecated once ui-form publishes (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. The ui-form stylesheet 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.17 gets them within its range, and each fix PR carries its ui-form port. 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 publish ui-form is 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.

  6. Scope. The ui-inputs controls, plus TimeInput, SearchInput, PasswordInput and InputGroup. 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).

    TextInput no longer offers the password or search types (#328). The date picker stays deferred; a consumer wires its own through the bound FormField.

  7. 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-describedby cannot drift from the markup. There is one package to maintain instead of two overlapping ones.
  • Negative: consumers of both packages face a breaking migration. useForm takes no HTTP service, the select family's label becomes optionLabel, 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 to take(), 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-form save received. A ui-form modal 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.

Enforcement ​

WhatMechanismScope
Coverage100 % root thresholdui-form
MutationStryker 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 controlsvue-tsc type tests (tests/types) plus the declaration checkui-form
Accessibilityaxe audits in the browser suiteui-form
Layer recipesA browser fixture per stack, with a positive control (WR-2715)ui-form, before the first publish
Neutral defaultsReview 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 doneAn architecture test per migrated component asserting it imports nothing from fs-form or ui-inputsConsumers

Architecture documentation for contributors and collaborators.