Skip to content

a11y(styles): make the Grid selected-card border opaque - #260

Merged
Pixnop merged 1 commit into
devfrom
fix/issue-258-grid-selected-card-contrast
Aug 28, 2026
Merged

a11y(styles): make the Grid selected-card border opaque#260
Pixnop merged 1 commit into
devfrom
fix/issue-258-grid-selected-card-contrast

Conversation

@Zaldaryon

@Zaldaryon Zaldaryon commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

The selected-card border in Grid.tsx reads below the 3:1 WCAG 1.4.11 floor for a UI boundary that carries a state on its own. This makes it opaque, which the same worst-case compositing the rest of tests/text-contrast.test.ts uses puts at about 4.82:1, and adds a real assertion where there was previously only a comment explaining why the border went unmeasured.

Observed problem

Raised during review of PR #251, filed separately as #258 since the gap predates that PR: "The Grid selected card border reads 1.53:1 worst case at 25 percent alpha over bg-vsd/50, up from 1.34, and the test declines to assert on it. I agree with not asserting and the comment explaining why is honest, but the selected state there is carried by fill and border colour with nothing else, so it is a 1.4.11 gap that predates you and deserves its own issue rather than a comment in a test."

Grid.tsx's GridItem, the only place this border ships:

className={clsx(
  "w-full h-full rounded-sm backdrop-blur-xs border cursor-pointer shadow-sm shadow-zinc-950/50 hover:shadow-none duration-200",
  selected ? "bg-vsd/50 border-vsl/25" : "bg-zinc-950/50 border-zinc-400/5",
  ...
)}

selected is set by ModListCard.tsx as selected={installed}, the only consumer: an installed mod's card in the ModDB browse grid gets this border and fill, with nothing else marking it as selected.

Root cause

border-vsl/25 sits at 25% alpha next to the card's own bg-vsd/50 fill. Since the default background-clip: border-box paints the fill under the border area too, the border's effective backdrop is that fill, itself composited over the grid panel's own scrim stack (GridWrapper's bg-zinc-950/40, itself under the page shell's bg-zinc-950/70). Verified the exact ratio with a standalone script reproducing text-contrast.test.ts's own compositing math (over, luminance, contrast) against the real token values: --color-vsl is #d49754, --color-vsd is #4f3110.

Border alpha Worst-case ratio
25% (current) 1.53:1
50% 2.32:1
75% 3.41:1
100% 4.82:1

Fix

Unlike ConfigPage.tsx's selected background-tile border, whose inner edge paints over an arbitrary user-chosen thumbnail so no single ratio can be pinned for it (its outer edge sits on the fixed section panel at 4.85:1, which is what holds 1.4.11 there, and the border-2 answers 1.4.1 instead), this card's backdrop is fully known on both sides: the card's own bg-vsd/50 fill composited over the grid panel's scrim stack. That makes the ratio actually computable, so the fix here is opacity, not width: border-vsl/25 becomes plain border-vsl, full alpha. Width is unchanged.

Regression proof

tests/text-contrast.test.ts previously only pinned that this border tracked --color-vsl, with a comment explaining why no ratio was asserted. It now computes the real worst-case ratio (reading the fill's alpha straight out of Grid.tsx rather than hardcoding it, so the assertion stays accurate if that changes) and asserts it clears the 3:1 non-text floor.

Verified the assertion actually pins the fix, not just the presence of a token: reverted the border to border-vsl/25 and confirmed the test's anchor regex fails to match (it requires the bare border-vsl string with no alpha suffix), then restored the fix and reran clean.

Testing

  • npm run typecheck: passes.
  • npm run lint:ci: 0 errors, 15 pre-existing warnings.
  • npm run format:check: passes.
  • npm run test:coverage: 128 files, 1,507 passed, 2 skipped, 0 failed. Coverage 92.38% statements, 89.32% branches, 91.3% functions, 93.97% lines, all at or above the vitest.config.ts floor.
  • npm run build:unpack: passes on Linux x64.
  • Mutation check on the new assertion: confirmed above.

Base branch

This PR targets fix/issue-248-vsl-link-palette (PR #251), not dev. The lighter --color-vsl token, the text-contrast.test.ts compositing infrastructure (themeColor, assertReadable, the "brand accent" describe block), and the ConfigPage border precedent this fix follows all live on that branch and not yet on dev. This should be retargeted to dev (or rebased and reopened against it) once #251 merges; until then it is a stack, not an independent change. Rebased onto #251's second review round on 2026-08-27.

Limitations

Not verified visually in the running app, for the same reason as #251: no safe, non-disruptive way to drive it through the affected screen on this machine. This rests on the same compositing math the rest of the file uses, not an eyeballed comparison.

Related issues

Fixes #258.

@Pixnop Pixnop left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed this on its own terms rather than as a continuation of #251, and recomputed the alpha table independently before reading yours.

The number

It reproduces exactly. Over the real stack, which is the shell at /70, the grid panel's before:bg-zinc-950/40, then the card's own bg-vsd/50 fill:

Border alpha Over a white image Over a black image Worst
25% 1.53:1 1.59:1 1.53:1
50% 2.32:1 2.67:1 2.32:1
75% 3.41:1 4.29:1 3.41:1
100% 4.82:1 6.54:1 4.82:1

So 4.82:1 worst case against a 3:1 floor.

The backdrop choice is the part worth defending and you got it right. Measuring the border against the panel alone would have given 4.85:1 and looked perfectly reasonable, but background-clip defaults to border-box, so the fill really does sit under the border area, and bg-vsd/50 being translucent means the panel reads through it. Composited properly, the fill over the panel lands on #423323 over a white image and #2b1c0d over a black one. Those are the colours the border is genuinely adjacent to.

Worth noting that 75% would also have cleared the floor at 3.41:1. Going to full opacity rather than the cheapest passing value is the better call, since it leaves headroom if the fill alpha or the panel scrim ever moves, and the test reads the fill alpha out of the source so it will tell the truth if they do.

Colour as the only cue

This is what I came in most sceptical about, since the change is nothing but an alpha. Selected and unselected still differ only in fill and border colour, with no icon, badge or label separating them. Held to the same bar we applied to the links in #251, that looks like a 1.4.1 problem rather than just the 1.4.11 one you filed for.

It is not, and the numbers say why. 1.4.1 is about information carried by hue. Here the selected and unselected borders sit 6.04:1 apart over a white image and 7.57:1 apart over a black one, which is a lightness difference and survives a greyscale render intact. Before this change they were 1.92:1 and 1.84:1 apart, faint enough to be doing almost no work. And the unselected border barely exists in the first place: border-zinc-400/5 reads 1.08:1 against its own fill. So the real cue after this change is bright line against no line, not warm against grey, which is a stronger position than the PR claims for itself. This closes a 1.4.1 gap as well as the 1.4.11 one.

Hover does not collide with any of it. hover:shadow-none applies to both states and moves the shadow, not the border, so the two cues stay independent.

Coherence with #251

No clash. Both borders resolve to the same --color-vsl, now #d49754, so the Settings tile and the mod card show one accent rather than two competing warm tones. That was the thing I would have pushed back on and it is not here.

The two fixes are also more coherent than the diff makes them look. They diverge in method, width on one and opacity on the other, for the reason you spell out: one paints over an arbitrary thumbnail with no computable backdrop and the other over a stack that is fully known. That is a real distinction, not a rationalisation.

The test row

It bites, and in both directions. Reverting the border to border-vsl/25 fails the anchor, as you reported. I also wanted to know whether the ratio assertion is doing anything or whether the regex is carrying it, so I dropped --color-vsl to the vs value and the row failed with Grid selected-card border reads 1.76:1 on the worse extreme, below 3:1. It is a real measurement, not a token-presence check wearing a ratio's clothes.

Reading fillAlpha out of Grid.tsx instead of hardcoding 50 is the right instinct. One thing to keep in mind: the border alpha is still hardcoded to 1 in the test, which is safe only because the anchor forces the bare border-vsl string. Those two have to move together if anyone reintroduces an alpha there. Not asking for a change, just flagging the coupling.

One for a follow-up, not for this PR

The installed state on a mod card is still invisible to assistive tech. ModListCard.tsx passes selected={installed} into GridItem, and GridItem renders a motion.li with no aria-selected, no aria-pressed and no text anywhere saying the mod is installed. A screen reader user gets nothing at all. Your own BackgroundTile in ConfigPage.tsx already handles this correctly with aria-pressed={selected}, so the precedent is sitting in the neighbouring PR.

That is 1.3.1 and 4.1.2 rather than 1.4.11, and clearly out of scope here. Worth its own issue.

Gates

Run on 55618fa.

  • npm run typecheck: passes.
  • npm run lint:ci: 0 errors, 15 warnings, the same 15 that are already on dev.
  • npm run format:check: clean.
  • npm run test:coverage: 128 files, 1507 passed, 2 skipped. Statements 92.38, branches 89.32, functions 91.3, lines 93.97, all above the floors in vitest.config.ts. Matches your figures.

Merge order

Approving. It cannot land ahead of #251 in any case, and I have asked for one change over there (a link anchor in text-contrast.test.ts that cannot fail), so expect a rebase once that settles. The retarget note in your description covers the rest.

@Zaldaryon

Copy link
Copy Markdown
Collaborator Author

Rebased onto PR #251's new head after its second review round. The only conflict was the ConfigPage comment block in text-contrast.test.ts, which this PR removes the Grid pin from and #251 rewrites in the same spot: resolved by keeping both, #251's rewritten ConfigPage comment alongside this PR's removal of the Grid comment and match. The Grid.tsx change and the new it("keeps the Grid selected-card border above the non-text bar") assertion are byte-identical to what you approved.

I also corrected the description and the commit message. Both said the ConfigPage border had no fixed backdrop to measure a ratio against, which #251's review established is wrong on its outer edge: that edge sits on the same fixed section-panel stack as the accent links, at 4.85:1, and that is what holds 1.4.11 there while the border-2 answers 1.4.1. The contrast this PR draws against ConfigPage still stands, the Grid card's backdrop is known on both edges and ConfigPage's is not, but the reason is now stated right.

Gates on the rebased head, local: typecheck clean, lint 0 errors and the same 15 warnings, format clean, test:coverage 128 files, 1,507 passed, 2 skipped, 0 failed (coverage 92.38% statements, 89.32% branches, 91.3% functions, 93.97% lines), build:unpack passes on Linux x64.

Still a draft, still stacked: #251 first, then this retargets to dev.

@Pixnop

Pixnop commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Re-checked after the new commit, and the approval stands.

The change since 55618fa is a rebase onto #251's current head and nothing else. I compared this branch's own delta before and after (53fb7de..55618fa against 111cd37..ac2ed5f) and the added and removed lines are identical line for line. Every difference in the raw diff is a context line picking up the reworded ConfigPage comment that landed on #251, plus the shifted hunk offsets that come with it.

Both anchors still bite. Putting the /25 back on the border fails the anchor with nothing matching /selected \? "bg-vsd\/(\d+) border-vsl"/ in components/ui/Grid.tsx, and the ratio assertion is genuinely live rather than decorative: darkening the token drops the border to 1.14:1 and the test reports it against the 3:1 floor by name.

Worth noting for anyone reading later that reverting --color-vsl to the old #ad7639 does not fail this particular test, because the border still cleared its own 3:1 non-text floor at that value. It fails the link assertions instead, at their stricter 4.5:1. That layering is correct, the two floors are doing separate jobs, so there is no gap here.

Gates green locally on ac2ed5f: typecheck, lint at 0 errors, format:check, and test:coverage at 128 files with 1507 passed and 2 skipped.

@Pixnop
Pixnop deleted the branch dev August 28, 2026 11:59
@Pixnop Pixnop closed this Aug 28, 2026
@Pixnop Pixnop reopened this Aug 28, 2026
@Pixnop
Pixnop changed the base branch from fix/issue-248-vsl-link-palette to dev August 28, 2026 12:00
Fixes #258.

The selected-card border in Grid.tsx (bg-vsd/50 border-vsl/25) sat at
25% alpha next to its own dark fill, reading about 1.53:1 worst case,
below the 3:1 WCAG 1.4.11 floor for a UI boundary that is the sole
indicator of a state (ModListCard.tsx's selected={installed}, the
only consumer). Raised during review of PR #251, filed separately
since the gap predates it.

Unlike ConfigPage's selected background-tile border, whose inner edge
paints over an arbitrary user-chosen thumbnail so no single ratio can
be asserted for it (its outer edge holds 1.4.11 against the fixed
section panel at 4.85:1, and the border-2 there answers 1.4.1
instead), the Grid card's backdrop is fully known on both sides: the
card's own bg-vsd/50 fill composited over the grid panel's fixed
scrim stack. That makes the ratio actually computable, so the fix is
opacity, not width: the border drops its /25 and reads at full
strength, which the same compositing math the rest of
text-contrast.test.ts already uses puts at about 4.82:1, comfortably
clear of the floor.

tests/text-contrast.test.ts gets a real assertion where there was
previously only a comment explaining why the border went unmeasured.
Verified it actually pins the fix: reverting the border to border-vsl/25
makes the test's anchor regex fail to match, since it now requires the
bare border-vsl string with no alpha suffix.
@Pixnop
Pixnop force-pushed the fix/issue-258-grid-selected-card-contrast branch from ac2ed5f to 9348e33 Compare August 28, 2026 12:04
@Pixnop

Pixnop commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Rebased onto dev now that #251 has landed, and re-verified. Head moves from ac2ed5f to 9348e33.

On the post-approval commit, since it predates the rebase and is worth stating plainly: ac2ed5f was 55618fa replayed onto #251's then head, nothing more. I compared this branch's own delta on both sides (53fb7de..55618fa against 111cd37..ac2ed5f) and the added and removed lines are identical line for line. Every difference in the raw diff was a context line picking up the reworded ConfigPage comment from #251, plus the hunk offsets that shift with it. No behaviour changed in it.

The conflict was not a content clash. This branch was stacked on #251's branch, so when that merged as a squash and its branch was deleted, this one still carried the four pre-squash commits whose content dev already had under a different SHA. Replaying just this branch's own commit onto dev applied cleanly with nothing to resolve by hand, and the PR is back to its real size of two files, 18 added and 6 removed.

Both sides of the shared contrast test survived, which was the thing worth checking. All eight of #251's link anchors are still there with the tightened \s+ bridges, its ConfigPage border-2 anchor is intact, and this PR's Grid border anchor and ratio assertion sit alongside them.

Re-ran the two mutations against the rebased head and both still bite. Putting the /25 back fails with nothing matching /selected \? "bg-vsd\/(\d+) border-vsl"/ in components/ui/Grid.tsx, and darkening the token fails on the ratio, with the assertion naming the measured value against its floor.

Gates green on 9348e33: typecheck, lint at 0 errors, format:check, and test:coverage at 136 files with 1619 passed and 2 skipped, statements 92.61% and branches 89.88%. One note for anyone rebasing this branch locally, npm ci needs re-running afterwards, since dev picked up @napi-rs/lzma in the meantime and a stale node_modules fails typecheck on an unrelated module resolution error.

Approval stands. Merging.

@Pixnop
Pixnop marked this pull request as ready for review August 28, 2026 12:08
@Pixnop
Pixnop merged commit a18f5d1 into dev Aug 28, 2026
7 checks passed
@Pixnop
Pixnop deleted the fix/issue-258-grid-selected-card-contrast branch August 28, 2026 12:08
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.

2 participants