43 KiB
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):
pnpm --filter server add culori @resvg/resvg-js wcag-contrast svgo playwright-core
Installation (UI — shadcn components):
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/testalready 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:
// 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).
// 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.
// 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.
// 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.
// 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)
// 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.
// 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, andforeignObjectsupport. 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.documentElementduring preview: Bleeds into nav/sidebar. Use.nexus-theme-previewcontainer scope viacontainer.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 afinallyblock. 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:
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)
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)
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)
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)
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)
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)
import { optimize } from "svgo";
const result = optimize(rawSvgString, { plugins: ["preset-default"] });
const optimizedSvg = result.data;
Mermaid Security Strip
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:
mermaidin jsdom: mermaid 8/9 could work with jsdom+dagre, but mermaid 10+ requires real browser SVG APIs. Do not attempt this path.HSLas intermediate for OKLCH math: Perceptually non-uniform; explicitly forbidden in STATE.md.- Storing multiple output files as separate
content_jobsrecords: Schema has oneresultAssetIdper job; multi-file output requires a bundle approach.
Open Questions
-
playwright-core vs @playwright/test for server-side browser launch
- What we know:
@playwright/testis a root devDependency; server has no playwright dep; Chromium binary is at~/.cache/ms-playwright/chromium-1217/chrome-linux64/chrome;playwright-coreis the correct server dep (no bundled browsers) - What's unclear: Nexus is a local desktop app, so devDeps are always available during
pnpm dev. Shouldplaywright-corebe added toserver/package.jsondependenciesor is the monorepo context sufficient? - Recommendation: Add
playwright-core@1.58.2toserver/package.jsondependenciesfor explicitness and correctness. UseexecutablePathfrom env varPLAYWRIGHT_BROWSERS_PATHor the known cache location.
- What we know:
-
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.
-
Theme "Apply to Nexus" — in-memory vs server persistence
- What we know:
ThemeContextreads from localStorage;nexus-settings.jsonpersists 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.
- What we know:
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.mdplaywright-core— Mermaid server-side rendering requires a headless browser
Missing dependencies with fallback:
@resvg/resvg-js—sharpis 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 (mockplaywright-corechromium 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-03ui/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 viasharp(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 withsecurityLevel: "strict"confirmed - Codebase:
ui/src/context/ThemeContext.tsx— theme injection viadocument.documentElement.styleconfirmed - 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.mdKey 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 vianpm info @resvg/resvg-jsoptionalDependencies — 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)