Skip to content

Contrast: the themes pass WCAG AA, and a high-contrast mode follows the OS or a setting - #302

Merged
KodeStar merged 13 commits into
mainfrom
feat/a11y-contrast
Nov 14, 2026
Merged

KodeStar merged 13 commits into
mainfrom
feat/a11y-contrast

Conversation

@KodeStar

Copy link
Copy Markdown
Owner

Part of #267 (accessibility for low vision): contrast. The large-text and screen-reader work follows in a second PR.

What

  • The normal Light and Dark themes now pass WCAG AA. Text pairs reach 4.5:1 and the non-text pairs reach 3:1. The tokens that changed:

    • subtle-foreground: 2.99 -> 4.76 light, 3.91 -> 5.59 dark.
    • success and warning (light only), on the page and on their soft fills.
    • border-strong and input (field outlines).
    • The dark brand button's label is now ink (3.43 -> 5.56), the way destructive-foreground already is.
    • A new success-foreground token for the finished check: 2.11 -> 9.07 in dark.
    • Filter-chip counts use muted text instead of 60% ink.
  • A new track token for progress bars and sliders. It keeps the brand fill at 3:1 or more against the track (3.40 light, 3.74 dark), because the fill is what shows the value (1.4.11).

  • High contrast. Stronger muted text (7:1 or more), borders and status colours, with the same layout. It turns on in either of two ways:

    • The OS asks for it: iOS Increase Contrast (isDarkerSystemColorsEnabled), Android high-contrast or outline text (isHighTextContrastEnabled), or the web's prefers-contrast: more. All three are followed live.
    • The new Settings > Appearance > High contrast setting, stored as audiosilo.highContrast. Off means follow the OS, and the row says when the device asks for it.

    It is applied through Uniwind.updateCSSVariables before the first paint, so Light, Dark and System are unchanged. In high contrast, bars and sliders get a 1px outline under the fill; chapter hairlines don't.

  • contrast.test.ts checks every text pair at 4.5 and every non-text pair at 3, in all four sets (normal and high contrast, light and dark).

  • New strings in every locale. de, es, fr, it and pt are machine-translated and want a native read.

Owner decisions are recorded on #267: follow the OS plus a switch; the system text size only; controls scale with the text.

Verified live

Combined with the large-text branch:

  • Web: axe over 48 runs (6 screens, light and dark, normal and prefers-contrast: more, 400 and 1280 wide) found 0 color-contrast and 0 aria-* violations. The setting changes the tokens live and survives a reload. The "this device asks" line shows under prefers-contrast: more. The outlines under the fill were checked on book progress, cover tiles, the speed slider and the scrubber.
  • Android (Pixel): turning on high-contrast text applies the tokens live, and the row reads "On: this device asks".
  • iOS simulator: Increase Contrast is detected and the high-contrast tokens apply.

Review

One full review (Fable, high) applied 15 fixes. One fix round followed: the success-foreground token, reviewed clean. Gate: tsc, lint, format, and 562 suites / 8115 tests.

…h-contrast sets

The low-vision audit (fe#267) measured the Stacks tokens: subtle text, the
dark brand button label, light success and warning text, the field outline,
the strong border and the slider/progress track all fell short of WCAG AA.
Each moves the smallest step that passes (subtle text 4.5:1 on the page,
cards, popovers and muted fills; outlines, strong borders and tracks 3:1),
the dark brand label turns ink like the destructive one, and a new `track`
token replaces bg-muted (1.06:1) under progress bars and the slider. Filter
chip counts use muted text instead of 60% ink (4.48:1).

tokens.json gains `highContrast` overrides per scheme; the generator lays
them over each theme into colors.highContrast in tokens.ts (validated: only
known tokens, real colours). contrast.test.ts holds every text pair at 4.5:1
and every outline, border and track at 3:1 for light, dark and both
high-contrast sets, and the high-contrast sets at 7:1 muted text, never
weaker than normal.

Part of #267.
Low-vision listeners asked for stronger contrast (fe#267). The high-contrast
token sets are on while the OS asks for more contrast (iOS Increase
Contrast via darker system colours, Android high contrast text, web
prefers-contrast: more, each with a change listener) or the new High
contrast switch is on. The switch is persisted beside the theme
(audiosilo.highContrast) and only adds: off follows the OS, and the row
says when the device has it on.

ThemeProvider reads the switch and the OS in the same hydrate chain as the
theme, so the first paint is already right, and writes the sets into both
schemes' Uniwind variables (updateCSSVariables), so every class, dark:
variant and scoped theme follows with Light / Dark / System unchanged.
useThemeColors and the fixed-scheme reads (Previously on, the companion's
inverse attribution, the reader, the cover wash's scheme check, which was
an identity test on colors.dark) follow the flag too. Strings in all six
locales.

Part of #267.
…ps tracks

A segmented control's selected segment is `secondary` in dark mode, and its
count is subtle text: 4.18:1 at #7a859e. #818ba2 makes it 4.53:1 (5.59:1 on
the page) and contrast.test.ts now guards that dark-only pair.

The high-contrast sets keep the tracks at their normal 3:1 (a stronger
track would sink the brand fill on it), so the High contrast description
no longer promises stronger tracks.

Part of #267.
A 3:1-on-the-page track left the brand fill at 1.40:1 (light) and 1.62:1
(dark) against it, and the fill against the track is the pair that carries
a progress bar's or slider's value (WCAG 1.4.11). The track goes back to a
light tone: #d9dee7 (fill 3.40:1) and #263150 (fill 3.74:1), a little
clearer on the page than bg-muted was (1.26 / 1.49 against 1.06 / 1.14).

In high contrast ProgressBar and the slider draw a 1px `input` outline over
the track (TrackOutline, 4.5:1 or better on the page), so its full length
shows without sinking the fill. contrast.test.ts now holds the brand and
ink fills at 3:1 on the track in every set, in place of track on the page.

Part of #267.
applyHighContrast pushed all 52 theme variables for each scheme when a
high-contrast set changes 17 of them, and on web Uniwind reads the computed
style and writes a rule for every variable. It now writes only the tokens
whose value differs between the normal and the high-contrast set.

It also remembers what the variables hold, so asking for the same state
twice writes nothing. ThemeProvider kept that in a ref and repeated the
compare, write and record steps in the hydrate chain and in its effect; a
remounted provider would have started from "normal" while the variables
still held the high-contrast values. The hydrate chain reads the theme, the
switch and the OS setting side by side and applies them in one step.

Part of #267.
The card passed the dark set's muted colour to its close icon by hand,
which meant fetching the high-contrast flag just to pick the set. The icon
sits inside the card's ScopedThemeColors, and an Icon without a colour
already takes that scope's muted colour, high contrast included.

ScopedThemeColors reads the flag through useThemeScheme, so "no provider
means normal contrast" is stated once.

Part of #267.
The dark high-contrast set overrode destructive-foreground with the value
the dark theme already has, which read as if high contrast changed the
label on a destructive fill. The generated output is the same without it.

contrast.test.ts ran the dark-only text pairs in a second describe with
the same title as the first; each set now carries the pairs its scheme
draws. The Settings test for the High contrast row is two tests with the
mock reset in beforeEach, so a failed assertion cannot leak "the device
asks" into later tests. TrackOutline's comment gave 3:1 for an outline the
suite holds to 4.5:1.

Part of #267.
TrackOutline is a 1px border on every side, and it was drawn over the fill.
On a default 4px bar (3.5px on native) its top and bottom edges left a 2px
strip of brand between two `input` lines, and brand against `input` is
about 1.2:1 (light) and 1.0:1 (dark), so half the fill read as outline. A
`minPercent` sliver sat wholly under the left edge. ProgressBar and Slider
now render the outline before the fill, which covers it; the unfilled
track keeps its outline.

The mini player's chapter line is 2.5px and the iOS accessory's is 2px:
there the two edges are the whole track, so high contrast hid the chapter
progress. ProgressBar takes `outline` (default true) and
ChapterProgressLine turns it off.

Part of #267.
The hydrate step applied the high-contrast variables first, under the
chain's swallow-all catch. Had that write thrown, the stored Light/Dark
choice and both contrast states were dropped with it and the app came up
following the OS. The theme is restored first, then the states, then the
variables, all before the first paint.

Part of #267.
parseHighContrast checked `name in themes[scheme]`, which is also true for
inherited names: a `toString` or `constructor` key under highContrast
passed as a theme token and was written into tokens.ts. It now asks
Object.hasOwn.

Part of #267.
The palette comment in tokens.json still offered white for "text on a
brand fill", the pair this branch replaced in dark (3.4:1) with
`brand-foreground`. CONVENTIONS said useThemeColors() returns
colors.light|dark with no word of the high-contrast sets, beside the new
rule never to read colors.dark directly. contrast.test.ts named a slider
tone that does not exist (the ink tone is `tone="ink"`).

Part of #267.
The hydrate step set the switch and OS states before writing the
variables. Had the write thrown, the chain's catch swallowed it with both
states already on, so useThemeColors() handed out the high-contrast set
over normal classes, and the effect then repeated the write outside any
catch. The write now comes after the stored theme and before the states:
when it fails the app starts in its stored theme at normal contrast.

Part of #267.
The white check on the solid success fill (a cover tile's finished flag, a
spine's finished foot) was 2.11:1 in dark and 1.71:1 in high-contrast dark.
A new success-foreground token keeps white in light (5.33:1, 8.02:1 high
contrast) and turns ink in dark (9.07:1, 11.19:1), as brand-foreground and
destructive-foreground do; contrast.test.ts holds it at 4.5:1 in all four
sets.

Part of #267.
@KodeStar
KodeStar merged commit 971c10a into main Nov 14, 2026
1 check passed
@KodeStar
KodeStar deleted the feat/a11y-contrast branch November 14, 2026 23:44
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