nexus/.planning/phases/41-diagrams-icons-theme-engine/41-RESEARCH.md

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/test already installed; must match exactly)

Architecture Patterns

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, 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:

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:

  • 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-jssharp is available as fallback for SVG to PNG (established org-chart pattern)
  • svgo — icons can be delivered unoptimized as fallback (not ideal but functional)
  • wcag-contrast — can be computed manually as fallback (fragile)

Validation Architecture

Test Framework

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

Phase Requirements to Test Map

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

Sampling Rate

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

Wave 0 Gaps

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

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

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

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

Sources

Primary (HIGH confidence)

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

Secondary (MEDIUM confidence)

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

Tertiary (LOW confidence)

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

Metadata

Confidence breakdown:

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

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