diff --git a/scripts/generate-org-chart-satori-comparison.ts b/scripts/generate-org-chart-satori-comparison.ts new file mode 100644 index 00000000..0f967d72 --- /dev/null +++ b/scripts/generate-org-chart-satori-comparison.ts @@ -0,0 +1,225 @@ +#!/usr/bin/env npx tsx +/** + * Standalone org chart comparison generator — pure SVG (no Playwright). + * + * Generates SVG files for all 5 styles × 3 org sizes, plus a comparison HTML page. + * Uses the server-side SVG renderer directly — same code that powers the routes. + * + * Usage: + * npx tsx scripts/generate-org-chart-satori-comparison.ts + * + * Output: tmp/org-chart-svg-comparison/ + */ +import * as fs from "fs"; +import * as path from "path"; +import { + renderOrgChartSvg, + renderOrgChartPng, + type OrgNode, + type OrgChartStyle, + ORG_CHART_STYLES, +} from "../server/src/routes/org-chart-svg.js"; + +// ── Sample org data ────────────────────────────────────────────── + +const ORGS: Record = { + sm: { + id: "ceo", + name: "CEO", + role: "Chief Executive", + status: "active", + reports: [ + { id: "eng1", name: "Engineer", role: "Engineering", status: "active", reports: [] }, + { id: "des1", name: "Designer", role: "Design", status: "active", reports: [] }, + ], + }, + med: { + id: "ceo", + name: "CEO", + role: "Chief Executive", + status: "active", + reports: [ + { + id: "cto", + name: "CTO", + role: "Technology", + status: "active", + reports: [ + { id: "eng1", name: "ClaudeCoder", role: "Engineering", status: "active", reports: [] }, + { id: "eng2", name: "CodexCoder", role: "Engineering", status: "active", reports: [] }, + { id: "eng3", name: "SparkCoder", role: "Engineering", status: "active", reports: [] }, + { id: "eng4", name: "CursorCoder", role: "Engineering", status: "active", reports: [] }, + { id: "qa1", name: "QA", role: "Quality", status: "active", reports: [] }, + ], + }, + { + id: "cmo", + name: "CMO", + role: "Marketing", + status: "active", + reports: [ + { id: "des1", name: "Designer", role: "Design", status: "active", reports: [] }, + ], + }, + ], + }, + lg: { + id: "ceo", + name: "CEO", + role: "Chief Executive", + status: "active", + reports: [ + { + id: "cto", + name: "CTO", + role: "Technology", + status: "active", + reports: [ + { id: "eng1", name: "Eng 1", role: "Engineering", status: "active", reports: [] }, + { id: "eng2", name: "Eng 2", role: "Engineering", status: "active", reports: [] }, + { id: "eng3", name: "Eng 3", role: "Engineering", status: "active", reports: [] }, + { id: "qa1", name: "QA", role: "Quality", status: "active", reports: [] }, + ], + }, + { + id: "cmo", + name: "CMO", + role: "Marketing", + status: "active", + reports: [ + { id: "des1", name: "Designer", role: "Design", status: "active", reports: [] }, + { id: "wrt1", name: "Content", role: "Engineering", status: "active", reports: [] }, + ], + }, + { + id: "cfo", + name: "CFO", + role: "Finance", + status: "active", + reports: [ + { id: "fin1", name: "Analyst", role: "Finance", status: "active", reports: [] }, + ], + }, + { + id: "coo", + name: "COO", + role: "Operations", + status: "active", + reports: [ + { id: "ops1", name: "Ops 1", role: "Operations", status: "active", reports: [] }, + { id: "ops2", name: "Ops 2", role: "Operations", status: "active", reports: [] }, + { id: "devops1", name: "DevOps", role: "Operations", status: "active", reports: [] }, + ], + }, + ], + }, +}; + +const STYLE_META: Record = { + monochrome: { name: "Monochrome", vibe: "Vercel — zero color noise, dark", bestFor: "GitHub READMEs, developer docs" }, + nebula: { name: "Nebula", vibe: "Glassmorphism — cosmic gradient", bestFor: "Hero sections, marketing" }, + circuit: { name: "Circuit", vibe: "Linear/Raycast — indigo traces", bestFor: "Product pages, dev tools" }, + warmth: { name: "Warmth", vibe: "Airbnb — light, colored avatars", bestFor: "Light-mode READMEs, presentations" }, + schematic: { name: "Schematic", vibe: "Blueprint — grid bg, monospace", bestFor: "Technical docs, infra diagrams" }, +}; + +// ── Main ───────────────────────────────────────────────────────── + +async function main() { + const outDir = path.resolve("tmp/org-chart-svg-comparison"); + fs.mkdirSync(outDir, { recursive: true }); + + const sizes = ["sm", "med", "lg"] as const; + const results: string[] = []; + + for (const style of ORG_CHART_STYLES) { + for (const size of sizes) { + const svg = renderOrgChartSvg([ORGS[size]], style); + const svgFile = `${style}-${size}.svg`; + fs.writeFileSync(path.join(outDir, svgFile), svg); + results.push(svgFile); + console.log(` ✓ ${svgFile}`); + + // Also generate PNG + try { + const png = await renderOrgChartPng([ORGS[size]], style); + const pngFile = `${style}-${size}.png`; + fs.writeFileSync(path.join(outDir, pngFile), png); + results.push(pngFile); + console.log(` ✓ ${pngFile}`); + } catch (e) { + console.log(` ⚠ PNG failed for ${style}-${size}: ${(e as Error).message}`); + } + } + } + + // Build comparison HTML + let html = ` + + +Org Chart Style Comparison — Pure SVG (No Playwright) + + +

Org Chart Export — Style Comparison

+

5 styles × 3 org sizes. Pure SVG — no Playwright, no Satori, no browser needed.

+
Server-side compatible — works on any route
+`; + + for (const style of ORG_CHART_STYLES) { + const meta = STYLE_META[style]; + html += `
+

${meta.name}

+
${meta.vibe} — Best for: ${meta.bestFor}
+
Small / Medium / Large
+
+
3 agents
+
8 agents
+
14 agents
+
+
`; + } + + html += ` +
+

Why Pure SVG instead of Satori?

+

+ Satori converts JSX → SVG using Yoga (flexbox). It's great for OG cards but has limitations for org charts: + no ::before/::after pseudo-elements, no CSS grid, limited gradient support, + and connector lines between nodes would need post-processing. +

+

+ Pure SVG rendering (what we're using here) gives us full control over layout, connectors, + gradients, filters, and patterns — with zero runtime dependencies beyond sharp for PNG. + It runs on any Node.js route, generates in <10ms, and produces identical output every time. +

+

+ Routes: GET /api/companies/:id/org.svg?style=monochrome and GET /api/companies/:id/org.png?style=circuit +

+
+`; + + fs.writeFileSync(path.join(outDir, "comparison.html"), html); + console.log(`\n✓ All done! ${results.length} files generated.`); + console.log(` Open: tmp/org-chart-svg-comparison/comparison.html`); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index cc168066..7b2bdc66 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -47,7 +47,7 @@ import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; import { findServerAdapter, listAdapterModels } from "../adapters/index.js"; import { redactEventPayload } from "../redaction.js"; import { redactCurrentUserValue } from "../log-redaction.js"; -import { renderOrgChartSvg, renderOrgChartPng, type OrgNode } from "./org-chart-svg.js"; +import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js"; import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server"; import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, @@ -899,9 +899,10 @@ export function agentRoutes(db: Db) { router.get("/companies/:companyId/org.svg", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); + const style = (ORG_CHART_STYLES.includes(req.query.style as OrgChartStyle) ? req.query.style : "warmth") as OrgChartStyle; const tree = await svc.orgForCompany(companyId); const leanTree = tree.map((node) => toLeanOrgNode(node as Record)); - const svg = renderOrgChartSvg(leanTree as unknown as OrgNode[]); + const svg = renderOrgChartSvg(leanTree as unknown as OrgNode[], style); res.setHeader("Content-Type", "image/svg+xml"); res.setHeader("Cache-Control", "no-cache"); res.send(svg); @@ -910,9 +911,10 @@ export function agentRoutes(db: Db) { router.get("/companies/:companyId/org.png", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); + const style = (ORG_CHART_STYLES.includes(req.query.style as OrgChartStyle) ? req.query.style : "warmth") as OrgChartStyle; const tree = await svc.orgForCompany(companyId); const leanTree = tree.map((node) => toLeanOrgNode(node as Record)); - const png = await renderOrgChartPng(leanTree as unknown as OrgNode[]); + const png = await renderOrgChartPng(leanTree as unknown as OrgNode[], style); res.setHeader("Content-Type", "image/png"); res.setHeader("Cache-Control", "no-cache"); res.send(png); diff --git a/server/src/routes/org-chart-svg.ts b/server/src/routes/org-chart-svg.ts index ec31df84..cfc49f88 100644 --- a/server/src/routes/org-chart-svg.ts +++ b/server/src/routes/org-chart-svg.ts @@ -1,7 +1,7 @@ /** * Server-side SVG renderer for Paperclip org charts. - * Renders the org tree in the "Warmth" style with Paperclip branding. - * Supports SVG output and PNG conversion via sharp. + * Supports 5 visual styles: monochrome, nebula, circuit, warmth, schematic. + * Pure SVG output — no browser/Playwright needed. PNG via sharp. */ import sharp from "sharp"; @@ -13,6 +13,10 @@ export interface OrgNode { reports: OrgNode[]; } +export type OrgChartStyle = "monochrome" | "nebula" | "circuit" | "warmth" | "schematic"; + +export const ORG_CHART_STYLES: OrgChartStyle[] = ["monochrome", "nebula", "circuit", "warmth", "schematic"]; + interface LayoutNode { node: OrgNode; x: number; @@ -22,85 +26,81 @@ interface LayoutNode { children: LayoutNode[]; } -// ── Design tokens (Warmth style — matches index.html s-warm) ────── -const CARD_H = 88; -const CARD_MIN_W = 150; -const CARD_PAD_X = 22; -const CARD_RADIUS = 6; -const AVATAR_SIZE = 34; -const GAP_X = 24; -const GAP_Y = 56; -const LINE_COLOR = "#d6d3d1"; -const LINE_W = 2; -const BG_COLOR = "#fafaf9"; -const CARD_BG = "#ffffff"; -const CARD_BORDER = "#e7e5e4"; -const CARD_SHADOW_COLOR = "rgba(0,0,0,0.05)"; -const NAME_COLOR = "#1c1917"; -const ROLE_COLOR = "#78716c"; -const FONT = "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"; -const PADDING = 48; -const LOGO_PADDING = 16; +// ── Style theme definitions ────────────────────────────────────── + +interface StyleTheme { + bgColor: string; + cardBg: string; + cardBorder: string; + cardRadius: number; + cardShadow: string | null; + lineColor: string; + lineWidth: number; + nameColor: string; + roleColor: string; + font: string; + watermarkColor: string; + /** Extra SVG defs (filters, patterns, gradients) */ + defs: (svgW: number, svgH: number) => string; + /** Extra background elements after the main bg rect */ + bgExtras: (svgW: number, svgH: number) => string; + /** Custom card renderer — if null, uses default avatar+name+role */ + renderCard: ((ln: LayoutNode, theme: StyleTheme) => string) | null; + /** Per-card accent (top bar, border glow, etc.) */ + cardAccent: ((tag: string) => string) | null; +} + +// ── Role icons (shared across styles) ──────────────────────────── -// Role config: descriptive labels, avatar colors, and SVG icon paths (Pango-safe) const ROLE_ICONS: Record = { ceo: { - bg: "#fef3c7", roleLabel: "Chief Executive", iconColor: "#92400e", - // Star icon + bg: "#fef3c7", roleLabel: "Chief Executive", iconColor: "#92400e", accentColor: "#f0883e", iconPath: "M8 1l2.2 4.5L15 6.2l-3.5 3.4.8 4.9L8 12.2 3.7 14.5l.8-4.9L1 6.2l4.8-.7z", }, cto: { - bg: "#dbeafe", roleLabel: "Technology", iconColor: "#1e40af", - // Terminal/code icon + bg: "#dbeafe", roleLabel: "Technology", iconColor: "#1e40af", accentColor: "#58a6ff", iconPath: "M2 3l5 5-5 5M9 13h5", }, cmo: { - bg: "#dcfce7", roleLabel: "Marketing", iconColor: "#166534", - // Globe icon + bg: "#dcfce7", roleLabel: "Marketing", iconColor: "#166534", accentColor: "#3fb950", iconPath: "M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zM1 8h14M8 1c-2 2-3 4.5-3 7s1 5 3 7c2-2 3-4.5 3-7s-1-5-3-7z", }, cfo: { - bg: "#fef3c7", roleLabel: "Finance", iconColor: "#92400e", - // Dollar sign icon + bg: "#fef3c7", roleLabel: "Finance", iconColor: "#92400e", accentColor: "#f0883e", iconPath: "M8 1v14M5 4.5C5 3.1 6.3 2 8 2s3 1.1 3 2.5S9.7 7 8 7 5 8.1 5 9.5 6.3 12 8 12s3-1.1 3-2.5", }, coo: { - bg: "#e0f2fe", roleLabel: "Operations", iconColor: "#075985", - // Settings/gear icon + bg: "#e0f2fe", roleLabel: "Operations", iconColor: "#075985", accentColor: "#58a6ff", iconPath: "M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM13 8a5 5 0 0 1-.1.9l1.5 1.2-1.5 2.5-1.7-.7a5 5 0 0 1-1.6.9L9.3 15H6.7l-.3-2.2a5 5 0 0 1-1.6-.9l-1.7.7L1.6 10l1.5-1.2A5 5 0 0 1 3 8c0-.3 0-.6.1-.9L1.6 6l1.5-2.5 1.7.7a5 5 0 0 1 1.6-.9L6.7 1h2.6l.3 2.2c.6.2 1.1.5 1.6.9l1.7-.7L14.4 6l-1.5 1.2c.1.2.1.5.1.8z", }, engineer: { - bg: "#f3e8ff", roleLabel: "Engineering", iconColor: "#6b21a8", - // Code brackets icon + bg: "#f3e8ff", roleLabel: "Engineering", iconColor: "#6b21a8", accentColor: "#bc8cff", iconPath: "M5 3L1 8l4 5M11 3l4 5-4 5", }, quality: { - bg: "#ffe4e6", roleLabel: "Quality", iconColor: "#9f1239", - // Checkmark/shield icon + bg: "#ffe4e6", roleLabel: "Quality", iconColor: "#9f1239", accentColor: "#f778ba", iconPath: "M4 8l3 3 5-6M8 1L2 4v4c0 3.5 2.6 6.8 6 8 3.4-1.2 6-4.5 6-8V4z", }, design: { - bg: "#fce7f3", roleLabel: "Design", iconColor: "#9d174d", - // Pen/brush icon + bg: "#fce7f3", roleLabel: "Design", iconColor: "#9d174d", accentColor: "#79c0ff", iconPath: "M12 2l2 2-9 9H3v-2zM9.5 4.5l2 2", }, finance: { - bg: "#fef3c7", roleLabel: "Finance", iconColor: "#92400e", + bg: "#fef3c7", roleLabel: "Finance", iconColor: "#92400e", accentColor: "#f0883e", iconPath: "M8 1v14M5 4.5C5 3.1 6.3 2 8 2s3 1.1 3 2.5S9.7 7 8 7 5 8.1 5 9.5 6.3 12 8 12s3-1.1 3-2.5", }, operations: { - bg: "#e0f2fe", roleLabel: "Operations", iconColor: "#075985", + bg: "#e0f2fe", roleLabel: "Operations", iconColor: "#075985", accentColor: "#58a6ff", iconPath: "M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM13 8a5 5 0 0 1-.1.9l1.5 1.2-1.5 2.5-1.7-.7a5 5 0 0 1-1.6.9L9.3 15H6.7l-.3-2.2a5 5 0 0 1-1.6-.9l-1.7.7L1.6 10l1.5-1.2A5 5 0 0 1 3 8c0-.3 0-.6.1-.9L1.6 6l1.5-2.5 1.7.7a5 5 0 0 1 1.6-.9L6.7 1h2.6l.3 2.2c.6.2 1.1.5 1.6.9l1.7-.7L14.4 6l-1.5 1.2c.1.2.1.5.1.8z", }, default: { - bg: "#f3e8ff", roleLabel: "Agent", iconColor: "#6b21a8", - // User icon + bg: "#f3e8ff", roleLabel: "Agent", iconColor: "#6b21a8", accentColor: "#bc8cff", iconPath: "M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM2 14c0-3.3 2.7-4 6-4s6 .7 6 4", }, }; @@ -121,19 +121,205 @@ function guessRoleTag(node: OrgNode): string { return "default"; } +function getRoleInfo(node: OrgNode) { + const tag = guessRoleTag(node); + return { tag, ...(ROLE_ICONS[tag] || ROLE_ICONS.default) }; +} + +// ── Style themes ───────────────────────────────────────────────── + +const THEMES: Record = { + // 01 — Monochrome (Vercel-inspired, dark minimal) + monochrome: { + bgColor: "#18181b", + cardBg: "#18181b", + cardBorder: "#27272a", + cardRadius: 6, + cardShadow: null, + lineColor: "#3f3f46", + lineWidth: 1.5, + nameColor: "#fafafa", + roleColor: "#71717a", + font: "'Inter', system-ui, sans-serif", + watermarkColor: "rgba(255,255,255,0.25)", + defs: () => "", + bgExtras: () => "", + renderCard: null, + cardAccent: null, + }, + + // 02 — Nebula (glassmorphism on cosmic gradient) + nebula: { + bgColor: "#0f0c29", + cardBg: "rgba(255,255,255,0.07)", + cardBorder: "rgba(255,255,255,0.12)", + cardRadius: 6, + cardShadow: null, + lineColor: "rgba(255,255,255,0.25)", + lineWidth: 1.5, + nameColor: "#ffffff", + roleColor: "rgba(255,255,255,0.45)", + font: "'Inter', system-ui, sans-serif", + watermarkColor: "rgba(255,255,255,0.2)", + defs: (_w, _h) => ` + + + + + + + + + + + + + `, + bgExtras: (w, h) => ` + + + `, + renderCard: null, + cardAccent: null, + }, + + // 03 — Circuit (Linear/Raycast — indigo traces, amethyst CEO) + circuit: { + bgColor: "#0c0c0e", + cardBg: "rgba(99,102,241,0.04)", + cardBorder: "rgba(99,102,241,0.18)", + cardRadius: 5, + cardShadow: null, + lineColor: "rgba(99,102,241,0.35)", + lineWidth: 1.5, + nameColor: "#e4e4e7", + roleColor: "#6366f1", + font: "'Inter', system-ui, sans-serif", + watermarkColor: "rgba(99,102,241,0.3)", + defs: () => "", + bgExtras: () => "", + renderCard: (ln: LayoutNode, theme: StyleTheme) => { + const { tag, roleLabel, iconPath, iconColor } = getRoleInfo(ln.node); + const cx = ln.x + ln.width / 2; + const isCeo = tag === "ceo"; + const borderColor = isCeo ? "rgba(168,85,247,0.35)" : theme.cardBorder; + const bgColor = isCeo ? "rgba(168,85,247,0.06)" : theme.cardBg; + + const avatarCY = ln.y + 24; + const nameY = ln.y + 52; + const roleY = ln.y + 68; + const iconScale = 0.7; + const iconOffset = (34 * iconScale) / 2; + + return ` + + + + + + ${escapeXml(ln.node.name)} + ${escapeXml(roleLabel).toUpperCase()} + `; + }, + cardAccent: null, + }, + + // 04 — Warmth (Airbnb — light, colored avatars, soft shadows) + warmth: { + bgColor: "#fafaf9", + cardBg: "#ffffff", + cardBorder: "#e7e5e4", + cardRadius: 6, + cardShadow: "rgba(0,0,0,0.05)", + lineColor: "#d6d3d1", + lineWidth: 2, + nameColor: "#1c1917", + roleColor: "#78716c", + font: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif", + watermarkColor: "rgba(0,0,0,0.25)", + defs: () => "", + bgExtras: () => "", + renderCard: null, + cardAccent: null, + }, + + // 05 — Schematic (Blueprint — grid bg, monospace, colored top-bars) + schematic: { + bgColor: "#0d1117", + cardBg: "rgba(13,17,23,0.92)", + cardBorder: "#30363d", + cardRadius: 4, + cardShadow: null, + lineColor: "#30363d", + lineWidth: 1.5, + nameColor: "#c9d1d9", + roleColor: "#8b949e", + font: "'JetBrains Mono', 'SF Mono', monospace", + watermarkColor: "rgba(139,148,158,0.3)", + defs: (w, h) => ` + + + `, + bgExtras: (w, h) => ``, + renderCard: (ln: LayoutNode, theme: StyleTheme) => { + const { tag, accentColor, iconPath, iconColor } = getRoleInfo(ln.node); + const cx = ln.x + ln.width / 2; + + // Schematic uses monospace role labels + const schemaRoles: Record = { + ceo: "chief_executive", cto: "chief_technology", cmo: "chief_marketing", + cfo: "chief_financial", coo: "chief_operating", engineer: "engineer", + quality: "quality_assurance", design: "designer", finance: "finance", + operations: "operations", default: "agent", + }; + const roleText = schemaRoles[tag] || schemaRoles.default; + + const avatarCY = ln.y + 24; + const nameY = ln.y + 52; + const roleY = ln.y + 68; + const iconScale = 0.7; + const iconOffset = (34 * iconScale) / 2; + + return ` + + + + + + + ${escapeXml(ln.node.name)} + ${escapeXml(roleText)} + `; + }, + cardAccent: null, + }, +}; + +// ── Layout constants ───────────────────────────────────────────── + +const CARD_H = 88; +const CARD_MIN_W = 150; +const CARD_PAD_X = 22; +const AVATAR_SIZE = 34; +const GAP_X = 24; +const GAP_Y = 56; +const PADDING = 48; +const LOGO_PADDING = 16; + +// ── Text measurement ───────────────────────────────────────────── + function measureText(text: string, fontSize: number): number { return text.length * fontSize * 0.58; } function cardWidth(node: OrgNode): number { - const tag = guessRoleTag(node); - const roleLabel = ROLE_ICONS[tag]?.roleLabel ?? node.role; + const { roleLabel } = getRoleInfo(node); const nameW = measureText(node.name, 14) + CARD_PAD_X * 2; const roleW = measureText(roleLabel, 11) + CARD_PAD_X * 2; return Math.max(CARD_MIN_W, Math.max(nameW, roleW)); } -// ── Tree layout (top-down, centered) ──────────────────────────── +// ── Tree layout (top-down, centered) ───────────────────────────── function subtreeWidth(node: OrgNode): number { const cw = cardWidth(node); @@ -173,81 +359,90 @@ function layoutTree(node: OrgNode, x: number, y: number): LayoutNode { return layoutNode; } -// ── SVG rendering ─────────────────────────────────────────────── +// ── SVG rendering ──────────────────────────────────────────────── function escapeXml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } -function renderCard(ln: LayoutNode): string { - const tag = guessRoleTag(ln.node); - const role = ROLE_ICONS[tag] || ROLE_ICONS.default; +function defaultRenderCard(ln: LayoutNode, theme: StyleTheme): string { + const { roleLabel, bg, iconColor, iconPath } = getRoleInfo(ln.node); const cx = ln.x + ln.width / 2; - // Vertical layout: avatar circle → name → role label const avatarCY = ln.y + 24; const nameY = ln.y + 52; const roleY = ln.y + 68; - // SVG icon inside the avatar circle, scaled to fit const iconScale = 0.7; const iconOffset = (AVATAR_SIZE * iconScale) / 2; const iconX = cx - iconOffset; const iconY = avatarCY - iconOffset; - return ` - - - - - - - - - - - ${escapeXml(ln.node.name)} - ${escapeXml(role.roleLabel)} - `; + const filterId = `shadow-${ln.node.id}`; + const shadowFilter = theme.cardShadow + ? `filter="url(#${filterId})"` + : ""; + const shadowDef = theme.cardShadow + ? ` + + + ` + : ""; + + // For dark themes without avatars, use a subtle circle + const isLight = theme.bgColor === "#fafaf9" || theme.bgColor === "#ffffff"; + const avatarBg = isLight ? bg : "rgba(255,255,255,0.06)"; + const avatarStroke = isLight ? "" : `stroke="rgba(255,255,255,0.08)" stroke-width="1"`; + + return ` + ${shadowDef} + + + + + + ${escapeXml(ln.node.name)} + ${escapeXml(roleLabel)} + `; } -function renderConnectors(ln: LayoutNode): string { +function renderConnectors(ln: LayoutNode, theme: StyleTheme): string { if (ln.children.length === 0) return ""; const parentCx = ln.x + ln.width / 2; const parentBottom = ln.y + ln.height; const midY = parentBottom + GAP_Y / 2; + const lc = theme.lineColor; + const lw = theme.lineWidth; let svg = ""; - - // Vertical line from parent to midpoint - svg += ``; + svg += ``; if (ln.children.length === 1) { const childCx = ln.children[0].x + ln.children[0].width / 2; - svg += ``; + svg += ``; } else { const leftCx = ln.children[0].x + ln.children[0].width / 2; const rightCx = ln.children[ln.children.length - 1].x + ln.children[ln.children.length - 1].width / 2; - svg += ``; + svg += ``; for (const child of ln.children) { const childCx = child.x + child.width / 2; - svg += ``; + svg += ``; } } for (const child of ln.children) { - svg += renderConnectors(child); + svg += renderConnectors(child, theme); } - return svg; } -function renderCards(ln: LayoutNode): string { - let svg = renderCard(ln); +function renderCards(ln: LayoutNode, theme: StyleTheme): string { + const render = theme.renderCard || defaultRenderCard; + let svg = render(ln, theme); for (const child of ln.children) { - svg += renderCards(child); + svg += renderCards(child, theme); } return svg; } @@ -267,13 +462,16 @@ function treeBounds(ln: LayoutNode): { minX: number; minY: number; maxX: number; return { minX, minY, maxX, maxY }; } -// Paperclip logo as inline SVG path const PAPERCLIP_LOGO_SVG = ` Paperclip `; -export function renderOrgChartSvg(orgTree: OrgNode[]): string { +// ── Public API ─────────────────────────────────────────────────── + +export function renderOrgChartSvg(orgTree: OrgNode[], style: OrgChartStyle = "warmth"): string { + const theme = THEMES[style] || THEMES.warmth; + let root: OrgNode; if (orgTree.length === 1) { root = orgTree[0]; @@ -297,16 +495,18 @@ export function renderOrgChartSvg(orgTree: OrgNode[]): string { const logoY = LOGO_PADDING; return ` - - + ${theme.defs(svgW, svgH)} + + ${theme.bgExtras(svgW, svgH)} + ${PAPERCLIP_LOGO_SVG} - ${renderConnectors(layout)} - ${renderCards(layout)} + ${renderConnectors(layout, theme)} + ${renderCards(layout, theme)} `; } -export async function renderOrgChartPng(orgTree: OrgNode[]): Promise { - const svg = renderOrgChartSvg(orgTree); +export async function renderOrgChartPng(orgTree: OrgNode[], style: OrgChartStyle = "warmth"): Promise { + const svg = renderOrgChartSvg(orgTree, style); return sharp(Buffer.from(svg)).png().toBuffer(); }