# 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)