ADR 0013 — Form subsystem separation
Status
Accepted, 2026-05-18.
Source: v1.0 architectural quiz outcomes
(docs-internal/architecture/v1-master-diff-merge.md §1.2, §2.1).
Context
The source spec for v1.0 treated form-context controls as variants of
generic primitives — a "Form-button" was just <button type="submit" class="button">. The same approach treated Field-group as a thin
composite around an Input.
Practical work on the 53-component v1.0 inventory exposed two problems with this collapse:
- Form-context buttons carry contracts the generic Button does
not. A submit button can be in states like
submitting(async pending),validation-error(form-level validation blocking),success-confirmed(post-submission). A generic Button has no need for any of these. Folding them into Button as variants either pollutes the generic API or leaves form authors hand-rolling markup and state. - Form-divider has no generic equivalent. "Or sign in with" between auth options, or "Personal information" / "Billing information" between field groups — these are dividers with text embedded for form composition, not generic content separation. Folding them into Divider would force Divider to carry form-aware typography and spacing for a case Divider does not otherwise need.
- Composite forms need a coherent vocabulary. A real form is
Field-group { Label, Input, HelperText, Error }× N +Form-button. Treating each member as ad-hoc means every form template re-derives the composition. Naming them as a family establishes the pattern.
The collapse felt clean in theory ("reuse the primitives") but failed the practical test: every real form ended up reaching for additional markup, state classes, or one-off helpers.
Decision
Form-context controls form a subsystem distinct from generic
Elements. The subsystem lives under elements/form/ and contains:
- Field-group — composite wrapper that arranges Label + Input + HelperText + Error with correct spacing, error visibility, and a11y wiring.
- Label —
<label>styled for form context, with required/optional indicators. - Input — text-like inputs (
<input type="text|email|url|...">). - Textarea — multi-line input.
- HelperText — supportive text below an input, visible when no error is present.
- Error — error message below an input, replaces HelperText when the field is invalid.
- Select — natively styled
<select>. - Radio-group — composite wrapper for a set of
<input type="radio">. - Checkbox — standalone, also re-used inside Checkbox-group.
- Checkbox-group — composite wrapper for a set of
<input type="checkbox">. - Form-button — submit/reset/cancel button with form-aware states.
- Form-divider — divider with embedded text used between field groups or auth provider sets.
Form-button is separate from Button
Form-button extends Button visually but adds form-context concerns:
| Concern | Button | Form-button |
|---|---|---|
Default type |
button |
submit (configurable) |
| States | hover, active, disabled, focus | + submitting, validation-error, success-confirmed |
| ARIA | aria-pressed if toggle |
aria-busy during submit, integrates with form-level aria-invalid |
| Loading affordance | optional, generic | required during submitting, typically a Spinner replacement of icon |
| Variant defaults | all 5 (primary/secondary/ghost/danger/success) | typically primary for submit, ghost for cancel; same 5 available |
Implementations may share CSS classes through token-level inheritance:
form-button may consume --button-* tokens for visual consistency,
overriding only the state-specific concerns. The component remains
distinct because its API and state machine are distinct.
Form-divider is separate from Divider
Divider is a Foundation: a CSS-only horizontal or vertical rule. Form-divider is an Element: a horizontal divider with embedded text designed for form composition.
<!-- Divider (foundation) -->
<hr class="divider" />
<!-- Form-divider (element/form) -->
<div class="form-divider">
<span>or sign in with</span>
</div>
Form-divider has typography (font-body-support or font-caption),
spacing tuned for form groups, and optional alignment (text on left,
center, right). None of these apply to generic Divider.
Field-group is the canonical composite
A Field-group renders:
[Label] [required/optional indicator]
[Input | Textarea | Select | RadioGroup | CheckboxGroup]
[HelperText OR Error]
It owns:
- Spacing between Label / control / supporting text
aria-describedbywiring from control to HelperText/Erroraria-invalidtoggling based ondata-state="error"- Visibility logic: Error replaces HelperText when present (CSS-only via attribute selectors; no JS required)
Author markup is small:
<div class="field-group" data-state="error">
<label class="label" for="email">Email</label>
<input class="input" type="email" id="email" aria-describedby="email-hint email-error">
<p class="helper-text" id="email-hint">We'll never share your email</p>
<p class="error" id="email-error">Enter a valid email address</p>
</div>
Field-group requires no runtime. State is driven by data-state
attribute on the wrapper; CSS handles visibility.
What stays in generic Button
Generic Button retains all five variants (primary/secondary/ghost/ danger/success) and remains the right choice for:
- Toolbar buttons
- Card actions ("Read more")
- Navigation triggers
- Anything not inside a
<form>with submit semantics
Authors choosing between Button and Form-button apply a simple test: Is the button submitting or resetting a form? Yes → Form-button. No → Button.
Consequences
Positive:
- Generic Button stays aesthetically and API-wise unencumbered by form-state concerns it doesn't need.
- Form-button has room to carry submitting/validation states without bloating Button.
- Field-group becomes the standard composite, removing per-form re-derivation.
- Form-divider gets its own home; Divider stays a clean Foundation.
- Documentation can present a "Forms" page that covers the subsystem coherently.
Negative / tradeoffs:
- Two button components require careful documentation so authors pick correctly. We mitigate with the "is it submitting?" test and decision-tree docs.
- Some visual duplication in CSS between Button and Form-button. We
share
--button-*tokens to keep visual styles in lockstep. - Migration of v0.1.0 examples that use
<button type="submit" class="button">to<button class="form-button">requires manual review (not codemod-able because intent matters).
Alternatives considered
- One Button, with form-state as data-attributes. Rejected: the submitting/validation-error states are heavy enough (require a loading affordance, ARIA wiring, focus management) that bundling them into generic Button bloats every Button usage. The subsystem separation contains the complexity.
- Form-button as a tweak/modifier on Button. Rejected: form-context state is a behavior contract, not a presentation variant. Modifiers (effect, surface, rhythm) are presentation; this is identity.
- No subsystem; form composition left to user. Rejected: every form re-deriving Field-group spacing, error visibility, and ARIA wiring is exactly the duplication a design system exists to prevent.
- Field-group as the only form primitive. Rejected: makes standalone Label/Input/etc. unusable outside Field-group. Authors sometimes need Input without a label (search input in a toolbar) or Label without HelperText. Separate primitives + canonical composite is the right factoring.
References
docs-internal/architecture/v1-master-diff-merge.md§1.2 (changes from source spec), §2.1 (component inventory).docs-internal/architecture/v1-principles.md— principles P-2.x (component classification), P-5.x (form composition).llm-wiki/decisions/0006-component-classification-by-functional-role.md— establishes Element sub-categorization (interactive/form/display) this ADR builds on.