Repository navigation
Contrast: the themes pass WCAG AA, and a high-contrast mode follows the OS or a setting - #302
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.successandwarning(light only), on the page and on their soft fills.border-strongandinput(field outlines).destructive-foregroundalready is.success-foregroundtoken for the finished check: 2.11 -> 9.07 in dark.A new
tracktoken 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:
isDarkerSystemColorsEnabled), Android high-contrast or outline text (isHighTextContrastEnabled), or the web'sprefers-contrast: more. All three are followed live.audiosilo.highContrast. Off means follow the OS, and the row says when the device asks for it.It is applied through
Uniwind.updateCSSVariablesbefore 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.tschecks 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:
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 underprefers-contrast: more. The outlines under the fill were checked on book progress, cover tiles, the speed slider and the scrubber.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.