---
phase: 41-diagrams-icons-theme-engine
plan: "03"
type: execute
wave: 2
depends_on: ["41-01"]
files_modified:
  - server/src/services/renderers/theme-renderer.ts
  - server/src/__tests__/theme-renderer.test.ts
  - server/src/services/nexus-settings.ts
  - server/src/__tests__/nexus-settings-custom-theme.test.ts
autonomous: true
requirements: [THEME-01, THEME-02, THEME-03, THEME-05, THEME-06, THEME-07]
must_haves:
  truths:
    - "buildPalette returns 7 named roles with dark and light variants from a single hex seed"
    - "All palette computations use OKLCH via culori -- no HSL intermediates anywhere"
    - "WCAG AA contrast is validated per foreground/background pair"
    - "Four export formatters produce CSS variables, Tailwind config, VS Code theme, and JSON strings"
    - "nexus-settings.json schema accepts optional customTheme field with seed and palette"
  artifacts:
    - path: "server/src/services/renderers/theme-renderer.ts"
      provides: "OKLCH palette engine with WCAG validation and 4 export formatters"
      exports: ["renderThemePalette", "buildPalette", "exportToCss", "exportToTailwind", "exportToVSCode", "exportToJson"]
    - path: "server/src/__tests__/theme-renderer.test.ts"
      provides: "Tests for palette generation, WCAG validation, and export formats"
    - path: "server/src/services/nexus-settings.ts"
      provides: "Extended schema with customTheme field"
      contains: "customTheme"
  key_links:
    - from: "server/src/services/renderers/theme-renderer.ts"
      to: "culori"
      via: "converter('oklch'), formatHex"
      pattern: "converter.*oklch"
    - from: "server/src/services/renderers/theme-renderer.ts"
      to: "wcag-contrast"
      via: "wcagContrast.hex(fg, bg)"
      pattern: "wcagContrast\\.hex"
---

<objective>
Implement the OKLCH theme palette engine: seed color to 7-role palette (dark + light variants), WCAG AA validation, four export formatters (CSS, Tailwind, VS Code, JSON), and extend nexus-settings.json to persist custom themes.

Purpose: Pure computational core of the theme engine -- no UI, no Playwright, no external services. Highly testable.
Output: Theme renderer with export formatters, extended settings schema, comprehensive tests.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/41-diagrams-icons-theme-engine/41-RESEARCH.md
@.planning/phases/41-diagrams-icons-theme-engine/41-01-SUMMARY.md

<interfaces>
<!-- From server/src/services/renderers/types.ts (created in Plan 01) -->
```typescript
export interface RenderResult {
  filename: string;
  contentType: string;
  buffer: Buffer;
}

export interface ThemePaletteBundle {
  type: "theme-palette-bundle";
  seedHex: string;
  palette: PaletteRole[];
  exports: { css: string; tailwind: string; vscode: string; json: string };
}

export interface PaletteRole {
  name: string;
  dark: { oklch: string; hex: string; wcagAA: boolean };
  light: { oklch: string; hex: string; wcagAA: boolean };
}
```

<!-- From server/src/services/nexus-settings.ts (existing) -->
```typescript
export const nexusSettingsSchema = z.object({
  mode: z.enum(NEXUS_MODES).default("both"),
  voiceEnabled: z.boolean().default(false),
  voiceMode: z.enum(VOICE_MODES).default("text"),
  telegramToken: z.string().optional(),
  piperBinaryPath: z.string().optional(),
  whisperBinaryPath: z.string().optional(),
});
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: OKLCH palette engine with WCAG validation and export formatters + tests</name>
  <files>server/src/services/renderers/theme-renderer.ts, server/src/__tests__/theme-renderer.test.ts</files>
  <read_first>server/src/services/renderers/types.ts, server/src/services/nexus-settings.ts, ui/src/index.css</read_first>
  <behavior>
    - buildPalette("#1e66f5") returns array of 7 PaletteRole objects with names: background, surface, overlay, text, accent-1, accent-2, accent-3
    - Each PaletteRole has dark.oklch starting with "oklch(" and dark.hex starting with "#"
    - Each PaletteRole has light.oklch starting with "oklch(" and light.hex starting with "#"
    - dark.wcagAA for background role: true if contrast ratio of dark background hex against dark text hex >= 4.5
    - text role always has wcagAA: true (it IS the text, not measured against text)
    - buildPalette with different seed hues produces different hex values but same role names
    - exportToCss(palette) contains "--background:" and "--foreground:" CSS custom property declarations
    - exportToTailwind(palette) contains valid JavaScript/TypeScript object with colors key
    - exportToVSCode(palette) contains "editor.background" and "editor.foreground" keys
    - exportToJson(palette) is valid parseable JSON
    - renderThemePalette({ seedHex: "#1e66f5" }) returns RenderResult with contentType "application/json"
  </behavior>
  <action>
1. Create `server/src/__tests__/theme-renderer.test.ts` FIRST (TDD red):
   - Test buildPalette returns 7 roles with correct names
   - Test all roles have dark and light variants with oklch and hex strings
   - Test WCAG AA computation: pick a known seed where we can verify contrast manually
   - Test text role always has wcagAA: true
   - Test different seeds produce different hex values
   - Test no HSL values anywhere in output (grep for "hsl" in all string fields)
   - Test exportToCss output contains "--background:" and "oklch(" values
   - Test exportToTailwind output contains "colors" object
   - Test exportToVSCode output is valid JSON with "editor.background" key
   - Test exportToJson is parseable JSON matching the palette structure
   - Test renderThemePalette returns valid RenderResult

2. Create `server/src/services/renderers/theme-renderer.ts`:
   - Import `converter`, `formatHex` from "culori" and `wcagContrast` from "wcag-contrast"
   - `const toOklch = converter("oklch")`
   - Define DARK_ROLES and LIGHT_ROLES arrays (L, C values from research):
     ```
     DARK: bg(0.14, 0.010), surface(0.17, 0.012), overlay(0.22, 0.015),
           text(0.93, 0.008), accent-1(0.72, 0.15), accent-2(0.65, 0.13), accent-3(0.58, 0.10)
     LIGHT: bg(0.94, 0.005), surface(0.91, 0.008), overlay(0.85, 0.012),
            text(0.28, 0.008), accent-1(0.55, 0.16), accent-2(0.48, 0.14), accent-3(0.40, 0.11)
     ```
   - `buildPalette(seedHex: string): PaletteRole[]`:
     - Parse seed with toOklch, extract hue (h ?? 0)
     - For each role pair (dark/light): compute hex via formatHex({ mode: "oklch", l, c, h: hue })
     - Compute WCAG AA: for non-text roles, check contrast of role hex against the text role hex (dark text for dark variant, light text for light variant). Text role itself always wcagAA: true.
     - Return 7 PaletteRole objects
   - `exportToCss(palette: PaletteRole[], variant: "dark" | "light"): string`:
     - Map role names to CSS tokens: background->--background, surface->--card, overlay->--secondary, text->--foreground, accent-1->--primary, accent-2->--accent, accent-3->--muted
     - Output `:root { --background: oklch(...); ... }` format
   - `exportToTailwind(palette: PaletteRole[]): string`:
     - Output a `module.exports = { theme: { extend: { colors: { ... } } } }` config snippet
   - `exportToVSCode(palette: PaletteRole[]): string`:
     - Output JSON with `editor.background`, `editor.foreground`, `activityBar.background`, etc.
   - `exportToJson(palette: PaletteRole[]): string`:
     - Output `JSON.stringify({ palette, generated: ISO date }, null, 2)`
   - `renderThemePalette(input: Record<string, unknown>): Promise<RenderResult>`:
     - Extract seedHex from input
     - Build palette, build all 4 exports
     - Return ThemePaletteBundle as JSON buffer

   CRITICAL: No HSL anywhere. All color math in OKLCH. All hex conversions via culori formatHex from oklch mode.
  </action>
  <verify>
    <automated>cd /opt/nexus && pnpm --filter server exec vitest run src/__tests__/theme-renderer.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c "oklch" server/src/services/renderers/theme-renderer.ts` returns at least 10
    - `grep -ci "hsl" server/src/services/renderers/theme-renderer.ts` returns 0 (no HSL anywhere)
    - `grep "wcagContrast" server/src/services/renderers/theme-renderer.ts` matches
    - `grep "converter.*oklch" server/src/services/renderers/theme-renderer.ts` matches
    - `grep "formatHex" server/src/services/renderers/theme-renderer.ts` matches
    - `grep "theme-palette-bundle" server/src/services/renderers/theme-renderer.ts` matches
    - All tests in theme-renderer.test.ts pass
  </acceptance_criteria>
  <done>Palette engine generates 7 roles with dark+light OKLCH variants, WCAG AA validated, 4 export formats working; all tests green</done>
</task>

<task type="auto">
  <name>Task 2: Extend nexus-settings schema with customTheme + test</name>
  <files>server/src/services/nexus-settings.ts, server/src/__tests__/nexus-settings-custom-theme.test.ts</files>
  <read_first>server/src/services/nexus-settings.ts, server/src/services/renderers/types.ts</read_first>
  <action>
1. Modify `server/src/services/nexus-settings.ts`:
   - Add paletteRoleSchema as a Zod object:
     ```typescript
     const paletteRoleSchema = z.object({
       name: z.string(),
       dark: z.object({ oklch: z.string(), hex: z.string(), wcagAA: z.boolean() }),
       light: z.object({ oklch: z.string(), hex: z.string(), wcagAA: z.boolean() }),
     });
     ```
   - Add `customTheme` optional field to nexusSettingsSchema:
     ```typescript
     customTheme: z.object({
       seedHex: z.string(),
       palette: z.array(paletteRoleSchema),
     }).optional(),
     ```
   - Ensure existing tests still pass (the optional field with no default should not break anything)

2. Create `server/src/__tests__/nexus-settings-custom-theme.test.ts`:
   - Test that nexusSettingsSchema.parse({}) succeeds (customTheme is optional)
   - Test that nexusSettingsSchema.parse({ customTheme: { seedHex: "#1e66f5", palette: [...valid palette...] } }) succeeds
   - Test that invalid customTheme (missing seedHex) fails validation
   - Test set() with customTheme persists and get() retrieves it (use temp directory for settings file -- look at existing nexus-settings tests for the pattern)
  </action>
  <verify>
    <automated>cd /opt/nexus && pnpm --filter server exec vitest run src/__tests__/nexus-settings-custom-theme.test.ts && pnpm tsc --noEmit --project server/tsconfig.json</automated>
  </verify>
  <acceptance_criteria>
    - `grep "customTheme" server/src/services/nexus-settings.ts` matches
    - `grep "paletteRoleSchema" server/src/services/nexus-settings.ts` matches
    - `grep "seedHex" server/src/services/nexus-settings.ts` matches
    - All tests pass
    - TypeScript compiles without errors
  </acceptance_criteria>
  <done>nexus-settings.json schema accepts customTheme with palette array; persists and retrieves correctly; existing settings behavior unchanged</done>
</task>

</tasks>

<verification>
- `pnpm --filter server exec vitest run src/__tests__/theme-renderer.test.ts src/__tests__/nexus-settings-custom-theme.test.ts` — all tests pass
- `pnpm tsc --noEmit --project server/tsconfig.json` — no type errors
- No `hsl` string appears anywhere in theme-renderer.ts
</verification>

<success_criteria>
- Palette engine produces 7 roles with OKLCH dark+light variants from single seed (THEME-01, THEME-02, THEME-06)
- WCAG AA contrast validated for all foreground/background pairs (THEME-03)
- Four export formatters produce CSS, Tailwind, VS Code, JSON (THEME-05)
- nexus-settings.json schema extended with customTheme (THEME-07)
- All tests pass, no HSL intermediates
</success_criteria>

<output>
After completion, create `.planning/phases/41-diagrams-icons-theme-engine/41-03-SUMMARY.md`
</output>
