Dark Mode
How the token system adapts for dark mode without duplicating component code.
First-class theming with light/dark parity
Every ePassLive component ships with a light and dark variant by default. Themes are not an afterthought layered onto a light interface — both palettes are designed, audited, and tested as part of the system.
Theme Toggle
Switch between light and dark mode. Your preference is saved under the app-wide epasslive-theme key; docs-theme is kept only as a legacy fallback.
Token mapping
All 70 color tokens below are consumed from the generated truth (_dark-mode.generated.ts), rebuilt from globals.css by pnpm --filter storefront docs:dark. Swatches show the literal generated values side by side; the stable badge marks tokens that keep the same value in both themes.
Brand
Text
Surface
Border
Semantic
Status tints
Status backgrounds
Button primary
Neutral
Chart series
Barcode
Overlay & scrims
Focus ring
Behaviors beyond color swaps
Some dark-mode adaptations go beyond swapping a hex value — they change how a value renders or resolves. They are documented here so they are not mistaken for bugs:
- Shadow halo (EX-QA-PASS-7a): all five shadow tokens gain an inset white halo in dark mode so elevation stays visible on #1A1A1A surfaces.
- Focus-ring inner flip: --ep-focus-ring flips its inner ring from #FFFFFF to #1A1A1A in dark mode, while the outer 4px teal ring stays stable.
- Barcode full inversion: --ep-barcode-fg and --ep-barcode-bg swap foreground and background in dark mode so scanners always read dark bars on a light field; the tone prop can override this for printed badges.
- Text-link swap: --ep-text-link switches from #0066FF to #009FCA in dark mode because blue on #1A1A1A reaches only 3.60:1 — below the AA threshold — while the teal passes at 5.65:1.
- Semantic alias indirection: --ep-semantic-error carries an identical definition in both theme blocks, yet resolves differently through var() chains — the aliasing, not a duplicated value, carries the dark behavior.
Theme-stable non-color tokens
The remaining 60 tokens (radii, spacing, fonts, font sizes, transitions, z-index, motion, tap-min) are defined only in :root — theme-stable by inheritance, not by duplication.
WCAG 2.2 contrast verification
Dark-mode text pairs recomputed at render time from the generated token values (light-side pairs live on the Colors page).
All pairs pass WCAG 2.2 AA (≥4.5:1 normal text). Exactly two pairs sit within 0.33 of the AA threshold: --ep-text-muted on #1E293B (4.76:1, 0.26 above) and --ep-text-teal on #1E293B (4.75:1, 0.25 above). Both were tightened in v3.1.2 (DS-CORR-00-03) to clear AA — watch them on regressions; no further low-contrast drift is permitted.
Implementation
Dark mode uses CSS custom properties. The data-theme="dark" attribute on the html element triggers the dark token values.
How the dark theme is triggered
- The runtime trigger is the data-theme attribute on document.documentElement — not a class toggle. The .dark class is never set anywhere in the live shell (see theme-init-script.tsx).
- The theme-init script runs before React hydration to prevent a flash of light content (FOUC).
- The user preference persists in localStorage under the epasslive-theme key; the resolved theme drives every --ep-* token through the [data-theme="dark"] cascade.
- Brand colors (teal, blue, cyan, amber, indigo, pink) and chart series are theme-stable: same hex in both modes by design.
Implementation checklist
- Apply data-theme="dark" as early as possible to avoid a flash of light content (FOUC).
- Use the dark: Tailwind variants that map to the same semantic tokens.
- Audit shadows separately — the same --ep-shadow-md token resolves to a heavier alpha in dark mode.
- Verify focus rings remain visible against dark surfaces.
What to avoid
- Hardcoding colors. Always reach for a semantic token.
- Branching on prefers-color-scheme inside a component. Branch on the token, not the theme.
- Inverting grays automatically. The dark palette was hand-tuned to keep contrast above WCAG AA.
- Forgetting any state. Test hover, focus-visible, disabled, and error in dark mode.
Architectural reality
- Storefront marketing shell: theme-adaptive, not dark-only — every surface uses semantic tokens (bg-ep-bg, text-ep-text-heading…) that follow the html data-theme attribute. The toggle lives in SiteHeaderV2 and the app-wide ThemeProvider defaults to dark; the chrome (header, drawer, footer) follows the global theme on every route per the amended ADR-062 (2026-11-04).
- Admin dashboard (packages/storefront/src/components/dashboard/): shares the app-wide ThemeProvider; ThemeContext re-publishes it to ThemeToggle and DashboardShell mirrors data-theme onto the .ep-admin wrapper.
- Docs shell (DocsThemeToggle.tsx): writes data-theme on the html element globally — there is no .ep-docs scoped wrapper. The docs shell reads epasslive-theme first and uses docs-theme only as a legacy fallback; the toggle writes both keys.
- Client portal (packages/client-portal): ships an independent PortalThemeToggle that persists under its own epasslive-portal-theme key.
Scoped theming
A subtree can go dark while the page stays light. The data-theme attribute on any element re-roots the token cascade for its whole subtree (ADR-062).
- No storefront surface is pinned dark today: the homepage v2 lane (home + market pages) follows the global theme since 2026-11-04 (ADR-062 Amendment 2) — its colour roles are declared light/dark in home-v2.css — and the SearchSheet uses themeScope="inherit" (ADR-063). The mechanism documented here remains available for future brand bands.
- The chrome follows the global theme: since the 2026-11-04 ADR-062 amendment, the site header, mobile drawer and footer left the band and follow the global theme on every route via the swapping --home-chrome-* layer in home-v2.css (docs/design/home-v2-deviations.md §1.1).
- Contrast is verified per effective background (ADR-065): text on a surface inside a scoped root is measured against that root, whatever the page-level theme is.