# Phase 41: Diagrams, Icons & Theme Engine — Research

**Researched:** 2026-04-04
**Domain:** Server-side SVG generation, OKLCH color engine, LLM-driven content rendering, React UI panels
**Confidence:** HIGH

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions
All implementation choices are at Claude's discretion — discuss phase was skipped per user setting. Use ROADMAP phase goal, success criteria, and codebase conventions to guide decisions.

### Claude's Discretion
Full discretion on: Mermaid server-side rendering approach, icon SVG generation strategy, OKLCH palette algorithm, multi-asset job output pattern, UI panel structure, export format implementations.

### Deferred Ideas (OUT OF SCOPE)
None — discuss phase skipped.
</user_constraints>

---

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| DIAG-01 | User can generate diagrams from natural language description | LLM generates Mermaid syntax from prompt; job routed via jobType="diagram" |
| DIAG-02 | System renders Mermaid syntax to SVG and PNG formats | Server-side: Playwright headless Chromium loads mermaid.js; SVG output sanitized with DOMPurify; PNG via @resvg/resvg-js or sharp following org-chart-svg.ts pattern |
| DIAG-03 | User can view and edit the Mermaid source for refinement | DiagramSourcePanel collapsible with editable Textarea; re-render sends new POST job |
| DIAG-04 | System supports architecture, flowchart, ERD, sequence, and mind map diagram types | Mermaid supports all five; diagram type selector maps to preamble hints in LLM prompt |
| DIAG-05 | Mermaid rendering enforces strict security level to prevent XSS | Strip %%{init}%% and click directives server-side (regex) before render; DOMPurify already in server deps (v3.3.2) sanitizes SVG output |
| ICON-01 | User can generate SVG icons from a text description | LLM (Claude/Ollama) generates SVG path markup via structured prompt; job routed jobType="icon-set" |
| ICON-02 | System produces icon sets with consistent visual style | Prompt enforces style (outline/filled/rounded), viewBox="0 0 24 24", stroke-width=1.5 convention; SVGO cleans output |
| ICON-03 | User can export icons in multiple sizes and formats (SVG, PNG) | SVG stored in bundle asset; PNG variants (16, 32, 64) generated server-side via sharp(svgBuffer, {density:96}).resize(N).png() |
| THEME-01 | User can pick a seed color and receive a complete palette | ThemeSeedInput sends hex to server; server computes palette via culori 4.0.2 OKLCH math |
| THEME-02 | System generates palette in OKLCH color space with Catppuccin-style naming | culori: parse hex, convert to oklch, derive palette roles (bg, surface, overlay, text, accent-1/2/3) by L/C adjustments; output as OKLCH + hex |
| THEME-03 | System validates WCAG AA contrast for all foreground/background pairs | wcag-contrast 3.0.0: score(fg, bg) >= 4.5 = AA pass; computed per swatch pair |
| THEME-04 | User can preview Nexus UI with the generated palette live | ThemePreviewPanel scoped to .nexus-theme-preview; inject CSS vars via JS into that container only; no global bleed |
| THEME-05 | User can export palette as CSS custom properties, Tailwind config, VS Code theme, or JSON | Four pure-TypeScript formatters; no external deps needed |
| THEME-06 | System generates dark and light variants from single seed color | Two palette passes: dark (Catppuccin Mocha L ranges) and light (Catppuccin Latte L ranges), both from same hue |
| THEME-07 | User can apply generated theme to their Nexus instance in one click | PATCH /api/nexus/settings with new theme tokens; ThemeContext extended to accept custom token map; persist in nexus-settings.json |
</phase_requirements>

---

## Summary

Phase 41 builds three content generators on top of the Phase 40 job infrastructure: Mermaid diagram rendering, LLM-driven SVG icon generation, and an OKLCH theme engine. All three follow the same async pattern — POST /content-jobs returns 202 + jobId, SSE stream delivers progress, result assets land in the `generated` namespace.

**Mermaid rendering** on the server cannot use the browser mermaid library directly via jsdom (mermaid 11 requires real browser SVG APIs that jsdom does not implement). The solution is to use Playwright's already-installed Chromium binary (`~/.cache/ms-playwright/chromium-1217/chrome-linux64/chrome`) to run a headless page that calls `mermaid.render()`. This avoids adding new binary dependencies — the Chromium is already downloaded for e2e tests. SVG output is sanitized with DOMPurify (already in server deps). PNG is produced by `sharp(svgBuffer, { density: 144 })` following the exact same pattern as `renderOrgChartPng` in `org-chart-svg.ts`.

**Icon generation** uses the LLM to produce SVG path markup from a text description, then SVGO to clean and optimize the output. The job produces a JSON bundle stored as the primary asset, containing N SVG strings plus metadata. PNG variants are generated server-side for the bundle. This sidesteps the "N icons = N separate assets" problem since `content_jobs.resultAssetId` is a single UUID.

**The theme engine** uses `culori` (needs to be added as a server dependency — not yet installed there) for all OKLCH math, with `wcag-contrast` for AA validation. "Apply theme" extends the existing `nexus-settings.json` persistence and `ThemeContext` to support a custom token map injected into `document.documentElement` CSS variables.

**Primary recommendation:** Add `culori`, `@resvg/resvg-js`, `svgo`, and `wcag-contrast` to server deps. Add `playwright-core` to server deps for headless Mermaid rendering using the already-installed Chromium. Keep icon generation LLM-only with SVGO cleanup — no external image API required.

---

## Standard Stack

### Core (Server — new additions needed)

| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| culori | 4.0.2 | OKLCH color math (parse, convert, derive palette) | Mandated by STATE.md: "OKLCH via culori — HSL is forbidden as an intermediate"; perceptually uniform; ships ESM + CJS bundle |
| @resvg/resvg-js | 2.6.2 | SVG to PNG rasterization | Pure Rust via NAPI, no librsvg dependency, cross-platform; linux-x64-gnu confirmed available |
| wcag-contrast | 3.0.0 | WCAG AA contrast ratio validation | Small, correct; uses WCAG 2.x relative luminance formula |
| svgo | 4.0.1 | SVG optimization and cleanup for LLM-generated icon SVGs | Cleans up LLM output, reduces file size 30-60%, fixes degenerate paths |
| playwright-core | 1.58.2 | Headless Chromium for server-side Mermaid rendering | `playwright-core` has no bundled browser; uses already-installed Chromium from devDep; keeps server dep minimal |

### Core (Server — already installed)

| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| sharp | 0.34.5 | PNG output for diagrams and icons (SVG buffer to PNG) | Already in server deps; established pattern in org-chart-svg.ts |
| dompurify | 3.3.2 | Sanitize SVG output from Mermaid render | Already in server deps; required by DIAG-05 |
| jsdom | 28.1.0 | DOM environment for DOMPurify in Node | Already in server deps; DOMPurify requires a DOM |
| mermaid | 11.14.0 | Client-side Mermaid rendering (UI only, loaded in headless page) | Already in ui/package.json; also loaded via CDN in headless page |

### Core (UI — new additions needed)

| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| shadcn progress | — | Job progress bar | Specified in UI-SPEC.md |
| shadcn toggle | — | Dark/light variant switcher in theme preview | Specified in UI-SPEC.md |

### Alternatives Considered

| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| playwright-core for Mermaid | @mermaid-js/mermaid-cli (mmdc) | mmdc ships its own Chromium (~300MB extra); STATE.md blocker explicitly asks to check whether playwright and mmdc can share a binary; playwright-core avoids the duplicate download |
| playwright-core for Mermaid | mermaid + jsdom on server | Mermaid 11 requires real browser DOM (SVG getBBox, foreignObject); jsdom does not implement these — rendering will fail silently |
| @resvg/resvg-js for SVG to PNG | sharp(svgBuffer) | sharp depends on librsvg for SVG input support; not guaranteed in all libvips builds; @resvg/resvg-js is self-contained Rust; use @resvg/resvg-js as primary, sharp as fallback |
| LLM-generated SVG for icons | Stable Diffusion / raster then trace | Explicitly out of scope per REQUIREMENTS.md; no external API; LLM SVG is instant and vector |
| culori 4.0.2 | chroma-js 3.2.0 | chroma-js lacks OKLCH support; STATE.md mandates culori |

**Installation (server):**
```bash
pnpm --filter server add culori @resvg/resvg-js wcag-contrast svgo playwright-core
```

**Installation (UI — shadcn components):**
```bash
pnpm --filter ui exec shadcn add progress
pnpm --filter ui exec shadcn add toggle
```

**Version verification (confirmed 2026-04-04):**
- `@resvg/resvg-js`: 2.6.2 (`npm info @resvg/resvg-js version`)
- `culori`: 4.0.2 (`npm info culori version`)
- `wcag-contrast`: 3.0.0 (`npm info wcag-contrast version`)
- `svgo`: 4.0.1 (`npm info svgo version`)
- `playwright-core`: 1.58.2 (same version as `@playwright/test` already installed; must match exactly)

---

## Architecture Patterns

### Recommended Project Structure

```
server/src/
├── services/
│   ├── content-job-runner.ts      # MODIFY: extend renderContent() switch with new jobTypes
│   ├── renderers/
│   │   ├── diagram-renderer.ts    # NEW: Mermaid headless rendering + DOMPurify + PNG
│   │   ├── icon-renderer.ts       # NEW: LLM SVG prompt + SVGO + PNG variants
│   │   └── theme-renderer.ts      # NEW: OKLCH palette engine + WCAG validation + exporters
│   └── ...
ui/src/
├── pages/
│   └── ContentStudio.tsx          # NEW: tabbed page hosting all three generators
├── components/
│   ├── DiagramGeneratePanel.tsx   # NEW
│   ├── DiagramPreview.tsx         # NEW (reuses .paperclip-mermaid CSS class from index.css)
│   ├── DiagramSourcePanel.tsx     # NEW
│   ├── IconGeneratePanel.tsx      # NEW
│   ├── IconResultGrid.tsx         # NEW
│   ├── IconDownloadBar.tsx        # NEW
│   ├── ThemeSeedInput.tsx         # NEW
│   ├── ThemePaletteGrid.tsx       # NEW
│   ├── ThemePreviewPanel.tsx      # NEW
│   ├── ThemeExportTabs.tsx        # NEW
│   └── ThemeApplyConfirmDialog.tsx # NEW
├── api/
│   └── contentJobs.ts             # NEW: POST/GET /content-jobs + SSE EventSource helper
└── hooks/
    └── useContentJob.ts           # NEW: submit job + subscribe to SSE progress
```

### Pattern 1: renderContent() Switch Extension (Server)

Phase 40 established the stub. Phase 41 fills in real renderers:

```typescript
// server/src/services/content-job-runner.ts — modified
import { renderDiagram } from "./renderers/diagram-renderer.js";
import { renderIconSet } from "./renderers/icon-renderer.js";
import { renderThemePalette } from "./renderers/theme-renderer.js";

export async function renderContent(
  jobType: string,
  input: Record<string, unknown>,
): Promise<{ filename: string; contentType: string; buffer: Buffer }> {
  switch (jobType) {
    case "diagram":        return renderDiagram(input);
    case "icon-set":       return renderIconSet(input);
    case "theme-palette":  return renderThemePalette(input);
    default:
      throw new Error(`Unknown jobType: ${jobType}`);
  }
}
```

### Pattern 2: Headless Mermaid Rendering (Server)

Playwright's Chromium is already installed at `~/.cache/ms-playwright/chromium-1217/chrome-linux64/chrome`. Use `playwright-core` (no bundled browsers) for server-side mermaid rendering. The playwright version in `package.json` must match the installed Chromium version (1.58.2).

```typescript
// server/src/services/renderers/diagram-renderer.ts
import { chromium } from "playwright-core";
import { JSDOM } from "jsdom";
import DOMPurify from "dompurify";
import { Resvg } from "@resvg/resvg-js";

const INIT_BLOCK_RE = /%%\{[\s\S]*?\}%%/g;
const CLICK_LINE_RE = /^\s*click\s+.*/gim;

function stripUnsafeDirectives(source: string): { cleaned: string; stripped: boolean } {
  const withoutInit = source.replace(INIT_BLOCK_RE, "");
  const withoutClick = withoutInit.replace(CLICK_LINE_RE, "");
  const cleaned = withoutClick.trim();
  return { cleaned, stripped: cleaned !== source.trim() };
}

export async function renderDiagram(
  input: Record<string, unknown>,
): Promise<{ filename: string; contentType: string; buffer: Buffer }> {
  const source = String(input.source ?? "");
  const darkMode = Boolean(input.darkMode ?? false);
  const { cleaned, stripped } = stripUnsafeDirectives(source);

  const browser = await chromium.launch({
    executablePath: resolveBrowserPath(), // resolves ~/.cache/ms-playwright/...
    headless: true,
  });
  try {
    const page = await browser.newPage();
    // Inline mermaid rendering page — mermaid loaded from ui/node_modules via relative import
    // or CDN for simplicity in headless context
    await page.setContent(buildMermaidHtml(cleaned, darkMode));
    await page.waitForSelector("#render svg", { timeout: 15_000 });
    const svgRaw = await page.$eval("#render", (el: Element) => el.innerHTML);

    // Sanitize SVG output
    const { window } = new JSDOM("");
    const purify = DOMPurify(window as unknown as Window);
    const svgClean = purify.sanitize(svgRaw, { USE_PROFILES: { svg: true } });
    const svgBuffer = Buffer.from(svgClean);

    // Rasterize to PNG via @resvg/resvg-js (no librsvg dependency)
    const resvg = new Resvg(svgClean, { dpi: 144 });
    const pngBuffer = resvg.render().asPng();

    return buildDiagramBundle(svgBuffer, pngBuffer, stripped);
  } finally {
    await browser.close();
  }
}
```

### Pattern 3: Multi-Asset Job Output (JSON Bundle)

The `content_jobs.resultAssetId` is a single UUID. For jobs producing multiple files (SVG + PNG for diagrams, N SVGs for icon sets), store all output in a **JSON bundle asset** (`application/json`). The UI parses the bundle to offer individual downloads.

```typescript
// Bundle schema (discriminated union by type field)
type DiagramBundle = {
  type: "diagram-bundle";
  svgBase64: string;
  pngBase64: string;
  mermaidSource: string;
  stripped: boolean;
};

type IconSetBundle = {
  type: "icon-set-bundle";
  icons: Array<{
    name: string;
    style: "outline" | "filled" | "rounded";
    svgSource: string;
    svgBase64: string;
  }>;
};

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

### Pattern 4: OKLCH Palette Engine (Server)

culori 4.0.2 ships a CJS bundle at `./bundled/culori.cjs` and ESM at `./src/index.js`. In the server (tsx/ESM context), import from culori directly. The STATE.md constraint "HSL is forbidden as an intermediate" means all color derivation must operate in OKLCH directly.

```typescript
// server/src/services/renderers/theme-renderer.ts
import { oklch, formatHex, converter } from "culori";
import wcagContrast from "wcag-contrast";

const toOklch = converter("oklch");

// L/C values approximate Catppuccin Mocha (dark) and Latte (light) ranges
const DARK_ROLES = [
  { name: "background",  l: 0.14, c: 0.010 },
  { name: "surface",     l: 0.17, c: 0.012 },
  { name: "overlay",     l: 0.22, c: 0.015 },
  { name: "text",        l: 0.93, c: 0.008 },
  { name: "accent-1",    l: 0.72, c: 0.15  },
  { name: "accent-2",    l: 0.65, c: 0.13  },
  { name: "accent-3",    l: 0.58, c: 0.10  },
];

const LIGHT_ROLES = [
  { name: "background",  l: 0.94, c: 0.005 },
  { name: "surface",     l: 0.91, c: 0.008 },
  { name: "overlay",     l: 0.85, c: 0.012 },
  { name: "text",        l: 0.28, c: 0.008 },
  { name: "accent-1",    l: 0.55, c: 0.16  },
  { name: "accent-2",    l: 0.48, c: 0.14  },
  { name: "accent-3",    l: 0.40, c: 0.11  },
];

export function buildPalette(seedHex: string): PaletteRole[] {
  const seed = toOklch(seedHex);
  if (!seed) throw new Error(`Invalid seed color: ${seedHex}`);
  const hue = seed.h ?? 0;

  return DARK_ROLES.map((darkRole, i) => {
    const lightRole = LIGHT_ROLES[i]!;
    const darkHex = formatHex({ mode: "oklch", l: darkRole.l, c: darkRole.c, h: hue });
    const lightHex = formatHex({ mode: "oklch", l: lightRole.l, c: lightRole.c, h: hue });
    // Text roles for contrast computation
    const darkTextHex = formatHex({ mode: "oklch", l: 0.93, c: 0.008, h: hue });
    const lightTextHex = formatHex({ mode: "oklch", l: 0.28, c: 0.008, h: hue });
    return {
      name: darkRole.name,
      dark: {
        oklch: `oklch(${darkRole.l} ${darkRole.c} ${hue.toFixed(1)})`,
        hex: darkHex,
        wcagAA: darkRole.name === "text" ? true : wcagContrast.hex(darkHex, darkTextHex) >= 4.5,
      },
      light: {
        oklch: `oklch(${lightRole.l} ${lightRole.c} ${hue.toFixed(1)})`,
        hex: lightHex,
        wcagAA: lightRole.name === "text" ? true : wcagContrast.hex(lightHex, lightTextHex) >= 4.5,
      },
    };
  });
}
```

### Pattern 5: Theme Preview CSS Injection (UI)

The `ThemePreviewPanel` must inject generated CSS variables into `.nexus-theme-preview` only — not into `document.documentElement`. This prevents the preview from bleeding into the nav/sidebar.

```typescript
// ui/src/components/ThemePreviewPanel.tsx
const ROLE_TO_TOKEN: Record<string, string> = {
  "background": "--background",
  "surface":    "--card",
  "overlay":    "--secondary",
  "text":       "--foreground",
  "accent-1":   "--primary",
  "accent-2":   "--accent",
  "accent-3":   "--muted",
};

function injectPreviewTokens(
  container: HTMLElement,
  palette: PaletteRole[],
  variant: "dark" | "light",
): void {
  // Imperative DOM update — not React state — to avoid re-render loop
  palette.forEach((role) => {
    const tokenName = ROLE_TO_TOKEN[role.name];
    if (!tokenName) return;
    const value = variant === "dark" ? role.dark.hex : role.light.hex;
    container.style.setProperty(tokenName, value);
  });
}
```

### Pattern 6: useContentJob Hook (UI)

```typescript
// ui/src/hooks/useContentJob.ts
import { useState, useRef, useCallback } from "react";
import { api } from "../api/client";

interface JobState {
  jobId: string | null;
  status: "idle" | "queued" | "running" | "done" | "failed";
  progress: number;      // 0-100
  resultAssetId: string | null;
  errorMessage: string | null;
}

export function useContentJob(companyId: string) {
  const [state, setState] = useState<JobState>({
    jobId: null, status: "idle", progress: 0, resultAssetId: null, errorMessage: null,
  });
  const esRef = useRef<EventSource | null>(null);

  const submit = useCallback(async (
    jobType: string,
    input: Record<string, unknown>,
  ) => {
    const { jobId } = await api.post<{ jobId: string; status: string }>(
      `/companies/${companyId}/content-jobs`,
      { jobType, input },
    );
    setState({ jobId, status: "queued", progress: 5, resultAssetId: null, errorMessage: null });

    esRef.current?.close();
    const es = new EventSource(
      `/api/companies/${companyId}/content-jobs/${jobId}/events`,
      { withCredentials: true },
    );
    esRef.current = es;

    es.addEventListener("status", (e) => {
      const data = JSON.parse((e as MessageEvent).data) as {
        status: string; resultAssetId?: string; errorMessage?: string;
      };
      setState((prev) => ({
        ...prev,
        status: data.status as JobState["status"],
        progress: data.status === "running" ? 50 : data.status === "done" ? 100 : prev.progress,
        resultAssetId: data.resultAssetId ?? prev.resultAssetId,
        errorMessage: data.errorMessage ?? null,
      }));
      if (data.status === "done" || data.status === "failed") es.close();
    });
    // EventSource reconnects automatically on error — no explicit handler needed
  }, [companyId]);

  return { state, submit };
}
```

### Pattern 7: "Apply Theme" to Nexus Instance

The existing `nexusSettingsService` persists JSON to `data/nexus-settings.json`. Extend its Zod schema with an optional `customTheme` field. When applied, the server stores the palette and the UI's `ThemeContext` reads it on startup.

```typescript
// Extend nexusSettingsSchema in nexus-settings.ts
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() }),
});

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(),
  customTheme: z.object({           // NEW
    seedHex: z.string(),
    palette: z.array(paletteRoleSchema),
  }).optional(),
});
```

The `ThemeContext` already calls `applyTheme(theme)` which sets CSS variables on `document.documentElement`. Add `applyCustomTheme(palette, variant)` that injects the palette tokens into the root. On initial load, check if `nexusSettings.customTheme` is set and apply it. Expose "custom" as a Theme option in the THEME_META map.

### Anti-Patterns to Avoid

- **Running mermaid directly in jsdom server-side:** Mermaid 11 requires `getBBox()`, `SVGMatrix`, and `foreignObject` support. jsdom does not implement these — rendering will silently produce empty or malformed SVG.
- **Storing N icons as N separate content_jobs:** Creates orphan accumulation. Store one JSON bundle asset per icon-set job containing all SVG strings.
- **HSL as intermediate in palette generation:** STATE.md explicitly forbids this. Use OKLCH throughout. No `hsl()` conversions anywhere in the palette pipeline.
- **Injecting theme CSS into `document.documentElement` during preview:** Bleeds into nav/sidebar. Use `.nexus-theme-preview` container scope via `container.style.setProperty()`.
- **Blocking HTTP on render:** All three renderers must go through the async job queue. No synchronous render endpoints.
- **Launching a new browser instance per request without cleanup:** Playwright `browser.close()` must be called in a `finally` block. Browser leaks will exhaust system memory.

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| OKLCH color math | Custom polar-to-cartesian | culori 4.0.2 | Correct gamut clamping, perceptual uniformity, all color space conversions; mandated by STATE.md |
| WCAG contrast ratio | Manual luminance formula | wcag-contrast 3.0.0 | WCAG 2.x formula requires exact sRGB linearization; easy to get wrong; library is small and correct |
| SVG optimization | String manipulation | svgo 4.0.1 | Handles degenerate paths, redundant attributes, precision issues in LLM output |
| SVG to PNG rasterization | Custom renderer | @resvg/resvg-js 2.6.2 | Production-quality Rust renderer; no librsvg dependency risk |
| Mermaid server-side | Custom graph layout | Playwright + mermaid.js | Mermaid's layout algorithms (dagre, elk) are non-trivial; existing browser implementation is correct and already maintained |

**Key insight:** The LLM-generated SVG for icons will contain verbose, non-optimized markup. SVGO with `preset-default` reduces file size by 30-60% and fixes common path errors from LLM output.

---

## Common Pitfalls

### Pitfall 1: Playwright Browser Path in Production

**What goes wrong:** `chromium.launch()` without `executablePath` triggers a fresh Chromium download attempt. In the Nexus monorepo dev context, `@playwright/test` is in root devDependencies; `playwright-core` has no bundled browsers. Without the explicit path, launch fails.

**Why it happens:** The Playwright Chromium cache is at `~/.cache/ms-playwright/chromium-1217/chrome-linux64/chrome` — installed by `@playwright/test` for e2e tests. `playwright-core` alone does not install any browsers.

**How to avoid:** Write a `resolveBrowserPath()` helper that reads `PLAYWRIGHT_BROWSERS_PATH` env var first, falls back to the default cache path `~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome`, and throws a clear error if not found. Document the required env var in server startup.

**Warning signs:** `Error: browserType.launch: Failed to launch chromium because executable doesn't exist` in server logs.

### Pitfall 2: Mermaid `click` Directive Strip — Incomplete Regex

**What goes wrong:** A simple regex like `click\s+\w+` only strips `click NodeId` but Mermaid supports `click NodeId "url"` and `click NodeId call functionName()`. Incomplete strip leaves partial directives that break rendering.

**How to avoid:** Use a line-oriented strip — remove any line starting with `click` after normalization:
```typescript
const CLICK_LINE_RE = /^\s*click\s+.*/gim;
const INIT_BLOCK_RE = /%%\{[\s\S]*?\}%%/g;
const cleaned = source.replace(INIT_BLOCK_RE, "").replace(CLICK_LINE_RE, "");
```

**Warning signs:** Mermaid render errors for diagrams containing click interactions.

### Pitfall 3: culori ESM-only Import in CommonJS Context

**What goes wrong:** culori 4.0.2 ships `"."` pointing to `"./src/index.js"` (ESM) as primary export. If the server's TypeScript `moduleResolution` is CommonJS-style, `import { oklch } from "culori"` may fail at runtime.

**Why it happens:** The server uses `tsx` which is ESM-compatible, but if `server/tsconfig.json` uses `"moduleResolution": "node"` (pre-Node16), the ESM conditional export may not be honored.

**How to avoid:** Verify `server/tsconfig.json` `moduleResolution` setting. If it's `"node"` (not `"node16"` or `"bundler"`), use the explicit CJS path: `import culori from "culori/require"`. If it's `"node16"` or `"nodenext"`, standard `import { oklch } from "culori"` works. Check during Wave 0.

**Warning signs:** `ERR_REQUIRE_ESM` or module resolution errors at `tsx` startup.

### Pitfall 4: LLM SVG Output — Invalid viewBox

**What goes wrong:** LLMs generating SVG icons sometimes produce `viewBox` values inconsistent with path coordinates, or omit the `xmlns` attribute. Icons appear clipped or invisible.

**How to avoid:** After SVGO optimization, validate the output: (1) `viewBox` attribute is present — normalize to `"0 0 24 24"` if absent; (2) `xmlns="http://www.w3.org/2000/svg"` is present for standalone SVG files; (3) at least one `<path>`, `<circle>`, or `<rect>` element exists. Return an error to the user with copy "Render failed — {detail}. Try again." if validation fails.

**Warning signs:** Empty white squares in the icon grid; zero byte SVG files.

### Pitfall 5: ThemePreviewPanel Re-render Loop

**What goes wrong:** The theme preview updates CSS properties, which triggers a React re-render, which reads the CSS properties, which triggers another update.

**How to avoid:** Use `useRef` for the container element and set CSS properties imperatively (`container.style.setProperty()`), not via React state. The debounce fires outside the React render cycle. Only the palette data (from server) is stored in React state; CSS injection is a side effect in `useEffect`.

### Pitfall 6: content_jobs.resultAssetId is Single — Multi-file Output

**What goes wrong:** Diagram jobs produce SVG + PNG. Icon sets produce N SVG files. Storing each as a separate asset and pointing `resultAssetId` to only one breaks the download flow.

**How to avoid:** Store all output as a single JSON bundle asset (`application/json`). The UI parses the bundle to offer individual file downloads. The bundle schema is discriminated by `type` field (`"diagram-bundle"`, `"icon-set-bundle"`, `"theme-palette-bundle"`).

### Pitfall 7: Browser Instance Leak in diagram-renderer

**What goes wrong:** If the mermaid render page throws or times out, `browser.close()` never runs and the Chromium process is orphaned.

**How to avoid:** Always wrap the `page`/`browser` lifecycle in `try { ... } finally { await browser.close(); }`. Set a render timeout (15s) so the process does not hang indefinitely. Add a startup-time browser pool or singleton if latency becomes a concern (single-user context, so one browser at a time is acceptable).

---

## Code Examples

### Culori OKLCH Parse and Derive (culori 4.0.2)

```typescript
import { oklch, formatHex, converter } from "culori";

const toOklch = converter("oklch");
const seed = toOklch("#1e66f5");
// seed = { mode: "oklch", l: ~0.45, c: ~0.22, h: ~262 }

// Derive a background role:
const bg = formatHex({ mode: "oklch", l: 0.14, c: 0.010, h: seed.h ?? 0 });
// bg = "#191727" (approximately)
```

### WCAG Contrast Check (wcag-contrast 3.0.0)

```typescript
import wcagContrast from "wcag-contrast";

const ratio = wcagContrast.hex("#89b4fa", "#1e1e2e"); // ~8.5
const passesAA = ratio >= 4.5;   // true
const passesAAA = ratio >= 7.0;  // true
```

### DOMPurify in Node (jsdom + dompurify — existing server deps)

```typescript
import { JSDOM } from "jsdom";
import DOMPurify from "dompurify";

const { window } = new JSDOM("");
const purify = DOMPurify(window as unknown as Window);
const cleanSvg = purify.sanitize(rawSvg, { USE_PROFILES: { svg: true } });
```

### Sharp SVG to PNG (Established pattern from org-chart-svg.ts)

```typescript
import sharp from "sharp";

const pngBuffer = await sharp(Buffer.from(svgString), { density: 144 })
  .resize(targetWidth, targetHeight)
  .png()
  .toBuffer();
```

### @resvg/resvg-js SVG to PNG (Primary alternative — no librsvg)

```typescript
import { Resvg } from "@resvg/resvg-js";

const resvg = new Resvg(svgString, { dpi: 144, fitTo: { mode: "width", value: 1200 } });
const pngBuffer = resvg.render().asPng();
```

### SVGO SVG Optimization (svgo 4.0.1)

```typescript
import { optimize } from "svgo";

const result = optimize(rawSvgString, { plugins: ["preset-default"] });
const optimizedSvg = result.data;
```

### Mermaid Security Strip

```typescript
const INIT_BLOCK_RE = /%%\{[\s\S]*?\}%%/g;
const CLICK_LINE_RE = /^\s*click\s+.*/gim;

function stripUnsafeDirectives(source: string): { cleaned: string; stripped: boolean } {
  const withoutInit = source.replace(INIT_BLOCK_RE, "");
  const withoutClick = withoutInit.replace(CLICK_LINE_RE, "");
  const cleaned = withoutClick.trim();
  return { cleaned, stripped: cleaned !== source.trim() };
}
```

---

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| HSL color math for UI themes | OKLCH via culori | CSS Color 4 spec (2022+) | Perceptually uniform; gradients do not go gray in the middle |
| Mermaid CLI (mmdc) for server rendering | Playwright headless + mermaid.js | mermaid 10+ dropped jsdom compat | No new Chromium download; reuses already-installed browser |
| Manual WCAG contrast formula | wcag-contrast library | WCAG 2.1 (2018) | Correct sRGB linearization handling |
| SVG to PNG via sharp(svgBuffer) | @resvg/resvg-js (Rust) | librsvg dependency unreliable across platforms | Self-contained; consistent output quality |

**Deprecated/outdated:**
- `mermaid` in jsdom: mermaid 8/9 could work with jsdom+dagre, but mermaid 10+ requires real browser SVG APIs. Do not attempt this path.
- `HSL` as intermediate for OKLCH math: Perceptually non-uniform; explicitly forbidden in STATE.md.
- Storing multiple output files as separate `content_jobs` records: Schema has one `resultAssetId` per job; multi-file output requires a bundle approach.

---

## Open Questions

1. **playwright-core vs @playwright/test for server-side browser launch**
   - What we know: `@playwright/test` is a root devDependency; server has no playwright dep; Chromium binary is at `~/.cache/ms-playwright/chromium-1217/chrome-linux64/chrome`; `playwright-core` is the correct server dep (no bundled browsers)
   - What's unclear: Nexus is a local desktop app, so devDeps are always available during `pnpm dev`. Should `playwright-core` be added to `server/package.json` `dependencies` or is the monorepo context sufficient?
   - Recommendation: Add `playwright-core@1.58.2` to `server/package.json` `dependencies` for explicitness and correctness. Use `executablePath` from env var `PLAYWRIGHT_BROWSERS_PATH` or the known cache location.

2. **Icon LLM prompt quality vs style coherence**
   - What we know: LLMs can generate SVG paths; consistency across a set (ICON-02: "cohesive visual style") requires a strong system prompt with explicit constraints
   - What's unclear: Which local model is active via Ollama? SVG generation quality varies significantly by model size and training.
   - Recommendation: Use a structured system prompt with explicit rules (24x24 viewBox, stroke-width=1.5, currentColor fill, no text elements); include existing icon names from lucide-react as style reference; validate output passes SVGO before storing; surface model quality issues to user via the generic error message.

3. **Theme "Apply to Nexus" — in-memory vs server persistence**
   - What we know: `ThemeContext` reads from localStorage; `nexus-settings.json` persists on server. The UI-SPEC toast says "Reload to see full effect."
   - Recommendation: Do both — write to server settings (PATCH /nexus/settings with customTheme), AND update ThemeContext in-memory immediately so the change is visible without reload. Toast message "Reload to see full effect" covers components that don't observe ThemeContext.

---

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Node.js | All | ✓ | v20.20.2 | — |
| Playwright Chromium binary | Mermaid server rendering | ✓ | Chromium 1217 (playwright 1.58.2) at ~/.cache/ms-playwright | Cannot render server-side Mermaid; fall back to client-only |
| sharp | SVG to PNG (fallback path) | ✓ | 0.34.5 in server node_modules | @resvg/resvg-js is primary |
| @resvg/resvg-js | SVG to PNG (primary) | ✗ not installed yet | 2.6.2 on npm; linux-x64-gnu confirmed | sharp(svgBuffer, {density:144}) as fallback |
| culori | OKLCH palette engine | ✗ not installed yet | 4.0.2 on npm | No fallback — mandated by STATE.md |
| wcag-contrast | WCAG AA validation | ✗ not installed yet | 3.0.0 on npm | Manual formula (fragile; not recommended) |
| svgo | LLM SVG cleanup | ✗ not installed yet | 4.0.1 on npm | Deliver unoptimized SVG (larger, possible LLM artifacts) |
| playwright-core | Server headless Chromium | ✗ not in server package.json | 1.58.2 on npm (must match installed Chromium) | Cannot render Mermaid server-side |
| dompurify | Mermaid SVG sanitization | ✓ | 3.3.2 in server node_modules | — |
| jsdom | DOMPurify DOM provider | ✓ | 28.1.0 in server node_modules | — |

**Missing dependencies with no fallback:**
- `culori` — OKLCH theme engine is blocked without it; mandated by STATE.md
- `playwright-core` — Mermaid server-side rendering requires a headless browser

**Missing dependencies with fallback:**
- `@resvg/resvg-js` — `sharp` is available as fallback for SVG to PNG (established org-chart pattern)
- `svgo` — icons can be delivered unoptimized as fallback (not ideal but functional)
- `wcag-contrast` — can be computed manually as fallback (fragile)

---

## Validation Architecture

### Test Framework

| Property | Value |
|----------|-------|
| Framework | vitest 3.x (monorepo root config at `/opt/nexus/vitest.config.ts`; server and UI included) |
| Config file | `/opt/nexus/vitest.config.ts` |
| Quick run command | `pnpm --filter server exec vitest run` |
| Full suite command | `pnpm test:run` (from `/opt/nexus`) |

### Phase Requirements to Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| DIAG-01 | POST /content-jobs jobType=diagram returns 202 | integration | `pnpm --filter server exec vitest run src/__tests__/diagram-renderer.test.ts` | ❌ Wave 0 |
| DIAG-02 | renderDiagram() returns bundle with svgBase64 + pngBase64 | unit | same file | ❌ Wave 0 |
| DIAG-03 | DiagramSourcePanel renders editable textarea | unit (renderToStaticMarkup) | `pnpm --filter ui exec vitest run src/components/DiagramSourcePanel.test.tsx` | ❌ Wave 0 |
| DIAG-04 | stripUnsafeDirectives removes %%{init}%% blocks | unit | `pnpm --filter server exec vitest run src/__tests__/diagram-renderer.test.ts` | ❌ Wave 0 |
| DIAG-05 | stripped flag set when click or init directives found | unit | same file | ❌ Wave 0 |
| ICON-01 | POST /content-jobs jobType=icon-set returns 202 | integration | `pnpm --filter server exec vitest run src/__tests__/content-jobs-routes.test.ts` | ✅ (existing test extended) |
| ICON-02 | renderIconSet() output bundle contains N icons with valid SVG | unit | `pnpm --filter server exec vitest run src/__tests__/icon-renderer.test.ts` | ❌ Wave 0 |
| ICON-03 | icon bundle JSON has svgBase64 entries for each icon | unit | same file | ❌ Wave 0 |
| THEME-01 | buildPalette() returns 7 roles from seed hex | unit | `pnpm --filter server exec vitest run src/__tests__/theme-renderer.test.ts` | ❌ Wave 0 |
| THEME-02 | All palette roles have oklch string values | unit | same file | ❌ Wave 0 |
| THEME-03 | wcagAA field is computed per swatch pair | unit | same file | ❌ Wave 0 |
| THEME-04 | ThemePreviewPanel injects CSS only to .nexus-theme-preview | unit (DOM) | `pnpm --filter ui exec vitest run src/components/ThemePreviewPanel.test.tsx` | ❌ Wave 0 |
| THEME-05 | exportToCss / exportToTailwind / exportToVSCode / exportToJson return strings | unit | same as THEME-01 | ❌ Wave 0 |
| THEME-06 | buildPalette() returns dark and light variants for each role | unit | same as THEME-01 | ❌ Wave 0 |
| THEME-07 | PATCH /nexus/settings with customTheme persists to nexusSettingsService | integration | `pnpm --filter server exec vitest run src/__tests__/nexus-settings-custom-theme.test.ts` | ❌ Wave 0 |

### Sampling Rate

- **Per task commit:** `pnpm --filter server exec vitest run src/__tests__/<relevant>.test.ts`
- **Per wave merge:** `pnpm test:run --filter server && pnpm test:run --filter ui`
- **Phase gate:** Full suite green before `/gsd:verify-work`

### Wave 0 Gaps

- [ ] `server/src/__tests__/diagram-renderer.test.ts` — covers DIAG-01, DIAG-02, DIAG-04, DIAG-05 (mock `playwright-core` chromium launch; test stripUnsafeDirectives and bundle structure)
- [ ] `server/src/__tests__/icon-renderer.test.ts` — covers ICON-02, ICON-03 (mock LLM call; validate SVG bundle structure with SVGO output)
- [ ] `server/src/__tests__/theme-renderer.test.ts` — covers THEME-01, THEME-02, THEME-03, THEME-05, THEME-06 (pure function tests; no mocking needed; culori and wcag-contrast are real)
- [ ] `server/src/__tests__/nexus-settings-custom-theme.test.ts` — covers THEME-07 (temp settings file; extend existing settings tests)
- [ ] `ui/src/components/DiagramSourcePanel.test.tsx` — covers DIAG-03
- [ ] `ui/src/components/ThemePreviewPanel.test.tsx` — covers THEME-04 (check that CSS variable set calls are scoped to container ref)

---

## Project Constraints (from CLAUDE.md and STATE.md)

No `CLAUDE.md` exists at `/opt/nexus/CLAUDE.md`. Constraints are sourced from `STATE.md` Key Decisions and the `design-guide` skill:

| Constraint | Source | Impact on Phase 41 |
|------------|--------|-------------------|
| OKLCH via culori — HSL is forbidden as an intermediate | STATE.md | Theme engine uses culori throughout; no HSL conversions anywhere in palette pipeline |
| Mermaid securityLevel must be "strict" — strip %%{init}%% and click directives before render, DOMPurify on SVG output | STATE.md | stripUnsafeDirectives + DOMPurify both mandatory for DIAG-05 |
| Async job pattern mandatory — all render requests return 202 + job ID | STATE.md | All three generators go through /content-jobs; no synchronous render routes |
| MAX_GENERATED_ASSET_BYTES constant — generated namespace, not upload namespace | STATE.md | All diagram/icon/theme assets stored in `generated` namespace |
| renderContent is a stub in Phase 40 — phases 41-45 add real renderers keyed by jobType | STATE.md | Extend the switch in content-job-runner.ts |
| shadcn new-york style, neutral base, cssVariables, lucide icons | design-guide SKILL.md | All new UI components follow this preset |
| Use semantic CSS variable tokens; never raw hex/rgb values | design-guide SKILL.md | ThemePreviewPanel CSS injection uses --background, --card, etc. token names |
| New reusable components must be added to /design-guide page | design-guide SKILL.md | DiagramPreview, ThemePaletteGrid, IconResultGrid etc. need design-guide entries |
| sourceTaskId is required on every generated asset | STATE.md | All content-job-runner asset creations pass sourceTaskId from the job |
| content_jobs uses no FK for resultAssetId | STATE.md | Schema unchanged; single UUID per job; multi-file output requires bundle asset pattern |

---

## Sources

### Primary (HIGH confidence)

- Codebase: `server/src/services/content-job-runner.ts` — renderContent stub confirmed; dispatch pattern confirmed
- Codebase: `server/src/routes/org-chart-svg.ts` — established SVG to PNG via `sharp(svgBuffer, {density:144})` pattern
- Codebase: `server/src/services/nexus-settings.ts` — theme persistence approach confirmed (Zod schema + JSON file)
- Codebase: `ui/src/components/MarkdownBody.tsx` — existing browser Mermaid rendering with `securityLevel: "strict"` confirmed
- Codebase: `ui/src/context/ThemeContext.tsx` — theme injection via `document.documentElement.style` confirmed
- Codebase: `server/src/routes/content-jobs.ts` — SSE pattern confirmed (EventSource subscription)
- Filesystem: `~/.cache/ms-playwright/chromium-1217/chrome-linux64/chrome` — Playwright Chromium binary confirmed present
- npm registry: `@resvg/resvg-js@2.6.2` — linux-x64-gnu confirmed available
- npm registry: `culori@4.0.2` — OKLCH exports and CJS bundle confirmed
- npm registry: `wcag-contrast@3.0.0` — confirmed
- npm registry: `svgo@4.0.1` — confirmed
- `.planning/STATE.md` Key Decisions — OKLCH/culori mandate, Mermaid securityLevel, async job pattern, multi-asset approach

### Secondary (MEDIUM confidence)

- culori package exports inspection — CJS bundle at `./bundled/culori.cjs`; ESM at `./src/index.js`; both confirmed via `npm info`
- `@resvg/resvg-js` optionalDependencies — linux-x64-gnu at 2.6.2 confirmed
- server node_modules inspection — dompurify 3.3.2, jsdom 28.1.0, sharp 0.34.5 confirmed in server deps via `pnpm list`

### Tertiary (LOW confidence)

- Assessment that Mermaid 11 requires a real browser DOM (no jsdom) — based on known mermaid breaking changes in v10+; jsdom-based mermaid solutions are absent from recent ecosystem; this is HIGH-confidence but not directly tested against mermaid 11.14 here. Validate during diagram-renderer implementation.

---

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — all package versions confirmed via npm info; all existing deps confirmed via pnpm list; Playwright Chromium binary confirmed on filesystem
- Architecture patterns: HIGH — based directly on Phase 40 established patterns (content-job-runner stub, org-chart-svg.ts sharp pattern, existing SSE test structure)
- Pitfalls: HIGH (Playwright path, culori ESM, browser cleanup) / MEDIUM (LLM SVG quality — model-dependent)

**Research date:** 2026-04-04
**Valid until:** 2026-05-04 (stable libraries; mermaid 11.x API is stable; culori 4.x API is stable)
