ADR-0073: A composite is one Tab stop, and the selection is the roving position
- Status: Accepted
- Date: 2026-08-17
- Relates to:
docs/ARCHITECTURE.md§7.2, §9, §11;docs/core-widgets.md§3;docs/design-system.md§3, §7.2; extends ADR-0059; applies ADR-0063; confirms ADR-0065
Context
button and checkbox are single controls: one node, one Tab stop, one value.
radio-group is the first widget that is a set, and three things that were
trivially true for a single control stop being true for it.
Traversal. docs/design-system.md §7.2 is explicit: “composites (radio
groups, menus, lists, tabs) are one Tab stop with roving arrow-key focus inside”.
Six options that take six Tab presses to cross is the thing that rule exists to
prevent. Nothing in the toolkit could express it — PointerRouter.moveFocus
collected every focusable node in the tree in document order, and a radio is a
focusable node.
The invariant. “Exactly one of these is on” is a fact about the set. No radio can hold it. A radio that owned its own checked state would let a document describe two selected options, or none, and every consumer of the group would then need a rule for what that means.
The action. button press="save" needs no argument — there is one thing to
say and the button is it. radio-group change="pickTheme" is the first case
where the handler is useless without knowing which option was picked, and
Actions mapped a name to a Runnable.
Decision
Traversal is the router’s, and a composite says so with one method
Handles.focusScope() — false by default. A scope contributes exactly one
entry to the Tab order, and the arrow keys move focus within it.
Both halves are the router’s, not the widget’s, by the same argument already
written on Tab: which node an arrow key reaches is a property of the group’s
shape, and the radio the focus is currently on cannot see its siblings. A widget
also has no route to the router — Handles receives events, not the object that
dispatched them — so a widget-side implementation would have needed a new
back-channel before it could have been wrong for the right reason.
Arrow keys are handled after the focused chain has declined the key, in the
same place accelerators are. A slider stepping its value and a text field moving
its caret both consume the arrow and keep it, and neither has to know it is
inside a group. Home and End reach the ends. A modified arrow is not
traversal.
A widget that is both focusable and a scope contributes one stop, not two:
the scope is asked first and its entry is what goes into the order. Asked the
other way round such a widget would be reachable twice by Tab, and the second
arrival would have no arrow keys at all, because a scope is found strictly
upwards from the focused node. radio-group is not focusable — the ring belongs
on the option the user is about to pick — but a toolbar plausibly is, and this is
the kind of thing that is free to get right now and expensive to notice later.
Both axes rove. A group’s direction is the stylesheet’s — flex-direction on
radio-group, which .inline flips — so input cannot know which pair of arrows
the user is looking at, and answering to only one pair would be wrong half the
time. That is also ARIA’s rule for a radio group. A composite that genuinely has
an axis (a tab list along the top, a menu bar) will have to say so; nothing needs
that yet.
The entry point is derived from :checked, not remembered
Tab into a group lands on the focusable descendant matching :checked, or on the
first if none does. Computed fresh on every traversal.
This is the decision worth the record. The obvious implementation of “roving focus” is a stored roving position — a map from scope to last-focused child — and it is wrong in a way that only shows up later: it is a second piece of state beside the selection, and the two disagree the first time an application sets the value itself. Tab would then return the user to the option they last looked at rather than the one that is on. There is no event that would fix it, because the application setting a property does not know a router exists.
Deriving it means the selection is the roving position. Nothing to
invalidate, nothing to leak when an element unmounts, and no way for the two to
drift, because there is only one. A composite whose items are not selectable — a
toolbar — has no :checked anywhere and always enters at the first, which is the
right answer for it too.
FocusScopeTest.selectionIsTheMemory is the test that would fail for the stored
version: focus leaves the group, the model changes underneath, and Tab comes back
to the option that is now selected.
Selection follows focus, through the application
Handles.onFocusChanged(focused, fromKeyboard). A radio raises its change the
moment keyboard focus lands on it.
It does not move its own tick. The value goes up as an event, the application sets the property, and the tick comes back down through the group’s binding — straight ADR-0063. So an arrow key on a group whose handler does nothing moves the focus ring and leaves the selection where it was, which is the visible form of “the state did not change” and is where the bug is.
The fromKeyboard half is load-bearing and not decoration. A mouse focus
deliberately does not select, because a press moves focus and the click that
follows it activates: a radio that acted on both would fire its change twice for
one click. That is the same distinction :focus-visible already draws, reused
rather than reinvented.
Re-picking the option already on is a no-op rather than a toggle, which is what
makes Tab returning into a group harmless — the entry raises a change for the
value already held, and Property.set swallows a value it already has.
The group holds the invariant, on every build
RadioGroup.children() rewrites each Radio with whether its value matches
the resolved one, what picking it does, and whether the group is disabled.
Nothing is stored, so there is no path by which two options are on at once.
selected is therefore not a KDL attribute. A document that could mark an
option selected could mark two. A radio inflated from markup starts unselected
and unwired, which is exactly the value a Java caller writes — so the parity
invariant holds without an exception.
A bound value that is not a String is compared by its toString, so an enum or
an Integer in the model works against the strings a document wrote. That is a
coercion and it is the narrow kind: it never guesses what an object means, only
how the author would have spelled it. A null — a model that has not loaded, or a
value from a newer document — selects nothing rather than falling back to the
first option, because a group that guessed would report a choice the user never
made.
A child that is not a Radio is laid out and left alone, so a group can carry a
heading. Silently dropping it would be a document whose text disappeared with no
error.
An action can be told which one
Actions.bind(String, Consumer<String>), resolved by
Actions.resolveValued(name). The argument is the picked option’s value — the
string the document already wrote down, so it crosses no type boundary and needs
no coercion rule. An application that wants an enum parses it in Java, where a
bad value is a bug it can see.
A plain Runnable resolves against change too, adapted to ignore the value:
change="refresh" is reasonable when the handler reads the model itself, and
making an author pick the matching bind overload would be a distinction only
the registry cares about. The reverse is refused — a valued action named by a
press= throws, naming which half of the registry it is in, because calling it
would mean inventing an argument here.
The alternative was one action per option, which would make adding an option an edit in Java as well as in markup, and would put a name in the registry for every value in the model.
:active reaches the whole ancestor chain
A bug found by trying to write §2.1’s pressed state, not by a test.
:hover walked the ancestor chain from the beginning — .card:hover .title has
to work. :active did not: it was set on the single deepest element the press
landed on. So pressing a checkbox’s 16px glyph lit up check-indicator, pressing
its label lit up text, and checkbox itself matched only in the sliver of
padding between them. checkbox:active has been in controls.css since the
control shipped and was very nearly a dead rule.
§2.1 requires every control to render a pressed state, and a control whose
pressed state depends on which of its own parts you happened to hit does not have
one. setPressed now moves :active across chains exactly as updateHover
does, comparing them so a press that moves within one widget does not invalidate
its ancestors.
This is why radio:active radio-indicator and checkbox:active check-indicator
are written against the control rather than the glyph: pressing the label now
darkens the glyph, which is what a user pressing anything in a 32px row means.
The mark stops being a mark, so it can scale
§3.1 gives checkbox and radio one row: “check/dot: scale 0.6→1 + opacity,
base · color fast”. The opacity half shipped with
ADR-0067; the scale half did
not arrive with transform in
ADR-0068 and has been an open
question since.
transform was never what was missing. A Box.Mark is drawn onto the box
that carries it, so scaling the indicator scales the 16px glyph along with the
tick — the ring grows with the dot, which is not the animation and reads as a
bug. The mark needs a transform of its own, a transform belongs to a
ComputedStyle, and a ComputedStyle belongs to an element.
So check-mark and radio-dot are elements — the third and fourth parts, and
the first two justified by something other than “two surfaces need two
backgrounds”. The argument is the same one in the animation dimension: two
things have to move independently, and the unit of independent movement is a
cascade node. That is what §1.7’s whitelist is for.
Two consequences worth stating because neither is obvious:
- The mark is built in every state, including unchecked. A node that appears
along with the value has no previous style to move from, and a newly built
element deliberately starts no transition (ADR-0067’s “a control appearing is
not a control changing”) — so a mark that came into existence checked would
snap. It is present throughout and hidden with
opacity: 0. An unchecked control therefore costs one fully transparent box, which is the price of the specified animation. - Unchecked draws a tick, not nothing.
CheckMarkhas to pick a shape for a state where none is visible, and it picksCHECKbecause unchecked → checked is the common transition. Going toMIXEDswaps to the dash instantly and then fades it in, which is right: the kind of mark is not on the whitelist, and a tick that morphed into a dash is not what §3.1 asks for.
radio-group-scaling.png is the frame at 80 ms of a 160 ms transition, with one
dot growing in and the one it replaced shrinking out. The assertion it carries is
that all three rings are the same 16px circle — which is exactly what the
naive fix gets wrong, and which no still frame of a settled control can show.
radio-indicator is the second part
ADR-0065 asked that the
part argument be made again rather than assumed, on the grounds that the
second instance is where a pattern either holds or turns out to have been a
special case. It holds, for the same two reasons and no new ones: a radio has two
surfaces a theme must style separately (the 32-tall row, the 16-square glyph) and
a ComputedStyle carries one background; and a radio-indicator outside a
radio is a circle that means nothing, so registering the node would let a
document create exactly that.
The circle needed no new drawing code. border-radius: 8px on a 16px box is a
circle, rounded by the four cubics
ADR-0064 already ships — so a theme
can square a radio off without a Java change, and no native symbol was added.
Box.Mark.Kind.DOT was put in the enum with CHECK and DASH and painted then;
this is its first caller.
There is no mixed state. A group’s “nothing selected yet” is no option
matching, not an option in a third state, which is why :indeterminate appears
nowhere in the radio rules.
The rest of the design system’s numbers, checked rather than assumed
Reading §1.3, §1.5, §2.1 and §2.2 against what had shipped turned up four more
divergences, all now closed and all applied to checkbox as well — §3 gives the
two controls one metrics row, so a rule that holds for one and not the other
is a spec that has stopped being true:
border-radius: 4pxon both controls. §1.5 puts small controls at 4 and §2.2 says the focus ring follows the control’s radius; neither carried one, so both drew a square ring besidebutton’s 8px one.- Hover changes a surface, not only a border. §2.1: “hover states change surface (one surface step)”. The glyph is the control’s surface at 16px, so the step lands on its background and its border together.
- A pressed appearance at all — see the
:activesection above for why there effectively was none. Checked controls press from their filled state, so the accent darkens rather than reverting to the empty surface. radio-groupgap 8,.inlinegap 16. It shipped at 4, which is on §1.3’s ramp but is not what §1.3 says: options are related controls, and those are 8. The inline variant takes “between groups 16” because side by side each glyph-plus-label is a unit — at 8 the previous label sits as close to the next glyph as to its own, and reads as belonging to the wrong option. Stacked, no such ambiguity exists, which is why the two directions legitimately differ.
Two bugs the work uncovered, neither of them about radio
An unnamed key crashed the window. keyPressed built a Shortcut from every
key that reached it, to use as a map key. Shortcut refuses to hold
Key.UNKNOWN — an accelerator on it could never fire, so the constructor is
right to say so — and the resulting IllegalArgumentException went up the UI
thread with nothing above it to catch it. This was not an edge case: Key names
the keys a shortcut might use, so every letter, digit and punctuation mark that
arrives as text is UNKNOWN, and the crash was one keystroke away at all times.
The accelerator tests never saw it because they only ever pressed keys that had
names. The lookup is now skipped for an unnamed key rather than attempted.
The glyph’s rest colour was a surface token. --gb-checkbox-bg was nord1,
which is --gb-surface — the exact colour of the panel a control normally sits
on — so an unchecked checkbox was invisible in the place it is most often put.
The light theme had the identical defect with #ffffff. The token’s own comment
(“one step up from the window so an unchecked box reads as a well”) explains it:
it was measured against --gb-bg, and almost nothing sits directly on the
window.
Both glyphs now take the button’s ramp — --gb-button-bg / -hover /
-active values on each theme — rather than a ramp of their own. That is the
scale §2.1’s “one surface step” is already defined by, it has somewhere to go in
both directions, and it is one ramp to keep correct instead of two.
The reason CI never caught it is worth more than the fix: every golden image in
the repository paints on --gb-bg. A control that vanishes on --gb-surface
was invisible to the whole suite. controls-on-surface-{dark,light}.png put a
checkbox and a radio group on a surface panel in both themes, which is the
missing axis rather than one more scene.
Consequences
radioandradio-groupship: records, nodes, CSS types, the invariant,bind+ valuedchange, keyboard,:disabled, and five golden images across both themes. Four of thirteen controls.- §7.2’s group-navigation gap is closed as a mechanism, not as a special case.
tabs,menu,select’s popup list and a toolbar all get one Tab stop and arrow keys by returningtruefrom one method.FocusScopeTestis written against bare widgets in:corerather than againstradio, because the next three users will look nothing like a radio. - Options are content-sized, not stretched:
align-items: flex-starton the group. A column’s flex children stretch on the cross axis by default, which would have run the focus ring and the click target out across empty space while.inline— a row, whose cross axis is height — kept hugging its label. The same widget would then have had two different hit targets depending on a class. The golden image is what showed it; no value assertion would have. Actions.bound()now returnsMap<String, Object>rather thanMap<String, Runnable>, because the registry holds two kinds of action. It had no callers. It also keeps insertion order now, which its own documentation had always claimed andMap.copyOfhad never provided.- Open: a scope has no axis. Both arrow pairs rove, which is right for a radio
group and will be wrong for a menu bar, where
Downshould open a menu rather than move along the bar. That is a decision formenu, and it will need to distinguish the two rather than adding a second mechanism beside this one. - §3.1 is now satisfied for every control in
controls.css. The check/dot scale was the last row with a half missing, and closing it forradioclosed it forcheckboxtoo, because the mechanism is one mechanism. Four parts exist where there was one. checkboxmoved, and deliberately: it gained the radius, the hover surface step, a working pressed state and the scaling tick.checkbox-states-darkand-lightare pixel-identical — the mark refactor changes nothing at rest, which is the check that it was a refactor — and onlycheckbox-interactionmoved, by exactly the ring radius and the hover step.- Open: a disabled group fades correctly only by an explicit undo. The group
is 45% and passes
disableddown to every option, so withoutradio-group:disabled radio:disabled { opacity: 1 }the fade would apply twice and land at 20%. The general fix is the one ADR-0065 left open —docs/core-widgets.md’s “a disabled container disables its descendants” — andformandgroup-boxare where it will have to be faced properly.