Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.
Give invalid controls a real cue, and compose the field primitive as an input group (#312)
* Give invalid controls a gated boundary and a non-colour cue
TextInput, Checkbox and Combobox signalled invalid by swapping
border.control, hard-gated at 3:1, for border.danger, which measures
1.72-1.99:1 against canvas and recessed. An invalid control was less
perceivable at its boundary than a valid one (SC 1.4.11), and colour was
its only cue (SC 1.4.1). errorMessage is optional on composeField, so a
bare isInvalid control had no sighted cue at all.
Invalid now takes a 2px boundary in background.danger.solid.rest, which
measures 5.79-6.87:1 across both themes and modes, and shows a filled
"!" badge. The badge is a pseudo-element on the recipes' existing
invalid selectors: those already cover data-invalid, aria-invalid,
:has(:invalid) and :has(input[aria-invalid]), and detecting the state
again in React would risk drifting from them. Nothing about the
component APIs or the rendered DOM changes.
Both cues survive forced colours, where author colours are replaced but
border widths and shapes are not.
background.danger.solid.rest is now hard-gated at 3:1 against canvas and
recessed, so a palette change cannot quietly drop the boundary below the
threshold. The gate is not extended to the other five roles: a role's
solid anchor is solved for 4.5:1 on-solid text, which puts warning at
2.43:1 against canvas in light mode. No generated colour value changes.
content uses the CSS alt-text syntax so the glyph stays out of the
accessible name, which matters for Checkbox, whose badge sits inside the
label element. That is asserted against Chromium's accessibility tree
over CDP, because the accname reimplementations in the test stack do not
parse the delimiter and report the raw string.
* Draw the invalid indicator with the icon set
Two problems review surfaced in the rendered result.
Required fields were painted invalid. inputStates matched
`:has(:invalid)`, and native `:invalid` matches an empty required input
from first render, before any interaction or submit, while aria-invalid
stays null until validation actually runs. A required untouched field
therefore told sighted users it was broken and assistive technology it
was fine. At the old 1.7:1 border this was nearly invisible; the new
treatment made it unmissable. React Aria sets data-invalid and
aria-invalid itself once a real failure is recorded, so dropping the
clause leaves the genuine case covered and the two states agreeing.
The cue itself was rejected on sight: a filled danger disc holding a "!"
text glyph. It was the heaviest thing on the row, heavier than the
checkbox indicator beside it, it belonged to no icon family, and it sat
too far from the content to read as part of the field.
It is now the exclamationCircle icon, added from Heroicons since the set
had no exclamation glyph, drawn at iconSize.small in
foreground.danger.rest — the same colour as the field's error message,
so the icon and the message read as one system rather than two unrelated
red marks.
The indicator stays a pseudo-element, because the recipes' invalid
selectors are what drive it and a React-rendered icon could drift from
them. It is painted as a CSS mask instead of a glyph, so build-icons now
emits icon-mask-data.ts alongside icon-data.ts, keeping one source of
truth for icon geometry. That module is deliberately separate: the Icon
component imports icon-data at runtime, and folding mask URLs into it
would drag every inlined SVG into the client bundle.
An empty content also retires the CSS alt-text trick. There is no text
for a wrapping label to absorb, so the accessible name is safe by
construction rather than by a syntax the accname libraries misread. The
forced-colours block loses forcedColorAdjust with it: that existed to
stop Chromium painting a backplate behind the glyph, and a mask has no
text run to backplate.
On Checkbox the icon aligns to the label's first line the same way the
control does, so a wrapping label does not leave it stranded at the top.
* Place the error icon the way the reference systems do
Reviewed against the five reference systems. Two of them ship an error
icon at all, and both split it by control type: Spectrum renders it
inside the field for TextField, Picker and ComboBox but passes
showErrorIcon to its Checkbox's HelpText, and Astryx does the same,
documenting the rule its hook enforces — attached puts the icon in the
control, detached puts it beside the message and suppresses the on-field
one so no field ever shows two.
Checkbox follows the detached side. Its icon led the label before, which
left it floating in the gap after the text; it now leads the error
message, where it reads as part of the message it belongs to. The
mechanism is a fieldMessageIcon var the checkbox recipe sets on its own
root, mirroring fieldMessageIndent, so FieldError is untouched for every
other consumer and no public API changes.
The icon is an inline-block pseudo-element rather than a flex row.
errorMessage is a ReactNode, and making the message a flex container
turned every top-level node of a rich message into its own column: a
message with a bold phrase inside it broke into three independently
wrapping blocks. Polaris and Spectrum can use flex because they wrap
their message text in an element of their own; ours comes straight from
the caller, and RAC's FieldError also takes a render prop, so wrapping
it is not safe. A hanging indent keeps wrapped lines aligned with the
text instead of tucking under the icon.
TextInput and Combobox keep the icon inside the control, and their
invalid border drops back to 1px. None of the five thickens a border for
the error state, and with the icon carrying the non-colour cue the extra
weight was doing nothing the gated colour was not already doing.
The in-control icon now scales with the control, 20px at medium and 16px
at small, matching the mapping the chevron and clear button already use.
It was fixed at 20px, so at the small size it sat a quarter larger than
the icons beside it.
Checkbox keeps its 2px box, which is where we knowingly leave the
references behind: both accept an invalid checkbox with no visible cue
at all when its message is absent, and errorMessage is optional here.
* Align an invalid checkbox's message with its label
fieldMessageIndent exists so a checkbox's message lines up under its
label rather than under the control. Leading that message with the error
icon handed the icon that position and pushed the text 24px further
right, so the message aligned with nothing:
indicator 2 label text 32 icon 32 message text 56
The icon now takes the control column and the text keeps the label's,
so the two rows read as the same two columns:
indicator 2–22 (centre 12) label text 32
icon 4–20 (centre 12) message text 32
The icon centres on the indicator at every checkbox size, and wrapped
lines still hang at the label's edge.
The alignment the indent is supposed to guarantee had no test, which is
why breaking it went unnoticed. There is one now, measuring the rendered
text nodes rather than the padding arithmetic that produced the bug.
* Put the invalid icon where Spectrum puts it
The icon was the last box in the control's row, so it landed after the
clear button and the trigger, jammed against the right border with the
affordances to its left. Spectrum renders the value, then the icon, then
the dropdown button, which is what makes the icon read as part of the
field rather than as something stuck on the end.
The trailing elements now take a flex order above the pseudo-element's
default, so the icon precedes them without touching document order:
value, icon, clear, trigger. Same for a text input's end adornment.
The glyph is Heroicons' exclamation-triangle, which holds its shape
better than the circle at 16px, and exclamationCircle goes with it since
this branch added it and nothing else uses it. Both the in-control icon
and the checkbox's message icon draw it, so there is one error glyph.
Both orderings now have tests that measure rendered geometry, including
a text-input scene with an end adornment, which had no coverage. I
checked they fail with the order reverted rather than passing vacuously.
* Compose the text field primitive as an input group
The primitive took its adornments as props, so a caller could pass
content but not position it, and anything the group wanted to place
between the input and an adornment had no seam to sit in. It is now
HeroUI's InputGroup pattern: the root owns the box, and InputGroupInput,
InputGroupPrefix and InputGroupSuffix are transparent children whose
order in the markup is their order on screen.
The composed TextField is unchanged. It keeps adornmentStart and
adornmentEnd and maps them onto the prefix and suffix parts, so the two
tiers speak the languages they should — Spectrum props above, HeroUI
composition below — matching how Checkbox is already split.
InputGroup renders the invalid icon itself rather than leaving it to
CSS. Group publishes isInvalid through context and Group merges it into
both data-invalid and its render props, so the React state and the
selector agree in every path, including required-and-empty before a
submit, where both are false. Rendering it from the group also means a
consumer composing the primitive by hand cannot end up with an invalid
control that has no cue, which is what #247 is for.
The icon is a real Icon now, not a mask, so the group provides icon size
through context the way the combobox trigger does, and a caller's own
icon in a prefix scales with the control too.
Verified structural: captured against the previous tip, every existing
scene is pixel-identical and the only new capture is the composition
scene the pattern exists to serve.
* Name the input group consistently
The recipe still said TextInput after the component became InputGroup,
and TextInputVariants is public through the recipes entry point, so the
mismatch was visible from outside. It is inputGroup() now, in
input-group.css.ts.
The combobox primitive already had HeroUI's shape but not its words:
ComboboxControl wraps the input and trigger the way ComboBox.InputGroup
does, so it is ComboboxInputGroup, and ComboboxTextInput is
ComboboxInput. The recipe slot follows. Trigger, Popover, ListBox, Item
and Section already matched and are untouched.
Nothing renders differently: captured against the previous tip, all 244
scenes are unchanged with none added or removed.
* Inset the invalid icon like the value it sits beside
The indicator carried a flat 4px on each side while the control's own
padding scales with size, so at medium the value sat 12px from the
leading border and the icon 4px from the trailing one. The icon looked
stuck to the edge because it was three times closer to it than the text
was to the other.
Its trailing margin now follows the control's padding per size, and the
leading margin goes: the control already pads its own trailing edge, so
that 4px was stacked on top and made the gap before the icon wider than
the gap after it, which is backwards.
The combobox had the same doubled leading gap for the same reason,
though its icon is not border-adjacent, so only the redundant margin
went there.
The relationship now has a test measuring rendered geometry — the icon's
trailing inset against the text's leading inset at each size — checked
to fail on the old values rather than pass vacuously.
* Sit the invalid icon where the chevron sits
Matching the icon's inset to the control's own padding made it scale
with size, which read as too much room at medium. The control's padding
is the wrong reference: it is what a run of text needs from a border,
not what a glyph does.
The system already had an answer. A combobox's chevron sits 8px from
its control's inner border, and it does not scale — 8px at both sizes,
because the trigger button's box is inset 4px and the glyph is centred
in it. The invalid icon is the same kind of thing in the same kind of
place, so it takes the same inset, and the two field types now read
alike at their trailing edge.
The rule is cross-component, so the test is too: it renders an invalid
text field beside a combobox and asserts the two trailing glyphs sit the
same distance from their borders, at both sizes. A test that only looked
at the text field could not have caught the drift that made this wrong.
* Update icons
* WIP
* Update tests to use for...of loops instead of test.each
* Address PR feedback and improve docs
* Declare the field control size union in one place
The two icon-size maps hard-coded 'medium' | 'small'. Deriving the key
type from each recipe closed a loop: combobox.css.ts consumes
COMBOBOX_ICON_SIZE to size its invalid icon, so typing that constant
from the recipe's inferred type made the config depend on itself.
Declare the union in a leaf module both maps consume, keep ComboboxSize
and InputGroupSize derived from their recipes, and assert the two agree
in both directions so they cannot drift.
* Rename the text field adornment props to prefix and suffix
The InputGroup primitive already names its parts InputGroupPrefix and
InputGroupSuffix, while the composed TextField above it took
adornmentStart and adornmentEnd. Unify on prefix and suffix and drop
the word adornment from the API, docs, stories and tests.
Breaking, with no alias: the public API is intentionally unstable
before 1.0.
* Simplify the docs prose and teach validation from constraints first
Cut accessibility sections that only restated an element's own name,
sections whose prose repeated the prop in their heading, and duplicated
paragraphs. Align heading names and example titles across components.
Rewrite the validation page around constraint validation, matching the
React Aria and Base UI guidance: declare the rule, let the field render
the message, and reserve controlled isInvalid for realtime feedback.
Apply ASD-STE100 Simplified Technical English throughout: shorter
sentences, active voice, simple tenses, and one term per concept.
* Say what the code does, not where it came from
Move the size-union guards out of the recipes into a type test, and cut
comment text that narrates prior states or cites another design system
to justify a decision. Reasoning that explains a non-obvious rule stays;
provenance does not.
expectTypeOf is a no-op at runtime, so the guard is enforced by
tsc --noEmit rather than by the test run. Verified by adding a third
size variant and watching check:types reject it.
* Record the docs writing style
The prose rules this branch applied lived only in the pull request, so
the next page written would drift back. State them once, in the guide
that already claims comments and JSDoc in its scope, and point AGENTS.md
at it.