ADR-0041 A declaration must reach the frame, and the check that says so may not be an absence
#Context
ADR-0040 found one defect by measuring one scene. The accordion wrote the hidden attribute; the attribute's entire mechanism is the UA stylesheet's [hidden] { display: none }, a presentational hint at the lowest priority in the cascade; and .wr-accordion__panel { display: flex }, a class selector, outranked it. The panel stayed laid out, and 41-accordion-hidden-demand.html — the scene that record declares must-fail — passed, reporting #seal-pressure rendered at 266×46 and counting it one of one against the View.
That record closed the accordion and left two things open, both of which it named in its own Consequences.
The first is the general case. It ends with a rule about one component: "a component that discloses by removing must carry an explicit [hidden] { display: none } on the element it removes whenever that element sets a display of its own." Nineteen other components declare states in markup — disabled, readonly, checked, selected, aria-expanded, aria-checked, aria-hidden — and nothing had ever asked whether any of those reach the frame either. The accordion was found because someone was looking at the accordion.
The second is the shape of the check. ADR-0040's own words: "Both mechanism checks passed on a component whose disclosure did nothing, because each asserted an absence — no clipping, no missing attribute — and an absence is satisfied by a component that discloses nothing at all." It drew the lesson for the accordion and did not go looking for the same shape anywhere else, and this repository is largely built out of zero-hit checks.
What was measured. packages/budget/tools/cascade-audit.js renders every scene on the CLI's roster in every theme it has — 155 scenes, 2 588 scene-state pairs across interior-light, interior-dark, field-day, field-night and the themeless Phase 0 set — and asks three questions of each declared state. The obvious question turned out to be the wrong one and is recorded here because the wrong one is instructive: "find a state-keyed declaration that lost the cascade" reports 280 disagreements on this roster, and every one of them is select and number-input overriding the UA's hardcoded disabled greys — rgba(118, 118, 118, 0.3), a colour on no ramp in this system — with their own token-derived treatment, having each declared :disabled { opacity: 0.6 } and :disabled { cursor: not-allowed; opacity: 0.4 } a few lines further down. A design system substituting its own disabled treatment for the platform's is the thing this repository exists to do. A check that fails on it is measuring authorship.
The question that separates those from the accordion is ablation: remove the attribute, re-read the computed style and the geometry, put it back. On the broken accordion the panel computes display: flex with hidden and display: flex without it, so the ablation is empty and the mechanism is dead. On the disabled select the opacity moves, so the state is carried whatever the UA lost. One measurement, no list of exceptions, and no opinion required about which rule ought to have won.
The sweep found one further instance, reached by a different route. Checkbox.tsx and RadioButton.tsx wrote readOnly={onChange === undefined}, and both documented it as protection: "the control renders readOnly in effect — this component stays presentational and never holds interaction state of its own." The HTML readonly attribute does not apply to type=checkbox or type=radio. There was no rule to outrank and nothing to cancel; the attribute simply never meant anything. Measured on the fixtures: the attribute present, el.readOnly returning true — the IDL property reflects the attribute for every input type, whether or not that type honours it — and a click moving the control from unchecked to checked. 120 scene-state pairs, five scenes, four themes.
Reading el.readOnly back is itself the failure this record is about. It agrees with the markup because it *is* the markup. The measurement that settles it is doing the thing the state forbids.
And the absence-shaped checks are not confined to the accordion. Three were found and each was made to fire before it was made to pass:
packages/core/test/constants.test.js— "every attribute the fixtures write is declared in core" is[...usedInFixtures].every(...), andeveryover nothing is true. Measured: replacing/data-wr-[a-z-]+/with a pattern the fixtures do not contain leaves the check reportingok — none unknownand the suite reporting "constants agree with the fixtures" while comparing core against an empty set.- The same file's engine check has the mirror-image problem. Zero raw spellings is the *goal* there, so "no unknown attribute" is equally true of an engine that takes its vocabulary from core and of one that reads no vocabulary at all.
packages/eslint-plugin/test/discipline.test.jsruns four zero-hit literal scans over a file list built bywalk(SRC)andwalk(TOOLS). Its existing guard coverstools/only. Measured: withwalk(SRC)removed andtools/untouched, the suite printedokon all four counts,okonrules sourcing vocabulary from core: 0/0, and exited zero with "DISCIPLINE OK — zero literals" — every rule in the package invisible to every check written to read it.
What breaks if this stays undecided is not any one component. It is that this system's checks are mostly written as "X does not appear", that shape is silently satisfied by X's subject not appearing either, and ADR-0040 recorded the lesson for one file while the same shape stood unexamined in three more.
#Decision
A state declared in markup must reach the frame, and "reaches the frame" is measured by ablation rather than argued from the cascade. Removing the attribute must change the element's computed style, its geometry, or its subtree. A declaration that changes nothing when removed is doing nothing when present, whatever the stylesheet appears to say, and it is a build failure naming the element, the scene, the theme and this record. This is enforced by packages/budget/tools/cascade-audit.js, which runs in CI on the budget job beside the engine, reads no baseline and no committed number, and therefore compares nothing across platforms.
A state attribute is written only on an element whose type honours it. readonly does not apply to type=checkbox or type=radio; writing it there is a claim the platform drops. Where such a control has no handler, the honest mechanism is the absence of a claim rather than the enforcement of one: the input is controlled when the component is given an onChange and uncontrolled — defaultChecked — when it is not. Checkbox.tsx and RadioButton.tsx write no readOnly, and packages/react/test/discipline.test.js asserts both halves.
A no-op onChange fallback was the first answer and it is rejected below for a reason found by running a suite rather than by reading: it is a function prop, and no component in this package declares use client. The consequence, which this record states plainly rather than softening: a presentational checkbox with no handler is genuinely uncontrolled, and a click moves the native control. What the reader sees is drawn from the wrapper class and stays correct either way. The component no longer claims the native control cannot move, which is a smaller claim than readOnly made and the only true one available in a tree that must render on the server.
Ablation is the instrument for a mechanism, and agreement is the instrument for a mirror. An aria-* state describes a fact carried elsewhere, so ablating it correctly changes nothing and ablation says nothing about it. Checkbox and radio hold the native control at opacity: 0 and draw the choice on a wrapper class the glyph rule keys on, which means the fact is declared twice, in two places nothing reads together. The check is that the two agree: measured across 128 controls in every theme, aria-checked and the modifier class agree in all of them, and indeterminate — which has no HTML attribute at all — is carried as aria-checked="mixed" beside the drawn bar, which is the only correct static authoring available.
A check whose passing condition is satisfied by its subject being absent must carry the positive half beside it, and the positive half must be seen to fail before it is trusted. Every check strengthened under this record was first made to fire on the exact breakage that previously went green, and the breakage is recorded in the check's own comment rather than in a commit message. This is ADR-0040's finding promoted from a lesson about the accordion to a rule about this repository's checks.
#Rejected options
Report a state-keyed declaration that lost the cascade, which is the obvious generalisation of "a class selector outranked the UA rule". Merit, and it is considerable: it is ADR-0040's sentence read literally, it needs no ablation and no second render, it can name the losing rule and the winning rule exactly, and it is the only form that can point at a specific line in a specific stylesheet. It was built first. It lost on measurement: 280 findings on this roster and not one of them a defect. Every one is a component replacing the UA's disabled greys with its own token-derived treatment, which is authorship rather than error, and a check that fails on it would have to carry a growing list of exceptions naming the components allowed to do the thing the system is for. The instrument was answering "did this declaration win" when the question is "does this state show", and those come apart precisely where a component substitutes one treatment for another.
Test each state against a hand-written table of what it ought to compute — hidden means display: none, disabled means a dimmed control, and so on. Merit: it is direct, it needs no ablation, each entry is readable, and for hidden it is exactly right — that entry is kept, because Chromium's CDP does not return the UA [hidden] rule in matchedCSSRules at all, so for that one state the cascade cannot be read and only its result can. It lost as a general instrument because the table is a second opinion about what every component ought to look like, maintained by hand, in a repository whose whole position is that the tokens decide that. It would also have been silent on readonly, whose defect is that it computes nothing anywhere and is supposed to.
Read el.readOnly and el.disabled back and compare them to the attribute. Merit: one line, no interaction, no timing, and it is the obvious way to ask whether a control is really disabled. It lost because it is the absence shape wearing a positive face. The IDL property reflects the attribute for every input type regardless of whether the type honours it, so it returns true on a read-only checkbox and agrees with the markup because it *is* the markup — this is the check that would have gone green on the defect this record found. What settles it is doing the forbidden thing: click the control, see whether it moved, put it back.
Hold the control with a no-op onChange fallback, so React restores its checkedness to the prop after every change event. Merit, and it was committed before it was refused: it is the mechanism that actually holds a checking-type control, it is React's own documented behaviour for a controlled input rather than anything invented here, it silences the controlled-input warning readOnly was really there to silence, and it renders no attribute at all so the markup stops making any claim about editability. It lost to a build. docs:build failed prerendering /components/radio-button with "Event handlers cannot be passed to Client Component props", because no component in packages/react declares use client — these are RSC-renderable presentational components by design, and an event handler on a DOM element is a build error in that position. The option is not merely inconvenient here; it is incompatible with a property of this package that predates the question. It is recorded rather than quietly replaced because the verdict it changed is a finding: every other check in this unit was green when it was committed, and the suite that reads docs/adr is what caught it.
Style [readonly] so the state becomes visible, rather than removing it from the checking types. Merit: it is the answer that treats the symptom the audit actually reports on the text-shaped controls — readonly on InputField, TextArea and NumberInput is honoured behaviourally, and measured here with a control condition (typing into the field with the attribute leaves it empty, typing after removing the attribute lands the text), but nothing in any component stylesheet keys on it, so a reader cannot tell a read-only field from an editable one. It lost because every control in every fixture is read-only — the fixtures are static and no scene passes an onChange — so styling the state would restyle the entire form set and move measured values on nine scenes to express a state that is an artifact of the harness rather than a product decision. Recorded as a standing open item rather than absorbed: the text-shaped controls carry a real state with no visible treatment, and the scene that would justify one does not exist yet.
Keep readOnly on the checking types and correct only the documentation. Merit: the smallest possible change, it removes the false claim from the prose where a reader would meet it, it silences React's controlled-input warning exactly as before, and it touches no rendered markup and therefore no measured value. It lost because the false claim is in the markup as much as in the prose. A shipped component would still render readonly on a control any reader can toggle, and the next person to audit this system would find the attribute, look up what it means, and be told a true thing about HTML and a false thing about this component. Correcting the sentence while leaving the attribute is the laundering shape ADR-0040 spent four paragraphs refusing, arriving as a documentation fix.
Fail the audit on readonly at the fixture rather than at the component. Merit: the fixture is where it was measured, and the finding is real there. It lost because renderToStaticMarkup drops event handlers, so the mechanism that fixes it cannot appear in a static fixture at all — an audit that failed the fixture would still be failing after the defect was fixed, which is a check that cannot be satisfied. The finding belongs to the component and the enforcement belongs in the React suite, where the fix is visible. The fixture's own clickability is not a defect for the reason ADR-0040 already gave about the accordion: there is nothing in a fixture to click.
Make the cascade audit a warning rather than a build failure. Merit: it is a new instrument on a large surface, and a new check that fails a build on its first day is a check people learn to route around. It lost to ADR-0028's finding about the same trade, which ADR-0040 has already applied once in this exact position: a check nobody fails on is a comment in an unusual place. It is also the wrong reading of the risk — the audit was proved to fire on a reconstruction of the known-broken accordion before it was run on anything else, and it reports zero on the roster as it stands, so it is failing nothing today and will fail only on a state somebody newly declares and does not connect.
#Consequences
What this makes easier. The question ADR-0040 answered for one component is now asked of every component, in every theme, on every scene, by something that reads the rendered frame rather than the stylesheet's text. The accordion's own three checks are static regex reads over accordion.css — they match .wr-accordion__panel { ... } and would not see a display set on the panel by any other selector — and the audit closes that gap by measuring instead of parsing. The remaining disclosure components ADR-0040 names as coming, tabs, content-switcher, tree-view and progress-indicator, arrive to an instrument that will fail them if their disclosure does nothing, without anyone remembering to look.
What this makes harder, stated plainly. The audit renders 155 scenes and ablates every declared state on each, which is the slowest check in the budget job. It is also an instrument with a judgement in it: ablation cannot see a mechanism that is behavioural rather than computed, so readonly and inert are measured by doing the forbidden thing, <option> is skipped because it is not laid out in the page frame, and checked is left to the agreement check because this system draws choice on a wrapper class over a control at opacity: 0. Each of those exclusions is a place where the audit is trusting a reading of how the system works, and each is written down in the tool rather than left to be inferred.
A finding recorded and not acted on. readonly on InputField, TextArea and NumberInput is honoured and invisible. That is not the defect this record fixes and it is not nothing, and the rejected option above says why the fix is not available yet.
No measured value moved, and it did not move twice. The fix removes 32 readOnly="" attributes from five fixtures and their fifteen themed siblings, and nothing else. renderToStaticMarkup emits the same checked="" for defaultChecked as for checked, so the correction from the rejected no-op handler to uncontrolled rendering left the regenerated fixtures byte-identical to the ones already produced: each changed file is byte-identical to its predecessor with that attribute stripped, proven per file rather than read off a diff. No component stylesheet keys on [readonly] or :read-only, so no pixel changes. Both suites were rerun on darwin-arm64: 38 unthemed scenes and 122 themed scenes, every one identical to six decimals against this platform's committed record, 0 first records, and all 43 committed receipts unchanged on disk. No rebaseline is required under ADR-0032, and that is a measurement rather than an expectation.
Enforcement table. Adds one CI step, cascade-audit, on the budget job's fixtures runner, and one script, pnpm cascade. Adds three checks to packages/react/test/discipline.test.js — one anti-absence guard and two per checking-type component. Strengthens two checks in packages/core/test/constants.test.js, one into a two-sided coverage claim and one by adding its positive half. Adds two guards to packages/eslint-plugin/test/discipline.test.js and turns its 0/0 line from ok into FAIL. measure.js and engine.js are untouched; this record adds no verdict to any receipt.