Skip to content

fix(ui): answer forced colours and reduced motion, and declare color-scheme - #578

Merged
blove merged 1 commit into
mainfrom
blove/platform-a11y-prefs
Sep 4, 2026
Merged

fix(ui): answer forced colours and reduced motion, and declare color-scheme#578
blove merged 1 commit into
mainfrom
blove/platform-a11y-prefs

Conversation

@blove

@blove blove commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Three platform accessibility settings the grid left to the consumer — only one of which a consumer could actually fix from outside.

1. Forced colours: a range selection was invisible

The selection fill is a translucent background-image, and forced colours drop the image and force the colour underneath to Canvas. Measured on the live grid under forced-colors: active with eleven cells selected:

background color
selected cell rgb(255,255,255) rgb(0,0,0)
unselected cell rgb(255,255,255) rgb(0,0,0)

Only the single focused cell was marked. In High Contrast, "what is ⌘C about to copy?" had no answer on screen.

Selected cells now take Highlight/HighlightText — the pair the platform guarantees against each other, and which are honoured inside a forced-colors block rather than forced away. Disabled menu items take GrayText.

The part worth reading: forced-color-adjust: none

It is load-bearing, not an escape hatch. Without it Chromium paints a text backplate — an opaque Canvas rectangle behind text that sits over a background image, which a selected cell counts as — and every word inside the fill rendered as a solid white block:

The computed styles were identical in both cases (background-color: rgba(5,0,73,0.8), color: rgb(255,255,255)); only a screenshot told them apart. Setting background-image: none explicitly does not lift the backplate — that was the first thing tried. Opting out is safe only because the colours in the same rule are the system's, so the cell still paints in the user's palette; the two travel together with the reason written above them.

Descendants inherit the cell's colour, so a red P&L or an amber badge inside a selection cannot paint itself onto the Highlight fill at whatever contrast it happened to have.

What needed nothing

The audit ran before any of this was written, and cleared most of the grid:

  • the border cage carries the structure when every surface flattens to Canvas;
  • the row-select checkbox's checked / indeterminate glyphs are SVG on currentColor, so they survive;
  • the focus ring is an outline, which the UA forces to CanvasText;
  • the frozen edge loses its gradient and falls back to the pinned cell's own border-right.

2. Reduced motion

grid.css animated three things (a twisty's 120ms rotate, two 0.1s icon fades) and offered no way out — and unlike the other two items, this is not something a consumer can patch without re-implementing rules they do not own. All three are decoration, so all three are switched off.

The guard is structural rather than a spot check: every rule in the file that declares a transition or animation must be named in the reduce block, so the next one added cannot ship without an entry.

3. color-scheme

Only pretable.css declared it. An Excel- or Material-themed grid in a dark app kept light scrollbars and a light <select> popup — the one part of the surface a theme cannot reach with a custom property. Both now declare it, Material in each mode it ships.

contract.test.ts also grew a THEME_FILES constant (the list was inlined in three places), so a theme added tomorrow inherits every theme guard rather than the ones whose inline copy someone remembered to update.

Verification

Browser, production build, 20 cells selected across the hero's text, numeric and P&L columns: white text on the Highlight fill, no backplate, the focused cell ringed in HighlightText.

Mutations that fail the guards — each applied, run, and reverted:

FAILS (good)  forced-colors block deleted
FAILS (good)  selection uses author colours
FAILS (good)  a new animation skips the reduce block
FAILS (good)  material dark drops color-scheme
FAILS (good)  browser proof: selection loses its forced-colours answer

That last one is a new bench spec that emulates forced colours and asserts a selected cell does not paint as an unselected one — the test that would have caught the original defect.

pnpm format, lint, typecheck, api:check, test, the 29-spec bench suite, and 64 website e2e specs all pass. The bench suite is in the list this time: leaving it out is what broke #577.

🤖 Generated with Claude Code

…scheme

Three platform settings the grid left to the consumer, only one of which a
consumer could actually fix from outside.

FORCED COLOURS. A range selection was invisible. The fill is a translucent
background-image; forced colours drop the image and force the colour
underneath to Canvas, so an eleven-cell selection measured identical to no
selection — every cell rgb(255,255,255) on rgb(0,0,0), with only the single
FOCUSED cell marked. The answer is the system pair Highlight/HighlightText,
which is honoured inside the block rather than forced away.

It needs `forced-color-adjust: none` to land, and that is the interesting
part: without it Chromium paints a text BACKPLATE — an opaque Canvas
rectangle behind text sitting over a background image, which a selected cell
counts as — and every word inside the fill came out a solid white block. The
computed styles were identical with and without it; only a screenshot told
the two apart, and dropping the background-image explicitly does NOT lift the
backplate (tried first). Opting out is safe only because the colours are the
system's, so the pair travels together in one rule with the reason written
above it. Descendants inherit the cell's colour so a red P&L cannot paint
itself onto the fill, and disabled menu items take GrayText.

Everything else in the grid survives forced colours unaided, which the audit
established before any of this was written: the border cage carries the
structure, the row-select glyph is drawn in currentColor, the focus ring is
an outline the UA forces to CanvasText, and the frozen edge falls back to the
pinned cell's own border when its gradient is dropped.

REDUCED MOTION. Three animated declarations, no way out, and not something a
consumer can patch without re-implementing rules they do not own. All three
are decoration, so all three are switched off. The guard is structural rather
than a spot check: every rule in the file that animates must be named in the
reduce block, so the next transition added cannot ship without one.

COLOR-SCHEME. Only pretable.css declared it, so an Excel- or Material-themed
grid kept light scrollbars and a light <select> popup inside a dark app —
the one part of the surface a theme cannot reach with a custom property.
Both now declare it, Material in each mode it ships, and contract.test.ts
grew a THEME_FILES constant so a theme added tomorrow inherits every theme
guard rather than the ones whose inline copy was remembered.

Verified in a browser at 20 selected cells across the hero's text, numeric
and P&L columns: white on the Highlight fill, no backplate, the focused cell
ringed in HighlightText. Five mutations fail the guards (block deleted,
author colours instead of system ones, backplate opt-out removed, a new
animation with no reduce entry, material dark dropping color-scheme), and
the browser proof fails when the answer is taken out. format, lint,
typecheck, api:check, test, the 29-spec bench suite and 64 website e2e specs
all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
pretable Ignored Ignored Sep 4, 2026 4:41pm UTC

Request Review

@blove
blove enabled auto-merge (squash) September 4, 2026 16:41
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Vercel preview ready

Preview: https://pretable-9pnyy300w-cacheplane.vercel.app
Commit: 0c0e761dd4e2d316268e46f809c56f0199f65e6b

Updated automatically by the deploy-preview job.

@blove
blove merged commit 2d7de95 into main Sep 4, 2026
21 checks passed
@blove
blove deleted the blove/platform-a11y-prefs branch September 4, 2026 17:00
@blove blove mentioned this pull request Sep 4, 2026
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.

1 participant