273. A code is a string, and the boxes are a drawing
Date: 2026-09-06
Status
Accepted. Builds docs/core-widgets.md §4’s code-input.
Context
§4 gives this widget one paragraph, and it is unusually complete:
code-input— the one-time-code field:length=6separate single-character boxes over one value,type="digits|alnum". It exists as its own widget rather than a styledtext-inputbecause its editing model is different, and that is the whole of the specification: typing advances,Backspaceon an empty box moves back and clears the previous one, a paste of the full code fills every box at once (the thing users actually do), and focus lands wherever the first empty box is.completefires when the last box fills, which is what lets a form submit without a button.mask=#truefor authenticator-style secrecy. Semantics: a single textbox with the whole code as its value — six boxes are a drawing, not six fields, and announcing them separately would be a lie.
The interesting thing about that paragraph is that its four editing sentences are not four rules. They are two, seen from four directions.
Decision
CodeEdit is a string and a box count, and nothing else.
No caret, no anchor, no undo stack, and no per-box array. The active box is
min(filled, length - 1) — derived, not held — and everything §4 asks for
falls out of that:
- “Typing advances” is appending.
- “Focus lands wherever the first empty box is” is not implemented at all. It is what the derivation says, on every frame, with nothing to keep in step.
- “
Backspaceon an empty box moves back and clears the previous one” is dropping the last code point, because the box the ring is on is always empty, so the box to clear is always the one before it. There is no second case. - “A paste of the full code fills every box at once” is the same append as typing: committed text arrives as a string, one character from a keystroke and six from a paste, so one operation does both.
No holes. The filled boxes are always a prefix. The alternative — an array of
length slots, each filled or not, with a caret that can be moved into the middle
— is what an editing model with a caret would have to be, and it fails on the
sentence §4 ends with: six boxes are announced as a single textbox with the
whole code as its value, and a code holding 12 and 56 with a gap between
them has no honest string to announce. A gap is not a state a one-time code has.
Per-character filtering, which is the toolkit’s one exception to
TextFilter’s rule. TextFilter is asked about the whole value an edit would
produce and answers yes or no, and its javadoc argues the case: a filter that
rewrote what was typed would move the caret out from under somebody mid-word.
CodeType is asked one code point at a time and drops what it does not want,
because neither half of that argument survives here. There is no caret to
disturb, and the case §4 calls “the thing users actually do” is a paste out of
Your code is 123 456 — which a whole-value filter rejects entirely and a
per-character one turns into six filled boxes. Refusing the paste a user was told
to make is a worse answer than ignoring a space. The alphabets themselves are
TextFilter’s, not copies: TextFilter.ALPHANUMERIC has said “what code-input type=\"alnum\" will want” since it was written.
complete fires on the edit that filled the last box, guarded by a flag
rather than by the code being full. A field that raised it whenever it was full
would submit a form again on every rebuild. A Backspace clears the flag, so a
mistyped code corrected and finished completes a second time — which is right,
because that is two codes.
One Tab stop, one textbox, and the boxes are parts. CodeField is the
focusable node, is Role.TEXT_FIELD, and is what a document names; CodeBox has
no focus, no keys and no semantics. That is §4’s last sentence built rather than
quoted.
Two things §2’s metrics row asked for that CSS could not say
- “Group gap 16 at the midpoint when
lengthis even.” §8’s selector subset has no:nth-child, so nothing in a stylesheet can say “wider after the third one”. The boxes go intocode-groupparts instead — the row of groups carries the 16 and a group carries the 8 — so both numbers are written where they are read. An odd length is one group, so the outer gap never applies and the row is the flat one §2 describes; the tree is the same shape either way, which is what keeps the stylesheet from having to know which case it is looking at. The alternative was a zero-width spacer between the halves, which turns one 8-point gap into two and arrives at 16 by an arithmetic nobody reading the CSS would see. - “Box 40×48 (36×44).” The one control in the catalog whose density is two
numbers rather than a height, so
--gb-code-box-widthand--gb-code-box-heightare both tokens anddensity-compact.cssmoves both. A code box is a character in a frame, and six boxes that narrowed without shortening would be a code drawn on graph paper.
What it does not have, and why
- No arrow keys.
text-inputconsumes its arrows because it has a caret they move, and consuming them is what stopsLeftfrom walking the focus scope out from under somebody editing. This has one insertion point that is a function of what is filled, soLeftwould either do nothing visible or move a ring the next keystroke moves back. They are left alone, so a code field sits inside an arrow-navigated scope and behaves like the single control it announces itself as. - No copy and no cut. §4 asks for the paste by name and asks for no way out,
and a
masked code must not have one forpassword’s reason. Offering it on an unmasked field only would be a control whose keys depend on how it is drawn. - No caret, so no blink timer. The focus ring on the active box is what says
where the next character goes, and §2.2 asks for it to be instant. A window
with a focused code field therefore asks for no frames at all, which is
§1.7’s idle loop holding for one more control —
text-inputhad to build a 530 ms timer to keep that true. - No
Validatorseam. A code is text until something checks it, which is exactly whatValidator<String>already is. The typed-value seam the TODO list names isdate-picker’s to open, not this one’s.
Consequences
- Five golden images, because every number in §2’s row is a geometry no
assertion can see: the 16 at the midpoint and not at every gap, the ring on one
box rather than around the six, a
titlecharacter centred in each box, and what a mask draws.code-input-focushas its-lighttwin, whichFocusGoldenPairTestrequires rather than this record. - Fifty tests, and most of them need no widget. The editing rules are
CodeEditTest’s, against the value, with no font and no frame —TextEdit’s arrangement, and the reason §4’s paragraph is testable sentence by sentence. - The Forms screen gained a seventh markup card, wired the way a real one is:
bind=down,change=back up, andcomplete=writing a status line. It is the only card on that screen that shows a control raising two events, which is the difference a screenshot can otherwise not show. code-boxgained afilledclass §2 does not ask for. A row of six identical frames says nothing about how far through a code somebody is; the stronger edge is the vocabularycardalready uses for “this edge is the meaningful one”, andborder-coloris on §1.7’s whitelist so it may fade.- §4 has three widgets left, and they are the pickers. Every one of them is a typed field plus a popover, which is a different shape from this one: this was the leftover that reused the least.
Alternatives considered
- A styled
text-input. §4 rules it out and the rule-out is right:TextEditis a string, a caret and an anchor, and this widget would have had to hold all three still while re-deriving the box index from the caret on every frame. - An array of slots with a movable caret. Priced above. It is the model a code field would need if a code had holes, and it does not.
- A
:nth-childselector for the group gap. Not in §8’s subset, and adding one for a single metric is a change to the styling language for a widget. - Announcing the boxes. §4 calls this a lie in as many words. The boxes carry
no
Roleat all.