Blazor layout shell and theming foundation (task 86) #23

Merged
rob merged 6 commits from feat/webui-shell into main 2026-08-03 17:28:16 +00:00
Owner

Header/nav/main layout replacing the template's sidebar, and a theming foundation of CSS custom properties on :root — no framework. Recorded in ADR-0027. The hosting model needed no new record: ADR-0008 already fixes Blazor WebAssembly.

Bootstrap is gone (44 files), along with the sidebar, the nav toggle, the About link and every Bootstrap-dependent rule in app.css. Dark mode exists as the cheapest proof the token layer is real: it rebinds the custom properties and nothing else, so a rule needing its own dark variant means a value has escaped. That no-literals rule is review-only — nothing enforces it, as with British English under ADR-0012.

Nav entries whose page is a later ticket render as dimmed <span aria-disabled="true"> rather than anchors, so the shell never offers a link that lands on not-found.

Needs a decision:

  • Nav labels and destinations are a guess — Home, Places, Groups at /places and /groups. Nothing in the backlog fixes them; a different information architecture is a one-line change in NavMenu.razor.cs.
  • Placeholder entries are dimmed non-links. The alternative is real links to a "coming soon" page — dead links avoided, at the cost of nav entries that do nothing when clicked.

Worth knowing: Blazor's CSS isolation does not stamp its scope attribute on the anchor NavLink renders, so the link rules matched the placeholder spans and nothing else. Caught only by rendering it in a browser — neither the build nor a unit test could have. The rules now go through ::deep.

docs/adr/README.md will conflict with PRs #20, #21 and #22 — 0027 belongs after 0026.

Header/nav/main layout replacing the template's sidebar, and a theming foundation of CSS custom properties on `:root` — no framework. Recorded in ADR-0027. The hosting model needed no new record: ADR-0008 already fixes Blazor WebAssembly. Bootstrap is gone (44 files), along with the sidebar, the nav toggle, the About link and every Bootstrap-dependent rule in `app.css`. Dark mode exists as the cheapest proof the token layer is real: it rebinds the custom properties and nothing else, so a rule needing its own dark variant means a value has escaped. That no-literals rule is review-only — nothing enforces it, as with British English under ADR-0012. Nav entries whose page is a later ticket render as dimmed `<span aria-disabled="true">` rather than anchors, so the shell never offers a link that lands on not-found. Needs a decision: - **Nav labels and destinations are a guess** — Home, Places, Groups at `/places` and `/groups`. Nothing in the backlog fixes them; a different information architecture is a one-line change in `NavMenu.razor.cs`. - **Placeholder entries are dimmed non-links.** The alternative is real links to a "coming soon" page — dead links avoided, at the cost of nav entries that do nothing when clicked. Worth knowing: Blazor's CSS isolation does not stamp its scope attribute on the anchor `NavLink` renders, so the link rules matched the placeholder spans and nothing else. Caught only by rendering it in a browser — neither the build nor a unit test could have. The rules now go through `::deep`. `docs/adr/README.md` will conflict with PRs #20, #21 and #22 — 0027 belongs after 0026.
Build the front-end layout shell and drop the template's chrome
All checks were successful
CI / build (pull_request) Successful in 46s
7dcf864171
Replaces the Blazor template's sidebar layout with a header, primary
navigation and content area, and deletes what the template left behind:
Bootstrap (1.2 MB of CSS, JS and source maps, now unreferenced), the
unused PWA icon and the sidebar's Bootstrap-dependent styles.

Theming is a set of CSS custom properties in wwwroot/css/theme.css, with
a prefers-color-scheme block that rebinds them for dark mode and nothing
else. No stylesheet outside that file declares a literal colour or size.
ADR-0027 records why no CSS framework replaced Bootstrap.

Navigation entries whose page is a later ticket render as dimmed spans
rather than anchors, so the shell never offers a link that lands on the
not-found page. The entry list lives in a .razor.cs partial, since
Razor's generated C# is exempt from the solution's analysers (#129).

Component tests use bUnit, added here for the first time.

Backlog #86.
Author
Owner

NavMenu.razor.css:23rgba(255, 255, 255, 0.12) is a literal colour outside theme.css, and it hard-codes the assumption that the header is dark: re-theme --colour-header to anything light and the hover affordance disappears. Add a --colour-header-hover token.

app.css:88rgba(0, 0, 0, 0.2) in the error banner's box-shadow, same rule. Tokenise it or drop the shadow; the border-top above it already separates the banner.

MainLayout.razor.css:12font-size: 1.25rem on .brand has no token. Add one to theme.css.

app.css:47–71 (8rem, 0.6rem, 20vh, 3.25rem, 0.2rem) and MainLayout.razor.css:27 (max-width: 30rem) are literal sizes. Media-query conditions cannot read custom properties, so ADR-0027's "declares no literal colour or size of its own" can never hold for breakpoints — amend the ADR to exempt breakpoints and the boot-progress geometry rather than leave a rule that is already false on merge.

NavMenu.razor:11aria-disabled on a <span> reaches no assistive technology: it is not a global ARIA attribute and the generic role does not support it. Chromium's accessibility tree exposes Places and Groups as bare StaticText with no properties at all, i.e. identical to having omitted the attribute. Either give the entry role="link" tabindex="0" so the state is exposed and it stays keyboard-reachable, or drop aria-disabled and add visually-hidden text ("Places, coming soon") — the second is the smaller change and the only one that conveys why. NavMenu_DestinationWithoutAPage_IsMarkedDisabledForAssistiveTechnology asserts an attribute that reaches nothing and needs renaming with whatever replaces it.

NavMenu.razor.css:39 — the dimmed placeholders compute to 4.26:1 against the header in light mode, below the 4.5:1 that 0.9rem text needs (dark mode is 4.81:1 and passes). opacity would have to reach 0.58; a --colour-header-text-muted token set for contrast in both schemes is the better fix, and removes the last escaped value from a scoped file.

NotFound.razor:4<h3> with no <h1> on the page, so FocusOnNavigate Selector="h1" matches nothing and focus stays on <body> after landing on /nowhere; the page also has no <PageTitle>, so the tab still reads "PlaceMark" on an error. Make it <h1> and add a title.

Nothing covers the not-found route — that /nowhere renders NotFound inside the layout with no nav entry active is the one routing behaviour the shell exists to provide.

Verdict: changes required

`NavMenu.razor.css:23` — `rgba(255, 255, 255, 0.12)` is a literal colour outside `theme.css`, and it hard-codes the assumption that the header is dark: re-theme `--colour-header` to anything light and the hover affordance disappears. Add a `--colour-header-hover` token. `app.css:88` — `rgba(0, 0, 0, 0.2)` in the error banner's `box-shadow`, same rule. Tokenise it or drop the shadow; the `border-top` above it already separates the banner. `MainLayout.razor.css:12` — `font-size: 1.25rem` on `.brand` has no token. Add one to `theme.css`. `app.css:47–71` (`8rem`, `0.6rem`, `20vh`, `3.25rem`, `0.2rem`) and `MainLayout.razor.css:27` (`max-width: 30rem`) are literal sizes. Media-query conditions *cannot* read custom properties, so ADR-0027's "declares no literal colour or size of its own" can never hold for breakpoints — amend the ADR to exempt breakpoints and the boot-progress geometry rather than leave a rule that is already false on merge. `NavMenu.razor:11` — `aria-disabled` on a `<span>` reaches no assistive technology: it is not a global ARIA attribute and the `generic` role does not support it. Chromium's accessibility tree exposes Places and Groups as bare `StaticText` with no properties at all, i.e. identical to having omitted the attribute. Either give the entry `role="link" tabindex="0"` so the state is exposed and it stays keyboard-reachable, or drop `aria-disabled` and add visually-hidden text ("Places, coming soon") — the second is the smaller change and the only one that conveys *why*. `NavMenu_DestinationWithoutAPage_IsMarkedDisabledForAssistiveTechnology` asserts an attribute that reaches nothing and needs renaming with whatever replaces it. `NavMenu.razor.css:39` — the dimmed placeholders compute to 4.26:1 against the header in light mode, below the 4.5:1 that 0.9rem text needs (dark mode is 4.81:1 and passes). `opacity` would have to reach 0.58; a `--colour-header-text-muted` token set for contrast in both schemes is the better fix, and removes the last escaped value from a scoped file. `NotFound.razor:4` — `<h3>` with no `<h1>` on the page, so `FocusOnNavigate Selector="h1"` matches nothing and focus stays on `<body>` after landing on `/nowhere`; the page also has no `<PageTitle>`, so the tab still reads "PlaceMark" on an error. Make it `<h1>` and add a title. Nothing covers the not-found route — that `/nowhere` renders `NotFound` inside the layout with no nav entry active is the one routing behaviour the shell exists to provide. Verdict: changes required
# Conflicts:
#	docs/adr/README.md
Act on review: contrast, inert ARIA, not-found page, escaped values
All checks were successful
CI / build (pull_request) Successful in 52s
432590f338
Six findings from the review of #23, all confirmed before fixing.

The placeholder nav entries were dimmed with `opacity`, compositing to
4.26:1 against the header in the light scheme — below AA for 0.9rem text
— while measuring 4.81:1 in dark and passing, so the failure was visible
only to whoever prefers the other scheme. They now take a
`--colour-header-text-muted` token, measured at 5.84:1 light and 5.94:1
dark. Contrast pairs belong in theme.css, where both schemes sit
together and each ratio can be stated beside the value.

`aria-disabled` on those spans reached nothing: it is not a global ARIA
attribute and the implicit role does not support it. Chromium's
accessibility tree exposed the entries as bare StaticText either way.
Replaced with visually-hidden ", coming soon" text, which is in the tree
and says why rather than only that. The test asserting the attribute
asserted something untrue of the running app, and went with it.

NotFound.razor had an `h3` and no title, so FocusOnNavigate matched
nothing and focus stayed on `body`, with the tab still reading
"PlaceMark" on an error. Now `h1` plus a PageTitle: focus lands on the
heading and the title reads "Not found — PlaceMark", both measured. Its
redundant `@layout` is gone — RouteView's DefaultLayout already applies
it.

Remaining escaped values tokenised: `--colour-header-hover` for the nav
hover, `--font-size-brand`; the error banner's shadow is dropped, since
the border above it already separates the banner. No literal colour now
exists outside theme.css.

ADR-0027 claimed no stylesheet declares a literal colour or size, which
its own code falsified and which a media query can never satisfy —
conditions are evaluated before custom properties resolve. Split into
two rules of honest strength: colours absolute, sizes limited to the
shared scale, with breakpoints and one-off geometry exempt and marked.

Adds RoutingTests: that an unknown URL renders NotFound inside the
layout, with a focusable h1, a title and no nav entry marked current.

Backlog #86.
Author
Owner

All six actioned in 432590f, plus the redundant @layout; origin/main merged and 0027 reordered after 0026.

Placeholders now take a --colour-header-text-muted token, measured 5.84:1 light and 5.94:1 dark; aria-disabled replaced with visually-hidden ", coming soon" (confirmed present and unignored in the AX tree); ADR-0027's colour rule is now absolute and its size rule exempts breakpoints and one-off geometry, both marked in the CSS.

All six actioned in `432590f`, plus the redundant `@layout`; `origin/main` merged and 0027 reordered after 0026. Placeholders now take a `--colour-header-text-muted` token, measured 5.84:1 light and 5.94:1 dark; `aria-disabled` replaced with visually-hidden ", coming soon" (confirmed present and unignored in the AX tree); ADR-0027's colour rule is now absolute and its size rule exempts breakpoints and one-off geometry, both marked in the CSS.
Author
Owner

NavMenu.razor.css:22 — the hover rule matches the placeholder spans, because they carry .navigation-link too, and this commit deleted the .navigation-link-unavailable:hover { background-color: transparent } that was cancelling it. With a real pointer over "Places" the span takes #1d5850, the same lifted pill a live link gets, so the entry advertises itself as clickable — the thing rendering it as a non-anchor exists to prevent. It also drops that entry's text contrast from 5.84:1 to 4.22:1 in the light scheme, back under AA, which is the failure this commit was fixing. Narrow the selector to a.navigation-link:hover rather than reinstating the override, so a future non-anchor entry type cannot reintroduce it. The comment at line 36, "there is nothing to hover or focus", holds for focus only.

Verdict: changes required

`NavMenu.razor.css:22` — the hover rule matches the placeholder spans, because they carry `.navigation-link` too, and this commit deleted the `.navigation-link-unavailable:hover { background-color: transparent }` that was cancelling it. With a real pointer over "Places" the span takes `#1d5850`, the same lifted pill a live link gets, so the entry advertises itself as clickable — the thing rendering it as a non-anchor exists to prevent. It also drops that entry's text contrast from 5.84:1 to **4.22:1** in the light scheme, back under AA, which is the failure this commit was fixing. Narrow the selector to `a.navigation-link:hover` rather than reinstating the override, so a future non-anchor entry type cannot reintroduce it. The comment at line 36, "there is nothing to hover or focus", holds for focus only. Verdict: changes required
Withhold the hover affordance from entries that are not links
All checks were successful
CI / build (pull_request) Successful in 44s
757cec3ac3
A regression from 432590f. That commit deleted the
`.navigation-link-unavailable:hover` override while keeping the generic
`.navigation-link:hover`, and justified it with "NavMenu renders these as
spans, so there is nothing to hover or focus" — true of focus, false of
`:hover`, which matches whatever is under the pointer regardless of what
the element is.

Confirmed by driving a pointer over each entry: the placeholders painted
the hover background, rendering as the same lifted pill as the current
page, and their contrast fell from 5.84:1 to 4.22:1 in the light scheme
— below AA, and the same failure mode ADR-0027 had just gained a
paragraph about. Dark reached 4.77:1.

Fixed by qualifying the affordance rules with `a` rather than by
reinstating the override. An override has to be remembered by whoever
adds the next kind of non-interactive entry; `a.navigation-link:hover`
cannot match a span whatever anyone adds later. `.active` is qualified
the same way, so the current-page highlight is a property of the
stylesheet rather than of NavLink's behaviour.

Audited the rest of the front end for the same assumption. `h1:focus`
suppressed the focus ring for any focus, where the reason it is
acceptable holds only for the programmatic focus FocusOnNavigate
performs; now qualified with `[tabindex="-1"]`, so a heading ever made
keyboard-reachable keeps its ring.

bUnit cannot observe this class of bug at all — no cascade, no layout, no
pointer — so the selector is the guard and the new stylesheet tests pin
its shape, with the effect verified in a browser. ADR-0027 records that
there is no third option.

Backlog #86.
Author
Owner

Fixed in 757cec3 by qualifying with a rather than reinstating the override, as suggested — reproduced at 4.22:1 light / 4.77:1 dark first, now 5.84/5.94 under the pointer with no background painted. .active qualified the same way.

Guard is the selector (correct by construction) plus stylesheet tests pinning its shape, since bUnit has no cascade or pointer; audit found one more, h1:focus suppressing the ring for any focus, now h1[tabindex="-1"]:focus. Separately: #blazor-error-ui .dismiss is a mouse-only <span> the framework wires by class — real, pre-existing, not this ticket.

Fixed in `757cec3` by qualifying with `a` rather than reinstating the override, as suggested — reproduced at 4.22:1 light / 4.77:1 dark first, now 5.84/5.94 under the pointer with no background painted. `.active` qualified the same way. Guard is the selector (correct by construction) plus stylesheet tests pinning its shape, since bUnit has no cascade or pointer; audit found one more, `h1:focus` suppressing the ring for any focus, now `h1[tabindex="-1"]:focus`. Separately: `#blazor-error-ui .dismiss` is a mouse-only `<span>` the framework wires by class — real, pre-existing, not this ticket.
Author
Owner

NavMenuStylesheetTests passes on three plausible spellings of the mistake it exists to catch, because it substring-matches an unparsed selector. Appending the placeholder to the existing rule's selector list — a.navigation-link:hover, .navigation-link-unavailable:hover — reintroduces the regression exactly (pointer on "Places" takes the hover background, 4.22:1 in the light scheme) with all sixteen tests green; :is(a.navigation-link, span.navigation-link):hover does the same; and a hover rule nested in @supports or @container is skipped entirely, since only @media is asserted absent while the doc comment claims no nested at-rule. Split each rule's selector on , and assert per selector, and reject any @ block rather than @media alone. Comments containing braces are genuinely handled.

app.css:118#blazor-error-ui .dismiss { cursor: pointer } is the same defect the ADR paragraph above it now describes: the span carries no listener on itself, its ancestors or the document, and a real click leaves the banner at display: block, so it promises an interaction it does not have, mouse-only, with no keyboard route to dismiss. Wire it in index.html or drop the cursor.

Verdict: changes required

`NavMenuStylesheetTests` passes on three plausible spellings of the mistake it exists to catch, because it substring-matches an unparsed selector. Appending the placeholder to the existing rule's selector list — `a.navigation-link:hover, .navigation-link-unavailable:hover` — reintroduces the regression exactly (pointer on "Places" takes the hover background, 4.22:1 in the light scheme) with all sixteen tests green; `:is(a.navigation-link, span.navigation-link):hover` does the same; and a hover rule nested in `@supports` or `@container` is skipped entirely, since only `@media` is asserted absent while the doc comment claims no nested at-rule. Split each rule's selector on `,` and assert per selector, and reject any `@` block rather than `@media` alone. Comments containing braces are genuinely handled. `app.css:118` — `#blazor-error-ui .dismiss { cursor: pointer }` is the same defect the ADR paragraph above it now describes: the span carries no listener on itself, its ancestors or the document, and a real click leaves the banner at `display: block`, so it promises an interaction it does not have, mouse-only, with no keyboard route to dismiss. Wire it in `index.html` or drop the cursor. Verdict: changes required
Make the stylesheet guard check selectors rather than substrings
All checks were successful
CI / build (pull_request) Successful in 45s
359bce1182
The guard added in 757cec3 substring-matched an unparsed selector, so it
passed on three spellings of the regression it exists to catch: a
selector list appending the placeholder to the qualified rule, an `:is()`
holding an anchor beside a span, and any rule nested in an at-rule other
than `@media`. The first two reintroduce the 4.22:1 hover; the third is
skipped silently. Its own doc comment claimed no nested at-rule while the
code checked one of them.

Now each rule's selector list is split on commas and judged per selector,
so a qualifier appearing anywhere in the rule no longer satisfies the
whole of it; any `@` is refused rather than `@media` alone, which makes
the parser's assumption and its assertion the same statement; and a
functional pseudo-class in an interaction rule is rejected outright,
checked before the comma split because `:is()` takes its own
comma-separated arguments. A substring check cannot see inside one, so it
is refused rather than parsed.

All three of the review's mutations now fail, each on the check meant for
it, as do the three from the previous round.

The doc comment now says what the test does and does not prove: shape
within one file, not effect, and nothing about a rule in another
stylesheet reaching `.navigation-link`.

Also records why `#blazor-error-ui .dismiss` keeps its pointer cursor.
Blazor attaches that span's click handler lazily, inside the function
that unhides the banner, so inspecting a hand-unhidden banner shows no
listener anywhere and a click that does nothing. Forcing a real unhandled
exception shows the handler attached and a click dismissing it, so the
affordance is honest and the missing part is the keyboard route — #139.

Backlog #86.
Author
Owner

Guard fixed in 359bce1 — per-selector after splitting on commas, any @ refused, and functional pseudo-classes rejected before the split (:is() carries its own commas). All three of your mutations now fail, each on the check meant for it, as do the three from the previous round.

On .dismiss: I get the opposite result, and I think the probe is the difference. Blazor attaches that handler lazily inside the function that unhides the banner, so a hand-unhidden banner has no listener and a dead click — exactly what you measured. Forcing a real unhandled exception gives dismiss.onclick = the framework's dismiss function, a click listener on the span, and a click that sets display: none. So the cursor is honest and the missing part is the keyboard route (#139, premise unchanged). Left it in place with a comment recording this; happy to be shown wrong if you can dismiss it with the banner raised by hand.

Guard fixed in `359bce1` — per-selector after splitting on commas, any `@` refused, and functional pseudo-classes rejected before the split (`:is()` carries its own commas). All three of your mutations now fail, each on the check meant for it, as do the three from the previous round. On `.dismiss`: I get the opposite result, and I think the probe is the difference. Blazor attaches that handler lazily *inside* the function that unhides the banner, so a hand-unhidden banner has no listener and a dead click — exactly what you measured. Forcing a real unhandled exception gives `dismiss.onclick` = the framework's dismiss function, a `click` listener on the span, and a click that sets `display: none`. So the cursor is honest and the missing part is the keyboard route (#139, premise unchanged). Left it in place with a comment recording this; happy to be shown wrong if you can dismiss it with the banner raised by hand.
Author
Owner

Withdrawing the .dismiss finding — it was wrong, and my method produced the result I reported. Raising the banner with a genuine unhandled exception (thrown from an @onclick handler, never touching style.display): before it, dismiss.onclick unbound and getEventListeners []; after it, dismiss.onclick and reload.onclick both bound, listeners ["click"], and a real mousedown/mouseup on the glyph sets display: none. The mechanism is as you describe — blazor.webassembly.js 10.0.10 has one function that does e.style.display="block" and then, behind a run-once flag, querySelectorAll("#blazor-error-ui .reload"/".dismiss").forEach(e=>e.onclick=…), reachable only from the runtime's error callback. Unhiding by hand cannot bind them, so cursor: pointer is honest and removing it would be the inverse defect. The keyboard gap is confirmed real (tabIndex -1, no role, never focused across twelve tabs) and #139 covers it as written.

NavMenuStylesheetTests.cs:76 — the functional-pseudo-class refusal filters selector lists on _interactionPseudoClasses only, so it never covers the .active rule, and :is(span.navigation-link, a.navigation-link).active passes: the comma split leaves a.navigation-link).active, which satisfies the qualifier. Whether it is caught depends only on the order the arguments are written — with the anchor listed first it fails. Include .active in the filter for that third test; that turns mutation into a failure, verified.

Answering the question as posed: hard to fool now, not merely harder. All three of my earlier spellings are caught, as are @container, :not(), and hovering the nav wrapper to reach every child. What still passes besides the above is a.navigation-link:hover ~ .navigation-link-unavailable, which does paint the spans but requires deliberately writing a sibling combinator, and an attribute selector carrying the qualifier as a string value — the residual limit is that the guard cannot identify a selector's subject, which is inherent to not having a CSS parser and is worth a sentence in the remarks rather than more machinery.

Verdict: changes required

**Withdrawing the `.dismiss` finding — it was wrong, and my method produced the result I reported.** Raising the banner with a genuine unhandled exception (thrown from an `@onclick` handler, never touching `style.display`): before it, `dismiss.onclick` unbound and `getEventListeners` `[]`; after it, `dismiss.onclick` and `reload.onclick` both bound, listeners `["click"]`, and a real mousedown/mouseup on the glyph sets `display: none`. The mechanism is as you describe — `blazor.webassembly.js` 10.0.10 has one function that does `e.style.display="block"` and then, behind a run-once flag, `querySelectorAll("#blazor-error-ui .reload"/".dismiss").forEach(e=>e.onclick=…)`, reachable only from the runtime's error callback. Unhiding by hand cannot bind them, so `cursor: pointer` is honest and removing it would be the inverse defect. The keyboard gap is confirmed real (`tabIndex -1`, no role, never focused across twelve tabs) and #139 covers it as written. `NavMenuStylesheetTests.cs:76` — the functional-pseudo-class refusal filters selector lists on `_interactionPseudoClasses` only, so it never covers the `.active` rule, and `:is(span.navigation-link, a.navigation-link).active` passes: the comma split leaves `a.navigation-link).active`, which satisfies the qualifier. Whether it is caught depends only on the order the arguments are written — with the anchor listed first it fails. Include `.active` in the filter for that third test; that turns mutation into a failure, verified. Answering the question as posed: hard to fool now, not merely harder. All three of my earlier spellings are caught, as are `@container`, `:not()`, and hovering the nav wrapper to reach every child. What still passes besides the above is `a.navigation-link:hover ~ .navigation-link-unavailable`, which does paint the spans but requires deliberately writing a sibling combinator, and an attribute selector carrying the qualifier as a string value — the residual limit is that the guard cannot identify a selector's subject, which is inherent to not having a CSS parser and is worth a sentence in the remarks rather than more machinery. Verdict: changes required
Apply the functional pseudo-class refusal to the highlight rule too
All checks were successful
CI / build (pull_request) Successful in 44s
ce4b18fe02
The refusal added in 359bce1 filtered by the interaction pseudo-classes,
and `.active` is a class, so it never reached the current-page highlight.
`:is(span.navigation-link, a.navigation-link).active` passed the whole
suite; the same mutation with the anchor written first was caught. Which
of the two a person wrote decided whether the guard worked.

Both now filter on one list of affordance markers — the interaction
pseudo-classes plus `.active` — so the highlight is checked the same way
as the hover, and the mutation fails in either argument order. All seven
mutations from this and the previous two rounds are killed.

Also records the limit this approach cannot pass, beside the one it
already conceded about other stylesheets. Identifying a selector's
subject needs a real CSS parser: a combinator can move the subject while
leaving the qualifier in place, and any string carrying the qualifier
satisfies a substring check. Neither gets written by accident, which is
why the test is a guard against a plausible slip rather than a proof —
worth stating so the next reader knows which it is.

Backlog #86.
Author
Owner

Fixed in ce4b18f — both tests now filter on one list of affordance markers (the interaction pseudo-classes plus .active). Reproduced first: :is(span…, a…).active was 17/17 green before, fails now, and fails in either argument order.

Recorded the combinator and attribute-value survivors in the remarks beside the existing app.css concession, as the inherent limit they are — a substring check cannot identify a selector's subject without a real parser, so this is a guard against a plausible slip, not a proof.

Fixed in `ce4b18f` — both tests now filter on one list of affordance markers (the interaction pseudo-classes plus `.active`). Reproduced first: `:is(span…, a…).active` was 17/17 green before, fails now, and fails in either argument order. Recorded the combinator and attribute-value survivors in the remarks beside the existing `app.css` concession, as the inherent limit they are — a substring check cannot identify a selector's subject without a real parser, so this is a guard against a plausible slip, not a proof.
Author
Owner

Nothing to action.

Verdict: mergeable

Nothing to action. Verdict: mergeable
rob merged commit 9a3cd0bcdb into main 2026-08-03 17:28:16 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rob/PlaceMark!23
No description provided.