5.0 KiB
Context
The site currently uses a single dark theme defined via CSS variables in site/public/styles/global.css (e.g., --bg0, --bg1, --fg, --muted, --stroke, --accent). There is no existing theme selection mechanism (no data-theme attribute, no persisted preference).
The site shell uses a sticky .site-header and a per-page .subnav row (when present). A theme switcher notch must be fixed-positioned such that it does not overlap the header and leaves enough space for the subnav region.
Goals / Non-Goals
Goals:
- Provide three themes:
dark,light,high-contrast. - Allow switching themes via a floating notch on the right side of the screen positioned below the primary nav bar and not overlapping the subnav area.
- Make switching feel premium:
- hover animation on the notch
- smooth theme transitions (without an abrupt flash)
- Ensure accessibility:
- keyboard operable
- visible focus
- respects
prefers-reduced-motion - high contrast theme is meaningfully higher contrast, not just a color swap
- Persist the user's selection across page loads.
Non-Goals:
- Rebuild the entire visual system or rewrite all CSS to a design-token framework.
- PWA theming (manifest/theme-color) beyond what is required to implement UI themes.
- Adding user accounts or server-side persistence of theme preference.
Decisions
1. Theme selection mechanism: data-theme on <html>
Use document.documentElement.dataset.theme = "dark" | "light" | "high-contrast".
Rationale:
- Works cleanly with CSS variables.
- Scopes theme styles across the entire page without specificity fights.
Alternatives considered:
- Adding theme classes to
body(works, but html-scoped variables are simpler for form control theming).
2. Token strategy: override existing CSS variables per theme
Keep the existing variable names and provide theme-specific overrides:
:rootremains the default (dark)html[data-theme="light"] { ... }html[data-theme="high-contrast"] { ... }
Rationale:
- Minimizes churn in existing CSS.
- Enables incremental migration of any remaining hard-coded colors to tokens.
3. Default theme resolution order
On first load, resolve the active theme in this order:
- stored user preference (
localStorage.theme) - forced colors / high-contrast OS mode (if detected) ->
high-contrast - system color scheme ->
lightifprefers-color-scheme: light, elsedark
Rationale:
- User choice wins.
- If the user is in a forced/high-contrast environment, defaulting to high-contrast aligns with accessibility intent.
4. Prevent flash of wrong theme with a tiny head script
Insert a small inline script in the document <head> that sets data-theme before first paint.
Rationale:
- Avoids "flash" where the page renders in dark before switching to light/high-contrast.
Trade-off:
- Inline scripts can constrain future CSP hardening; keep script small and self-contained.
5. Smooth transitions without animating on every page load
Use a transient attribute/class (e.g., data-theme-transition="on") only during user-initiated theme changes.
Implementation shape:
- When switching: set
data-theme-transition="on", updatedata-theme, then remove after ~250ms. - CSS applies transitions for color/background/border/shadow only when the attribute is present.
Rationale:
- Avoids "everything animates" feeling during initial load.
- Avoids subtle jank on navigation.
6. Notch UI: fixed-position, expands on hover/focus
Implement the switcher as a fixed-position control at the right edge:
- Default collapsed: a small vertical tab.
- On
:hoverand:focus-within: expands into a small panel exposing the three theme options.
Accessibility decisions:
- Use a real
<button>to open/close (for touch) OR a<fieldset role="radiogroup">with three radio-like buttons. - Ensure it is reachable via keyboard and has clear
aria-labels.
Placement decisions:
- Use a CSS variable
--theme-notch-topto position it. - A small inline script computes this based on
.site-headerheight and, if a.subnavexists near the top, positions below it.
7. High Contrast theme semantics
The high-contrast theme will be a dedicated palette (not only increased brightness) with:
- strong background/foreground contrast
- high visibility focus ring
- more assertive stroke borders
Additionally, handle OS forced-colors mode:
- In
@media (forced-colors: active), prefer system colors and avoid gradients that reduce clarity.
Risks / Trade-offs
- [Notch overlaps content] -> compute top offset from header/subnav; provide safe-area padding; add responsive rules for small viewports.
- [Theme transitions reduce readability] -> scope transitions to a short window and limit properties; disable via
prefers-reduced-motion. - [High contrast breaks brand feel] -> keep layout/typography unchanged and only adjust palette and borders.
- [CSP constraints] -> keep head script minimal and consider moving to an external script if CSP hardening becomes a priority.