Migrates the storefront's colourblind palettes into tokens, generated as [data-cb] CSS blocks (dark mode only) for both brands. The storefront's ΔE / contrast check now runs in the tokens build and fails on a bad palette; it caught the deuteranopia error orange at 4.4:1 on classic's navy, so that value is now #c86000. Animation speed rides along: animations.css scales durations by --motion-scale, and both data-motion="0" and the classic brand bypass animation outright. Brand tokens carry an `animations` flag so React Native forces motion to 0 for classic too. DTWebThemeProvider and DTThemeProvider take colorVision and motionScale; buildThemeFromBrand moves into the react-native package from the showcase. DTSettingsPanel (react and react-native) renders theme, animation speed and colour vision as controlled sections. Changesets: onlyUpdatePeerDependentsWhenOutOfRange, so a minor on tokens no longer majors react through its `*` peer range. Versions bumped: tokens 0.4.0, web 0.6.0, react 2.2.0, react-native 0.5.0. Design: docs/plans/2026-09-12-color-vision-and-settings-design.md Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5.2 KiB
Colour vision palettes + standard settings panel — design
Date: 2026-09-12. Source feature: dt-shopify storefront commits 893f3c1,
35dbdc0 (palettes), bd5ea8b (settings page), 6ce2ea5 (animation speed).
What the storefront built
/account/settings with three controls: theme (cyberpunk/clean), animation
speed (scale 1 → 0), colour vision (none / protanopia / deuteranopia /
tritanopia). The colour vision choice lands on <html data-cb="…"> before
first paint; three :root[data-cb="…"] blocks in app.css redefine the
--color-* base tokens and every --mode-* alias follows, so the whole site
recolours. scripts/cb-check.mjs simulates each deficiency (Machado 2009)
and fails if any two of the five roles a visitor must tell apart fall under
CIE76 ΔE 25, or under 4.5:1 on black.
The palettes are hand-copied hex values with hand-copied -rgb triplets,
living in app CSS, checked by an app script. That is the part that belongs in
the design system.
Decisions
Made without a live conversation; each is reversible.
- Palettes are tokens. One shared
colorVisionPalettesconst in@dangerousthings/tokens, referenced from both brand files ascolorVision. Both storefront themes already use the same three palettes, so a per-brand copy would be duplication with nothing to say. A brand can still override. - Dark mode only. The storefront is dark in both themes. A white success
colour on a white light-mode background is unreadable, so the generated
data-cbblocks are emitted between the dark and light blocks: equal specificity, later wins, light mode ignores them. Light palettes are a follow-up with their own ΔE search; the type leaves room (colorVisionisPartial<ColorTokens>per deficiency, applied overdark). - Selector is
[data-cb]on the brand root, matching the storefront so its bootstrap keeps working unchanged. Generated as:root[data-brand="dt"][data-cb="protanopia"](plus the no-brand default for dt).-rgbtriplets are generated, never typed. - The ΔE check moves into the tokens build as
cb-check.ts, run after CSS generation. A palette that fails does not publish. Same matrices, same threshold, but it reads the token objects directly instead of parsing CSS. - Achromatopsia stays out, for the storefront's reason: a luminance ramp cannot carry five meanings; that needs redundant encoding per component.
- Settings panels are controlled.
DTSettingsPanel(react and react-native) takes values and change handlers. Persistence (localStorage, customer metafield, AsyncStorage) is the app's business; the storefront already has a good three-layer model and the design system should not grow a second one. Each section renders only when its handler is passed. - Animation speed comes along. The storefront panel has it, and the
design system's animation CSS can honour
--motion-scalewithcalc()in a dozen lines. React Native gets amotionScalein theme context thatuseScaleInandusePulsemultiply by. Stops (1, 0.75, 0.5, 0.25, 0) live in tokens so both panels agree. - React Native gets
buildThemeFromBrandmoved in from the showcase app, with acolorVisionoption that merges the palette overbrand.darkbefore building.DTThemeProvideracceptsbrand+colorVision+motionScaleas an alternative to a prebuilttheme. The showcase imports the moved function.
Layers
| Layer | Change |
|---|---|
| tokens | ColorVisionMode types, colorVisionModes registry (labels for UI), colorVisionPalettes, motionStops; colorVision on both brands; generator emits [data-cb] blocks; cb-check in build |
| web | animations.css durations scale by --motion-scale; [data-motion="0"] kills animation |
| react | DTWebThemeProvider gains colorVision, motionScale; DTSettingsPanel |
| react-native | buildThemeFromBrand, provider gains brand/colorVision/motionScale, useDTMotionScale, DTSettingsPanel |
Web CSS needs no other change: every component already reads --mode-*,
which the generated block re-points.
Check
packages/tokens/src/scripts/cb-check.ts runs in npm run build:tokens.
It prints the default palette's worst pair under each simulation (the number
to beat) and fails the build if any deficiency palette scores under ΔE 25 or
any role falls under 4.5:1 on black. Expected: protanopia 37.9,
deuteranopia 36.5, tritanopia 29.9, matching the storefront.
Storefront follow-up (dt-shopify, separate PR)
- Bump
@dangerousthings/tokens,web,react. - Delete the three
:root[data-cb]blocks fromapp.cssandscripts/cb-check.mjs+ its npm script; the design system now ships and checks them. SiteSettingsFormrendersDTSettingsPaneland keeps its persistence (setTheme,setMotionScale,setColorblind, the fetcher POST).colorblind.tsimportsparseColorVisionModeandCOLOR_VISION_MODESfrom tokens instead of its own list; the bootstrap's inline array stays inline because it runs before any module loads.
Not done
- Light-mode palettes (see decision 2).
- Redundant encoding audit for WCAG 1.4.1 (legends, stock states).
- Showcase pages demonstrating the panel. Cheap to add once the API settles.