Skip to content

fix(#462): text dimmed to 70% or below fails AAA on every surface — remove it, then guard it - #800

Merged
TortoiseWolfe merged 1 commit into
mainfrom
fix/462-remove-sub-70-text-opacity
Aug 18, 2026
Merged

fix(#462): text dimmed to 70% or below fails AAA on every surface — remove it, then guard it#800
TortoiseWolfe merged 1 commit into
mainfrom
fix/462-remove-sub-70-text-opacity

Conversation

@TortoiseWolfe

Copy link
Copy Markdown
Owner

text-base-content at 70% opacity or below fails the 7:1 AAA gate on every surface in the
light theme. #411 measured that and removed it. It came back, and nothing noticed for months.

Full finding in #462, which is rewritten around what remains rather than what this fixes.

Why nothing noticed

#459: axe-core returns a pass with contrastRatio: null on these nodes, so
color-contrast.spec.ts — which asserts on violations — has been green on them the whole
time. A removal without a guard is a removal that gets undone, and that is exactly what
happened between #411 and today.

The measurement (#462, canvas readback — getComputedStyle returns oklch() unparsed)

scripthammer-light, gate 7:1:

/100 /85 /80 /70
on base-100 12.96 8.30 7.08 5.20
on base-200 11.73 7.73 6.62 4.98
on base-300 10.10 6.88 6.05 4.57

/70 and below fails everywhere, with no judgement call about which surface the text landed
on. That is what this removes.

The instances were worse than the table

All eight real ones sat in src/twin/cesium/ on bg-base-100/90 with backdrop-blur,
floating over the Cesium canvas at 10–11px. The table assumes opaque surfaces, so a
translucent panel over an arbitrary 3D scene is unbounded-worse than its worst row — the #715
situation, where the answer was solid text on a raised surface.

Replaced with solid text-base-content. The de-emphasis survives: these are already 10–11px
beside 13px+ labels, and size costs no contrast.

Verified the symptom first

The five instances #462 originally named are all already cleanMessagingGate,
MessageThread, messages/new-group, messages/setup. The issue as written did not
reproduce. The real finding was elsewhere, which is why #462 is rewritten rather than closed.

Why this is Refs and not Closes

184 /80 and /85 remain, across ~100 files, and they cannot be swept mechanically —
/80 passes on base-100 (7.08, a margin of 0.08) and fails on base-200 (6.62). That
needs a per-call-site surface determination and is its own piece of work.

The guard

scripts/__tests__/text-opacity-aaa-floor.test.js, in pnpm test:scripts and therefore inside
the required Test (20.x) check.

It exempts genuinely decorative content, because WCAG does — aria-hidden="true", or
<Icon … decorative /> which Icon.tsx:61 turns into aria-hidden. It does not accept a
comment claiming decorativeness
: docs/page.tsx carries exactly such a comment, and it is the
prop six lines below that actually hides the glyph. Accepting prose would let a stale comment
exempt live markup.

globals.css is allowlisted with its reason (code-block line numbers, select-none) and
flagged in #462 as arguably informational rather than decorative — recorded, not silently
skipped.

Mutation-verified, mutant confirmed in the file first: restoring /60 at BuildingCard.tsx:151
fails the guard naming that exact file:line. Controls prove the detector fails in both
directions, that an aria-hidden 20 lines away does not exempt, and that prose containing
the word "decorative" does not either.

One process note worth keeping

The first sed for this change silently did nothing| delimiter against \|
alternation, the exact trap CLAUDE.md documents. It was caught only by comparing before/after
counts (6 -> 6), which is why that comparison is in the workflow rather than trust.

Verified

type-check clean · lint clean · component tests 33 passed · pnpm test:scripts 363 passed

Refs #462, #411, #459, #715

…emove it, then guard it

#411 measured `text-base-content/60` and `/70` failing the 7:1 AAA gate and removed
them. They came back. Eight live instances in `src/twin/cesium/` plus two in a story,
unnoticed for months -- and nothing could have noticed: #459 records that axe-core
returns a PASS with `contrastRatio: null` on these nodes, so `color-contrast.spec.ts`,
which asserts on `violations`, has been green on them throughout.

Measured (#462, canvas readback because getComputedStyle returns oklch() unparsed),
`scripthammer-light`, gate 7:1:

             /100    /85    /80    /70
  base-100  12.96   8.30   7.08   5.20
  base-200  11.73   7.73   6.62   4.98
  base-300  10.10   6.88   6.05   4.57

`/70` and below fails on EVERY surface, with no judgement call about which surface the
text landed on. That is what is removed here.

The instances found were worse than that table, which assumes OPAQUE surfaces: they sat
on `bg-base-100/90` with `backdrop-blur`, floating over the Cesium canvas at 10-11px, so
the effective background was an arbitrary 3D scene. That is the #715 situation, where the
answer was solid text over a raised surface.

Replaced with solid `text-base-content`. The de-emphasis survives -- these are already
10-11px next to 13px+ labels, and size costs no contrast.

VERIFIED THE SYMPTOM FIRST. The five instances #462 originally named
(MessagingGate, MessageThread, messages/new-group, messages/setup) are all already
clean; the issue as written did not reproduce. The real finding was elsewhere, and #462
is rewritten around what is actually left: 184 `/80` and `/85` across ~100 files, whose
safety is surface-DEPENDENT (`/80` passes on base-100 at 7.08 -- a 0.08 margin -- and
fails on base-200 at 6.62). That needs a per-call-site surface determination and is not
this change, so this is `Refs`, not `Closes`.

GUARDED by scripts/__tests__/text-opacity-aaa-floor.test.js, in `pnpm test:scripts` and
so inside the required `Test (20.x)` check. A removal without a guard is a removal that
gets undone -- which is the whole history here.

It exempts genuinely decorative content, because WCAG does: `aria-hidden="true"`, or
`<Icon … decorative />`, which Icon.tsx:61 turns into `aria-hidden`. It does NOT accept
a comment claiming decorativeness -- `docs/page.tsx` carries exactly such a comment and
it is the prop six lines below that actually hides the glyph. Accepting prose would let
a stale comment exempt live markup.

`globals.css` is allowlisted WITH ITS REASON (code-block line numbers, `select-none`),
flagged in #462 as arguably informational rather than decorative -- recorded, not
silently skipped.

Mutation-verified with the mutant confirmed in the file first: restoring `/60` at
BuildingCard.tsx:151 fails the guard naming that exact file:line. Controls prove the
detector fails in both directions, that `aria-hidden` 20 lines away does NOT exempt, and
that prose containing the word "decorative" does not either.

The first sed for this change silently did nothing -- `|` delimiter against `\|`
alternation, the exact trap CLAUDE.md documents. Caught only by comparing before/after
counts, which is why that check is in the workflow rather than trust.

type-check clean, lint clean, component tests 33 passed, `pnpm test:scripts` 363 passed.

Refs #462, #411, #459, #715
@TortoiseWolfe
TortoiseWolfe merged commit 0a63307 into main Aug 18, 2026
62 of 65 checks passed
@TortoiseWolfe
TortoiseWolfe deleted the fix/462-remove-sub-70-text-opacity branch August 18, 2026 09:42
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