Files
dt-design-system/docs/plans/2026-09-12-color-vision-and-settings-design.md
michaelandClaude Fable 5.1 ed1d3c060b
Release / publish (push) Canceled after 0s
feat: colour vision palettes, motion scale, and a standard settings panel
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>
2026-09-12 17:22:10 -07:00

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.

  1. Palettes are tokens. One shared colorVisionPalettes const in @dangerousthings/tokens, referenced from both brand files as colorVision. 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.
  2. 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-cb blocks 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 (colorVision is Partial<ColorTokens> per deficiency, applied over dark).
  3. 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). -rgb triplets are generated, never typed.
  4. 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.
  5. Achromatopsia stays out, for the storefront's reason: a luminance ramp cannot carry five meanings; that needs redundant encoding per component.
  6. 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.
  7. Animation speed comes along. The storefront panel has it, and the design system's animation CSS can honour --motion-scale with calc() in a dozen lines. React Native gets a motionScale in theme context that useScaleIn and usePulse multiply by. Stops (1, 0.75, 0.5, 0.25, 0) live in tokens so both panels agree.
  8. React Native gets buildThemeFromBrand moved in from the showcase app, with a colorVision option that merges the palette over brand.dark before building. DTThemeProvider accepts brand + colorVision + motionScale as an alternative to a prebuilt theme. 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)

  1. Bump @dangerousthings/tokens, web, react.
  2. Delete the three :root[data-cb] blocks from app.css and scripts/cb-check.mjs + its npm script; the design system now ships and checks them.
  3. SiteSettingsForm renders DTSettingsPanel and keeps its persistence (setTheme, setMotionScale, setColorblind, the fetcher POST).
  4. colorblind.ts imports parseColorVisionMode and COLOR_VISION_MODES from 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.