Level 1. A label, a field, the mark beside it and the row that holds them. 122 placements on 105 painted screens, in THREE kinds, and every one of them is about money. Re-measured and rebuilt 2026-08-08 after the currency mark left the value.
The level and the reason for it stay on the atoms page. What this page holds is the three kinds and what a person may type into each, plus the pair of arguments a money field cannot avoid: where a scoped rule reaches, and what focus means.
| kind | worn | where | what it is | element |
|---|---|---|---|---|
| the sheet field | 110 | every deposit and withdraw dialog | a value you fill in | <input type="number"> |
| the bet field | 12 | 8 in the desktop panel, 4 in the phone sheet | a number you are about to commit | <input type="number"> |
| the address | 1 | the deposit dialog | a value you CHECK, and it is not a field | <span> |
The split is by what a person is doing, not by what the box looks like. The sheet field and the bet field are the same control under two different moments: one is a number you are choosing and the other is a number you are committing, and everything that differs between them follows from that.
There is no plate behind this specimen, and until 2026-08-08 there had to
be one. Every rule this face had was written dialog.app-dialog .amount-input, so
the stand could not show the field without rebuilding a dialog around it, and outside one the
specimen rendered as the User Agent's white box in a dark theme. A stand that has to reproduce
an ORGANISM in order to show an ATOM is the stand reporting a defect, and section 6 is the
fix.
A hairline box on the well, a 10 corner, the display face at 18px, and 44 tall by rule. With the mark beside it the field is 312 wide at 390 and 383 at 1280, against 331 and 402 before the mark existed.
No box at all: a 1.5px underline, 30px bold display, right aligned, 150 wide and never more than 56 per cent of its row. It is the only field in the product that is typography rather than a container, and the reason is the moment it stands in: the number IS the sentence on that sheet, and a box around it would make it one form control among several.
The mark takes the figure's own 30px and is pushed to the right, because that row is a space-between flex of two items and a third would otherwise land in the middle of it. The panel and the sheet are one face at two widths: 8 placements measure 0x0 at 390 and 4 measure the same 150x44 at both.
Until 2026-08-08 all 121 fields carried
type="text" inputmode="decimal" with the mark inside the value,
value="$20.00". The keyboard was right on a phone and wrong everywhere else: a
text input accepts letters, so a person on a hardware keyboard could type
twenty dollars into the field a bet is placed with.
And the guard that looked like one was inert. 113 of the 121
carried pattern="[0-9.$]*". A pattern is only consulted at form validation, and this
product contains 0 <form> elements, measured across all 106 painted
screens. It validated nothing at any moment, on any screen, and it read as a constraint to
everyone who opened the markup.
The other 8 did not carry it, and the two trees disagreed about which. All 8 were in the bet PANEL; the painted tree had four bet fields with the pattern and the grey tree had none. Absent and inert are the same amount of nothing, which is why the divergence had gone unnoticed.
| before | after | |
|---|---|---|
| the mark | inside the value | a sibling, .amount-cur |
| the element | type="text" | type="text" since 2026-08-17, with inputmode="decimal" and a pattern, and the rail's own minimum as data-min: 10 on the deposit fields, 1 on the bet fields. It was type="number" until then, which hands the browser three things that belong to the product: the spinner, the decimal separator, and the wheel. docs/backlog.md 184 |
| the value | $20.00 | 20.00, a number a machine can read |
| the guard | a pattern that never fired, on 113 of 121 | the browser, on 121 of 121 |
| the two trees | disagreed on 8 fields | identical markup, 116 grey and 121 painted |
The mark had to move FIRST, and that is the whole order of the fix.
$ in the value is the reason the field could not be a number: a number input rejects
a value it cannot parse and would have rendered empty. So the mark became a sibling, the field
became a number, and the browser itself refuses the letters.
And the honest limit is written down rather than implied:
e, + and - are still reachable in a number input on a
desktop keyboard. Markup cannot close a field completely; the last of the filtering is the
implementation's, and this page says so instead of leaving a reader to assume the field is
shut. The contract is one line: digits and at most one dot, no exponent, no sign, and it
is written in components/input.css as well, because a person wiring this up opens
the stylesheet that owns the class and not the page that demonstrates it.
Typed with a keyboard, not assigned by a script, because those are two
different paths through the value sanitiser and only one of them is what a person does.
Measured 2026-08-10 on a field carrying type="number" step="0.01" min="10": All three of those are gone with the attribute, 2026-08-17. Rendered in both engines with the same value and the same font, a text input and a number input do not paint the same pixels, and the text input paints the SAME pixels in en-US and de-DE while the number input does not: 842 bytes either way in Chromium against 895, and 1,142 either way in WebKit against 1,568 and 1,629. The separator belongs to the page now. The wheel over the focused field left the stake at 5.00 on both engines, where it had moved it to 5.01.
| typed | .value | what it means |
|---|---|---|
abc12 | 12 | the letters are refused, which is what the type buys |
1.2.3 | 1.23 | the second dot is dropped |
+5 | 5 | the sign is swallowed and the number survives |
-5 | -5 | a negative stake, held as a value |
1e5 | 1e5 | an exponent, held as a value, and valid is true: it is 100000 |
5e | '' | the field shows two characters and reads as empty. Only validity.badInput can tell that from a field nobody touched |
The last row is item 95 in a new costume. That one was
money() writing $5.00 into a number field and the field going empty.
This is a person typing 5e and the field going empty. Both are a value a reader can
see and a script cannot, and both are invisible to a source read and to a rendering at rest.
Read badInput before reading value is the second line of the
contract.
And min is a flag, not a filter, which is
pattern arriving a second time. Until 2026-08-10 every deposit field carried
min="0" beside a visible Minimum $10 on 134 screens; it is
min="10" on the 217 deposit fields and min="1" on the 35 bet fields
now, which is what PRODUCT.md decides. What that buys is a validity STATE:
:invalid matches and validity.rangeUnderflow is true. Nothing
consults either, because this product still contains 0 <form> elements
and no page script calls checkValidity(). It does not stop a person typing 4. The
difference from pattern is that the number is now stated where a machine can read
it rather than stated nowhere.
The spinner the browser adds with the type is suppressed in two spellings, because the two engines hide it differently. Both are written against the class rather than the type, so if the field ever stops being a number the rules do nothing and say so.
Both faces were written as CONTAINERS. The whole sheet face was
dialog.app-dialog .amount-input and the whole bet face was
.app-case :is(.bet-panel,.bet-sheet) .bp-amount-row .amount-input. So this atom had
no face of its own, only faces its containers lent it, and
the atoms page had already written that sentence down as a
finding without anybody acting on it.
| before | after | |
|---|---|---|
| the sheet face | dialog.app-dialog .amount-input | .amount-input, the default, 110 of 122 |
| the bet face | four ancestors deep | .amount-input.amount-bet, a modifier on the element, 12 |
| the address | dialog.app-dialog .amount-input.addr | .amount-input.addr, 1 |
| what the row keeps | everything | two declarations: the width the field takes of the bet row, and the auto margin that pulls the pair right |
| the specimen | a rebuilt dialog around every field | the field |
It is the same correction this repository has now made five times. The signed-out prompt was a box in the comments file and became a button; the fetch control was a button in the load-more file and became a chip; the toast's dismiss and the dialog's close disc both became an icon button. Every one says the same thing: a face is a class, and a container may only say where the control sits.
The chip's amount face was the version of this that actually broke.
It was scoped .app-case .chip-amount, and 526 of the 559 buttons that stand in a
dialog in this product are in a dialog that carries no app-case: the chip beside
the amount field was drawn by the browser while the field itself was right, because this file had
at least named the second place its control stands. Naming the second place was never the
answer, it was the smaller mistake.
And nothing leaks now that the scope is gone.
wireframes/ links no stylesheet from components/, and not one of the 28
course pages loads components/index.css, both measured. An unscoped rule here reaches
the painted screens and this stand, and nothing else.
| question | who asks it | the answer |
|---|---|---|
:focus | anybody, however they got there | THE FIELD IS ACTIVE: the border goes to the action colour and a 3px glow sits behind it |
:focus-visible | the person who arrived by keyboard | THE RING, the system's one indicator, 2px offset outside the box |
The outline:none is kept, and the rule that puts a ring back has
to come after it. The default ring in base.css is square and this field has a 10px
radius, so the field turns the default off and draws its own. The two rules are both (0,3,1), which
means specificity cannot decide between them and source order does. There was no such rule
until 2026-08-02, so outline:none at (0,3,1) beat base.css at (0,1,0),
and the field took the ring away and gave back a 16 per cent brass wash, which is not an
indicator at any alpha.
There were TWO fields doing it and only one was caught, and the reason is written into the file: the sweep that found the first read the DECLARATION and not the pair of rules around it. The comment that survived said "the one control in the product that took outline:none and gave nothing back", and a comment that says "the one" is a comment the next pass trusts instead of counting.
The label is one face across all 245 placements: 11px, uppercase, 0.33px of tracking, muted ink. Measured on 106 screens, there is no second reading of it anywhere. Two contexts recolour it and nothing else: the reconcile box on the win screen tints it toward the won role, because the label there is naming a figure that is the good news.
Disabled is the system's one answer, --opacity-disabled
with not-allowed, plus a swap from the well to the surface so a dead field stops
looking like a hole you can type into.
The third kind is not a field at all. The deposit address wears
.amount-input.addr on a <span>: mono, 13px, muted, ellipsised. It
borrows the box because it stands in the same row as one, and it is deliberately not an
input, because it is a value you check rather than one you type. It took no currency mark for
the same reason.
This is one of the few controls in the system that does not need the touch floor, because it wrote its own and wrote it unconditionally. Measured with a mouse and with a finger, at 390 and at 1280: 44 in all four readings, on every one of the 121, and the mark beside it did not move a single one of them.
And it is a min-height rather than padding, which is the whole point. The field used to reach 45px because its padding happened to add up to it. On the 4px grid the same padding lands at 41 and the target is gone without anything looking different. A target is a rule, so it is written as one.
One placement, on the wallet withdrawal sheet, and it wears
.amount-input without being an input at all. It is a
<span>: a destination address is pasted by the wallet, not typed, so the
product gives it the field's face and none of the field's behaviour. That is a real decision and
the page had never shown it, so the file read as if it drew four kinds when it draws three plus
a lookalike.
A currency mark is not part of the value. The value is a number, the mark is a sibling, and the moment they are one string the element can no longer be the right element.
A scope has to name every place the control stands. Scoping is how this system keeps a field from painting the browser's own inputs, and it costs nothing as long as the list is complete.
A target is a min-height, never an addition. Padding that happens to total 44 is a coincidence that survives until the next spacing edit.
Active and focused are two states and the second belongs to the keyboard.
Never write a guard that cannot fire. A pattern with no form validates nothing, on 113 fields, and reads as a constraint to everyone who opens the markup.
Never put a symbol in a value a machine has to read. The
$ is why the field was text, and text is why letters could be typed into it.
Never take the outline away without putting one back. Two fields did it here and the second went uncaught for months.
Never write "the one" in a comment. It is the sentence the next pass trusts instead of counting, and it was wrong the day it was written.
Never claim markup closed a field. e,
+ and - reach a number input, and the last of the filtering is the
implementation's.
The level and its reason: the atoms page.
The stylesheet: components/input.css. The chips that left in 2026-08-05:
quick amounts. The sheet and the panel that hold the second kind:
bet panel. The dialog the first kind stands in:
dialog. The scope defect this file avoided, in full:
chip.