---
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"
---
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.
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
@.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
```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 };
}
```
```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(),
});
```
Task 1: OKLCH palette engine with WCAG validation and export formatters + tests
server/src/services/renderers/theme-renderer.ts, server/src/__tests__/theme-renderer.test.ts
server/src/services/renderers/types.ts, server/src/services/nexus-settings.ts, ui/src/index.css
- 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"
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): Promise`:
- 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.
cd /opt/nexus && pnpm --filter server exec vitest run src/__tests__/theme-renderer.test.ts
- `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
Palette engine generates 7 roles with dark+light OKLCH variants, WCAG AA validated, 4 export formats working; all tests green
Task 2: Extend nexus-settings schema with customTheme + test
server/src/services/nexus-settings.ts, server/src/__tests__/nexus-settings-custom-theme.test.ts
server/src/services/nexus-settings.ts, server/src/services/renderers/types.ts
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)
cd /opt/nexus && pnpm --filter server exec vitest run src/__tests__/nexus-settings-custom-theme.test.ts && pnpm tsc --noEmit --project server/tsconfig.json
- `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
nexus-settings.json schema accepts customTheme with palette array; persists and retrieves correctly; existing settings behavior unchanged
- `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
- 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