Measured Results
Chrome for Testing 149.0.7827.55 (arm64 macOS) via playwright-core/CDP.
Reproduce: cd ../src && npm run all. Accessibility trees come from
Accessibility.getFullAXTree — the browser's real computed tree, not inferred from markup.
1. Accessibility tree — three implementations of one UI
| variant | AX nodes | named | landmarks | headings | interactive | table roles | focusable |
|---|---|---|---|---|---|---|---|
| semantic HTML | 56 | 38 | 4 | 2 | 3 | 7 | 3 |
| div soup | 46 | 24 | 0 | 0 | 0 | 0 | 0 |
| div + ARIA | 48 | 33 | 4 | 2 | 3 | 7 | 3 |
Landmarks exposed:
- semantic —
banner, navigation, main, contentinfo - div soup — (none)
- div + ARIA —
banner, navigation, main, contentinfo
Markup size, normalised:
| variant | bytes | vs semantic |
|---|---|---|
| semantic HTML | 459 | 1.00× |
| div soup | 634 | 1.38× |
| div + ARIA | 778 | 1.69× |
Div soup costs 38% more markup and delivers nothing. It is not a shortcut. ARIA can rebuild the
tree at 69% more markup — and note the focusable column: the ARIA version needed explicit
tabindex to restore keyboard access that semantic elements provide for free. ARIA changes the
accessibility tree; it does not confer behaviour.
2. Dialog behaviours — driven with real keyboard input
| behaviour | <dialog> showModal() | custom, naive | custom, careful |
|---|---|---|---|
| opens | yes | yes | yes |
| focus moves in | yes | NO | yes |
| focus trapped (8 × Tab) | yes | NO | yes |
| background inert | yes | NO | yes |
| Escape closes | yes | NO | yes |
| focus restored to trigger | yes | NO | yes |
::backdrop available | yes | NO | NO |
| implementation | JS bytes | vs native |
|---|---|---|
<dialog> showModal() | 222 | 1.0× |
| custom, naive | 241 | 1.1× |
| custom, careful | 1200 | 5.4× |
The naive custom modal costs more bytes than native and passes 1 of 7 behavioural checks —
and it is what "just use a div with role="dialog"" produces in practice.
The careful version is a good-faith implementation with focus trap, inert, Escape and focus
restore. It reaches 6/7 at 5.4× the code, and it must keep working across new focusable element
types, shadow DOM, iframes, and browser changes — forever.
3. Native constraint validation — zero lines of validation JS
| field | constraint | value | valid | ValidityState flag |
|---|---|---|---|---|
type=email | "not-an-email" | NO | typeMismatch | |
type=email | "a@b.co" | yes | — | |
| age | min=18 | "9" | NO | rangeUnderflow |
| age | min=18 | "30" | yes | — |
| code | pattern | "abc-12" | NO | patternMismatch |
| code | pattern | "ABC-1234" | yes | — |
| site | type=url | "nope" | NO | typeMismatch |
| site | type=url | "https://x.dev" | yes | — |
- submission blocked while invalid: yes
- submission allowed once valid: yes
:invalidmatches: yes:user-invalidsupported: yes — and it is the one you want
The dirty-value trap
| how the value was set | valid | tooShort |
|---|---|---|
el.value = 'short' (programmatic) | true | false |
user types "short" (real keystrokes) | false | true |
minlength/maxlength apply only to values the user edited — the HTML spec's dirty value
flag. A test that sets .value directly never sees tooShort and passes while the real form
rejects the same input.
This is the same class of finding as fe-01's .click() result: synthetic interaction and real
interaction have different semantics, and the test harness is the thing that is wrong.
What native validation does not do
- inline error text positioned in your layout (the bubble is unstyleable)
aria-describedbywiring between input and error- localisation — browser messages follow browser locale, not application locale
- cross-field rules (confirm password, end-date after start-date)
- server-side validation, which is the only one that is a security control
Correct architecture: native constraints as the source of truth, read via ValidityState, with
your own presentation layer on top. Reimplementing the constraints is the mistake; building the
presentation is the work.