Skip to contentWolf-Rayet

Decision records

ADR-0110 A shadow resolves where it is declared

Accepted2026-09-18Phase 7

Amends ADR-0108. Does not supersede it: the decision that each stacking context casts is unchanged, and this is where the cast is declared.

#Context

ADR-0108 emitted two shadow tokens into css/stacking.css, both on :root:

:root {
  --wr-stacking-raised-shadow: 0 0.25rem 0.75rem rgb(from var(--wr-scrim) r g b / 0.1);
}

The emitter carried a comment saying the colour "resolves in whatever theme the element is rendered in rather than in the theme this file was written for". That is not what CSS does. A var() inside a custom property is substituted when the property is computed, on the element the declaration applies to. The computed value then inherits already substituted. So the scrim was resolved against :root and against nothing else.

--wr-scrim is a theme role and lives on [data-wr-theme="..."]. Whether that selector matches :root is a property of the consuming application, not of the token, and the two consumers differ:

  • The docs site puts data-wr-theme on <html>, so :root is the themed

element and the shadow resolved. It resolved to the root theme and stayed there: a nested Scope declaring another theme inherited the root theme's scrim. Measured before the fix, a field-night element nested under interior-light carried the interior-light scrim in its shadow.

  • The proof app and 268 of the 276 budget fixtures put data-wr-theme on

<body>, because <body> is the Substrate; none put it on <html>, and the remaining 8 are the themeless Phase 0 set. At :root there, var(--wr-scrim) is undefined, rgb(from var(--wr-scrim) r g b / 0.1) is invalid at computed-value time, and the property computes to the guaranteed-invalid value and inherits empty. Measured before the fix on /incident: four elements carrying data-wr-stacking="raised", all four box-shadow: none.

So elevation was live in the gallery, wrong under a nested theme, and absent from the product and from every scene the budget measures. ADR-0108 reported four modal scenes moving for the shadow. What moved them was the declaration it replaced: modal.css had drawn a hard offset in --wr-modal-mark, a colour that resolves, and that offset was removed in the same change. The cast ADR-0108 decided was never drawn in a measured scene and never priced.

#Decision

  • The shadow tokens are emitted on [data-wr-theme], not on :root. That

puts the declaration on the same element as the scrim it names, so each themed element re-declares the property in its own theme and a nested Scope casts its own theme's colour.

  • Borders and --wr-scrim-alpha stay on :root. They are the same number

in every theme, which is ADR-0014's reason for shipping the scrim as a colour and an alpha in the first place. Only the values naming a role move.

  • The rule is checked, and checked structurally. verify-output.js reads

every emitted stylesheet, brace-matches each :root block, and fails any declaration whose value names a theme role. The check is general: it is about where a role may be resolved, not about these two tokens.

#Consequences

The shadows now render in the fixtures, so the budget prices them for the first time. 35 of the 74 compared scenes moved, between 1.32e-4 and 1.16e-3, in both directions: a near-black cast on a dark ground pulls a Scope toward the substrate and lowers its emission, and the same cast on a lighter ground raises it. Both baselines re-recorded. No scene changed outcome and the two scenes over a theme ceiling are still the two intentional violation fixtures.

Confirmed after the change: on /incident in field-night, the three shell panels cast color(srgb 0.016 0.012 0.011 / 0.1) 0px 4px 12px. region declares a raised context and still casts nothing, which is correct: five components draw a shadow and region takes border-subtle instead. On /components/popover, a nested field-night element now carries the field-night scrim and the root case is unchanged.

#Alternatives

Move the theme to <html> in the proof app. Fixes the app and not the token. It leaves the nested-theme case wrong everywhere, and it asks every future consumer to mount the theme on one specific element or lose elevation silently. The proof app puts the theme on <body> because <body> is its Substrate, which is a reasonable thing for a consumer to do.

Compose the shadow at use, as the scrim background already is. Consistent with the note at the foot of stacking.css. Rejected because a box-shadow is one property: every one of the five consumers would restate the geometry, and the geometry is the part the generator owns.

Emit one shadow token per theme, pre-mixed. Removes the var() entirely. Rejected for ADR-0014's reason: it writes one value per theme for a geometry that does not vary by theme, and the alpha would go with it.