# Dark / Light Theme System

**Date:** 2026-05-31  
**Status:** Draft  
**Spec:** 114  
**Tier:** All tiers  
**Depends on:** `foundation-design-system`, `foundation-auth-rbac`  
**Referenced by:** `foundation-design-system`, all UI specs

---

## Overview

Zync.is supports two themes toggled via a `.dark` class on `<html>`. This spec defines the full OKLCH token sets for both modes, the toggle mechanism, preference persistence, and SSR flash prevention.

- **Dark mode** (default): Navy/teal aesthetic — deep navy backgrounds, bright teal accent. Colors derived from Zync mockup screenshots (`#0f1729` bg, `#00f0e8` accent).
- **Light mode**: VirtuAc teal/navy palette — white/pale-teal backgrounds, dark teal accent, navy text.

All tokens OKLCH-only. ESLint `no-hardcoded-colors` enforces no hex/rgb/hsl in component code.

> **Supersedes:** `foundation-design-system` (spec 2) defined a warm-paper/oxblood palette (`oklch(97% 0.006 60)` bg, `oklch(35% 0.14 14)` accent, `--accent-soft`). This spec replaces that system entirely and is the single source of truth for theme tokens. The two specs that had inlined the old values — `bulk-operations.md` and `zync-www-marketing-site.md` — now reference this token set. The token alias `--accent-soft` from spec 2 is retained here (same name, new teal hue). The zc-ui-dev skill references this spec's token set.

---

## Token Definitions

### Dark Mode (`.dark` on `<html>` — default)

```css
html.dark {
  /* Backgrounds — deep navy scale */
  --bg:        oklch(13% 0.035 240);   /* page background */
  --surface:   oklch(16% 0.042 240);   /* cards, panels */
  --elevated:  oklch(18% 0.048 240);   /* modals, dropdowns */
  --hover:     oklch(21% 0.05 240);    /* hover state */

  /* Accent — bright teal (source: #00f0e8 / #00ffff from mockups) */
  --accent:         oklch(88% 0.16 191);  /* primary action color */
  --accent-mid:     oklch(78% 0.14 191);  /* hover on accent */
  --accent-soft:    oklch(18% 0.06 195);  /* badge bg, selected row */
  --accent-border:  oklch(32% 0.08 195);  /* teal border on dark */

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

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

  /* 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%);
}
```

### Light Mode (no `.dark` class — `:root`)

```css
:root {
  /* Backgrounds — white/pale-teal */
  --bg:        oklch(100% 0 0);           /* pure white page */
  --surface:   oklch(95% 0.016 195);      /* pale teal card bg */
  --elevated:  oklch(100% 0 0);           /* pure white modals */
  --hover:     oklch(92% 0.022 195);      /* light teal hover */

  /* Accent — dark teal (VirtuAc brand #007070) */
  --accent:         oklch(44% 0.12 195);  /* primary action */
  --accent-mid:     oklch(54% 0.13 195);  /* hover on accent */
  --accent-soft:    oklch(95% 0.022 195); /* selected row, badge bg */
  --accent-border:  oklch(82% 0.04 195);  /* light teal border */

  /* Text — navy scale (VirtuAc brand #002040) */
  --ink:        oklch(12% 0.04 240);      /* near-black navy */
  --ink-soft:   oklch(22% 0.045 240);     /* secondary text */
  --ink-faint:  oklch(43% 0.035 235);     /* placeholder, disabled */
  --ink-on-accent: oklch(100% 0 0);       /* white text on teal */

  /* Borders */
  --line:        oklch(90% 0.03 195);     /* light teal border */
  --line-subtle: oklch(96% 0.014 195);    /* barely-visible divider */

  /* 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, defined once on :root */
:root {
  --z-base:     0;
  --z-sticky:   100;   /* sticky headers, table frozen columns */
  --z-dropdown: 200;   /* dropdowns, popovers, select menus */
  --z-modal:    300;   /* dialogs, sheets */
  --z-toast:    400;   /* toast notifications (above modals) */
  --z-tooltip:  500;   /* tooltips (topmost) */
}
```

---

## Theme Toggle

### Toggle Mechanics

```
html.dark  ← dark theme active (default for new users)
html       ← light theme (no class, :root tokens apply)
```

Client-side toggle: add/remove `dark` class on `document.documentElement`. Save preference immediately.

### ThemeToggle Component

```
┌─────────────────────┐
│  [☀] Light  [◐] Dark │   ← in Settings > Appearance, and nav header
└─────────────────────┘
```

Three options in user preferences:
- **Dark** — always dark
- **Light** — always light  
- **System** — follows `prefers-color-scheme` media query

---

## SSR Flash Prevention

Blocking script injected in `<head>` before any CSS loads:

```html
<script>
  (function() {
    const 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>
```

Cookie `ui_theme` set server-side on login (from `user_preferences.ui_theme`) and on toggle.

---

## Persistence

`user_preferences.ui_theme TEXT DEFAULT 'dark' CHECK (ui_theme IN ('dark', 'light', 'system'))`

New column (see schema delta below). On toggle:
1. Update `document.documentElement` classList immediately (instant feedback)
2. `PATCH /api/user/preferences` with `{ ui_theme: 'dark'|'light'|'system' }`
3. Server sets `Set-Cookie: ui_theme={value}; Path=/; HttpOnly; SameSite=Strict`

Unauthenticated users: localStorage only. Cookie not set.

---

## Schema Delta

```sql
ALTER TABLE user_preferences ADD COLUMN ui_theme TEXT DEFAULT 'dark'
  CHECK (ui_theme IN ('dark', 'light', 'system'));
```

---

## API

```
PATCH /api/user/preferences
      → update preferences
        body: { ui_theme?: 'dark'|'light'|'system' }
        Requires: authenticated
        Side-effect: Set-Cookie ui_theme
```

---

## Component Requirements

All Zync UI components must:
1. Use only CSS custom properties from the token set above
2. Never hardcode hex/rgb/hsl/oklch literals inline (use tokens)
3. Test in both dark and light mode (Storybook: both modes in addon panel)
4. Use `color-scheme: dark` / `color-scheme: light` on `<html>` to get browser-native UI matching (scrollbars, form controls)

```css
html.dark  { color-scheme: dark; }
html:not(.dark) { color-scheme: light; }
```

---

## CSP Nonce Requirement

The SSR flash-prevention script (see "SSR Flash Prevention" section above) is an **inline `<script>`** injected in `<head>`. A strict `Content-Security-Policy` with `script-src 'self'` will block this script unless it carries a nonce.

This spec depends on the security headers middleware defined in `foundation-monorepo` (spec 1) generating a per-request nonce. The nonce must be threaded through to this inline script:

**For `zync-api` / Hono (when serving the app shell HTML):**

```html
<!-- Nonce value injected by Hono security-headers middleware -->
<script nonce="{REQUEST_NONCE}">
  (function() {
    const 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>
```

The CSP header for `script-src` must include `'nonce-{REQUEST_NONCE}'`. Without this, the inline script is blocked and users see a theme flash on every load.

**For `zync-www` (Astro):** Astro middleware generates the nonce and makes it available via `locals.nonce`. Inject into both the script tag and the CSP response header:

```ts
// apps/zync-www/src/middleware/security.ts
export const onRequest = defineMiddleware((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',
    `script-src 'self' 'nonce-${nonce}'; ...`)
  return response
})
```

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| `.dark` class on `<html>` | Not CSS `prefers-color-scheme` only | Class-based allows user override independent of system; JS can toggle without media query |
| Dark default | Not light default | Zync's visual identity is navy/teal dark; new users see brand-correct UI immediately |
| OKLCH-only tokens | Not hex in tokens | OKLCH perceptual uniformity ensures consistent contrast ratios across theme; easier to derive variants (same L, adjust C/H) |
| Blocking inline script | Not defer/async | Deferred scripts run after CSS → flash of wrong theme on load; blocking prevents it |
| Cookie + localStorage | Not localStorage only | Server-side rendering (Cloudflare Worker) reads cookie to emit correct class before HTML reaches browser — eliminates flash entirely |
