Restore a theme control: OS by default, with an explicit override #36

Merged
mikoteko merged 1 commit from new-design-ux-pass into main 2026-07-26 15:28:37 +00:00
Collaborator

Retiring the « Confort de lecture » bar removed the only thing that could
override prefers-color-scheme, so the app rendered charbon for every
visitor on a dark-mode OS with no way back to the charter's crème paper.

Diagnosed in-browser: prefersDark true, no data-theme on <html>,
--lfi-bg #212320. The vendored a11y widget can't substitute — it has no
light/dark control, and « Contraste clair » is a filter that paints white
boxes behind text while leaving the page charbon.

Three states, not a boolean: auto (default, OS decides) → clair →
sombre. On a dark-mode OS a two-position toggle can never reach light,
because "off" means no attribute, which lets the media query win, so
both positions render dark. "auto" has to be its own state, and
applyTheme("auto") must REMOVE the attribute rather than set anything.

  • lib/theme.ts + components/ThemeToggle.tsx, persisted under a dedicated
    aec-theme:v1 key. Deliberately NOT the aec-prefs:v1 blob left
    behind by the retired engine: a stale pinned theme in there would
    silently defeat OS-by-default. That key is now orphaned in existing
    visitors' storage and nothing reads it.
  • Pre-paint script in layout.tsx so a pinned theme can't flash the OS
    one; mirrors applyTheme().
  • The button sits outside — a theme control isn't navigation, and
    that keeps it reachable at <=768px without opening the mobile menu. It
    names its current state rather than flipping aria-pressed (three
    states), mirrors that into a live region because a name change on the
    focused element isn't announced reliably, and uses data-active to fill
    the pill for the two pins so "auto" reads as the resting state.

Also corrects design.md and CLAUDE.md, which still described the comfort
bar as live — missed when it was removed. They now document the theme
contract, the inert data-fs/data-falc/data-contrast hooks, and why the
orphaned key isn't read.

npm run test 143/143 · typecheck clean · test:design-system 157/157 ·
test:legacy 6/6. Verified in the container: fresh visitor on a dark OS
gets auto/charbon; one click gives crème while prefersDark is still true;
a reload with light pinned stamps data-theme pre-paint with no flash.

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

Retiring the « Confort de lecture » bar removed the only thing that could override `prefers-color-scheme`, so the app rendered charbon for every visitor on a dark-mode OS with no way back to the charter's crème paper. Diagnosed in-browser: prefersDark true, no data-theme on <html>, --lfi-bg #212320. The vendored a11y widget can't substitute — it has no light/dark control, and « Contraste clair » is a filter that paints white boxes behind text while leaving the page charbon. Three states, not a boolean: auto (default, OS decides) → clair → sombre. On a dark-mode OS a two-position toggle can never reach light, because "off" means *no attribute*, which lets the media query win, so both positions render dark. "auto" has to be its own state, and applyTheme("auto") must REMOVE the attribute rather than set anything. - lib/theme.ts + components/ThemeToggle.tsx, persisted under a dedicated `aec-theme:v1` key. Deliberately NOT the `aec-prefs:v1` blob left behind by the retired engine: a stale pinned theme in there would silently defeat OS-by-default. That key is now orphaned in existing visitors' storage and nothing reads it. - Pre-paint script in layout.tsx so a pinned theme can't flash the OS one; mirrors applyTheme(). - The button sits outside <nav> — a theme control isn't navigation, and that keeps it reachable at <=768px without opening the mobile menu. It names its current state rather than flipping aria-pressed (three states), mirrors that into a live region because a name change on the focused element isn't announced reliably, and uses data-active to fill the pill for the two pins so "auto" reads as the resting state. Also corrects design.md and CLAUDE.md, which still described the comfort bar as live — missed when it was removed. They now document the theme contract, the inert data-fs/data-falc/data-contrast hooks, and why the orphaned key isn't read. npm run test 143/143 · typecheck clean · test:design-system 157/157 · test:legacy 6/6. Verified in the container: fresh visitor on a dark OS gets auto/charbon; one click gives crème while prefersDark is still true; a reload with light pinned stamps data-theme pre-paint with no flash. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Retiring the « Confort de lecture » bar removed the only thing that could
override `prefers-color-scheme`, so the app rendered charbon for every
visitor on a dark-mode OS with no way back to the charter's crème paper.

Diagnosed in-browser: prefersDark true, no data-theme on <html>,
--lfi-bg #212320. The vendored a11y widget can't substitute — it has no
light/dark control, and « Contraste clair » is a filter that paints white
boxes behind text while leaving the page charbon.

Three states, not a boolean: auto (default, OS decides) → clair →
sombre. On a dark-mode OS a two-position toggle can never reach light,
because "off" means *no attribute*, which lets the media query win, so
both positions render dark. "auto" has to be its own state, and
applyTheme("auto") must REMOVE the attribute rather than set anything.

- lib/theme.ts + components/ThemeToggle.tsx, persisted under a dedicated
  `aec-theme:v1` key. Deliberately NOT the `aec-prefs:v1` blob left
  behind by the retired engine: a stale pinned theme in there would
  silently defeat OS-by-default. That key is now orphaned in existing
  visitors' storage and nothing reads it.
- Pre-paint script in layout.tsx so a pinned theme can't flash the OS
  one; mirrors applyTheme().
- The button sits outside <nav> — a theme control isn't navigation, and
  that keeps it reachable at <=768px without opening the mobile menu. It
  names its current state rather than flipping aria-pressed (three
  states), mirrors that into a live region because a name change on the
  focused element isn't announced reliably, and uses data-active to fill
  the pill for the two pins so "auto" reads as the resting state.

Also corrects design.md and CLAUDE.md, which still described the comfort
bar as live — missed when it was removed. They now document the theme
contract, the inert data-fs/data-falc/data-contrast hooks, and why the
orphaned key isn't read.

npm run test 143/143 · typecheck clean · test:design-system 157/157 ·
test:legacy 6/6. Verified in the container: fresh visitor on a dark OS
gets auto/charbon; one click gives crème while prefersDark is still true;
a reload with light pinned stamps data-theme pre-paint with no flash.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
repos/aec2027!36
No description provided.