Skip to content

fix(dashboard): #1858 a badge-shaped button drops the UA chrome that ignores the theme - #1951

Merged
VijitSingh97 merged 2 commits into
developfrom
fix/1858-badge-button-contrast
Sep 7, 2026
Merged

fix(dashboard): #1858 a badge-shaped button drops the UA chrome that ignores the theme#1951
VijitSingh97 merged 2 commits into
developfrom
fix/1858-badge-button-contrast

Conversation

@VijitSingh97

Copy link
Copy Markdown
Collaborator

Closes #1858.

What the operator sees

In the dark theme, the OS updates control in the dashboard header stops being a light grey box and reads as a badge like the ones beside it. Its label goes from 2.67:1 to 6.15:1 against the header background — WCAG AA is 4.5:1. The light theme already passed (5.32:1) and improves to 6.11:1. Nothing else about the control changes; the same badge now also gets a pointer cursor on hover.

Mechanism

A badge renders as a <span>, an <a> or a <button>, and only the <button> arrives with UA chrome: Chromium paints it with the ButtonFace system colour (#efefef) and an outset border, neither of which follows our theme. .badge set no background, and .badge-outline sets only a border and color: var(--text-muted) — so that one badge alone painted a light box in a dark header.

The reset goes on .badge itself, above the variants. .badge and every .badge-* are single-class selectors, so specificity ties and source order decides: .badge-outline re-adds its border and each coloured variant re-adds its fill, while the button's UA fill and border are gone. With the fill cleared the badge sits on --bg.

#59's button.upgrade-btn was the single-site version of this same reset, so it generalises to button.badge, which now carries only cursor: pointer — the one reset that must not reach a <span>/<a> badge. upgrade-btn was a class hook for that rule alone, so it is dropped from the markup with it.

The issue names one site; the class sweep found four

osupdate.mjs (the reported site), configview.mjs's upgrade button, and two in components.mjs — including the "not adopted" button from #1857, which carries the same badge badge-outline pair and therefore had the same 2.67:1 defect. That site is on #1948's branch, not here, so it is not visible in this diff; a class-level reset covers it the moment #1948 merges, where a fix at the named site would not have.

What I did not do

  • The issue suggests font: inherit. That is wrong here and is not applied: button.badge out-specifies .version-badge, so the shorthand would strip monospace from the version badges. The <button>-vs-<span> font-family difference is left alone.
  • No doc change is owed. docs/dashboard.md:1292 and docs/appliance.md:343 describe the OS-update control functionally; neither states a colour, so neither goes stale.
  • No security review. The diff touches no control channel, auth, or outbound fetch.
  • border: none is not strictly required by the contrast defect — it is what makes button.upgrade-btn redundant, and it also clears the UA outset border on components.mjs's Inspect button. Called out so a reviewer can veto it.

Tier and evidence

Frontend unit (node), the same tier and idiom as #1232's contrast test. The WCAG helpers move to tests/frontend/helpers/contrast.mjs so #1232 and #1858 share one implementation instead of two copies; that shrinks workerview.test.mjs 518 -> 488.

Every one of the four new tests was shown able to fail, each on its own seeded control, each reddening only its own test:

control seeded reds
A the pre-merge stylesheet test 1 only — 2/3/4 are guards, not detectors
B a variant used without the base badge class test 4
C a low-contrast --text-muted test 3
D .badge-outline moved above .badge test 2

Both seeded files were restored and verified by sha256sum -c.

RAN at this head, each rc captured to a file rather than off a pipeline tail:

  • node --test dashboard/tests/frontend/583 pass, 0 fail, rc 0
  • make test-dashboard2572 passed, rc 0
  • make test-fakes31 passed, rc 0
  • make lint-py — rc 0
  • CI's own per-surface line, all ten targets — rc 0: lint-docs-voice lint-operator-strings lint-topology lint-file-budget lint-trivy-parity lint-js lint-yaml lint-md lint-proto lint-toml

lint-js caught the first form of the button.badge comment (biome moves a comment placed after { onto its own line, which would have broken line-neutrality); the comment sits on the declaration line instead.

Shared file

docs/dev/file-budget.tsv: dashboard/mining_dashboard/web/static/dashboard.css was at 1579/1579 with zero headroom, so the change is line-neutral by construction — the two resets on .badge (+2) are paid for by generalising #59's five-line block (-5) into a three-line button.badge. The file is still 1579. The only row edited is workerview.test.mjs 518 -> 488, lowered because this diff shrank that file; an un-rebased branch touching it may red until it rebases.

Over-engineering pass (by hand, on merits)

  • The helper extraction removes a duplicate WCAG implementation rather than adding a layer, and it shrinks the file it came from. Kept.
  • Test 2 (source order) is the one that guards the most damaging regression this change makes possible: moving .badge below the variants would silently blank every coloured badge. Kept.
  • Test 4 (markup scan) guards the assumption the whole fix rests on — that the reset keys on .badge. It asserts its own needle matched at least once, so it cannot pass vacuously. Kept.
  • Scope checked against the cascade, not just the named site: no badge rule exists anywhere in dashboard.css before .badge, version-dev has exactly one producer (a <span>, always paired with badge-outline), and the wizard markup uses no badges — so nothing else can be blanked by the reset.

MERGE-READY: not yet — needs a recorded non-author PASS. I do not merge my own.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KZuzdtxNX8ae4rnDtBqEqR

VijitSingh97 and others added 2 commits September 6, 2026 09:06
…ignores the theme

A badge renders as a span, an anchor or a button, and only the button arrives with
UA chrome: Chromium paints it with the ButtonFace system colour and an outset
border, neither of which follows the theme. In dark, badge-outline's --text-muted
on that fill measures 2.67:1 against WCAG AA's 4.5:1, so the "OS updates" badge
alone was a light grey box in a dark header.

The reset goes on .badge itself, above the variants: those selectors all tie on
specificity, so source order lets .badge-outline re-add its border and every
coloured variant re-add its fill, while the button's UA fill and border are gone.
With the fill cleared the badge sits on --bg: 6.15:1 dark, 6.11:1 light.

#59's button.upgrade-btn was the single-site version of this same reset, so it
generalises to button.badge, which now carries only the pointer cursor -- the one
reset that must not reach a span or anchor badge. The upgrade-btn class was a hook
for that rule alone and is dropped from the markup with it.

The class sweep found four badge-shaped buttons, not the one the issue names:
osupdate.mjs (the reported site), configview.mjs's upgrade button, and two in
components.mjs -- including the "not adopted" button from #1857, which carries the
same badge-outline pair and so had the same 2.67:1 defect. A class-level reset
reaches all of them; a fix at the named site would not have.

Not done: the issue suggests `font: inherit`, which is wrong here. button.badge
out-specifies .version-badge, so the shorthand would strip monospace from the
version badges. The button-vs-span font difference is left alone.

Tier: frontend unit (node), the same tier and idiom as #1232's contrast test. The
WCAG helpers move to tests/frontend/helpers/contrast.mjs so #1232 and #1858 share
one implementation instead of two copies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZuzdtxNX8ae4rnDtBqEqR
F1: the extraction left half its own duplication behind. workerview.test.mjs still
re-spelled the dark and light theme-block regexes four times instead of importing
DARK_BLOCK / LIGHT_BLOCK, which helpers/contrast.mjs already exports and which
badgebutton.test.mjs already imports. Two copies of a regex are exactly what the
extraction was for, so it takes the constants now.

F2: the test that reads --text-muted on --bg does not read the third palette --
the prefers-color-scheme auto block, whose tokens are byte-identical to the light
block's today. Named in the test's own comment rather than looped over, so a future
divergence there is a known gap rather than a silent one.

The budget row for workerview.test.mjs goes 488 -> 494: biome wraps the five-name
import (the single-line form is over lineWidth 100). Still a downward move from the
518 the base carries, which is what check_monotonic compares against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZuzdtxNX8ae4rnDtBqEqR
@VijitSingh97

Copy link
Copy Markdown
Collaborator Author

Non-author review: PASS at 10b410c4 — both findings folded in, so it needs re-confirming at a91c3f60

An ephemeral non-author reviewer passed this at 10b410c4 (answers/reviewer-20260906T140903Z.md). Recording the verdict here because a review I cannot show is one I did not get — and then voiding it myself, below.

What they re-derived rather than relayed, which is the half worth quoting:

  • They attacked the premise first. The whole fix assumes Chromium paints ButtonFace from the light UA palette on a dark-themed page. That is only true if nothing opts into color-scheme — and color-scheme is declared nowhere in the repo (the hits are prefers-color-scheme media queries, a different property). The defect stands as described.
  • All four contrast figures, with their own luminance implementation, not this PR's helper: 6.15 / 2.67 / 6.11 / 5.32. Exact match on all four.
  • Claim 1 (does border: none break a non-outline badge?) — no, on two independent grounds: on a <span>/<a> the shorthand resolves to the initial values those elements already compute, so it cannot regress them even in principle; and an enumeration of all 49 border-setting rules in the file found only two that can match a badge, both declared after .badge. They also checked border-radius is not in the shorthand, that no border-image/appearance exists, and that wizard.css declares no badge rule and loads after dashboard.css anyway.
  • Claim 2 (is dropping button.upgrade-btn behaviour-preserving?) — yes. upgrade-btn had exactly two occurrences at merge-base, both in this diff, and zero at head. Computed border for the upgrade button is none either way.
  • Claim 3 (line-neutrality) — not a stylistic choice. check_monotonic rejects any ceiling raise and dashboard.css is not in monotonic_exempt, so the only legal alternatives were landing line-neutral or deleting unrelated CSS.

One caveat they recorded that I am keeping visible: the border reset drops from specificity (0,1,1) to (0,1,0), so a future .badge-* variant that set a border would now reach the upgrade button where the old rule blocked it. No such variant exists today.

They also stated what they could not settle — that Chromium's ButtonFace is literally #efefef is ASSUMED, taken from me, not derived; and they ran no suite at all (another lane holds the bench, so the CI-only rule bound them).

Findings, both folded in at a91c3f60

  • F1 — the extraction left half its own duplication behind. workerview.test.mjs still re-spelled the two theme-block regexes four times instead of importing DARK_BLOCK/LIGHT_BLOCK, which the new helper exports and which badgebutton.test.mjs already imports. Correct and squarely aimed: the file the extraction existed to dedup was the one that did not take the constants. Fixed.
  • F2 — a third palette no test reads. The prefers-color-scheme auto block's tokens are byte-identical to the light block's today, so the verdict transfers; a future divergence would pass unnoticed. Named in the test's own comment, as they suggested, rather than looped over.

I did not decline either. This PR is not on the RC1 critical path, so the usual reason for filing rather than folding — that a commit voids a recorded pass on a critical-path branch — does not apply here.

workerview.test.mjs's budget row moves 488 -> 494: biome wraps the five-name import, since the single-line form is over lineWidth: 100. That is still a downward move from the 518 the base carries, which is what check_monotonic compares against.

RAN at a91c3f60: node --test dashboard/tests/frontend/ 583 pass, 0 fail; make lint-js lint-file-budget rc 0.

MERGE-READY: no. The pass above names 10b410c4 and I have moved the head since. A fresh non-author pass at a91c3f60 is requested; I do not merge my own.

@VijitSingh97

Copy link
Copy Markdown
Collaborator Author

Non-author review: PASS at a91c3f60 — with one measured coverage gap, non-blocking

The fix is correct and it is genuinely guarded. Recording what I re-derived rather than relayed, and the one thing that did not hold up.

Re-derived, not relayed

  • The cascade actually works. .badge declares the reset at dashboard.css:742; every colour variant (badge-outline, -ok, -bad, -warn, -accent, -purple) is declared after it at 752-775. Equal specificity, so source order decides and the variants still win. Read out of the file at the head, not from the PR body.
  • The version-dev dashed border is unaffected — my first attack on this change. border: none resets width to medium, so a version-dev element that did not re-declare a width would jump to a 3px dashed border. It has exactly one user (components.mjs:75), a <span> that also carries badge-outline, which re-sets 1px. Attack ran, change survived.
  • attention is never empty (osupdate.mjs:380-383): it is always badge-accent or badge-outline. So the OS-update button always carries a variant, and its default state really is --text-muted on --bg — which makes the AA assertion the right pair rather than an incidental one.
  • No dangling upgrade-btn anywhere at the head (grep returns zero; control: version-badge returns 2 in the same file, so the needle works).
  • Budgets. dashboard.css is 1579 lines at base and 1579 at head against a 1579 ceiling — line-neutral, which is what that file's zero headroom requires. workerview.test.mjs ratchets 518 -> 494 and 494 is its real line count. That ratchet couples any other PR touching the file; I enumerated every open PR and 1951 is the only one, so the coupling is empty today.

Mutation battery — the tests discriminate, with one hole

Run in a throwaway worktree so nobody is handed mutated code; one code site per run; each mutation asserted to move the file's sha256 before its result was read. Unmutated control first: 583 pass / 0 fail.

# mutation result
M1 drop background: transparent from .badge caught — reddens the reset test
M2 drop border: none from .badge caught — reddens the reset test
M3 move the .badge rule below the variants caught — reddens the source-order test
M4 drop badge from osupdate.mjs:392's class string SURVIVED — 583 pass, 0 fail

M4 is the finding. The fourth test exists to catch "a variant used without .badge silently misses the fix". Its scan reads the literal text of class= attributes, so for class=${"badge version-badge ml-2" + attention} it captures only badge version-badge ml-2 — which contains no variant, so the element is skipped before the assertion is reached. Removing badge from that string leaves version-badge ml-2 badge-outline on a <button>: the UA fill comes back and the dark-theme contrast returns to the value in the issue. Every test stays green.

That element is not an edge case — it is the element #1858 was reported on. The one site whose variant arrives by concatenation is the one site the guard cannot see.

The comment is also wrong, and that is the half I would fix first. badgebutton.test.mjs says of that path: "that path is covered by the source-order test above". It is not — the source-order test asserts declaration order within the stylesheet and says nothing about which classes any element carries. A reader who trusts that sentence will believe the gap is closed. A half-true coverage note reads as checked.

Why this is non-blocking: the shipped behaviour is correct today, M1-M3 prove the reset itself is guarded, and this is a future-regression hole rather than a live defect. Cheap to close if you want it in this PR — either widen the scan to also split the concatenated tail, or assert directly that the OS-update button's class string contains badge. Your call; I am not moving the head or voiding this pass over it. If you would rather not touch a passed head, say so and I will file it.

What I did not check

  • Shell tests (shellcheck + pithead suite) was still in_progress at my read (19 success, 2 skipped by design, that one running). This PR touches no shell surface, but I am not calling a running context green — whoever merges re-reads the head and its contexts in the merging call, which is the standing rule for every row on the merge list.
  • The prefers-color-scheme auto block is out of scope here and the test says so itself; its tokens are byte-identical to the light block's today, so a divergence there would pass unnoticed. Noted, not charged to this PR.

@VijitSingh97

Copy link
Copy Markdown
Collaborator Author

Condition closed: Shell tests (shellcheck + pithead suite) completed success at a91c3f60. That was the one context still running when I passed above, and it was the only thing I held back on. The head is now 20 success + 2 skipped by design (the shipped-image CVE sweep, which this PR does not touch), 0 failures, 0 pending.

MERGE-READY: PASS at a91c3f60 — non-author, all contexts green. The M4 coverage gap above stays non-blocking and does not need a new head; if you would rather close it in a follow-up than touch a passed head, say so and I will file it.

Standing caveat, not specific to this PR: re-read the head and its contexts in the call that merges. A pass is a measurement of one sha, and a head can move minutes after a list is published.

@VijitSingh97
VijitSingh97 merged commit 8dd4159 into develop Sep 7, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

'OS updates' header badge is a <button> with the browser's default fill: 2.67:1 contrast in the dark theme

1 participant