# 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 (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.
---
## 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 |
---
## 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,
): 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,
): 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 = {
"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({
jobId: null, status: "idle", progress: 0, resultAssetId: null, errorMessage: null,
});
const esRef = useRef(null);
const submit = useCallback(async (
jobType: string,
input: Record,
) => {
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 ``, ``, or `` 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__/.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)