# Dark / Light Theme System — Implementation Plan

**Spec:** docs/specs/2026-05-31-dark-light-theme.md  ·  **Slug:** dark-light-theme  ·  **Wave:** 2
**Depends on:** foundation-auth-rbac, foundation-design-system

## Goal
Deliver Zync.is's two-theme system (dark default, light, system) toggled via a `.dark` class on `<html>`. This spec owns the **single source of truth** for all OKLCH color tokens — both the dark (`html.dark`, navy/teal) and light (`:root`, VirtuAc teal/navy) palettes plus the mode-independent z-index scale — which `foundation-design-system` and every UI spec consume. It adds the `ui_theme` preference column, the `PATCH /api/user/preferences` API, cookie+localStorage persistence, a `ThemeToggle` component, and an SSR flash-prevention inline script that is CSP-nonce-compliant.

## Architecture
- **Token ownership:** This plan writes the color + z-index tokens into `packages/ui/src/tokens/index.css` (the file `foundation-design-system` scaffolds but deliberately leaves color-free per spec 2's note "Color tokens are owned by dark-light-theme"). The design-system file owns only non-color primitives (`--radius`, `--font-*`, structural `--shadow` overrides are also defined here for both modes). Tailwind preset (`packages/config/tailwind.preset.ts`, owned by design-system) maps utilities onto these CSS vars — no change needed there.
- **Persistence (DB):** Adds `ui_theme` column to the existing `user_preferences` table from `foundation-auth-rbac` (composite PK `(user_id, tenant_id)`, FK `user_id UUID REFERENCES users(id)`, `tenant_id UUID REFERENCES tenants(id)`).
- **API:** New `PATCH /api/user/preferences` route in `zync-api` (Hono). Reads authenticated session (`user_id`, `tenant_id`), updates `user_preferences.ui_theme`, and emits `Set-Cookie: ui_theme=…; HttpOnly; SameSite=Strict; Path=/`. Body validated with Zod per the monorepo Input Validation Mandate.
- **Cookie on login:** The login handler in `foundation-auth-rbac` already sets the session cookie; this plan adds a `ui_theme` cookie emission on login by reading `user_preferences.ui_theme`. Implemented as a small helper consumed by the login route.
- **SSR flash prevention:** A blocking inline `<script nonce="…">` in `<head>` reads the `ui_theme` cookie → localStorage → defaults to `'dark'`, and toggles the `.dark` class before CSS loads. Injected into both `apps/zync-app/index.html` (Vite SPA, nonce from the Worker that serves the shell) and the `zync-www` Astro root layout (`locals.nonce` from Astro security middleware). The nonce mechanism reuses `REQUEST_NONCE` from `foundation-monorepo`'s `security-headers` middleware.
- **Client toggle:** `ThemeToggle` (in `packages/ui`) + a `useTheme` hook in `apps/zync-app` apply the class instantly to `document.documentElement`, write localStorage, and (when authenticated) fire the PATCH. Honors `prefers-color-scheme` for `'system'` and respects `prefers-reduced-motion` for the toggle transition.

## Tech Stack
- **Packages:** `packages/ui` (tokens CSS, `ThemeToggle`, `ThemeProvider`/`useTheme` primitives exported via barrel).
- **Apps:** `apps/zync-api` (Hono — preferences route, cookie helper), `apps/zync-app` (Vite+React — `index.html` flash script, `useTheme` integration, Settings > Appearance), `apps/zync-www` (Astro — flash script in root layout via `locals.nonce`).
- **DB:** Drizzle migration adding `ui_theme` to `user_preferences` (Neon Postgres via Hyperdrive).
- **Libraries:** Hono, Zod (validation), Drizzle ORM, React, Radix Switch/SegmentedControl pattern (from design-system primitives).
- **Bindings:** none new; uses existing Hyperdrive DB binding and the per-request nonce from security-headers middleware.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — Tokens | 1 | `packages/ui/src/tokens/index.css` | Independent |
| B — DB + API | 2, 3, 4 | `packages/db/src/schema`, migrations, `apps/zync-api/src/routes`, middleware | 2 blocks 3/4; 3 and 4 parallel after 2 |
| C — Client | 5, 6, 7 | `packages/ui`, `apps/zync-app` | 5 blocks 6/7; can start after A |
| D — Flash script | 8, 9 | `apps/zync-app/index.html`, `apps/zync-www` layout/middleware | Parallel with C, after A |
| E — Settings + tests | 10, 11 | `apps/zync-app` settings page, Storybook, tests | After 5–9 |

## Tasks

### Task 1: Author canonical OKLCH token sets (light + dark + z-index)
**Blocks:** 5, 6, 7, 8, 9, 10, 11  ·  **Blocked by:** —
**Files:**
- Modify: `packages/ui/src/tokens/index.css`
**Steps:**
- [ ] Add the `:root` (light mode) color block with every token exactly as specified, OKLCH-only.
- [ ] Add the `html.dark` (dark mode) color block with every token exactly as specified.
- [ ] Add the mode-independent z-index scale on `:root`.
- [ ] Add `color-scheme` rules so native browser UI (scrollbars, form controls) matches the active theme.
- [ ] Confirm no hex/rgb/hsl literals are introduced (ESLint `no-hardcoded-colors` must pass — all values are `oklch()` or numeric z-index).
- [ ] Retain the `--accent-soft` alias name from spec 2 (now teal-hued) — already present in both blocks below.
**Schema / Interfaces:**
```css
/* packages/ui/src/tokens/index.css — color tokens owned by dark-light-theme (spec 114) */

/* ── Light Mode (no .dark class) ───────────────────────────── */
:root {
  /* Backgrounds — white/pale-teal */
  --bg:        oklch(100% 0 0);
  --surface:   oklch(95% 0.016 195);
  --elevated:  oklch(100% 0 0);
  --hover:     oklch(92% 0.022 195);

  /* Accent — dark teal (VirtuAc brand) */
  --accent:         oklch(44% 0.12 195);
  --accent-mid:     oklch(54% 0.13 195);
  --accent-soft:    oklch(95% 0.022 195);
  --accent-border:  oklch(82% 0.04 195);

  /* Text — navy scale */
  --ink:           oklch(12% 0.04 240);
  --ink-soft:      oklch(22% 0.045 240);
  --ink-faint:     oklch(43% 0.035 235);
  --ink-on-accent: oklch(100% 0 0);

  /* Borders */
  --line:        oklch(90% 0.03 195);
  --line-subtle: oklch(96% 0.014 195);

  /* Semantic */
  --success:    oklch(40% 0.13 155);
  --success-bg: oklch(95% 0.025 155);
  --warning:    oklch(52% 0.14 80);
  --warning-bg: oklch(97% 0.022 85);
  --danger:     oklch(44% 0.19 25);
  --danger-bg:  oklch(97% 0.018 25);
  --info:       oklch(44% 0.12 195);
  --info-bg:    oklch(95% 0.022 195);

  /* Shadows */
  --shadow-sm: 0 1px 4px oklch(12% 0.04 240 / 8%), 0 0 0 1px oklch(12% 0.04 240 / 4%);
  --shadow-md: 0 4px 12px oklch(12% 0.04 240 / 10%);
  --shadow-lg: 0 12px 32px oklch(12% 0.04 240 / 12%);
}

/* ── Z-index scale — mode-independent ──────────────────────── */
:root {
  --z-base:     0;
  --z-sticky:   100;
  --z-dropdown: 200;
  --z-modal:    300;
  --z-toast:    400;
  --z-tooltip:  500;
}

/* ── Dark Mode (.dark on <html> — default) ─────────────────── */
html.dark {
  /* Backgrounds — deep navy scale */
  --bg:        oklch(13% 0.035 240);
  --surface:   oklch(16% 0.042 240);
  --elevated:  oklch(18% 0.048 240);
  --hover:     oklch(21% 0.05 240);

  /* Accent — bright teal */
  --accent:         oklch(88% 0.16 191);
  --accent-mid:     oklch(78% 0.14 191);
  --accent-soft:    oklch(18% 0.06 195);
  --accent-border:  oklch(32% 0.08 195);

  /* Text — cool light scale */
  --ink:           oklch(95% 0.016 195);
  --ink-soft:      oklch(80% 0.028 210);
  --ink-faint:     oklch(55% 0.025 215);
  --ink-on-accent: oklch(13% 0.035 240);

  /* Borders */
  --line:        oklch(24% 0.045 230);
  --line-subtle: oklch(19% 0.04 235);

  /* Semantic */
  --success:    oklch(72% 0.16 155);
  --success-bg: oklch(18% 0.04 155);
  --warning:    oklch(82% 0.18 85);
  --warning-bg: oklch(17% 0.04 85);
  --danger:     oklch(68% 0.22 25);
  --danger-bg:  oklch(17% 0.05 25);
  --info:       oklch(88% 0.16 191);
  --info-bg:    oklch(18% 0.06 195);

  /* Shadows */
  --shadow-sm: 0 1px 3px oklch(0% 0 0 / 50%), inset 0 1px 0 oklch(100% 0 0 / 4%);
  --shadow-md: 0 4px 8px oklch(0% 0 0 / 60%), inset 0 1px 0 oklch(100% 0 0 / 5%);
  --shadow-lg: 0 12px 24px oklch(0% 0 0 / 70%), inset 0 1px 0 oklch(100% 0 0 / 5%);
}

/* ── Native browser UI matching ────────────────────────────── */
html.dark        { color-scheme: dark; }
html:not(.dark)  { color-scheme: light; }
```
**Acceptance:**
- [ ] Toggling `.dark` on `<html>` swaps every token; computed `--bg` is `oklch(13% 0.035 240)` in dark, `oklch(100% 0 0)` in light.
- [ ] `color-scheme` flips so native scrollbars/form controls match the theme.
- [ ] ESLint `no-hardcoded-colors` passes (zero hex/rgb/hsl).

### Task 2: Add `ui_theme` column to `user_preferences` (schema + migration)
**Blocks:** 3, 4  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/user-preferences.ts`
- Create: `packages/db/migrations/<timestamp>_add_ui_theme_to_user_preferences.sql`
**Steps:**
- [ ] Add `uiTheme` to the Drizzle `userPreferences` table definition with default `'dark'` and a CHECK constraint via `sql` clause.
- [ ] Generate/write the SQL migration with the exact `ALTER TABLE` below.
- [ ] Run `drizzle-kit` generate + verify the migration applies cleanly against a Neon branch.
**Schema / Interfaces:**
```sql
-- migration: add_ui_theme_to_user_preferences
ALTER TABLE user_preferences
  ADD COLUMN ui_theme TEXT DEFAULT 'dark'
  CHECK (ui_theme IN ('dark', 'light', 'system'));
```
```ts
// packages/db/src/schema/user-preferences.ts (added column)
uiTheme: text('ui_theme').default('dark'),
// table-level check (Drizzle):
// check('user_preferences_ui_theme_check', sql`ui_theme IN ('dark','light','system')`)
```
**Acceptance:**
- [ ] Migration applies; `\d user_preferences` shows `ui_theme TEXT DEFAULT 'dark'` with the IN-list CHECK.
- [ ] Inserting `ui_theme='invalid'` is rejected by the CHECK constraint.

### Task 3: `PATCH /api/user/preferences` route with Zod validation + cookie side-effect
**Blocks:** 6  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/routes/user-preferences.ts`
- Modify: `apps/zync-api/src/index.ts` (mount route)
**Steps:**
- [ ] Define a Zod schema `updateUserPreferencesSchema` with optional `ui_theme` enum `['dark','light','system']`.
- [ ] Require authenticated session; read `user_id` and `tenant_id` from the verified JWT session context.
- [ ] `UPDATE user_preferences SET ui_theme = $1 WHERE user_id = $2 AND tenant_id = $3` (only when `ui_theme` present).
- [ ] On success set `Set-Cookie: ui_theme={value}; Path=/; HttpOnly; SameSite=Strict` (add `Secure` in production).
- [ ] Return `200 { ui_theme }`; `401` if unauthenticated; `400` on Zod failure.
- [ ] Reject raw `c.req.json()` — must parse through the Zod schema (satisfies `require-zod-validation-in-routes`).
**Schema / Interfaces:**
```ts
// apps/zync-api/src/routes/user-preferences.ts
import { z } from 'zod'

export const updateUserPreferencesSchema = z.object({
  ui_theme: z.enum(['dark', 'light', 'system']).optional(),
})
export type UpdateUserPreferencesBody = z.infer<typeof updateUserPreferencesSchema>

// PATCH /api/user/preferences
//   auth: required (session JWT cookie)
//   body: { ui_theme?: 'dark' | 'light' | 'system' }
//   side-effect: Set-Cookie ui_theme={value}; Path=/; HttpOnly; SameSite=Strict
//   200 -> { ui_theme: 'dark' | 'light' | 'system' }
```
**Acceptance:**
- [ ] `PATCH` with `{ ui_theme: 'light' }` updates the row and returns `Set-Cookie: ui_theme=light; ...; HttpOnly; SameSite=Strict`.
- [ ] Unauthenticated request → `401`; invalid `ui_theme` → `400`.
- [ ] Row update is scoped to the session's `(user_id, tenant_id)`.

### Task 4: Emit `ui_theme` cookie on login
**Blocks:** 8, 9  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/lib/theme-cookie.ts`
- Modify: `apps/zync-api/src/routes/auth.ts` (login handler — set cookie after session issued)
**Steps:**
- [ ] Add helper `setThemeCookie(c, uiTheme)` that writes `Set-Cookie: ui_theme={value}; Path=/; HttpOnly; SameSite=Strict`.
- [ ] In the login handler, after authenticating, read `user_preferences.ui_theme` for `(user_id, tenant_id)`, defaulting to `'dark'` if the row/column is null.
- [ ] Call `setThemeCookie` so the server emits the correct class on the next HTML render (flash prevention).
**Schema / Interfaces:**
```ts
// apps/zync-api/src/lib/theme-cookie.ts
export function setThemeCookie(c: Context, uiTheme: 'dark' | 'light' | 'system'): void
// Set-Cookie: ui_theme=<uiTheme>; Path=/; HttpOnly; SameSite=Strict
```
**Acceptance:**
- [ ] After a successful login, the response carries `Set-Cookie: ui_theme=<persisted value>`.
- [ ] When the user has no stored preference, the cookie value is `dark`.

### Task 5: `ThemeProvider` + `useTheme` hook
**Blocks:** 6, 7, 10  ·  **Blocked by:** 1
**Files:**
- Create: `packages/ui/src/theme/theme-provider.tsx`
- Create: `packages/ui/src/theme/use-theme.ts`
- Modify: `packages/ui/src/index.ts` (barrel export)
**Steps:**
- [ ] Implement `ThemeProvider` holding the current preference (`'dark' | 'light' | 'system'`) and computing the effective mode (resolving `'system'` against `matchMedia('(prefers-color-scheme: dark)')`).
- [ ] On mount and on preference change, add/remove the `dark` class on `document.documentElement` and write `localStorage.setItem('ui_theme', value)`.
- [ ] Subscribe to the `prefers-color-scheme` media query while in `'system'` mode and re-apply the class on system change.
- [ ] Initialize from `localStorage` → cookie `ui_theme` → default `'dark'` (matching the flash script's precedence so no mismatch).
- [ ] `useTheme()` returns `{ theme, effectiveMode, setTheme }`.
- [ ] Export both from the barrel.
**Schema / Interfaces:**
```ts
// packages/ui/src/theme/use-theme.ts
export type ThemePreference = 'dark' | 'light' | 'system'
export type EffectiveMode = 'dark' | 'light'
export interface ThemeContextValue {
  theme: ThemePreference
  effectiveMode: EffectiveMode
  setTheme: (theme: ThemePreference) => void
}
export function useTheme(): ThemeContextValue
```
**Acceptance:**
- [ ] `setTheme('light')` removes `.dark` synchronously and persists to localStorage.
- [ ] In `'system'` mode, changing OS color scheme flips the class without reload.

### Task 6: `useThemeSync` — persist preference to API when authenticated
**Blocks:** 10  ·  **Blocked by:** 3, 5
**Files:**
- Create: `apps/zync-app/src/hooks/use-theme-sync.ts`
**Steps:**
- [ ] Wrap `useTheme().setTheme` so that, when the user is authenticated, it fires `PATCH /api/user/preferences` with `{ ui_theme }` (TanStack Query mutation).
- [ ] Apply the class + localStorage change immediately (instant feedback) before/independent of the network call.
- [ ] For unauthenticated users, skip the PATCH (localStorage only; cookie not set) per spec.
- [ ] On PATCH failure, keep local state (optimistic) — no rollback that would re-flash; surface a non-blocking toast.
**Schema / Interfaces:**
```ts
// apps/zync-app/src/hooks/use-theme-sync.ts
export function useThemeSync(): {
  theme: ThemePreference
  setTheme: (theme: ThemePreference) => void  // local apply + API PATCH when authenticated
}
```
**Acceptance:**
- [ ] Authenticated toggle issues exactly one `PATCH /api/user/preferences` with the chosen value.
- [ ] Unauthenticated toggle issues no network request and persists only to localStorage.

### Task 7: `ThemeToggle` component
**Blocks:** 10  ·  **Blocked by:** 5
**Files:**
- Create: `packages/ui/src/theme/theme-toggle.tsx`
- Modify: `packages/ui/src/index.ts` (barrel export)
**Steps:**
- [ ] Build a three-option segmented control: **Light** (`☀`), **Dark** (`◐`), **System**, reflecting `useTheme().theme`.
- [ ] Use a `radiogroup` ARIA pattern: `role="radiogroup"` with `aria-label`, each option `role="radio"` + `aria-checked`, full keyboard nav (arrow keys, Space/Enter).
- [ ] Drive selection through the `setTheme` passed in via props (so app can inject `useThemeSync`); default to context `useTheme` when no prop given.
- [ ] Style only with token classes — no hardcoded colors.
- [ ] Wrap the active-indicator transition in `@media (prefers-reduced-motion: reduce)` to disable animation.
- [ ] Export from the barrel.
**Schema / Interfaces:**
```ts
// packages/ui/src/theme/theme-toggle.tsx
export interface ThemeToggleProps {
  value?: ThemePreference
  onChange?: (theme: ThemePreference) => void
  'aria-label'?: string
}
export function ThemeToggle(props: ThemeToggleProps): JSX.Element
```
**Acceptance:**
- [ ] Renders three radio options; the active one has `aria-checked="true"`.
- [ ] Keyboard arrow navigation moves selection; Space/Enter activates.
- [ ] With `prefers-reduced-motion: reduce`, the indicator does not animate.

### Task 8: SSR flash-prevention script in `zync-app` shell (CSP-nonce)
**Blocks:** —  ·  **Blocked by:** 1, 4
**Files:**
- Modify: `apps/zync-app/index.html`
- Modify: the Worker/template that serves the shell HTML (inject `REQUEST_NONCE` into the script tag)
**Steps:**
- [ ] Inject the blocking inline `<script nonce="{REQUEST_NONCE}">` as the first element in `<head>`, before any CSS link.
- [ ] Thread the per-request nonce from `foundation-monorepo`'s `security-headers` middleware into the `nonce` attribute and the CSP `script-src 'nonce-…'` directive.
- [ ] Script precedence: cookie `ui_theme` → `localStorage.getItem('ui_theme')` → default `'dark'`; resolve `'system'` via `matchMedia('(prefers-color-scheme: dark)')`.
- [ ] Verify the script is **blocking** (not `defer`/`async`) so it runs before CSS paints.
**Schema / Interfaces:**
```html
<!-- apps/zync-app/index.html — first child of <head> -->
<script nonce="{REQUEST_NONCE}">
  (function () {
    var theme = (document.cookie.match(/ui_theme=([^;]+)/) || [])[1]
      || localStorage.getItem('ui_theme')
      || 'dark';
    if (theme === 'dark' ||
        (theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches)) {
      document.documentElement.classList.add('dark');
    }
  })();
</script>
```
**Acceptance:**
- [ ] First paint matches the persisted theme — no flash of the wrong theme on reload.
- [ ] CSP `script-src 'self' 'nonce-<value>'` matches the script's `nonce`; browser console shows no CSP violation.

### Task 9: SSR flash-prevention script in `zync-www` (Astro, `locals.nonce`)
**Blocks:** —  ·  **Blocked by:** 1, 4
**Files:**
- Create: `apps/zync-www/src/middleware/security.ts` (if not already present from monorepo; otherwise extend)
- Modify: `apps/zync-www/src/layouts/BaseLayout.astro` (root layout `<head>`)
**Steps:**
- [ ] In Astro middleware, generate a per-request nonce (`crypto.getRandomValues(new Uint8Array(16))` → base64url), set `context.locals.nonce`, and add `script-src 'self' 'nonce-${nonce}'` to the `Content-Security-Policy` response header.
- [ ] In the root layout `<head>`, render the blocking inline script with `nonce={Astro.locals.nonce}`, same cookie→localStorage→`'dark'` precedence.
- [ ] Ensure the script is the first `<head>` child, before CSS.
**Schema / Interfaces:**
```ts
// apps/zync-www/src/middleware/security.ts
import { defineMiddleware } from 'astro:middleware'
export const onRequest = defineMiddleware(async (context, next) => {
  const nonce = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('base64url')
  context.locals.nonce = nonce
  const response = await next()
  response.headers.set(
    'Content-Security-Policy',
    `default-src 'self'; script-src 'self' 'nonce-${nonce}'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'`,
  )
  return response
})
```
```astro
<!-- apps/zync-www/src/layouts/BaseLayout.astro — first child of <head> -->
<script nonce={Astro.locals.nonce} is:inline>
  (function () {
    var theme = (document.cookie.match(/ui_theme=([^;]+)/) || [])[1]
      || localStorage.getItem('ui_theme')
      || 'dark';
    if (theme === 'dark' ||
        (theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches)) {
      document.documentElement.classList.add('dark');
    }
  })();
</script>
```
**Acceptance:**
- [ ] `zync-www` pages render with the correct theme on first paint, no flash.
- [ ] Response CSP `script-src` carries the same nonce as the inline script; no CSP violation logged.

### Task 10: Settings > Appearance page + nav-header toggle wiring
**Blocks:** —  ·  **Blocked by:** 5, 6, 7
**Files:**
- Create: `apps/zync-app/src/pages/settings/appearance.tsx`
- Modify: `apps/zync-app/src/main.tsx` (wrap app in `ThemeProvider`)
- Modify: the app shell nav header component (place a compact `ThemeToggle`)
**Steps:**
- [ ] Wrap the React tree in `ThemeProvider` so `useTheme` is available app-wide.
- [ ] Build the Appearance settings section rendering `ThemeToggle` bound to `useThemeSync().setTheme` / `.theme`.
- [ ] Add a compact `ThemeToggle` to the nav header, also bound to `useThemeSync`.
- [ ] Verify both controls stay in sync (shared context state).
**Acceptance:**
- [ ] Changing the theme in Settings updates the nav-header toggle instantly and vice versa.
- [ ] Selection persists across reload (cookie/localStorage) and across sessions (DB) for authenticated users.

### Task 11: Storybook dual-mode + theme tests
**Blocks:** —  ·  **Blocked by:** 5, 6, 7, 8, 9, 10
**Files:**
- Create: `packages/ui/src/theme/theme-toggle.stories.tsx`
- Create: `packages/ui/.storybook/preview.tsx` modification (dark/light addon panel)
- Create: `apps/zync-api/test/user-preferences.test.ts`
**Steps:**
- [ ] Add Storybook global toolbar / addon switch that toggles the `.dark` class so every story renders in both modes.
- [ ] Add a `ThemeToggle` story exercising all three states and keyboard nav.
- [ ] Add an API test: `PATCH /api/user/preferences` with each valid value updates the row + sets the cookie; invalid value → 400; unauthenticated → 401.
- [ ] Add a contrast assertion (or document the verification) that `--ink`/`--ink-soft` meet ≥ 12:1 on `--bg` per mode and `--accent` meets ≥ 4.5:1 (WCAG AA) per spec 115.
**Acceptance:**
- [ ] Storybook renders components in both dark and light from the addon panel.
- [ ] API test suite passes for valid/invalid/unauthenticated cases.
- [ ] Documented contrast checks pass for both modes.
