visual-explainer
PublicRepository: nicobailon/visual-explainer
Low Risk
No security issues found
Skill manifest does not include a 'license' field. Specifying a license helps users understand usage terms.
Remediation Add 'license' field to SKILL.md frontmatter (e.g., MIT, Apache-2.0)
Description
Generate self-contained HTML visual explanations for systems, code changes, plans, data, and technical concepts. Use for diagrams, architecture overviews, diff or plan reviews, project recaps, comparison tables, slide decks, and other visual explanations.
Details
Requires a browser to view generated HTML files. Optional surf-cli for AI image generation.
- author
- nicobailon
- version
- 0.8.1
Skill Files
# Visual Explainer Generate self-contained HTML pages that explain systems, code changes, plans, data, and technical concepts visually. Use this skill for diagram requests, architecture overviews, diff/plan reviews, project recaps, comparison tables, slide decks, and any visual explanation. ## Trigger and delivery rules - Prefer an HTML page over terminal ASCII when the output is inherently visual. - If a table would have 4+ rows or 3+ columns, render it as HTML and give only a short chat summary. - Write files to `~/.agent/diagrams/` or the explicit eval output path. Use descriptive filenames. - Open generated pages in the browser when running normally. In Pi package installs, use `visual_explainer` with `prepare` for planning/context and `render` only after the complete HTML document exists. - The final page must be a complete self-contained HTML document, including embedded CSS and any needed JS. ## Reference routing Read only the references needed for the current output: | Need | Read | |---|---| | Text-heavy architecture/cards | `./templates/architecture.html` | | Mermaid flowcharts, sequence, ER, state, class, C4, data flow | `./templates/mermaid-flowchart.html`, Mermaid sections in `./references/libraries.md` | | Data tables, comparisons, audits | `./templates/data-table.html` | | Slide decks | `./templates/slide-deck.html`, `./references/slide-patterns.md` | | CSS layout, overflow, depth, collapsibles, SVG connectors, generated images | `./references/css-patterns.md` | | Pages with 4+ major sections | `./references/responsive-nav.md` | | Prose-heavy pages | “Prose Page Elements” in `css-patterns.md`, typography sections in `libraries.md` | ## Choose the representation | Content | Default representation | |---|---| | Flowchart, pipeline, state machine, decision tree | Mermaid | | Sequence, ER/schema, class, C4, topology-focused architecture | Mermaid | | Text-heavy architecture, module internals, implementation plans | CSS grid cards, optionally with a Mermaid overview | | 15+ element architecture | Hybrid: small Mermaid overview + CSS detail cards | | Comparison/audit/status matrix | Semantic HTML `<table>` | | Timeline/roadmap | CSS timeline | | Dashboard/metrics | CSS grid + charts/KPIs | | Slide deck | `100dvh` slides using slide template patterns | ## Mermaid invariants - Use `theme: 'base'` with custom `themeVariables` matching the page palette. - For complex diagrams use ELK layout when available. - Never use bare `<pre class="mermaid">`. - Use the canonical `diagram-shell` pattern from `templates/mermaid-flowchart.html`: `.diagram-shell` > `.mermaid-wrap` > `.zoom-controls` + `.mermaid-viewport` > `.mermaid-canvas`. - Every Mermaid diagram needs zoom in/out/reset/expand controls, Ctrl/Cmd+scroll zoom, drag panning, and click-to-expand. - Prefer `flowchart TD` for complex diagrams. Use `LR` only for simple 3–4 node linear flows. - Use `<br/>` in quoted flowchart labels. Do not use escaped `\n` labels. - Never define page-level `.node`; Mermaid uses it internally. Use namespaced page classes such as `.ve-card`. - For 15+ elements, do not cram everything into one Mermaid diagram. Use the hybrid overview + cards pattern. ## Layout and style invariants - Use semantic HTML where it helps accessibility and copy/paste: `<table>`, headings, lists, `<details>`, captions. - Use CSS custom properties for palette: `--bg`, `--surface`, `--border`, `--text`, `--text-dim`, and 3–5 accents. - Pick a clear aesthetic direction before writing: blueprint, editorial, paper/ink, terminal, IDE-inspired, or data-dense. - Avoid generic defaults: no body font that is only Inter, Roboto, Arial, Helvetica, or system-ui; no violet/fuchsia Tailwind-default accents as the main palette (`#8b5cf6`, `#7c3aed`, `#a78bfa`, `#d946ef`); no cyan+magenta+purple neon dashboard; no gradient-mesh blobs. - Good font pair families: DM Sans + Fira Code; Instrument Serif + JetBrains Mono; IBM Plex Sans + IBM Plex Mono; Bricolage Grotesque + Fragment Mono; Plus Jakarta Sans + Azeret Mono. - Good accent directions: terracotta+sage, teal+slate, rose+cranberry, amber+emerald, deep blue+gold. - Prevent overflow: `min-width: 0` on grid/flex children, `overflow-wrap: break-word` for long text, and scroll containers for wide tables/code. - Do not set `display: flex` directly on `<li>` when list markers matter. - Use depth sparingly: hero/elevated only for primary sections; flat/recessed for reference material. - Use entrance/hover animation only when it clarifies hierarchy. Respect `prefers-reduced-motion`. Do not use continuous glow, pulse, or breathing effects on static content. ## Slide deck mode Use slides only when explicitly requested or when a command asks for slides. Slides are a different medium, not a paginated article: - Each slide is one viewport (`100dvh`) with no page-level scrolling. - Use larger type, fewer objects per slide, varied compositions, and visible navigation. - Include slide nav chrome from `slide-deck.html`: prev/next controls, slide count, keyboard navigation, and carousel dots/indicators. - Before writing HTML, inventory the source and map every source item to slides. - Do not drop content to fit a fixed slide count. Add slides instead. - Use the 10 slide types from `slide-patterns.md`: Title, Section Divider, Content, Split, Diagram, Dashboard, Table, Code, Quote, Full-Bleed. ## Optional generated images If `surf` is available, generated images may be embedded as base64 for hero banners, conceptual illustrations, or educational visuals. Skip images for data-heavy, structural, or Mermaid/CSS-suitable content. Pages must stand on CSS, typography, and diagrams without images. ## Final checklist Before delivery, verify: - complete HTML document; - output written to the requested path; - no console errors when opened; - no horizontal overflow at normal desktop width; - fonts load with fallbacks; - tables preserve rows/columns and wrap long text; - Mermaid diagrams use `diagram-shell` with zoom/pan/expand; - slides fit one viewport, include carousel dots, and preserve source coverage; - visual hierarchy makes the main idea obvious in the first viewport; - styling would still be recognizable if compared against a generic dark/violet template.
{
"name": "visual-explainer",
"description": "Generate beautiful HTML pages for diagrams, diff reviews, plan reviews, slides, and data tables",
"version": "0.8.1",
"author": {
"name": "nicobailon"
},
"repository": "https://github.com/nicobailon/visual-explainer",
"license": "MIT"
}
--- name: diff-review description: Generate a visual diff review for code changes --- Load the visual-explainer skill and generate a self-contained HTML diff review. ## Scope detection Interpret `$@` as a branch, commit, range, PR, or `HEAD`. If no argument is given, compare the working tree against `main`/`master`. ## Data gathering before HTML Run the relevant git commands for: diff stats, name-status, changed files, line counts, public API/type/function changes, added/removed files, docs/changelog changes, tests touched, dependencies/config changes. Read changed files in full plus surrounding code paths needed to validate behavior. If reviewing committed work, read commit messages. If this session created the work, use available progress/plan notes for rationale. ## Source verification Before generating, know and cite: - exact changed files and line-count scope; - each function/type/module name referenced; - before/after behavior for important changes; - likely coupling and test impact. Use file paths, command outputs, or file:line evidence. Do not invent rationale or code paths. ## Required page sections 1. Executive summary: intuition, problem solved, factual scope. 2. File map: full tree, color-coded new/modified/deleted; compact, `<details>` if long. 3. Architecture impact: Mermaid or hybrid diagram when relationships matter. 4. Before/after behavior: side-by-side visual comparison. 5. Risk review: correctness, tests, API compatibility, security/privacy, performance, maintainability. 6. Coupling map: dependencies, hidden coupling, migration/release concerns. 7. Review recommendation: merge/readiness, blockers, follow-ups. Use diff color language consistently: red removed/before, green added/after, amber modified/risk, blue neutral context. Use responsive section navigation for 4+ sections. Follow the skill’s Mermaid and overflow rules. Write to `~/.agent/diagrams/` and open in browser.
--- name: fact-check description: Verify a generated document against actual code and git history --- Load the visual-explainer skill and fact-check the document named by `$@`. If no argument is given, use the most recently modified HTML file in `~/.agent/diagrams/`. ## Claim extraction Read the target document. Extract verifiable claims about file paths, function/type/module names, behavior, architecture, data flow, APIs, commands, dependencies, tests, performance/security assertions, and git history. Skip subjective design opinions. ## Verification For each claim, inspect the actual source or git history. Re-read referenced files. For diff reviews, compare before/after with `git show` or the relevant range. For plan docs, verify referenced files/functions/types exist and behave as described. Classify claims as verified, corrected, unsupported, or unverifiable. Preserve the document’s structure. Correct factual errors in place and add a verification summary that lists what was checked and changed. For HTML, match the existing page style and open it in the browser. For markdown, report the path in chat.
--- name: generate-slides description: Generate a slide deck as a self-contained HTML page --- Load the visual-explainer skill and generate a slide deck for: $@ Before writing HTML, read `./templates/slide-deck.html`, `./references/slide-patterns.md`, and only the shared CSS/library sections needed for the source. Plan the deck first: inventory the source, map every item to slides, choose a narrative arc, and assign a composition to each slide. Use the 10 slide types and nav chrome from `slide-patterns.md`/`slide-deck.html`, including carousel dots, prev/next, slide count, and keyboard controls. Keep each slide to `100dvh`; split dense content across slides rather than scrolling or dropping content. Use visual-first slides: diagrams, charts, tables, SVG accents, and images from `surf` only when they clarify the story. Vary compositions; three centered slides in a row is a smell. Write to `~/.agent/diagrams/` and open in the browser.
--- name: generate-visual-plan description: Generate a visual implementation plan --- Load the visual-explainer skill and generate a self-contained HTML implementation plan for: $@ ## Research first Read relevant repo files before planning. Identify entry points, existing patterns, affected modules, public APIs, tests, config/schema/data model, similar features, and constraints from README/CHANGELOG/docs. ## Required page sections 1. Goal and scope: what will change and what is intentionally out. 2. Current state: short diagram/table of existing architecture. 3. Proposed design: architecture/data/control flow, preferably Mermaid or hybrid cards. 4. Implementation sequence: ordered phases with dependencies. 5. File map: files to create/edit/delete and why. 6. Interface/contracts: types, APIs, schemas, CLI flags, config, events. 7. Risk and decision matrix: correctness, tests, migration, release, UX, security/privacy, performance. 8. Test plan: unit/integration/e2e/edge cases mapped to files. 9. Acceptance checklist: observable done criteria. Use hierarchy: overview and architecture dominate; detailed file/test/reference sections stay compact or collapsible. Follow the skill’s Mermaid, table, overflow, and delivery rules. Write to `~/.agent/diagrams/` and open in browser.
--- name: generate-web-diagram description: Generate a standalone HTML diagram and open it in the browser --- Load the visual-explainer skill and generate an HTML visual explainer for: $@ Use the skill’s reference routing and final checklist. Pick a representation that fits the topic: Mermaid for connected flows/topologies; CSS cards for text-heavy explanations; tables for matrices; timelines for linear history. Write to `~/.agent/diagrams/` with a descriptive filename and open the result in the browser. In Pi package installs, this is an explicit visual request: use `visual_explainer.prepare` when planning/context scouting helps, then `visual_explainer.render` with the complete HTML.
--- name: plan-review description: Compare an implementation plan against the current codebase --- Load the visual-explainer skill and generate a self-contained HTML plan review. ## Inputs Use `$@` as the plan path or plan text. If no path is given, ask for the plan. ## Data gathering before HTML Read the plan in full. Extract goals, assumptions, proposed files/functions/types, migrations, tests, rollout/release notes, and explicit risks. Read every referenced file, plus importers/dependents that may be affected. Use ripgrep for existing patterns, similar implementations, public API boundaries, config/schema files, and tests. ## Source verification For each proposed change, verify whether referenced files/functions/types exist, whether current behavior matches the plan, what ripple effects are missing, and whether the proposed test coverage fits the current test style. Cite plan sections and file:line evidence. ## Required page sections 1. Plan summary: problem, core idea, scope. 2. Accuracy verdict: correct, stale, risky, unsupported, missing. 3. Current architecture: diagram of affected subsystem only. 4. Proposed architecture: matching visual diff against current state. 5. Gap/risk matrix: correctness, tests, API, data model, UX, security/privacy, performance, maintainability, release. 6. File-by-file review: proposed edits, current reality, recommendation. 7. Better plan: concrete corrections or simplifications. 8. Decision: approve, revise, or reject with rationale. Use current-vs-planned visual language. Include responsive nav. Follow the skill’s Mermaid, overflow, and evidence rules. Write to `~/.agent/diagrams/` and open in browser.
--- name: project-recap description: Generate a visual project recap for context switching --- Load the visual-explainer skill and generate a self-contained HTML project recap. ## Data gathering before HTML Read project identity files (`README`, changelog, package/build files), top-level tree, current git status, recent commits, unmerged/stale branches, TODO/FIXME in recent files, progress/todo memory if present, and key entry points/source files. Focus on what a returning developer needs to rebuild the mental model. ## Verify before generating Cite command output or file:line evidence for project state, module/function/type names, recent activity, current blockers, and next-step claims. Do not fabricate momentum or rationale. ## Required page sections 1. Project identity: what this repo is, stack, entry points. 2. Architecture snapshot: Mermaid or hybrid diagram of current conceptual modules. 3. Recent activity: grouped narrative, not raw log. 4. Current state: uncommitted work, branches, TODOs, known blockers. 5. Mental model map: key modules, data flow, command/test/deploy paths. 6. Risks and cognitive debt: hotspots and gotchas. 7. Useful commands and files. 8. Likely next steps, based only on evidence. Use responsive nav. Use compact reference tables for file maps and commands. Follow the skill’s Mermaid, overflow, and delivery rules. Write to `~/.agent/diagrams/` and open in browser.
import { existsSync, lstatSync, mkdirSync, writeFileSync } from "node:fs";
import { spawn } from "node:child_process";
import { homedir } from "node:os";
import { join } from "node:path";
import type { AgentToolResult, ExtensionAPI } from "@earendil-works/pi-coding-agent";
type VisualExplainerParams = {
action: "prepare" | "render";
topic?: string;
goal?: string;
files?: string[];
audience?: string;
preferSubagent?: boolean;
filename?: string;
html?: string;
open?: boolean;
};
type OpenStatus = "disabled" | "unsupported" | "dispatched" | "failed";
type OpenResult = {
openAttempted: boolean;
openStatus: OpenStatus;
openError?: string;
};
type SubagentDetection = {
available: boolean;
allToolsHasSubagent?: boolean;
error?: string;
};
type PrepareDetails = {
action: "prepare";
topic: string;
goal?: string;
audience?: string;
files: string[];
subagentAvailable: boolean;
subagentAllToolsAvailable?: boolean;
subagentDetectionError?: string;
recommendedFlow: string[];
subagentPrompt?: string;
};
type RenderDetails = OpenResult & {
action: "render";
path: string;
};
type VisualExplainerDetails = PrepareDetails | RenderDetails;
const visualExplainerParameters = {
type: "object",
properties: {
action: {
type: "string",
enum: ["prepare", "render"],
description: "Choose prepare to plan a visual explanation, or render to write complete HTML to ~/.agent/diagrams/.",
},
topic: {
type: "string",
description: "For action=prepare: what the visual explanation should cover.",
},
goal: {
type: "string",
description: "For action=prepare: what the user wants to understand, decide, or communicate.",
},
files: {
type: "array",
items: { type: "string" },
description: "For action=prepare: relevant files or paths the agent may inspect before generating the visual explanation.",
},
audience: {
type: "string",
description: "For action=prepare: intended audience, such as developer, PM, team, reviewer, or executive.",
},
preferSubagent: {
type: "boolean",
description: "For action=prepare: when true, recommend a scout subagent first if the subagent tool is active. Defaults to true.",
},
filename: {
type: "string",
description: "For action=render: basename or slug for the output file. The tool appends .html if missing.",
},
html: {
type: "string",
description: "For action=render: complete self-contained HTML document to write.",
},
open: {
type: "boolean",
description: "For action=render: open the written HTML file in the browser. Defaults to true.",
},
},
required: ["action"],
additionalProperties: false,
} as const;
function detectSubagent(pi: ExtensionAPI): SubagentDetection {
let error: string | undefined;
try {
return { available: pi.getActiveTools().includes("subagent") };
} catch (caught) {
error = caught instanceof Error ? caught.message : String(caught);
}
try {
return {
available: false,
allToolsHasSubagent: pi.getAllTools().some((tool) => tool.name === "subagent"),
error,
};
} catch (caught) {
const fallbackError = caught instanceof Error ? caught.message : String(caught);
return { available: false, error: error ? `${error}; ${fallbackError}` : fallbackError };
}
}
function outputFilename(input: string) {
const raw = input.trim().replace(/^@/, "");
if (!raw) throw new Error("filename is required");
if (raw.includes("/") || raw.includes("\\")) throw new Error("filename must be a basename, not a path");
if (raw.includes("..")) throw new Error("filename must not contain '..'");
if (/[\0-\x1f\x7f]/.test(raw)) throw new Error("filename must not contain control characters");
return /\.html?$/i.test(raw) ? raw : `${raw}.html`;
}
function assertHtmlDocument(html: string) {
const trimmed = html.trim();
if (!trimmed) throw new Error("html is required");
const start = trimmed.replace(/^\s*<!doctype\s+html\b[^>]*>\s*/i, "").replace(/^(?:<!--[\s\S]*?-->\s*)+/, "");
if (!/^<html[\s>]/i.test(start) || !/<\/html>\s*$/i.test(trimmed)) {
throw new Error("html must be a complete HTML document starting with <!doctype html> or <html> and ending with </html>");
}
}
async function openInBrowser(path: string): Promise<OpenResult> {
let command: string;
let args: string[];
if (process.platform === "darwin") {
command = "open";
args = [path];
} else if (process.platform === "linux") {
command = "xdg-open";
args = [path];
} else if (process.platform === "win32") {
command = "cmd";
args = ["/c", "start", "", path];
} else {
return { openAttempted: false, openStatus: "unsupported" };
}
return await new Promise<OpenResult>((resolve) => {
const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
let settled = false;
const settle = (result: OpenResult) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(result);
};
child.once("error", (error) => {
settle({
openAttempted: true,
openStatus: "failed",
openError: error.message,
});
});
child.once("exit", (code, signal) => {
if (code !== 0) {
settle({
openAttempted: true,
openStatus: "failed",
openError: code === null ? `opener exited with signal ${signal ?? "unknown"}` : `opener exited with code ${code}`,
});
}
});
const timer = setTimeout(() => {
settle({ openAttempted: true, openStatus: "dispatched" });
}, 250);
child.unref();
});
}
function prepareVisualExplanation(pi: ExtensionAPI, params: VisualExplainerParams): AgentToolResult<VisualExplainerDetails> {
if (typeof params.topic !== "string") throw new Error("topic must be a string for action=prepare");
if (params.goal !== undefined && typeof params.goal !== "string") throw new Error("goal must be a string when provided");
if (params.audience !== undefined && typeof params.audience !== "string") throw new Error("audience must be a string when provided");
if (params.files !== undefined && (!Array.isArray(params.files) || !params.files.every((file) => typeof file === "string"))) {
throw new Error("files must be an array of strings when provided");
}
if (params.preferSubagent !== undefined && typeof params.preferSubagent !== "boolean") {
throw new Error("preferSubagent must be a boolean when provided");
}
const topic = params.topic.trim();
if (!topic) throw new Error("topic is required");
const goal = params.goal?.trim();
const audience = params.audience?.trim();
const files = params.files?.map((file) => file.trim()).filter(Boolean) ?? [];
const subagent = detectSubagent(pi);
const shouldUseSubagent = params.preferSubagent !== false && subagent.available;
const subagentPrompt = shouldUseSubagent
? `Scout the codebase for a visual explanation about ${topic}.${goal ? ` The user goal is: ${goal}.` : ""}${files.length ? ` Start with these files/paths: ${files.join(", ")}.` : ""} Return concise findings, important entities/flows, and visual structure recommendations. Do not edit files.`
: undefined;
const recommendedFlow = shouldUseSubagent
? [
"Run a scout subagent to gather repo context and identify the visual structure.",
"Synthesize the findings into a concise visual outline for the target audience.",
"Read the relevant visual-explainer references or templates before generating the page.",
"Generate a complete self-contained HTML document using the visual-explainer skill.",
"Choose a basename filename and call visual_explainer with action=render, filename, html, and optional open.",
]
: [
"Gather the needed context directly in the main agent.",
"Create a concise visual outline for the target audience.",
"Read the relevant visual-explainer references or templates before generating the page.",
"Generate a complete self-contained HTML document using the visual-explainer skill.",
"Choose a basename filename and call visual_explainer with action=render, filename, html, and optional open.",
];
const summaryLines = [
`Prepared visual explanation for: ${topic}`,
goal ? `Goal: ${goal}` : undefined,
audience ? `Audience: ${audience}` : undefined,
shouldUseSubagent ? "Recommended start: use a scout subagent for context." : "Recommended start: gather context directly in this session.",
"Recommended flow:",
...recommendedFlow.map((step, i) => `${i + 1}. ${step}`),
subagentPrompt ? `Suggested subagent task:\n${subagentPrompt}` : undefined,
];
return {
content: [{ type: "text" as const, text: summaryLines.filter((line): line is string => Boolean(line)).join("\n") }],
details: {
action: "prepare",
topic,
goal,
audience,
files,
subagentAvailable: subagent.available,
subagentAllToolsAvailable: subagent.allToolsHasSubagent,
subagentDetectionError: subagent.error,
recommendedFlow,
subagentPrompt,
},
};
}
async function renderVisualExplanation(params: VisualExplainerParams, signal?: AbortSignal): Promise<AgentToolResult<VisualExplainerDetails>> {
if (typeof params.filename !== "string") throw new Error("filename must be a string for action=render");
if (typeof params.html !== "string") throw new Error("html must be a string for action=render");
if (params.open !== undefined && typeof params.open !== "boolean") throw new Error("open must be a boolean when provided");
signal?.throwIfAborted();
const filename = outputFilename(params.filename);
assertHtmlDocument(params.html);
const outputDir = join(homedir(), ".agent", "diagrams");
const outputPath = join(outputDir, filename);
mkdirSync(outputDir, { recursive: true });
if (lstatSync(outputDir).isSymbolicLink()) throw new Error(`${outputDir} must not be a symlink`);
if (existsSync(outputPath) && lstatSync(outputPath).isSymbolicLink()) {
throw new Error(`${outputPath} must not be a symlink`);
}
signal?.throwIfAborted();
writeFileSync(outputPath, params.html, "utf8");
signal?.throwIfAborted();
const openResult = params.open === false
? { openAttempted: false, openStatus: "disabled" as const }
: await openInBrowser(outputPath);
let message = `Wrote ${outputPath}.`;
if (openResult.openStatus === "dispatched") {
message += " Browser open requested.";
} else if (openResult.openStatus === "failed") {
message += ` Browser open failed: ${openResult.openError ?? "unknown error"}.`;
} else if (openResult.openStatus === "unsupported") {
message += " Browser opening is unsupported on this platform.";
}
return {
content: [{ type: "text" as const, text: message }],
details: { action: "render", path: outputPath, ...openResult },
};
}
export default function (pi: ExtensionAPI) {
pi.registerTool<typeof visualExplainerParameters, VisualExplainerDetails>({
name: "visual_explainer",
label: "Visual Explainer",
description: "Use with action=prepare after generating or reviewing a plan, architecture, diff, or substantial implementation when a visual explanation would help; use action=render after the complete HTML is ready to write it to ~/.agent/diagrams/ and optionally open it.",
promptSnippet: "Plan or render visual-explainer HTML. Ask before action=prepare unless visuals were explicitly requested; use action=render as the final write/open step with complete HTML.",
promptGuidelines: [
"After generating or reviewing a plan, architecture, diff, or substantial implementation, consider offering a visual explanation if it would clarify the work for the user.",
"Because visual explanations can consume many tokens, ask before calling visual_explainer with action=prepare unless the user explicitly requested a diagram, visual review, recap, or visual plan.",
"If visual_explainer action=prepare recommends subagent scouting and the subagent tool is available, gather context first, then synthesize complete HTML and finish with visual_explainer action=render.",
"Use visual_explainer action=render only after generating a complete visual-explainer HTML document; pass a basename-style filename because it writes under ~/.agent/diagrams/.",
],
parameters: visualExplainerParameters,
executionMode: "sequential",
async execute(_toolCallId: string, params: VisualExplainerParams, signal?: AbortSignal) {
if (params.action !== "prepare" && params.action !== "render") {
throw new Error("action must be either 'prepare' or 'render'");
}
if (params.action === "prepare") return prepareVisualExplanation(pi, params);
return await renderVisualExplanation(params, signal);
},
});
}
# CSS Patterns for Diagrams
Reusable patterns for layout, connectors, theming, and visual effects in self-contained HTML diagrams.
## Theme Setup
Always define both light and dark palettes via custom properties. Start with whichever fits the chosen aesthetic, ensure both work.
```css
:root {
--font-body: 'Outfit', system-ui, sans-serif;
--font-mono: 'Space Mono', 'SF Mono', Consolas, monospace;
--bg: #f8f9fa;
--surface: #ffffff;
--surface-elevated: #ffffff;
--border: rgba(0, 0, 0, 0.08);
--border-bright: rgba(0, 0, 0, 0.15);
--text: #1a1a2e;
--text-dim: #6b7280;
--accent: #0891b2;
--accent-dim: rgba(8, 145, 178, 0.1);
/* Semantic accents for diagram elements */
--node-a: #0891b2;
--node-a-dim: rgba(8, 145, 178, 0.1);
--node-b: #059669;
--node-b-dim: rgba(5, 150, 105, 0.1);
--node-c: #d97706;
--node-c-dim: rgba(217, 119, 6, 0.1);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1117;
--surface: #161b22;
--surface-elevated: #1c2333;
--border: rgba(255, 255, 255, 0.06);
--border-bright: rgba(255, 255, 255, 0.12);
--text: #e6edf3;
--text-dim: #8b949e;
--accent: #22d3ee;
--accent-dim: rgba(34, 211, 238, 0.12);
--node-a: #22d3ee;
--node-a-dim: rgba(34, 211, 238, 0.12);
--node-b: #34d399;
--node-b-dim: rgba(52, 211, 153, 0.12);
--node-c: #fbbf24;
--node-c-dim: rgba(251, 191, 36, 0.12);
}
}
```
## Background Atmosphere
Flat backgrounds feel dead. Use subtle gradients or patterns.
```css
/* Radial glow behind focal area */
body {
background: var(--bg);
background-image: radial-gradient(ellipse at 50% 0%, var(--accent-dim) 0%, transparent 60%);
}
/* Faint dot grid */
body {
background-color: var(--bg);
background-image: radial-gradient(circle, var(--border) 1px, transparent 1px);
background-size: 24px 24px;
}
/* Diagonal subtle lines */
body {
background-color: var(--bg);
background-image: repeating-linear-gradient(
-45deg, transparent, transparent 40px,
var(--border) 40px, var(--border) 41px
);
}
/* Gradient mesh (pick 2-3 positioned radials) */
body {
background: var(--bg);
background-image:
radial-gradient(at 20% 20%, var(--node-a-dim) 0%, transparent 50%),
radial-gradient(at 80% 60%, var(--node-b-dim) 0%, transparent 50%);
}
```
## Link Styling
**Never rely on browser default link colors.** The default blue (`#0000EE`) has poor contrast on dark backgrounds. Style links with `color: var(--accent)` and keep underlines for discoverability. On dark backgrounds, use bright accents (`#22d3ee`, `#34d399`, `#fbbf24`). On light backgrounds, use deeper tones (`#0891b2`, `#059669`, `#d97706`).
## Section / Card Components
The fundamental building block. A colored card representing a system component, pipeline step, or data entity.
**IMPORTANT: Never use `.node` as a CSS class name.** Mermaid.js internally uses `.node` on its SVG `<g>` elements with `transform: translate(x, y)` for positioning. Any page-level `.node` styles (hover transforms, box-shadows, transitions) will leak into Mermaid diagrams and break their layout. Use `.ve-card` instead (namespaced to avoid collisions with CSS frameworks like Bootstrap/Tailwind that also use `.card`).
```css
.ve-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px 20px;
position: relative;
}
/* Colored accent border (left or top) */
.ve-card--accent-a {
border-left: 3px solid var(--node-a);
}
/* --- Depth tiers: vary card depth to signal importance --- */
/* Elevated: KPIs, key sections, anything that should pop */
.ve-card--elevated {
background: var(--surface-elevated);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04);
}
/* Recessed: code blocks, secondary content, detail panels */
.ve-card--recessed {
background: color-mix(in srgb, var(--bg) 70%, var(--surface) 30%);
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.06);
border-color: var(--border);
}
/* Hero: executive summaries, focal elements — demands attention */
.ve-card--hero {
background: color-mix(in srgb, var(--surface) 92%, var(--accent) 8%);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.04);
border-color: color-mix(in srgb, var(--border) 50%, var(--accent) 50%);
}
/* Glass: special-occasion overlay effect (use sparingly) */
.ve-card--glass {
background: color-mix(in srgb, var(--surface) 60%, transparent 40%);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-color: rgba(255, 255, 255, 0.1);
}
/* Section label (monospace, uppercase, small) */
.ve-card__label {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--node-a);
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
}
/* Colored dot indicator */
.ve-card__label::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
```
## Code Blocks
Code blocks need explicit whitespace preservation and a max-height constraint. Without these, code runs together and long files overwhelm the page.
### Basic Pattern
```css
.code-block {
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.5;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
overflow-x: auto;
/* CRITICAL: preserve line breaks and indentation */
white-space: pre-wrap;
word-break: break-word;
}
/* Constrain height for long code */
.code-block--scroll {
max-height: 400px;
overflow-y: auto;
}
```
```html
<pre class="code-block code-block--scroll"><code>// Your code here
function example() {
return true;
}</code></pre>
```
### With File Header
```css
.code-file {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.code-file__header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-dim);
}
.code-file__body {
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.5;
padding: 16px;
background: var(--surface-elevated);
white-space: pre-wrap;
word-break: break-word;
max-height: 500px;
overflow: auto;
}
```
```html
<div class="code-file">
<div class="code-file__header">
<span>src/extension.ts</span>
</div>
<pre class="code-file__body"><code>export function activate() {
// ...
}</code></pre>
</div>
```
### Implementation Plans: Don't Dump Full Files
For implementation plans and architecture docs, **don't display entire source files inline**. Instead:
1. **Show structure, not code:**
```html
<div class="file-structure">
<div class="file-structure__path">src/extension.ts</div>
<ul class="file-structure__outline">
<li><code>BOOMERANG_INSTRUCTIONS</code> — System prompt for autonomous mode</li>
<li><code>clearState()</code> — Reset extension state</li>
<li><code>updateStatus()</code> — Update UI status indicator</li>
<li><code>/boomerang</code> command — Start autonomous task</li>
<li><code>/boomerang-cancel</code> command — Cancel active task</li>
<li><code>before_agent_start</code> hook — Inject instructions</li>
<li><code>agent_end</code> hook — Generate summary</li>
</ul>
</div>
```
2. **Use collapsible sections for full code:**
```html
<details class="collapsible">
<summary>Full implementation (87 lines)</summary>
<pre class="code-file__body"><code>...</code></pre>
</details>
```
3. **Show key snippets only:**
```html
<p>The core logic intercepts task completion:</p>
<pre class="code-block"><code>pi.on("agent_end", async () => {
const summary = generateSummary(workEntries);
boomerangComplete = true;
});</code></pre>
```
**Anti-patterns:**
- Displaying full source files inline (100+ lines overwhelming the page)
- Code blocks without `white-space: pre-wrap` (code runs together into unreadable wall)
- No height constraint on long code (page becomes endless scroll)
If someone needs the full file, put it in a collapsible section or link to it.
## Directory Tree
For file structures, use `<pre>` with monospace + `white-space: pre`. Tree connectors (`├──`, `└──`, `│`) only work when vertically aligned — they become noise if text wraps.
```css
.dir-tree {
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.7;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px 20px;
overflow-x: auto;
white-space: pre;
}
.dir-tree .ann { color: var(--text-dim); font-size: 11px; font-style: italic; }
.dir-tree .hl { color: var(--accent); font-weight: 600; }
```
```html
<pre class="dir-tree">my-project/
├── src/
│ ├── <span class="hl">index.ts</span> <span class="ann">— entry point</span>
│ ├── services/
│ │ └── <span class="hl">api.py</span> <span class="ann">(142 lines)</span>
│ └── utils/
├── tests/ <span class="ann">(14 test files)</span>
└── README.md</pre>
```
For labeled trees, wrap in a card. For side-by-side comparisons, put two cards in a grid:
```css
.dir-tree-card { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.dir-tree-card__header {
display: flex; align-items: center; gap: 8px;
padding: 10px 16px; background: var(--surface); border-bottom: 1px solid var(--border);
font-family: var(--font-mono); font-size: 11px; font-weight: 600;
text-transform: uppercase; letter-spacing: 1.5px;
}
.dir-tree-card .dir-tree { border: none; border-radius: 0; }
/* Side-by-side: two .dir-tree-card in a grid */
.dir-compare { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: start; }
@media (max-width: 900px) { .dir-compare { grid-template-columns: 1fr; } }
```
**Never** render tree connectors inside wrapping text (`white-space: normal`), flex children, or grid items — the vertical pipes lose alignment and the hierarchy becomes unreadable.
## Overflow Protection
Grid and flex children default to `min-width: auto`, which prevents them from shrinking below their content width. Long text, inline code badges, and non-wrapping elements will blow out containers.
### Global rules
```css
/* Every grid/flex child must be able to shrink */
.grid > *, .flex > *,
[style*="display: grid"] > *,
[style*="display: flex"] > * {
min-width: 0;
}
/* Long text wraps instead of overflowing */
body {
overflow-wrap: break-word;
}
```
### Side-by-side comparison panels
```css
.comparison {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.comparison > * {
min-width: 0;
overflow-wrap: break-word;
}
@media (max-width: 768px) {
.comparison { grid-template-columns: 1fr; }
}
```
### Never use `display: flex` on `<li>` for marker characters
Using `display: flex` on a list item to position a `::before` marker creates an anonymous flex item for the remaining text content. That anonymous flex item gets `min-width: auto` and you **cannot** set `min-width: 0` on anonymous boxes. Lines with many inline `<code>` badges will overflow their container with no CSS fix possible.
Use absolute positioning for markers instead:
```css
/* WRONG — causes overflow with inline code badges */
li {
display: flex;
align-items: baseline;
gap: 6px;
}
li::before {
content: '›';
flex-shrink: 0;
}
/* RIGHT — text wraps normally */
li {
padding-left: 14px;
position: relative;
}
li::before {
content: '›';
position: absolute;
left: 0;
}
```
### List markers overlapping container borders
By default, `list-style-position: outside` places list markers (bullets, numbers) outside the content box. When lists are inside bordered containers (cards, callout boxes), the markers can overlap or extend beyond the border.
```css
/* WRONG — markers overlap container border */
.card ol, .card ul {
padding-left: 20px; /* Not enough for outside markers */
}
/* RIGHT — use inside positioning */
.card ol, .card ul {
list-style-position: inside;
}
/* OR — adequate padding for outside markers */
.card ol, .card ul {
padding-left: 2em; /* ~32px gives room for markers */
}
/* OR — custom markers with absolute positioning (most control) */
.card ol {
list-style: none;
padding-left: 0;
counter-reset: item;
}
.card ol li {
counter-increment: item;
padding-left: 2em;
position: relative;
}
.card ol li::before {
content: counter(item) ".";
position: absolute;
left: 0;
color: var(--accent);
font-weight: 600;
}
```
**Rule of thumb:** Any `<ol>` or `<ul>` inside a bordered container needs either `list-style-position: inside` or `padding-left: 2em` minimum. The default 20px padding is not enough for outside-positioned markers.
## Mermaid Containers
Mermaid diagrams have two common layout issues: they render too small to read, and they left-align in their container leaving awkward dead space (especially for narrow vertical flowcharts).
### Centering (Required)
Mermaid SVGs render at a fixed size based on content. Without explicit centering, they default to top-left alignment. **Always center Mermaid diagrams** — narrow vertical flowcharts look particularly bad when left-aligned in a wide container.
```css
/* WRONG — diagram hugs left edge */
.mermaid-container {
padding: 24px;
border: 1px solid var(--border);
}
/* RIGHT — diagram centers in container */
.mermaid-wrap {
display: flex;
justify-content: center;
align-items: flex-start; /* or center for shorter diagrams */
padding: 24px;
border: 1px solid var(--border);
}
```
### Scaling Small Diagrams
Mermaid sizes diagrams based on content, not container. Complex diagrams with many nodes render small to fit everything, leaving the text nearly unreadable. Three fixes:
**1. Increase fontSize in themeVariables** (most effective):
```javascript
mermaid.initialize({
theme: 'base',
themeVariables: {
fontSize: '18px', // default is 16px, bump to 18-20px for complex diagrams
}
});
```
**2. CSS zoom** for diagrams that still render too small:
```css
.mermaid-wrap--scaled .mermaid {
zoom: 1.3;
}
```
**3. Constrain container width** so the diagram doesn't float in dead space:
```css
.mermaid-wrap--constrained {
max-width: 800px;
margin: 0 auto;
}
```
**Rule of thumb:** If the diagram has 10+ nodes or the text is smaller than 12px rendered, increase fontSize to 18-20px or apply CSS zoom.
### Zoom Controls
Add zoom controls to every `.mermaid-wrap` container for complex diagrams.
**Small diagrams in slides.** If a diagram has fewer than ~7 nodes with no branching, it will render tiny in a full-viewport slide container. For simple linear flows (A → B → C → D), use CSS pipeline cards instead of Mermaid — see `slide-patterns.md` "CSS Pipeline Slide." Reserve Mermaid for complex graphs where automatic edge routing is actually needed.
### Full Pattern
```css
.mermaid-wrap {
position: relative;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 32px 24px;
overflow: auto;
/* CRITICAL: center the diagram both horizontally and vertically */
display: flex;
justify-content: center;
align-items: center;
/* Prevent vertical flowcharts from compressing into unreadable thumbnails */
min-height: 400px;
}
/* For shorter diagrams that don't need the full height */
.mermaid-wrap--compact { min-height: 200px; }
/* For very tall vertical flowcharts */
.mermaid-wrap--tall { min-height: 600px; }
.zoom-controls {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 2px;
z-index: 10;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px;
}
.zoom-controls button {
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 14px;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s ease, color 0.15s ease;
}
.zoom-controls button:hover {
background: var(--border);
color: var(--text);
}
.mermaid-wrap { cursor: grab; }
.mermaid-wrap.is-panning { cursor: grabbing; user-select: none; }
/* Multi-diagram structure */
.diagram-shell {
position: relative;
}
.diagram-shell__hint {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
margin-bottom: 8px;
opacity: 0.7;
}
.mermaid-viewport {
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
min-height: 300px;
}
.mermaid-canvas {
position: absolute;
top: 0;
left: 0;
}
.zoom-label {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
padding: 0 6px;
white-space: nowrap;
}
```
**How the new zoom/pan engine works:**
The SVG is rendered into `.mermaid-canvas` which is absolutely positioned inside `.mermaid-viewport`. Zooming sets the SVG's `width` and `height` styles directly. Panning applies `transform: translate()` to the canvas. The viewport has `overflow: hidden` to clip the panned content. This approach avoids CSS `zoom` (which had cross-browser quirks) and gives precise control over the diagram's size and position.
### HTML
```html
<section class="diagram-shell">
<p class="diagram-shell__hint">
Ctrl/Cmd + wheel to zoom. Scroll to pan. Drag to pan when zoomed. Double-click to fit.
</p>
<div class="mermaid-wrap">
<div class="zoom-controls">
<button type="button" data-action="zoom-in" title="Zoom in">+</button>
<button type="button" data-action="zoom-out" title="Zoom out">−</button>
<button type="button" data-action="zoom-fit" title="Smart fit">↺</button>
<button type="button" data-action="zoom-one" title="1:1 zoom">1:1</button>
<button type="button" data-action="zoom-expand" title="Open full size">⛶</button>
<span class="zoom-label">Loading...</span>
</div>
<div class="mermaid-viewport">
<div class="mermaid mermaid-canvas"></div>
</div>
</div>
<script type="text/plain" class="diagram-source">
graph TD
A --> B
</script>
</section>
```
Use one `.diagram-shell` per diagram. The source Mermaid text lives in `<script type="text/plain" class="diagram-source">`, so multiple diagrams can coexist on a page without ID collisions.
### JavaScript
Use a closure-based initializer. Per-diagram state lives inside `initDiagram(shell)`, while shared drag listeners stay at module scope:
```javascript
const config = { /* fitPadding, zoom bounds, readabilityFloor */ };
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
let activeDrag = null;
addEventListener('mousemove', (e) => activeDrag?.onMove(e));
addEventListener('mouseup', () => { activeDrag?.onEnd(); activeDrag = null; });
function initDiagram(shell) {
const wrap = shell.querySelector('.mermaid-wrap');
const viewport = shell.querySelector('.mermaid-viewport');
const canvas = shell.querySelector('.mermaid-canvas');
const source = shell.querySelector('.diagram-source');
const label = shell.querySelector('.zoom-label');
if (!wrap || !viewport || !canvas || !source || !label) {
console.error('initDiagram: missing required elements in', shell);
return;
}
// Per-diagram state in closure
let zoom = 1;
let fitMode = 'contain';
let panX = 0;
let panY = 0;
let svgW = 0;
let svgH = 0;
async function render() {
try {
const code = source.textContent.trim();
if (!code) {
label.textContent = 'Error: Empty source';
return;
}
const id = 'diagram-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
const { svg } = await mermaid.render(id, code);
const parsed = new DOMParser().parseFromString(svg, 'text/html');
const parsedSvg = parsed.body.querySelector('svg');
if (!parsedSvg) throw new Error('Mermaid produced no SVG');
canvas.replaceChildren(document.adoptNode(parsedSvg));
// readSvgNaturalSize(svgNode) + setAdaptiveHeight() + fitDiagram()
// wire controls from data-action attributes
// wire wheel/drag/touch handlers scoped to this shell
} catch (err) {
console.error('Mermaid render failed:', err);
label.textContent = 'Error: ' + (err.message || 'Render failed');
}
}
render();
}
document.querySelectorAll('.diagram-shell').forEach(initDiagram);
```
This pattern removes all hardcoded IDs and supports unlimited diagrams per page. For the full implementation (including smart fit, pinch zoom, and shared drag state), use `templates/mermaid-flowchart.html` as the canonical source.
### Mermaid SVG insertion
Mermaid 10+ can emit HTML inside SVG `<foreignObject>` labels, including unclosed HTML tags such as `<br>`. Do not parse Mermaid output with `DOMParser(..., 'image/svg+xml')`: the strict XML parser can silently truncate labels or edges. Avoid `canvas.innerHTML = svg` in reusable templates too, because security scanners often flag it as an HTML sink.
Use `DOMParser(..., 'text/html')`, then adopt the parsed `<svg>` node into the canvas. The HTML parser accepts Mermaid's label markup and preserves the SVG namespace for browser rendering.
## Grid Layouts
### Architecture Diagram (2-column with sidebar)
```css
.arch-grid {
display: grid;
grid-template-columns: 260px 1fr;
grid-template-rows: auto;
gap: 20px;
max-width: 1100px;
margin: 0 auto;
}
.arch-grid__sidebar { grid-column: 1; }
.arch-grid__main { grid-column: 2; }
.arch-grid__full { grid-column: 1 / -1; }
```
### Pipeline (horizontal steps)
```css
.pipeline {
display: flex;
align-items: stretch;
gap: 0;
overflow-x: auto;
padding-bottom: 8px;
}
.pipeline__step {
min-width: 130px;
flex-shrink: 0;
}
.pipeline__arrow {
display: flex;
align-items: center;
padding: 0 4px;
color: var(--border-bright);
font-size: 18px;
flex-shrink: 0;
}
/* Parallel branch within a pipeline */
.pipeline__parallel {
display: flex;
flex-direction: column;
gap: 6px;
}
```
### Card Grid (dashboard / metrics)
```css
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
}
```
### Data Tables
Use real `<table>` elements for tabular data. Wrap in a scrollable container for wide tables.
```css
/* Scrollable wrapper for wide tables */
.table-wrap {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.table-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Base table */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
line-height: 1.5;
}
/* Header */
.data-table thead {
position: sticky;
top: 0;
z-index: 2;
}
.data-table th {
background: var(--surface-elevated, var(--surface2, var(--surface)));
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-dim);
text-align: left;
padding: 12px 16px;
border-bottom: 2px solid var(--border-bright);
white-space: nowrap;
}
/* Cells */
.data-table td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
vertical-align: top;
color: var(--text);
}
/* Let text-heavy columns wrap naturally */
.data-table .wide {
min-width: 200px;
max-width: 500px;
}
/* Right-align numeric columns */
.data-table td.num,
.data-table th.num {
text-align: right;
font-variant-numeric: tabular-nums;
font-family: var(--font-mono);
}
/* Alternating rows */
.data-table tbody tr:nth-child(even) {
background: var(--accent-dim);
}
/* Row hover */
.data-table tbody tr {
transition: background 0.15s ease;
}
.data-table tbody tr:hover {
background: var(--border);
}
/* Last row: no bottom border (container handles it) */
.data-table tbody tr:last-child td {
border-bottom: none;
}
/* Code inside cells */
.data-table code {
font-family: var(--font-mono);
font-size: 11px;
background: var(--accent-dim);
color: var(--accent);
padding: 1px 5px;
border-radius: 3px;
}
/* Secondary detail text */
.data-table small {
display: block;
color: var(--text-dim);
font-size: 11px;
margin-top: 2px;
}
```
#### Status Indicators
Styled spans for match/gap/warning states. Never use emoji.
```css
.status {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
padding: 3px 10px;
border-radius: 6px;
white-space: nowrap;
}
.status--match {
background: var(--green-dim, rgba(5, 150, 105, 0.1));
color: var(--green, #059669);
}
.status--gap {
background: var(--red-dim, rgba(239, 68, 68, 0.1));
color: var(--red, #ef4444);
}
.status--warn {
background: var(--orange-dim, rgba(217, 119, 6, 0.1));
color: var(--orange, #d97706);
}
.status--info {
background: var(--accent-dim);
color: var(--accent);
}
/* Dot variant (compact, no text) */
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-dot--match { background: var(--green, #059669); }
.status-dot--gap { background: var(--red, #ef4444); }
.status-dot--warn { background: var(--orange, #d97706); }
```
Usage in table cells:
```html
<td><span class="status status--match">Match</span></td>
<td><span class="status status--gap">Gap</span></td>
<td><span class="status status--warn">Partial</span></td>
```
#### Table Summary Row
For totals, counts, or aggregate status at the bottom:
```css
.data-table tfoot td {
background: var(--surface-elevated, var(--surface2, var(--surface)));
font-weight: 600;
font-size: 12px;
border-top: 2px solid var(--border-bright);
border-bottom: none;
padding: 12px 16px;
}
```
#### Sticky First Column (for very wide tables)
```css
.data-table th:first-child,
.data-table td:first-child {
position: sticky;
left: 0;
z-index: 1;
background: var(--surface);
}
.data-table tbody tr:nth-child(even) td:first-child {
background: color-mix(in srgb, var(--surface) 95%, var(--accent) 5%);
}
```
## Connectors
### CSS Arrow (vertical, between stacked sections)
```css
.flow-arrow {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 12px;
padding: 6px 0;
}
/* Down arrow via SVG icon */
.flow-arrow svg {
width: 20px;
height: 20px;
fill: none;
stroke: var(--border-bright);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
```
Down arrow SVG (reuse inline):
```html
<svg viewBox="0 0 20 20"><path d="M10 4 L10 16 M6 12 L10 16 L14 12"/></svg>
```
### CSS Arrow (horizontal, between inline steps)
Use `::after` or a literal arrow character:
```css
.h-arrow::after {
content: '→';
color: var(--border-bright);
font-size: 18px;
padding: 0 4px;
}
```
### SVG Curved Connector (between arbitrary nodes)
For connections that aren't simple vertical/horizontal, use an absolutely positioned SVG overlay:
```html
<svg class="connectors" style="position:absolute;inset:0;width:100%;height:100%;pointer-events:none;">
<path d="M 150,100 C 150,200 350,100 350,200" fill="none" stroke="var(--accent)" stroke-width="1.5" stroke-dasharray="4 3"/>
<!-- Arrowhead -->
<polygon points="348,195 352,205 356,195" fill="var(--accent)"/>
</svg>
```
Position the parent container as `position: relative` to scope the SVG overlay.
## Animations
### Staggered Fade-In on Load
Define the keyframe once, then stagger via a `--i` CSS variable set per element. This approach works regardless of DOM nesting or interleaved non-animated elements (unlike `nth-child` which breaks when siblings aren't all the same type).
```css
@keyframes fadeUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.ve-card {
animation: fadeUp 0.4s ease-out both;
animation-delay: calc(var(--i, 0) * 0.05s);
}
```
Set `--i` per element in the HTML to control stagger order:
```html
<div class="ve-card" style="--i: 0">First</div>
<div class="connector">...</div>
<div class="ve-card" style="--i: 1">Second</div>
<div class="connector">...</div>
<div class="ve-card" style="--i: 2">Third</div>
```
### Hover Lift
```css
.ve-card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.ve-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
```
### Scale-Fade (for KPI cards, badges, status indicators)
```css
@keyframes fadeScale {
from { opacity: 0; transform: scale(0.92); }
to { opacity: 1; transform: scale(1); }
}
.kpi-card {
animation: fadeScale 0.35s ease-out both;
animation-delay: calc(var(--i, 0) * 0.06s);
}
```
### SVG Draw-In (for connectors, progress rings, path elements)
```css
@keyframes drawIn {
from { stroke-dashoffset: var(--path-length); }
to { stroke-dashoffset: 0; }
}
/* Set --path-length to the path's getTotalLength() value */
.connector path {
stroke-dasharray: var(--path-length);
animation: drawIn 0.8s ease-in-out both;
animation-delay: calc(var(--i, 0) * 0.1s);
}
```
### CSS Counter (for hero numbers without JS)
Uses `@property` to animate a custom property as an integer, then display it via `counter()`. No JS required. Falls back to showing the final value immediately in browsers without `@property` support.
```css
@property --count {
syntax: '<integer>';
initial-value: 0;
inherits: false;
}
@keyframes countUp {
to { --count: var(--target); }
}
.kpi-card__value--animated {
--target: 247;
counter-reset: val var(--count);
animation: countUp 1.2s ease-out forwards;
}
.kpi-card__value--animated::after {
content: counter(val);
}
```
### Choreography
Don't use the same animation for everything. Mix types by element role, with easing stagger (fast-then-slow, not linear):
- **Cards**: `fadeUp` — the default entrance, reliable and subtle
- **KPI / badges**: `fadeScale` — scale draws the eye to important numbers
- **SVG connectors**: `drawIn` — reveals flow direction, pairs with card stagger
- **Hero numbers**: `countUp` — counting motion signals "this number matters"
- **Stagger timing**: `calc(var(--i) * 0.06s)` with lower `--i` values on important elements so they appear first
### Respect Reduced Motion
```css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
```
## Sparklines and Simple Charts (Pure SVG)
For simple inline visualizations without a library:
```html
<!-- Sparkline -->
<svg viewBox="0 0 100 30" style="width:100px;height:30px;">
<polyline points="0,25 15,20 30,22 45,10 60,15 75,5 90,12 100,8"
fill="none" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<!-- Progress bar -->
<div style="height:6px;background:var(--border);border-radius:3px;overflow:hidden;">
<div style="height:100%;width:72%;background:var(--accent);border-radius:3px;"></div>
</div>
```
## Responsive Breakpoint
Include a single breakpoint for narrow viewports:
```css
@media (max-width: 768px) {
.arch-grid { grid-template-columns: 1fr; }
.pipeline { flex-wrap: wrap; gap: 8px; }
.pipeline__arrow { display: none; }
body { padding: 16px; }
}
```
## Badges and Tags
Small inline labels for categorizing elements:
```css
.tag {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
padding: 2px 7px;
border-radius: 4px;
background: var(--node-a-dim);
color: var(--node-a);
}
```
## Lists Inside Nodes
For tool listings, feature lists, table columns:
```css
.node-list {
list-style: none;
padding: 0;
margin: 0;
font-size: 12px;
line-height: 1.8;
}
.node-list li {
padding-left: 14px;
position: relative;
}
.node-list li::before {
content: '›';
color: var(--text-dim);
font-weight: 600;
position: absolute;
left: 0;
}
.node-list code {
font-family: var(--font-mono);
font-size: 11px;
background: var(--accent-dim);
color: var(--accent);
padding: 1px 5px;
border-radius: 3px;
}
```
## KPI / Metric Cards
Large hero number with trend indicator and label. For dashboards, review summaries, and impact sections.
```css
.kpi-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
}
.kpi-card {
background: var(--surface-elevated);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.kpi-card__value {
font-size: 36px;
font-weight: 700;
letter-spacing: -1px;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.kpi-card__label {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-dim);
margin-top: 6px;
}
.kpi-card__trend {
font-family: var(--font-mono);
font-size: 12px;
margin-top: 4px;
}
.kpi-card__trend--up { color: var(--node-b, #059669); }
.kpi-card__trend--down { color: var(--red, #ef4444); }
```
```html
<div class="kpi-row">
<div class="kpi-card">
<div class="kpi-card__value">247</div>
<div class="kpi-card__label">Lines Added</div>
<div class="kpi-card__trend kpi-card__trend--up">+34%</div>
</div>
<!-- ... more cards -->
</div>
```
## Before / After Panels
Two-column comparison with diff-colored headers. For review pages, migration docs, and feature comparisons.
```css
.diff-panels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
.diff-panels > * { min-width: 0; overflow-wrap: break-word; }
.diff-panel__header {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
padding: 10px 16px;
}
.diff-panel__header--before {
background: var(--red-dim, rgba(239, 68, 68, 0.08));
color: var(--red, #ef4444);
border-bottom: 2px solid var(--red, #ef4444);
}
.diff-panel__header--after {
background: var(--green-dim, rgba(5, 150, 105, 0.08));
color: var(--green, #059669);
border-bottom: 2px solid var(--green, #059669);
}
.diff-panel__body {
padding: 16px;
background: var(--surface);
font-size: 13px;
line-height: 1.6;
}
/* Highlight changed items within a panel */
.diff-changed {
background: var(--accent-dim);
border-radius: 3px;
padding: 0 3px;
}
@media (max-width: 768px) {
.diff-panels { grid-template-columns: 1fr; }
}
```
```html
<div class="diff-panels">
<div class="diff-panel__header diff-panel__header--before">Before</div>
<div class="diff-panel__header diff-panel__header--after">After</div>
<div class="diff-panel__body">Previous implementation...</div>
<div class="diff-panel__body">New implementation...</div>
</div>
```
## Collapsible Sections
Native `<details>/<summary>` with styled disclosure. Zero JS, accessible. For lower-priority content: file maps, decision logs, reference sections.
```css
details.collapsible {
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
details.collapsible summary {
padding: 14px 20px;
background: var(--surface);
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
transition: background 0.15s ease;
}
details.collapsible summary:hover {
background: var(--surface-elevated, var(--surface));
}
details.collapsible summary::-webkit-details-marker { display: none; }
/* Chevron indicator */
details.collapsible summary::before {
content: '▸';
font-size: 11px;
color: var(--text-dim);
transition: transform 0.15s ease;
}
details.collapsible[open] summary::before {
transform: rotate(90deg);
}
details.collapsible .collapsible__body {
padding: 16px 20px;
border-top: 1px solid var(--border);
font-size: 13px;
line-height: 1.6;
}
```
```html
<details class="collapsible">
<summary>File Map (14 files changed)</summary>
<div class="collapsible__body">
<!-- content here -->
</div>
</details>
```
## Prose Page Elements
Patterns for documentation, articles, blog posts, and other reading-first content. The key difference from visual explanations: optimize for sustained reading, not scanning.
### Body Text Settings
```css
/* Comfortable reading baseline */
.prose {
font-size: clamp(17px, 1.1vw + 14px, 19px);
line-height: 1.7;
max-width: 65ch; /* ~600-680px */
text-wrap: pretty;
}
.prose p {
margin-bottom: 1.5em;
}
/* Narrow column for essays/literary content */
.prose--narrow {
max-width: 60ch;
line-height: 1.8;
}
/* Wide column for technical content with code */
.prose--wide {
max-width: 75ch;
line-height: 1.6;
}
```
### Lead Paragraph
Opening paragraph styled distinctly from body text.
```css
/* Larger size */
.lead {
font-size: 20px;
line-height: 1.6;
color: var(--text-bright);
margin-bottom: 32px;
}
/* With drop cap */
.lead--dropcap::first-letter {
float: left;
font-family: var(--font-display);
font-size: 64px;
font-weight: 600;
line-height: 0.85;
padding-right: 12px;
padding-top: 6px;
color: var(--accent);
}
```
### Pull Quotes
Key insights pulled out for emphasis. Use sparingly — one or two per article maximum.
```css
/* Border left — most versatile */
.pullquote {
margin: 48px 0;
padding-left: 24px;
border-left: 3px solid var(--accent);
}
.pullquote p {
font-size: 22px;
font-style: italic;
line-height: 1.4;
color: var(--text-bright);
margin: 0;
}
/* Centered with quotation mark */
.pullquote--centered {
margin: 56px 0;
padding: 32px 40px;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
text-align: center;
position: relative;
}
.pullquote--centered::before {
content: '"';
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
background: var(--bg);
padding: 0 16px;
font-family: var(--font-display);
font-size: 48px;
color: var(--accent);
line-height: 1;
}
```
### Section Dividers
```css
/* Horizontal rule */
hr {
border: none;
height: 1px;
background: var(--border);
margin: 48px 0;
}
/* Ornamental divider — use: <div class="divider">✦ ✦ ✦</div> */
.divider {
text-align: center;
margin: 48px 0;
color: var(--text-dim);
font-size: 18px;
letter-spacing: 12px;
}
```
### Article Hero Patterns
```css
/* Centered minimal — essays, personal posts */
.hero--centered {
text-align: center;
padding: 80px 24px 64px;
max-width: 800px;
margin: 0 auto;
}
.hero__category {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--accent);
margin-bottom: 16px;
}
.hero__title {
font-size: clamp(32px, 5vw, 48px);
font-weight: 600;
line-height: 1.15;
margin-bottom: 16px;
}
.hero__subtitle {
font-size: 20px;
font-style: italic;
color: var(--text-dim);
max-width: 600px;
margin: 0 auto 24px;
}
.hero__meta {
font-size: 13px;
color: var(--text-dim);
}
/* Left-aligned editorial — features, documentation */
.hero--editorial {
padding: 100px 40px 60px;
max-width: 1000px;
margin: 0 auto;
}
.hero--editorial .hero__title {
font-size: clamp(40px, 7vw, 72px);
font-weight: 800;
line-height: 1.0;
letter-spacing: -2px;
}
```
### Author Byline
```css
.byline {
display: flex;
align-items: center;
gap: 12px;
margin-top: 24px;
}
.byline__avatar {
width: 40px;
height: 40px;
border-radius: 50%;
}
.byline__name {
font-weight: 600;
color: var(--text-bright);
display: block;
}
.byline__meta {
font-size: 13px;
color: var(--text-dim);
}
```
### Callout Boxes
For warnings, tips, notes, and key takeaways.
```css
.callout {
padding: 16px 20px;
border-radius: 8px;
border-left: 4px solid var(--callout-border);
background: var(--callout-bg);
margin: 24px 0;
}
.callout--info {
--callout-border: var(--accent);
--callout-bg: color-mix(in srgb, var(--accent) 10%, transparent);
}
.callout--warning {
--callout-border: var(--amber);
--callout-bg: color-mix(in srgb, var(--amber) 10%, transparent);
}
.callout--success {
--callout-border: var(--green);
--callout-bg: color-mix(in srgb, var(--green) 10%, transparent);
}
.callout__title {
font-weight: 600;
margin-bottom: 8px;
color: var(--callout-border);
}
/* Lists inside callouts need padding fix */
.callout ul, .callout ol {
padding-left: 1.5em;
margin: 8px 0 0 0;
}
```
### Theme Toggle
Use `data-theme` attribute for user-controllable light/dark modes. Random initial theme adds variety.
```css
:root, [data-theme="light"] {
--bg: #fafaf9;
--surface: #ffffff;
--text: #1c1917;
--text-dim: #78716c;
--border: #e7e5e4;
--accent: #0d9488;
}
[data-theme="dark"] {
--bg: #0c0a09;
--surface: #1c1917;
--text: #fafaf9;
--text-dim: #a8a29e;
--border: #292524;
--accent: #14b8a6;
}
```
```javascript
// Random initial theme
const themes = ['light', 'dark'];
document.documentElement.setAttribute('data-theme', themes[Math.floor(Math.random() * 2)]);
// Toggle function
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
document.documentElement.setAttribute('data-theme', current === 'light' ? 'dark' : 'light');
}
```
```html
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle theme">
<svg class="theme-toggle__sun" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
<svg class="theme-toggle__moon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
```
```css
.theme-toggle {
position: fixed;
top: 20px;
right: 20px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px;
cursor: pointer;
z-index: 100;
}
[data-theme="light"] .theme-toggle__moon { display: none; }
[data-theme="dark"] .theme-toggle__sun { display: none; }
```
### Prose Anti-Patterns
Avoid these in reading-first content:
- Body text smaller than 16px
- Line-height below 1.5
- Measure wider than 75ch (text spanning full viewport)
- Pull quotes every other paragraph
- Drop caps on every section
- Busy background patterns behind text
## Generated Images
For AI-generated illustrations embedded as base64 data URIs via `surf gemini --generate-image`. Use sparingly — hero banners, conceptual illustrations, educational diagrams, decorative accents.
### Hero Banner
Full-width image cropped to a fixed height with a gradient fade into the page background. Place at the top of the page before the title, or between the title and the first content section.
```css
.hero-img-wrap {
position: relative;
border-radius: 12px;
overflow: hidden;
margin-bottom: 24px;
}
.hero-img-wrap img {
width: 100%;
height: 240px;
object-fit: cover;
display: block;
}
/* Gradient fade into page background */
.hero-img-wrap::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 50%;
background: linear-gradient(to top, var(--bg), transparent);
pointer-events: none;
}
```
```html
<div class="hero-img-wrap">
<img src="data:image/png;base64,..." alt="Descriptive alt text">
</div>
```
Generate with `--aspect-ratio 16:9` for hero banners.
### Inline Illustration
Centered image with border, shadow, and optional caption. Use within content sections for conceptual or educational illustrations.
```css
.illus {
text-align: center;
margin: 24px 0;
}
.illus img {
max-width: 480px;
width: 100%;
border-radius: 10px;
border: 1px solid var(--border);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.illus figcaption {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
margin-top: 8px;
}
```
```html
<figure class="illus">
<img src="data:image/png;base64,..." alt="Descriptive alt text">
<figcaption>How the message queue routes events between services</figcaption>
</figure>
```
Generate with `--aspect-ratio 1:1` or `--aspect-ratio 4:3` for inline illustrations.
### Side Accent
Small image floated beside a section. Use when the illustration supports but doesn't dominate the content.
```css
.accent-img {
float: right;
max-width: 200px;
margin: 0 0 16px 24px;
border-radius: 10px;
border: 1px solid var(--border);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
@media (max-width: 768px) {
.accent-img {
float: none;
max-width: 100%;
margin: 0 0 16px 0;
}
}
```
```html
<img class="accent-img" src="data:image/png;base64,..." alt="Descriptive alt text">
```
# External Libraries (CDN)
Optional CDN libraries for cases where pure CSS/HTML isn't enough. Only include what the diagram actually needs — most diagrams need zero external JS.
## Mermaid.js — Diagramming Engine
Use for flowcharts, sequence diagrams, ER diagrams, state machines, mind maps, class diagrams, and any diagram where automatic node positioning and edge routing saves effort. Mermaid handles layout — you handle theming.
Do NOT use for dashboards — CSS Grid card layouts with Chart.js look better for those. Data tables use `<table>` elements.
**CDN:**
```html
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
mermaid.initialize({ startOnLoad: true, /* ... */ });
</script>
```
**With ELK layout** (required for `layout: 'elk'` — it's a separate package, not bundled in core):
```html
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
import elkLayouts from 'https://cdn.jsdelivr.net/npm/@mermaid-js/layout-elk/dist/mermaid-layout-elk.esm.min.mjs';
mermaid.registerLayoutLoaders(elkLayouts);
mermaid.initialize({ startOnLoad: true, layout: 'elk', /* ... */ });
</script>
```
Without the ELK import and registration, `layout: 'elk'` silently falls back to dagre. Only import ELK when you actually need it — it adds significant bundle weight. Most simple diagrams render fine with dagre.
### Deep Theming
Always use `theme: 'base'` — it's the only theme where all `themeVariables` are fully customizable. The built-in themes (`default`, `dark`, `forest`, `neutral`) ignore most variable overrides.
```html
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
mermaid.initialize({
startOnLoad: true,
theme: 'base',
look: 'classic',
themeVariables: {
// Background and surfaces — teal/slate palette (not violet/indigo!)
primaryColor: isDark ? '#134e4a' : '#ccfbf1',
primaryBorderColor: isDark ? '#14b8a6' : '#0d9488',
primaryTextColor: isDark ? '#f0fdfa' : '#134e4a',
secondaryColor: isDark ? '#1e293b' : '#f0fdf4',
secondaryBorderColor: isDark ? '#059669' : '#16a34a',
secondaryTextColor: isDark ? '#f1f5f9' : '#1e293b',
tertiaryColor: isDark ? '#27201a' : '#fef3c7',
tertiaryBorderColor: isDark ? '#d97706' : '#f59e0b',
tertiaryTextColor: isDark ? '#fef3c7' : '#27201a',
// Lines and edges
lineColor: isDark ? '#64748b' : '#94a3b8',
// Text
fontSize: '16px',
fontFamily: 'var(--font-body)',
// Notes and labels
noteBkgColor: isDark ? '#1e293b' : '#fefce8',
noteTextColor: isDark ? '#f1f5f9' : '#1e293b',
noteBorderColor: isDark ? '#fbbf24' : '#d97706',
}
});
</script>
```
**FORBIDDEN in Mermaid themeVariables:** `#8b5cf6`, `#7c3aed`, `#a78bfa` (indigo/violet), `#d946ef` (fuchsia). Use teal, slate, amber, emerald, or colors from your page's palette.
### CSS Overrides on Mermaid SVG
Mermaid renders SVG. Override its classes for pixel-perfect control that `themeVariables` can't reach:
```css
/* Container — see css-patterns.md "Mermaid Zoom Controls" for the full zoom pattern */
.mermaid-wrap {
position: relative;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
overflow: auto;
}
/* CRITICAL: Force node/edge text to follow the page's color scheme.
Without this, themeVariables.primaryTextColor works for DEFAULT nodes,
but any classDef that sets color: will hardcode a single value that
breaks in the opposite color scheme. Fix: never set color: in classDef,
and always include these CSS overrides. */
.mermaid .nodeLabel { color: var(--text) !important; }
.mermaid .edgeLabel { color: var(--text-dim) !important; background-color: var(--bg) !important; }
.mermaid .edgeLabel rect { fill: var(--bg) !important; }
/* Node shapes */
.mermaid .node rect,
.mermaid .node circle,
.mermaid .node polygon {
stroke-width: 1.5px;
}
/* Edge paths */
.mermaid .edge-pattern-solid {
stroke-width: 1.5px;
}
/* Edge labels — smaller than node labels for visual hierarchy */
.mermaid .edgeLabel {
font-family: var(--font-mono) !important;
font-size: 13px !important;
}
/* Node labels — 16px default; drop to 14px for complex diagrams (20+ nodes) */
.mermaid .nodeLabel {
font-family: var(--font-body) !important;
font-size: 16px !important;
}
/* Sequence diagram actors */
.mermaid .actor {
stroke-width: 1.5px;
}
/* Sequence diagram messages */
.mermaid .messageText {
font-family: var(--font-mono) !important;
font-size: 12px !important;
}
/* ER diagram entities */
.mermaid .er.entityBox {
stroke-width: 1.5px;
}
/* Mind map nodes */
.mermaid .mindmap-node rect {
stroke-width: 1.5px;
}
```
### classDef and style Gotchas
`classDef` values and per-node `style` directives are static text inside `<pre>` — they can't use CSS variables or JS ternaries. Two rules:
1. **Never set `color:` in classDef or per-node `style` directives.** It hardcodes a text color that breaks in the opposite color scheme. This applies to both `classDef highlight fill:...,color:#2c2a25` and `style I fill:...,color:#2c2a25`. Let the CSS overrides above handle text color via `var(--text)`.
2. **Use semi-transparent fills (8-digit hex) for node backgrounds.** They layer over whatever Mermaid's base theme background is, producing a tint that works in both light and dark modes. Use `20`–`44` alpha for subtle, `55`–`77` for prominent:
```
classDef highlight fill:#b5761433,stroke:#b57614,stroke-width:2px
classDef muted fill:#7c6f6411,stroke:#7c6f6444,stroke-width:1px
```
### Node Label Special Characters
Mermaid uses certain characters for shape syntax. Node labels containing these characters cause syntax errors unless quoted.
**Shape characters to watch:**
- `[/text/]` — parallelogram
- `[\text\]` — trapezoid (alt)
- `[/text\]` — trapezoid
- `[\text/]` — trapezoid (alt)
- `[(text)]` — cylindrical
- `[[text]]` — subroutine
- `((text))` — circle
- `{{text}}` — hexagon
**If your node label starts with `/`, `\`, `(`, or `{`, wrap it in quotes:**
```
%% WRONG — syntax error (/ starts parallelogram shape)
CMD[/gallery command] --> SRV[server]
%% RIGHT — quotes escape the special character
CMD["/gallery command"] --> SRV[server]
```
**Edge labels with special characters also need quotes:**
```
%% WRONG — quotes inside edge label
UI -->|"Use as Reference"| RET
%% RIGHT — use single quotes or escape
UI -->|'Use as Reference'| RET
UI -->|Use as Reference| RET
```
Avoid opaque light fills like `fill:#fefce8` — they render as bright boxes in dark mode.
### stateDiagram-v2 Label Limitations
State diagram transition labels have a strict parser. Avoid:
- `<br/>` — only works in flowcharts; causes a parse error in state diagrams
- Parentheses in labels — `cancel()` can confuse the parser
- Multiple colons — the first `:` is the label delimiter; extra colons in the label text may break parsing
If you need multi-line labels or special characters, use a `flowchart` instead of `stateDiagram-v2`. Flowcharts support quoted labels (`|"label with: special chars"|`) and `<br/>` for line breaks.
### Writing Valid Mermaid
Most Mermaid failures come from a few recurring issues. Follow these rules to avoid invalid diagrams:
**For multi-line flowchart node labels, use `<br/>` (not `\n`).** Mermaid flowcharts interpret `<br/>` as a line break, but escaped `\n` in labels often renders as literal text:
```
%% WRONG — renders literal "\n" in node text
A["Copilot Backend\n/api + /api/voicebot"] --> B["Redis"]
%% RIGHT — renders on two lines
A["Copilot Backend<br/>/api + /api/voicebot"] --> B["Redis"]
```
**Quote labels with special characters.** Parentheses, colons, commas, brackets, and ampersands break the parser when unquoted. Wrap any label containing special characters in double quotes:
```
A["handleRequest(ctx)"] --> B["DB: query users"]
A[handleRequest] --> B[query users]
```
**Keep IDs simple.** Node IDs should be alphanumeric with no spaces or punctuation. Put the readable name in the label, not the ID:
```
userSvc["User Service"] --> authSvc["Auth Service"]
```
**Max 10-12 nodes per Mermaid diagram.** Beyond that, readability collapses even with zoom controls and increased fontSize. For complex architectures (15+ elements), use the **hybrid pattern**: a simple 5-8 node Mermaid overview showing module relationships, followed by CSS Grid cards with detailed function lists. Never cram everything into one diagram. Use `subgraph` blocks to group related nodes when under the limit:
```
subgraph Auth
login --> validate --> token
end
subgraph API
gateway --> router --> handler
end
Auth --> API
```
**Arrow styles for semantic meaning:**
| Arrow | Meaning | Use for |
|-------|---------|---------|
| `-->` | Solid | Primary flow |
| `-.->` | Dotted | Optional, async, or fallback paths |
| `==>` | Thick | Critical or highlighted path |
| `--x` | Cross | Rejected or blocked |
| `-->\|label\|` | Labeled | Decision branches, data descriptions |
**Escape pipes in labels.** If a label contains a literal `|`, use `#124;` (HTML entity) or rephrase to avoid it — pipes delimit edge labels in flowcharts.
**Sequence diagram messages must be plain text.** Unlike flowchart labels, sequence diagram messages (the text after `:`) cannot be quoted or escaped. Curly braces `{}`, square brackets `[]`, angle brackets `<>`, and `&` will silently break the parser and the entire diagram renders as raw text. Write human-readable descriptions, not code:
```
%% WRONG — parser chokes on braces, brackets, ampersand
A->>B: web_search({ queries: [...] })
B->>B: User removes query 2, keeps 1 & 3
B->>S: POST /submit { selected: [0, 2] }
%% RIGHT — plain English, no special characters
A->>B: Call web_search with queries
B->>B: User removes query 2, keeps 1 and 3
B->>S: POST /submit with selected indices
```
**Don't mix diagram syntax.** Each diagram type has its own syntax. `-->` works in flowcharts but not in sequence diagrams (`->>` instead). `:::className` works in flowcharts but not in ER diagrams. When in doubt, check the examples below for correct syntax per type.
### Layout Direction: TD vs LR
`flowchart LR` (left-to-right) spreads horizontally. With many nodes, Mermaid scales everything down to fit the width, making text unreadable. `flowchart TD` (top-down) is almost always better.
**When to use each:**
| Direction | Use when | Avoid when |
|-----------|----------|------------|
| `TD` (top-down) | Complex diagrams, 5+ nodes, hierarchies, architecture | Simple A→B→C linear flows |
| `LR` (left-to-right) | Simple linear flows, 3-4 nodes, pipelines | Complex graphs, many branches |
**Rule of thumb:** If the diagram has more than one row of nodes or any branching, use `TD`. The extra vertical space makes labels readable.
```
%% WRONG — LR with many nodes produces wide, short, unreadable diagram
flowchart LR
A --> B --> C --> D --> E
A --> F --> G --> H
%% RIGHT — TD uses vertical space, labels stay readable
flowchart TD
A --> B --> C --> D --> E
A --> F --> G --> H
```
### Diagram Type Examples
**Flowchart with decisions:**
```html
<pre class="mermaid">
graph TD
A[Request] --> B{Authenticated?}
B -->|Yes| C[Load Dashboard]
B -->|No| D[Login Page]
D --> E[Submit Credentials]
E --> B
C --> F{Role?}
F -->|Admin| G[Admin Panel]
F -->|User| H[User Dashboard]
</pre>
```
**Sequence diagram:**
```html
<pre class="mermaid">
sequenceDiagram
participant C as Client
participant G as Gateway
participant S as Service
participant D as Database
C->>G: POST /api/data
G->>G: Validate JWT
G->>S: Forward request
S->>D: Query
D-->>S: Results
S-->>G: Response
G-->>C: 200 OK
</pre>
```
**ER diagram:**
```html
<pre class="mermaid">
erDiagram
USERS ||--o{ ORDERS : places
ORDERS ||--|{ LINE_ITEMS : contains
LINE_ITEMS }o--|| PRODUCTS : references
USERS { string email PK }
ORDERS { int id PK }
LINE_ITEMS { int quantity }
PRODUCTS { string name }
</pre>
```
**State diagram:**
```html
<pre class="mermaid">
stateDiagram-v2
[*] --> Draft
Draft --> Review : submit
Review --> Approved : approve
Review --> Draft : request_changes
Approved --> Published : publish
Published --> Archived : archive
Archived --> [*]
</pre>
```
**Mind map:**
```html
<pre class="mermaid">
mindmap
root((Project))
Frontend
React
Next.js
Tailwind
Backend
Node.js
PostgreSQL
Redis
Infrastructure
AWS
Docker
Terraform
</pre>
```
**Class diagram:**
```html
<pre class="mermaid">
classDiagram
class User {
+string email
+string name
+login()
+logout()
}
class Order {
+int id
+decimal total
+submit()
}
class Product {
+string name
+decimal price
}
User "1" --> "*" Order : places
Order "*" --> "*" Product : contains
</pre>
```
**C4 architecture (flowchart-as-C4):**
```html
<pre class="mermaid">
graph TD
user("👤 User<br/><small>Browser client</small>")
subgraph boundary["Web Platform"]
app["Web App<br/><small>Node.js</small>"]
db[("Database<br/><small>PostgreSQL</small>")]
end
email["📧 Email Service"]:::ext
payment["💳 Payment Gateway"]:::ext
user -->|"HTTPS"| app
app -->|"SQL"| db
app -->|"SMTP"| email
app -->|"API"| payment
classDef ext fill:none,stroke-dasharray:5 5
</pre>
```
Do NOT use native `C4Context` / `C4Container` syntax — it hardcodes sharp corners, its own font, and inline colors that ignore `themeVariables`. Use `graph TD` + `subgraph` for C4 boundaries instead; it inherits all theme settings automatically.
### Which Mermaid Diagram Type?
Quick-reference for choosing the right Mermaid syntax:
| You want to show... | Use | Syntax keyword |
|---|---|---|
| Process flow, decisions, pipelines | Flowchart | `graph TD` / `graph LR` |
| Request/response, API calls, temporal interactions | Sequence diagram | `sequenceDiagram` |
| Database tables and relationships | ER diagram | `erDiagram` |
| OOP classes, domain models with methods | Class diagram | `classDiagram` |
| System architecture at multiple zoom levels | C4 diagram | `graph TD` + `subgraph` (not native `C4Context`) |
| State transitions, lifecycles | State diagram | `stateDiagram-v2` |
| Hierarchical breakdowns, brainstorms | Mind map | `mindmap` |
### Dark Mode Handling
Mermaid initializes once — it can't reactively switch themes. Read the preference at load time inside your `<script type="module">`:
```javascript
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
// Use isDark to pick light or dark values in themeVariables
```
The CSS overrides on the container (`.mermaid-wrap`) and page will still respond to `prefers-color-scheme` normally — only the Mermaid SVG internals are static.
## Chart.js — Data Visualizations
Use for bar charts, line charts, pie/doughnut charts, radar charts, and other data-driven visualizations in dashboard-type diagrams. Overkill for static numbers — use pure SVG/CSS for simple progress bars and sparklines.
```html
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<canvas id="myChart" width="600" height="300"></canvas>
<script>
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const textColor = isDark ? '#8b949e' : '#6b7280';
const gridColor = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)';
const fontFamily = getComputedStyle(document.documentElement)
.getPropertyValue('--font-body').trim() || 'system-ui, sans-serif';
new Chart(document.getElementById('myChart'), {
type: 'bar',
data: {
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May'],
datasets: [{
label: 'Feedback Items',
data: [45, 62, 78, 91, 120],
backgroundColor: isDark ? 'rgba(129, 140, 248, 0.6)' : 'rgba(79, 70, 229, 0.6)',
borderColor: isDark ? '#818cf8' : '#4f46e5',
borderWidth: 1,
borderRadius: 4,
}]
},
options: {
responsive: true,
plugins: {
legend: { labels: { color: textColor, font: { family: fontFamily } } },
},
scales: {
x: { ticks: { color: textColor, font: { family: fontFamily } }, grid: { color: gridColor } },
y: { ticks: { color: textColor, font: { family: fontFamily } }, grid: { color: gridColor } },
}
}
});
</script>
```
Wrap the canvas in a styled container:
```css
.chart-container {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
position: relative;
}
.chart-container canvas {
max-height: 300px;
}
```
## anime.js — Orchestrated Animations
Use when a diagram has 10+ elements and you want a choreographed entrance sequence (staggered reveals, path drawing, count-up numbers). For simpler diagrams, CSS `animation-delay` staggering is sufficient.
```html
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/anime.min.js"></script>
<script>
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!prefersReduced) {
anime({
targets: '.ve-card',
opacity: [0, 1],
translateY: [20, 0],
delay: anime.stagger(80, { start: 200 }),
easing: 'easeOutCubic',
duration: 500,
});
anime({
targets: '.connector path',
strokeDashoffset: [anime.setDashoffset, 0],
easing: 'easeInOutCubic',
duration: 800,
delay: anime.stagger(150, { start: 600 }),
});
document.querySelectorAll('[data-count]').forEach(el => {
anime({
targets: { val: 0 },
val: parseInt(el.dataset.count),
round: 1,
duration: 1200,
delay: 400,
easing: 'easeOutExpo',
update: (anim) => { el.textContent = anim.animations[0].currentValue; }
});
});
}
</script>
```
When using anime.js, set initial opacity to 0 in CSS so elements don't flash before the animation:
```css
.ve-card { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
.ve-card { opacity: 1 !important; }
}
```
## Google Fonts — Typography
Always load with `display=swap` for fast rendering. Pick a distinctive pairing — body + mono at minimum, optionally a display font for the title.
**FORBIDDEN as `--font-body` (AI slop signals):**
- Inter — the single most overused AI default font
- Roboto — generic Android/Google default
- Arial, Helvetica — system defaults with no character
- system-ui alone without a named font — signals zero design intent
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
```
Define as CSS variables for easy reference:
```css
:root {
--font-body: 'Outfit', system-ui, sans-serif;
--font-mono: 'Space Mono', 'SF Mono', Consolas, monospace;
}
```
**Font pairings** (rotate — never use the same pairing twice in a row):
| Body / Headings | Mono / Labels | Feel | Use for |
|---|---|---|---|
| DM Sans | Fira Code | Friendly, developer | Blueprint, technical docs |
| Instrument Serif | JetBrains Mono | Editorial, refined | Plan reviews, decision logs |
| IBM Plex Sans | IBM Plex Mono | Reliable, readable | Architecture diagrams |
| Bricolage Grotesque | Fragment Mono | Bold, characterful | Data tables, dashboards |
| Plus Jakarta Sans | Azeret Mono | Rounded, approachable | Status reports, audits |
| Outfit | Space Mono | Clean geometric, modern | Flowcharts, pipelines |
| Sora | IBM Plex Mono | Technical, precise | ER diagrams, schemas |
| Crimson Pro | Noto Sans Mono | Scholarly, serious | RFC reviews, specs |
| Fraunces | Source Code Pro | Warm, distinctive | Project recaps |
| Geist | Geist Mono | Sharp, modern | Modern API docs |
| Red Hat Display | Red Hat Mono | Cohesive family | System overviews |
| Libre Franklin | Inconsolata | Classic, reliable | Data-dense tables |
| Playfair Display | Roboto Mono | Elegant contrast | Executive summaries |
The first 5 pairings are recommended for most use cases. Vary across consecutive diagrams.
### Typography by Content Voice
For prose-heavy pages (documentation, articles, essays), match typography to the content's voice:
| Voice | Fonts | Best For |
|-------|-------|----------|
| **Literary / Thoughtful** | Literata, Lora, Newsreader, Merriweather | Essays, personal posts, long-form articles |
| **Technical / Precise** | IBM Plex Sans + Mono, Geist + Geist Mono, Source family | Documentation, READMEs, API references |
| **Bold / Contemporary** | Bricolage Grotesque, Space Grotesk, DM Sans | Product pages, feature announcements |
| **Minimal / Focused** | Source Serif 4 + Source Sans 3, Karla + Inconsolata | Tutorials, how-tos, focused reading |
**Literata** deserves special mention — it has optical sizing designed specifically for screen reading. Google's answer to Georgia, but modernized.
# Responsive Section Navigation
Navigation pattern for multi-section pages (reviews, recaps, dashboards). Provides a sticky sidebar TOC on desktop and a sticky horizontal scrollable bar on mobile.
## Layout Structure
The page uses a two-column CSS Grid: sidebar (TOC) + main content. On mobile it collapses to single-column with the TOC becoming a horizontal bar.
```html
<body>
<div class="wrap">
<nav class="toc" id="toc">
<div class="toc-title">Contents</div>
<a href="#s1">1. First Section</a>
<a href="#s2">2. Second Section</a>
<!-- one link per section -->
</nav>
<div class="main">
<h1>Page Title</h1>
<p class="subtitle">Subtitle text</p>
<div id="s1" class="sec-head ...">1 — First Section</div>
<!-- section content -->
<div id="s2" class="sec-head ...">2 — Second Section</div>
<!-- section content -->
</div><!-- /main -->
</div><!-- /wrap -->
</body>
```
Key structural rules:
- `<nav class="toc">` is the **first child** of `.wrap`
- All page content goes inside `<div class="main">`
- Every section heading gets an `id="s1"`, `id="s2"`, etc.
- TOC links use `href="#s1"` matching those IDs
- Keep TOC link text short (truncate long section names)
## CSS
### Wrap (grid layout)
```css
.wrap {
max-width: 1400px;
margin: 0 auto;
display: grid;
grid-template-columns: 170px 1fr;
gap: 0 40px;
}
.main { min-width: 0; }
```
### TOC — Desktop (sticky sidebar)
```css
.toc {
position: sticky;
top: 24px;
align-self: start;
padding: 14px 0;
grid-row: 1 / -1;
max-height: calc(100dvh - 48px);
overflow-y: auto;
}
.toc::-webkit-scrollbar { width: 3px; }
.toc::-webkit-scrollbar-thumb { background: var(--surface-elevated); border-radius: 2px; }
.toc-title {
font-family: var(--font-mono);
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--text-dim);
padding: 0 0 10px;
margin-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.toc a {
display: block;
font-size: 11px;
color: var(--text-dim);
text-decoration: none;
padding: 4px 8px;
border-radius: 5px;
border-left: 2px solid transparent;
transition: all 0.15s;
line-height: 1.4;
margin-bottom: 1px;
}
.toc a:hover { color: var(--text); background: var(--surface2); }
.toc a.active { color: var(--text); border-left-color: var(--accent); }
```
Replace `var(--accent)` with your page's primary accent color variable (e.g., `var(--orange)`, `var(--blue)`).
### TOC — Mobile (sticky horizontal bar)
```css
@media (max-width: 1000px) {
.wrap { grid-template-columns: 1fr; padding-top: 0; }
body { padding-top: 0; }
.toc {
position: sticky;
top: 0;
z-index: 200;
max-height: none;
display: flex;
gap: 4px;
align-items: center;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
background: var(--bg);
border-bottom: 1px solid var(--border);
padding: 10px 0;
margin: 0 -40px;
padding-left: 40px;
padding-right: 40px;
grid-row: auto;
}
.toc::-webkit-scrollbar { display: none; }
.toc-title { display: none; }
.toc a {
white-space: nowrap;
flex-shrink: 0;
border-left: none;
border-bottom: 2px solid transparent;
border-radius: 4px 4px 0 0;
padding: 6px 10px;
font-size: 10px;
}
.toc a.active {
border-left: none;
border-bottom-color: var(--accent);
background: var(--surface);
}
.main { padding-top: 20px; }
/* Offset scroll target so headings clear the sticky bar */
.sec-head { scroll-margin-top: 52px; }
}
```
Adjust `margin: 0 -40px` and `padding-left/right: 40px` to match your `body` padding so the bar bleeds edge-to-edge.
## JavaScript — Scroll Spy
Place before `</body>`, after any Mermaid init:
```html
<script>
(function() {
const toc = document.getElementById('toc');
const links = toc.querySelectorAll('a');
const sections = [];
links.forEach(link => {
const id = link.getAttribute('href').slice(1);
const el = document.getElementById(id);
if (el) sections.push({ id, el, link });
});
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
links.forEach(l => l.classList.remove('active'));
const match = sections.find(s => s.el === entry.target);
if (match) {
match.link.classList.add('active');
// On mobile, auto-scroll the active tab into view
if (window.innerWidth <= 1000) {
match.link.scrollIntoView({
behavior: 'smooth', block: 'nearest', inline: 'center'
});
}
}
}
});
}, { rootMargin: '-10% 0px -80% 0px' });
sections.forEach(s => observer.observe(s.el));
links.forEach(link => {
link.addEventListener('click', e => {
e.preventDefault();
const id = link.getAttribute('href').slice(1);
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
history.replaceState(null, '', '#' + id);
}
});
});
})();
</script>
```
## Adaptation Notes
- The `.toc-title` text, link labels, accent color, and section IDs change per page. Everything else is copy-paste.
- For pages with fewer than 4 sections, skip the TOC entirely — it adds clutter without value.
- The `grid-template-columns: 170px 1fr` width works for most TOCs. If section names are longer, go up to `200px`.
- The `rootMargin: '-10% 0px -80% 0px'` means a section is "active" when its heading enters the top 10-20% of the viewport. This works well with sticky headers.
- On mobile, the horizontal bar uses `overflow-x: auto` with hidden scrollbar. The active tab auto-scrolls into the center of the bar as the user scrolls the page.
# Slide Deck Patterns
CSS patterns, JS engine, slide type layouts, transitions, navigation chrome, and curated presets for self-contained HTML slide presentations. All slides are viewport-fit (100dvh), single-file, same philosophy as scrollable pages.
**When to use slides:** Only when the user explicitly requests them — `/generate-slides`, `--slides` flag on an existing prompt, or natural language like "as a slide deck." Never auto-select slide format.
**Before generating**, also read `./css-patterns.md` for shared patterns (Mermaid zoom controls, overflow protection, depth tiers, status badges) and `./libraries.md` for Mermaid theming, Chart.js, and font pairings. Those patterns apply to slides too — this file adds slide-specific patterns on top.
## Planning a Deck from a Source Document
When converting a plan, spec, review, or any structured document into slides, follow this process before writing any HTML. Skipping it leads to polished-looking decks that silently drop 30–40% of the source material.
**Step 1 — Inventory the source.** Read the entire source document and enumerate every section, subsection, card, table row, decision, specification, collapsible detail, and footnote. Count them. A plan with 7 sections, 6 decision cards, a 7-row file table, 4 presets, 6 technique guides, and an engine spec with 3 sub-specs and 2 collapsibles is ~25 distinct content items that all need slide real estate.
**Step 2 — Map source to slides.** Assign each inventory item to one or more slides. Every item must appear somewhere. Rules:
- If a section has 6 decisions, all 6 need slides — not the 2 that fit on one split slide.
- If a table has 7 rows, all 7 rows show up.
- Collapsible/expandable details in the source are not optional in the deck — they become their own slides.
- Subsections with multiple cards (e.g., "6 Visual Technique cards") may need 2–3 slides to cover at readable density.
- Each plan section typically needs a divider slide + 1–3 content slides depending on density.
**Step 3 — Choose layouts.** For each planned slide, pick a slide type and spatial composition. Vary across the sequence (see Compositional Variety below). This is where narrative pacing happens — alternate dense slides with sparse ones.
**Step 4 — Plan images.** Run `which surf`. If surf-cli is available, plan 2–4 generated images for the deck. At minimum, target the **title slide** (16:9 background that sets the visual tone) and **one full-bleed slide** (immersive background for a key moment). Content slides with conceptual topics also benefit from a 1:1 illustration in the aside area. Generate these images early — before writing HTML — so you can embed them as base64 data URIs. See the Proactive Imagery section below for the full workflow. If surf isn't available, degrade to CSS gradients and SVG decorations — note the fallback in a comment but don't error.
**Step 5 — Verify before writing HTML.** Scan the inventory from Step 1. Is anything unmapped? Would a reader of the source document notice something missing from the deck? If yes, add slides. A source document with 7 sections typically produces 18–25 slides, not 10–13.
**The test:** After generating the deck, a reader who has never seen the source document should be able to reconstruct every major point from the slides alone. If they'd miss entire sections, the deck is incomplete.
## Slide Engine Base
The deck is a scroll-snap container. Each slide is exactly one viewport tall.
```html
<body>
<div class="deck">
<section class="slide slide--title"> ... </section>
<section class="slide slide--content"> ... </section>
<section class="slide slide--diagram"> ... </section>
<!-- one <section> per slide -->
</div>
</body>
```
```css
/* Scroll-snap container */
.deck {
height: 100dvh;
overflow-y: auto;
scroll-snap-type: y mandatory;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
/* Individual slide */
.slide {
height: 100dvh;
scroll-snap-align: start;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
padding: clamp(40px, 6vh, 80px) clamp(40px, 8vw, 120px);
isolation: isolate; /* contain z-index stacking */
}
```
## Typography Scale
Slide typography is 2–3× larger than scrollable pages. Page-sized text on a viewport-sized canvas looks like a mistake.
```css
.slide__display {
font-size: clamp(48px, 10vw, 120px);
font-weight: 800;
letter-spacing: -3px;
line-height: 0.95;
text-wrap: balance;
}
.slide__heading {
font-size: clamp(28px, 5vw, 48px);
font-weight: 700;
letter-spacing: -1px;
line-height: 1.1;
text-wrap: balance;
}
.slide__body {
font-size: clamp(16px, 2.2vw, 24px);
line-height: 1.6;
text-wrap: pretty;
}
.slide__label {
font-family: var(--font-mono);
font-size: clamp(10px, 1.2vw, 14px);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-dim);
}
.slide__subtitle {
font-family: var(--font-mono);
font-size: clamp(14px, 1.8vw, 20px);
color: var(--text-dim);
letter-spacing: 0.5px;
}
```
| Element | Size range | Notes |
|---------|-----------|-------|
| Display (title slides) | 48–120px | `10vw` preferred, weight 800 |
| Section numbers | 100–240px | Ultra-light (weight 200), decorative |
| Headings | 28–48px | `5vw` preferred, weight 700 |
| Body / bullets | 16–24px | `2.2vw` preferred, 1.6 line-height |
| Code blocks | 14–18px | `1.8vw` preferred, mono |
| Quotes | 24–48px | `4vw` preferred, serif italic |
| Labels / captions | 10–14px | Mono, uppercase, dimmed |
## Cinematic Transitions
IntersectionObserver adds `.visible` when a slide enters the viewport. Slides animate in once and stay visible when scrolling back.
```css
/* Slide entrance — fade + lift + subtle scale */
.slide {
opacity: 0;
transform: translateY(40px) scale(0.98);
transition:
opacity 0.6s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
.slide.visible {
opacity: 1;
transform: none;
}
/* Staggered child reveals — add .reveal to each content element */
.slide .reveal {
opacity: 0;
transform: translateY(20px);
transition:
opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.slide.visible .reveal {
opacity: 1;
transform: none;
}
/* Stagger delays — up to 6 children per slide */
.slide.visible .reveal:nth-child(1) { transition-delay: 0.1s; }
.slide.visible .reveal:nth-child(2) { transition-delay: 0.2s; }
.slide.visible .reveal:nth-child(3) { transition-delay: 0.3s; }
.slide.visible .reveal:nth-child(4) { transition-delay: 0.4s; }
.slide.visible .reveal:nth-child(5) { transition-delay: 0.5s; }
.slide.visible .reveal:nth-child(6) { transition-delay: 0.6s; }
@media (prefers-reduced-motion: reduce) {
.slide,
.slide .reveal {
opacity: 1 !important;
transform: none !important;
transition: none !important;
}
}
```
## Navigation Chrome
All navigation is `position: fixed` with high z-index, layered above slides. Styled to be visible on any background.
### Progress Bar
```css
.deck-progress {
position: fixed;
top: 0;
left: 0;
height: 3px;
background: var(--accent);
z-index: 100;
transition: width 0.3s ease;
pointer-events: none;
}
```
### Nav Dots
```css
.deck-dots {
position: fixed;
right: clamp(12px, 2vw, 24px);
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 8px;
z-index: 100;
}
.deck-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-dim);
opacity: 0.3;
border: none;
padding: 0;
cursor: pointer;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.deck-dot:hover {
opacity: 0.6;
}
.deck-dot.active {
opacity: 1;
transform: scale(1.5);
background: var(--accent);
}
```
### Slide Counter
```css
.deck-counter {
position: fixed;
bottom: clamp(12px, 2vh, 24px);
right: clamp(12px, 2vw, 24px);
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-dim);
z-index: 100;
font-variant-numeric: tabular-nums;
}
```
### Keyboard Hints
Auto-fade after first interaction or after 4 seconds.
```css
.deck-hints {
position: fixed;
bottom: clamp(12px, 2vh, 24px);
left: 50%;
transform: translateX(-50%);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
opacity: 0.6;
z-index: 100;
transition: opacity 0.5s ease;
white-space: nowrap;
}
.deck-hints.faded {
opacity: 0;
pointer-events: none;
}
```
### Chrome Visibility on Mixed Backgrounds
For decks where some slides are light and some dark (especially full-bleed slides), nav chrome needs to remain visible. Two approaches:
```css
/* Approach A: subtle backdrop on chrome elements */
.deck-dots,
.deck-counter {
background: color-mix(in srgb, var(--bg) 70%, transparent 30%);
padding: 6px;
border-radius: 20px;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
/* Approach B: text shadow for legibility on any background */
.deck-counter,
.deck-hints {
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
```
## SlideEngine JavaScript
Add once at the end of the page. Handles navigation, chrome updates, and scroll-triggered reveals. Event delegation ensures slide-internal interactions (Mermaid zoom, scrollable code, overflow tables) don't trigger slide navigation.
```javascript
class SlideEngine {
constructor() {
this.deck = document.querySelector('.deck');
this.slides = [...document.querySelectorAll('.slide')];
this.current = 0;
this.total = this.slides.length;
this.buildChrome();
this.bindEvents();
this.observe();
this.update();
}
buildChrome() {
// Progress bar
var bar = document.createElement('div');
bar.className = 'deck-progress';
document.body.appendChild(bar);
this.bar = bar;
// Nav dots
var dots = document.createElement('div');
dots.className = 'deck-dots';
var self = this;
this.slides.forEach(function(_, i) {
var d = document.createElement('button');
d.className = 'deck-dot';
d.title = 'Slide ' + (i + 1);
d.onclick = function() { self.goTo(i); };
dots.appendChild(d);
});
document.body.appendChild(dots);
this.dots = [].slice.call(dots.children);
// Counter
var ctr = document.createElement('div');
ctr.className = 'deck-counter';
document.body.appendChild(ctr);
this.counter = ctr;
// Keyboard hints
var hints = document.createElement('div');
hints.className = 'deck-hints';
hints.textContent = '\u2190 \u2192 or scroll to navigate';
document.body.appendChild(hints);
this.hints = hints;
this.hintTimer = setTimeout(function() {
hints.classList.add('faded');
}, 4000);
}
bindEvents() {
var self = this;
// Keyboard — skip if focus is inside interactive content
document.addEventListener('keydown', function(e) {
if (e.target.closest('.mermaid-wrap, .table-scroll, .code-scroll, input, textarea, [contenteditable]')) return;
if (['ArrowDown', 'ArrowRight', ' ', 'PageDown'].includes(e.key)) {
e.preventDefault(); self.next();
} else if (['ArrowUp', 'ArrowLeft', 'PageUp'].includes(e.key)) {
e.preventDefault(); self.prev();
} else if (e.key === 'Home') {
e.preventDefault(); self.goTo(0);
} else if (e.key === 'End') {
e.preventDefault(); self.goTo(self.total - 1);
}
self.fadeHints();
});
// Touch swipe
var touchY;
this.deck.addEventListener('touchstart', function(e) {
touchY = e.touches[0].clientY;
}, { passive: true });
this.deck.addEventListener('touchend', function(e) {
var dy = touchY - e.changedTouches[0].clientY;
if (Math.abs(dy) > 50) { dy > 0 ? self.next() : self.prev(); }
});
}
observe() {
var self = this;
var obs = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
self.current = self.slides.indexOf(entry.target);
self.update();
}
});
}, { threshold: 0.5 });
this.slides.forEach(function(s) { obs.observe(s); });
}
goTo(i) {
this.slides[Math.max(0, Math.min(i, this.total - 1))]
.scrollIntoView({ behavior: 'smooth' });
}
next() { if (this.current < this.total - 1) this.goTo(this.current + 1); }
prev() { if (this.current > 0) this.goTo(this.current - 1); }
update() {
this.bar.style.width = ((this.current + 1) / this.total * 100) + '%';
var self = this;
this.dots.forEach(function(d, i) { d.classList.toggle('active', i === self.current); });
this.counter.textContent = (this.current + 1) + ' / ' + this.total;
}
fadeHints() {
clearTimeout(this.hintTimer);
this.hints.classList.add('faded');
}
}
```
**Usage:** Instantiate after the DOM is ready and any libraries (Mermaid, Chart.js) have rendered. Always call `autoFit()` before `new SlideEngine()` so content is sized correctly before intersection observers fire.
```html
<script>
// After Mermaid/Chart.js initialization (if used), or at end of <body>:
document.addEventListener('DOMContentLoaded', function() {
autoFit();
new SlideEngine();
});
</script>
```
## Auto-Fit
A single post-render function that handles all known content overflow cases. Agents can't perfectly predict how text reflows at every viewport size, so `autoFit()` is a required safety net. Call it after Mermaid/Chart.js render but before SlideEngine init.
```javascript
function autoFit() {
// Mermaid SVGs: fill container instead of rendering at intrinsic size
document.querySelectorAll('.mermaid svg').forEach(function(svg) {
svg.removeAttribute('height');
svg.style.width = '100%';
svg.style.maxWidth = '100%';
svg.style.height = 'auto';
svg.parentElement.style.width = '100%';
});
// KPI values: visually scale down text that overflows card width
document.querySelectorAll('.slide__kpi-val').forEach(function(el) {
if (el.scrollWidth > el.clientWidth) {
var s = el.clientWidth / el.scrollWidth;
el.style.transform = 'scale(' + s + ')';
el.style.transformOrigin = 'left top';
}
});
// Blockquotes: reduce font proportionally for long text
document.querySelectorAll('.slide--quote blockquote').forEach(function(el) {
var len = el.textContent.trim().length;
if (len > 100) {
var scale = Math.max(0.5, 100 / len);
var fs = parseFloat(getComputedStyle(el).fontSize);
el.style.fontSize = Math.max(16, Math.round(fs * scale)) + 'px';
}
});
}
```
Three cases, one function:
- **Mermaid:** SVGs render with fixed dimensions inside flex containers — force them to fill available width.
- **KPI values:** Long text strings at hero scale overflow card boundaries — `transform: scale()` shrinks visually without reflow.
- **Blockquotes:** Quotes longer than ~100 characters get proportionally smaller font. The 0.5 floor prevents unreadably small text; if it needs more than 50% shrink, it should have been a content slide.
## Slide Type Layouts
Each type has a defined HTML structure and CSS layout. The agent can adapt colors, fonts, and spacing per aesthetic, but the structural patterns stay consistent.
### Title Slide
Full-viewport hero. Background treatment via gradient, texture, or surf-generated image. 80–120px display type.
```html
<section class="slide slide--title">
<svg class="slide__decor" ...><!-- optional decorative accent --></svg>
<div class="slide__content reveal">
<h1 class="slide__display">Deck Title</h1>
<p class="slide__subtitle reveal">Subtitle or date</p>
</div>
</section>
```
```css
.slide--title {
justify-content: center;
align-items: center;
text-align: center;
}
```
### Section Divider
Oversized decorative number (200px+, ultra-light weight) with heading. Breathing room between topics. SVG accent marks optional.
```html
<section class="slide slide--divider">
<span class="slide__number">02</span>
<div class="slide__content">
<h2 class="slide__heading reveal">Section Title</h2>
<p class="slide__subtitle reveal">Optional subheading</p>
</div>
</section>
```
```css
.slide--divider {
justify-content: center;
}
.slide--divider .slide__number {
font-size: clamp(100px, 22vw, 260px);
font-weight: 200;
line-height: 0.85;
opacity: 0.08;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -55%);
pointer-events: none;
font-variant-numeric: tabular-nums;
}
```
### Content Slide
Heading + bullets or paragraphs. Asymmetric layout — content offset to one side. Max 5–6 bullets (2 lines each).
```html
<section class="slide slide--content">
<div class="slide__inner">
<div class="slide__text">
<h2 class="slide__heading reveal">Heading</h2>
<ul class="slide__bullets">
<li class="reveal">First point</li>
<li class="reveal">Second point</li>
</ul>
</div>
<div class="slide__aside reveal">
<!-- optional: illustration, icon, mini-diagram, accent SVG -->
</div>
</div>
</section>
```
```css
.slide--content .slide__inner {
display: grid;
grid-template-columns: 3fr 2fr;
gap: clamp(24px, 4vw, 60px);
align-items: center;
width: 100%;
}
/* For right-heavy variant: swap to 2fr 3fr */
.slide--content .slide__bullets {
list-style: none;
padding: 0;
}
.slide--content .slide__bullets li {
padding: 8px 0 8px 20px;
position: relative;
font-size: clamp(16px, 2vw, 22px);
line-height: 1.6;
color: var(--text-dim);
}
.slide--content .slide__bullets li::before {
content: '';
position: absolute;
left: 0;
top: 18px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
}
```
### Split Slide
Asymmetric two-panel (60/40 or 70/30). Before/after, text+diagram, text+image. Each panel has its own background tier. Zero padding on the slide itself — panels fill edge to edge.
```html
<section class="slide slide--split">
<div class="slide__panels">
<div class="slide__panel slide__panel--primary">
<h2 class="slide__heading reveal">Left Panel</h2>
<div class="slide__body reveal">Content...</div>
</div>
<div class="slide__panel slide__panel--secondary">
<!-- diagram, image, code block, or contrasting content -->
</div>
</div>
</section>
```
```css
.slide--split {
padding: 0;
}
.slide--split .slide__panels {
display: grid;
grid-template-columns: 3fr 2fr;
height: 100%;
}
.slide--split .slide__panel {
padding: clamp(40px, 6vh, 80px) clamp(32px, 4vw, 60px);
display: flex;
flex-direction: column;
justify-content: center;
}
.slide--split .slide__panel--primary {
background: var(--surface);
}
.slide--split .slide__panel--secondary {
background: var(--surface2);
}
```
### Diagram Slide
Full-viewport Mermaid diagram. Max 8–10 nodes (presentation scale — fewer, larger than page diagrams). Node labels at 18px+, edges at 2px+. Zoom controls from `css-patterns.md` apply here.
**When to use Mermaid vs CSS in slides.** Mermaid renders SVGs at a fixed size the agent can't control — node dimensions are set by the library, not by CSS. This creates a recurring problem: small diagrams (fewer than ~7 nodes, no branching) render as tiny elements floating in a huge viewport with acres of dead space. The rule:
- **Use Mermaid** for complex graphs: 8+ nodes, branching paths, cycles, multiple edge crossings — anything where automatic edge routing saves real effort.
- **Use CSS Pipeline** (below) for simple linear flows: A → B → C → D sequences, build steps, deployment stages. CSS cards give full control over sizing, typography, and fill the viewport naturally.
- **Never leave a small Mermaid diagram alone on a slide.** If the diagram is small, either switch to CSS, or pair it with supporting content (description cards, bullet annotations, a summary panel) in a split layout. A slide with a tiny diagram and empty space is a failed slide.
**Mermaid centering fix.** When you do use Mermaid, add `display: flex; align-items: center; justify-content: center;` to `.mermaid-wrap` so the SVG centers within its container instead of hugging the top-left corner. Change `transform-origin` to `center center` so zoom radiates from the middle.
```html
<section class="slide slide--diagram">
<h2 class="slide__heading reveal">Diagram Title</h2>
<div class="mermaid-wrap reveal" style="flex:1; min-height:0;">
<div class="zoom-controls">
<button onclick="zoomDiagram(this,1.2)" title="Zoom in">+</button>
<button onclick="zoomDiagram(this,0.8)" title="Zoom out">−</button>
<button onclick="resetZoom(this)" title="Reset">↺</button>
<button onclick="openDiagramFullscreen(this)" title="Open full size in new tab">⛶</button>
</div>
<pre class="mermaid">
graph TD
A --> B
</pre>
</div>
</section>
```
**Click to expand.** Clicking anywhere on the diagram (without dragging) opens it full-size in a new browser tab. The expand button (⛶) provides the same functionality for discoverability.
```css
.slide--diagram {
padding: clamp(24px, 4vh, 48px) clamp(24px, 4vw, 60px);
}
.slide--diagram .slide__heading {
margin-bottom: clamp(8px, 1.5vh, 20px);
}
.slide--diagram .mermaid-wrap {
border-radius: 12px;
overflow: auto;
display: flex;
align-items: center;
justify-content: center;
}
.slide--diagram .mermaid-wrap .mermaid {
transform-origin: center center;
}
```
**Auto-fit SVG to container.** Mermaid renders SVGs with fixed dimensions and an inline `max-width` style that keeps diagrams tiny inside large slides. The `autoFit()` function (see above) handles this at runtime. Keep the CSS as a belt-and-suspenders fallback:
```css
.slide--diagram .mermaid svg {
width: 100% !important;
height: auto !important;
max-width: 100% !important;
}
```
**Mermaid overrides for presentation scale** (add alongside the standard Mermaid CSS overrides from `libraries.md`):
```css
.slide--diagram .mermaid .nodeLabel {
font-size: 18px !important;
}
.slide--diagram .mermaid .edgeLabel {
font-size: 14px !important;
}
.slide--diagram .mermaid .node rect,
.slide--diagram .mermaid .node circle,
.slide--diagram .mermaid .node polygon {
stroke-width: 2px;
}
.slide--diagram .mermaid .edge-pattern-solid {
stroke-width: 2px;
}
```
### CSS Pipeline Slide
For simple linear flows (build steps, deployment stages, data pipelines) where Mermaid would render too small. CSS cards with arrow connectors give full control over sizing and fill the viewport naturally. Each step card expands to fill available space via `flex: 1`.
```html
<section class="slide" style="background-image:radial-gradient(...);">
<p class="slide__label reveal">Pipeline Label</p>
<h2 class="slide__heading reveal">Pipeline Title</h2>
<div class="pipeline reveal">
<div class="pipeline__step" style="border-top-color:var(--accent);">
<div class="pipeline__num">01</div>
<div class="pipeline__name">Step Name</div>
<div class="pipeline__desc">What this step produces or does</div>
<div class="pipeline__file">output-file.md</div>
</div>
<div class="pipeline__arrow">
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M5 12h14m-4-4l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<div class="pipeline__step"> ... </div>
<!-- repeat step + arrow pairs -->
</div>
</section>
```
```css
.pipeline {
display: flex;
align-items: stretch;
gap: 0;
flex: 1;
min-height: 0;
margin-top: clamp(12px, 2vh, 24px);
}
.pipeline__step {
flex: 1;
background: var(--surface);
border: 1px solid var(--border);
border-top: 3px solid var(--accent);
border-radius: 10px;
padding: clamp(14px, 2.5vh, 28px) clamp(12px, 1.5vw, 22px);
display: flex;
flex-direction: column;
min-width: 0;
overflow-wrap: break-word;
}
.pipeline__num {
font-size: clamp(10px, 1.2vw, 13px);
font-weight: 600;
color: var(--accent);
letter-spacing: 1px;
}
.pipeline__name {
font-size: clamp(16px, 2vw, 24px);
font-weight: 700;
margin: clamp(4px, 0.8vh, 8px) 0;
}
.pipeline__desc {
font-size: clamp(12px, 1.3vw, 16px);
color: var(--text-dim);
line-height: 1.5;
flex: 1;
}
.pipeline__file {
font-size: clamp(10px, 1.1vw, 12px);
color: var(--accent);
background: var(--accent-dim);
padding: 3px 8px;
border-radius: 4px;
margin-top: clamp(8px, 1.5vh, 16px);
align-self: flex-start;
}
.pipeline__arrow {
display: flex;
align-items: center;
padding: 0 clamp(3px, 0.4vw, 6px);
color: var(--accent);
flex-shrink: 0;
opacity: 0.4;
}
@media (max-width: 768px) {
.pipeline { flex-direction: column; }
.pipeline__arrow { justify-content: center; padding: 4px 0; transform: rotate(90deg); }
}
```
Each `.pipeline__step` uses `flex: 1` to fill available width equally, and the pipeline container itself uses `flex: 1` to fill available vertical space in the slide. Step cards stretch to fill, so the content isn't floating in empty space. The `.pipeline__file` badge at the bottom anchors each card and adds a practical detail. Max 5–6 steps — beyond that, split across two slides.
### Dashboard Slide
KPI cards at presentation scale (48–64px hero numbers). Mini-charts via Chart.js or SVG sparklines. Max 6 KPIs.
```html
<section class="slide slide--dashboard">
<h2 class="slide__heading reveal">Metrics Overview</h2>
<div class="slide__kpis">
<div class="slide__kpi reveal">
<div class="slide__kpi-val" style="color:var(--accent)">247</div>
<div class="slide__kpi-label">Lines Added</div>
</div>
<!-- more KPI cards -->
</div>
</section>
```
```css
.slide--dashboard .slide__kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(clamp(140px, 20vw, 220px), 1fr));
gap: clamp(12px, 2vw, 24px);
}
.slide__kpi {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: clamp(16px, 3vh, 32px) clamp(16px, 2vw, 24px);
min-width: 0;
overflow: hidden;
}
.slide__kpi-val {
font-size: clamp(36px, 6vw, 64px);
font-weight: 800;
letter-spacing: -1.5px;
line-height: 1.1;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.slide__kpi-label {
font-family: var(--font-mono);
font-size: clamp(9px, 1.2vw, 13px);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-dim);
margin-top: 8px;
}
```
**KPI hero values should be short** — numbers, percentages, 1–3 word labels. Ideal length is 1–6 characters at hero scale. Longer strings like `store=false` break the layout at 64px. If you must show a longer value, put it in the label or body text instead. The `autoFit()` function (see below) will scale down overflows as a safety net.
### Table Slide
18–20px cell text for projection readability. Max 8 rows per slide — overflow paginates to the next slide. Stronger alternating row contrast than page tables.
```html
<section class="slide slide--table">
<h2 class="slide__heading reveal">Data Title</h2>
<div class="table-wrap reveal" style="flex:1; min-height:0;">
<div class="table-scroll">
<table class="data-table"> ... </table>
</div>
</div>
</section>
```
```css
.slide--table {
padding: clamp(24px, 4vh, 48px) clamp(24px, 4vw, 60px);
}
.slide--table .data-table {
font-size: clamp(14px, 1.8vw, 20px);
}
.slide--table .data-table th {
font-size: clamp(10px, 1.3vw, 14px);
padding: clamp(8px, 1.5vh, 14px) clamp(12px, 2vw, 20px);
}
.slide--table .data-table td {
padding: clamp(10px, 1.5vh, 16px) clamp(12px, 2vw, 20px);
}
```
### Code Slide
18px mono on a recessed dark background. Max 10 lines. Floating filename label. Centered on the viewport for focus.
```html
<section class="slide slide--code">
<h2 class="slide__heading reveal">What Changed</h2>
<div class="slide__code-block reveal">
<span class="slide__code-filename">worker.ts</span>
<pre><code>function processQueue(items) {
// highlighted code here
}</code></pre>
</div>
</section>
```
```css
.slide--code {
align-items: center;
}
.slide__code-block {
background: var(--code-bg, #1a1a2e);
border: 1px solid var(--border);
border-radius: 12px;
padding: clamp(24px, 4vh, 48px) clamp(24px, 4vw, 48px);
max-width: 900px;
width: 100%;
position: relative;
}
.slide__code-filename {
position: absolute;
top: -12px;
left: 24px;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
padding: 4px 12px;
border-radius: 4px;
background: var(--accent);
color: var(--bg);
}
.slide__code-block pre {
margin: 0;
overflow-x: auto;
}
.slide__code-block code {
font-family: var(--font-mono);
font-size: clamp(14px, 1.6vw, 18px);
line-height: 1.7;
color: var(--code-text, #e6edf3);
}
```
### Quote Slide
36–48px serif with dramatic line-height. Oversized quotation mark as SVG or typographic decoration. Generous whitespace is the design.
```html
<section class="slide slide--quote">
<div class="slide__quote-mark reveal">“</div>
<blockquote class="reveal">
The best code is the code you don't have to write.
</blockquote>
<cite class="reveal">— Someone Wise</cite>
</section>
```
```css
.slide--quote {
justify-content: center;
align-items: center;
text-align: center;
padding: clamp(60px, 10vh, 120px) clamp(60px, 12vw, 200px);
}
.slide__quote-mark {
font-size: clamp(80px, 14vw, 180px);
line-height: 0.5;
opacity: 0.08;
font-family: Georgia, serif;
pointer-events: none;
margin-bottom: -20px;
}
.slide--quote blockquote {
font-size: clamp(24px, 4vw, 48px);
font-weight: 400;
line-height: 1.35;
font-style: italic;
margin: 0;
}
.slide--quote cite {
font-family: var(--font-mono);
font-size: clamp(11px, 1.4vw, 14px);
font-style: normal;
margin-top: clamp(16px, 3vh, 32px);
display: block;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--text-dim);
}
```
### Full-Bleed Slide
Background image (surf-generated or CSS gradient) dominates the viewport. Text overlay with gradient scrim ensuring contrast. Zero slide padding.
```html
<section class="slide slide--bleed">
<div class="slide__bg" style="background-image:url('data:image/png;base64,...')"></div>
<div class="slide__scrim"></div>
<div class="slide__content">
<h2 class="slide__heading reveal">Headline Over Image</h2>
<p class="slide__subtitle reveal">Supporting text</p>
</div>
</section>
```
```css
.slide--bleed {
padding: 0;
justify-content: flex-end;
color: #ffffff;
}
.slide__bg {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
z-index: 0;
}
.slide__scrim {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.7) 0%, rgba(0, 0, 0, 0.1) 50%, transparent 100%);
z-index: 1;
}
.slide--bleed .slide__content {
position: relative;
z-index: 2;
padding: clamp(40px, 6vh, 80px) clamp(40px, 8vw, 120px);
}
/* When no generated image, use a bold CSS gradient background */
.slide__bg--gradient {
background: linear-gradient(135deg, var(--accent) 0%, color-mix(in srgb, var(--accent) 60%, var(--bg) 40%) 100%);
}
```
## Decorative SVG Elements
Inline SVG accents lift slides from functional to editorial. Use sparingly — one or two per slide, never on every slide.
### Corner Accent
```html
<!-- Top-right corner mark -->
<svg class="slide__decor slide__decor--corner" width="120" height="120" viewBox="0 0 120 120">
<line x1="120" y1="0" x2="120" y2="40" stroke="var(--accent)" stroke-width="2" opacity="0.2"/>
<line x1="80" y1="0" x2="120" y2="0" stroke="var(--accent)" stroke-width="2" opacity="0.2"/>
</svg>
```
```css
.slide__decor {
position: absolute;
pointer-events: none;
z-index: 0;
}
.slide__decor--corner {
top: 0;
right: 0;
}
```
### Section Divider Mark
```html
<!-- Horizontal rule with diamond -->
<svg class="slide__decor slide__decor--divider" width="200" height="20" viewBox="0 0 200 20">
<line x1="0" y1="10" x2="85" y2="10" stroke="var(--accent)" stroke-width="1" opacity="0.3"/>
<rect x="92" y="3" width="14" height="14" transform="rotate(45 99 10)" fill="none" stroke="var(--accent)" stroke-width="1" opacity="0.3"/>
<line x1="115" y1="10" x2="200" y2="10" stroke="var(--accent)" stroke-width="1" opacity="0.3"/>
</svg>
```
### Geometric Background Pattern
```css
/* Faint grid dots behind a slide */
.slide--with-grid::before {
content: '';
position: absolute;
inset: 0;
background-image: radial-gradient(circle, var(--border) 1px, transparent 1px);
background-size: 32px 32px;
opacity: 0.5;
pointer-events: none;
z-index: 0;
}
```
### Per-Slide Background Variation
Vary gradient direction and accent glow position across slides to create visual rhythm. Don't use a uniform background for every slide.
```css
/* Vary these per slide via inline style or nth-child */
.slide:nth-child(odd) {
background-image: radial-gradient(ellipse at 20% 80%, var(--accent-dim) 0%, transparent 50%);
}
.slide:nth-child(even) {
background-image: radial-gradient(ellipse at 80% 20%, var(--accent-dim) 0%, transparent 50%);
}
```
## Proactive Imagery
Slides should reach for visuals before defaulting to text alone. If a slide could be more compelling with an image, chart, or diagram, add one.
**surf-cli integration:** Check `which surf` at the start of every slide deck generation. If available, **generate 2–4 images minimum** for any deck over 10 slides. This is not optional when surf is available — a deck with AI-generated imagery is dramatically more compelling than one with only CSS gradients. Target these slides in priority order:
1. **Title slide** (always): background image that sets the deck's visual tone. Match the topic and palette. Use `--aspect-ratio 16:9`. Prompt example: "abstract dark geometric pattern with green accent lines, technical and minimal" for Terminal Mono preset.
2. **Full-bleed slide** (always if deck has one): immersive background for the deck's visual anchor moment. Style should match the preset — photo-realistic for Midnight Editorial, abstract/geometric for Swiss Clean, circuit-board or terminal aesthetic for Terminal Mono.
3. **Content slides with conceptual topics** (1–2 if the deck has room): illustration in the `.slide__aside` area for slides about abstract concepts. Use `--aspect-ratio 1:1`.
**Generate images before writing HTML** so they're ready to embed. The workflow:
```bash
# Check availability
which surf
# Generate (one per target slide)
surf gemini "descriptive prompt matching deck palette" --generate-image /tmp/ve-slide-title.png --aspect-ratio 16:9
# Base64 encode for self-containment (macOS)
TITLE_IMG=$(base64 -i /tmp/ve-slide-title.png)
# Linux: TITLE_IMG=$(base64 -w 0 /tmp/ve-slide-title.png)
# Embed in the slide
# <div class="slide__bg" style="background-image:url('data:image/png;base64,${TITLE_IMG}')"></div>
# Clean up
rm /tmp/ve-slide-title.png
```
**Prompt craft for slides:** Be specific about style, dominant colors, and mood. Pull colors from the preset's CSS variables. Examples:
- Terminal Mono: "dark abstract circuit board pattern, green (#50fa7b) traces on near-black (#0a0e14), minimal, technical"
- Midnight Editorial: "deep navy abstract composition, warm gold accent light, cinematic depth of field, premium editorial feel"
- Warm Signal: "warm cream textured paper with terracotta geometric accents, confident modern design"
**When surf fails or isn't available:** Degrade gracefully to CSS gradients and SVG decorations. Use the `.slide__bg--gradient` pattern with bold `linear-gradient` or `radial-gradient` backgrounds. The deck should stand on its own visually without generated images — they enhance, they don't carry. Note the fallback in an HTML comment (`<!-- surf unavailable, using CSS gradient fallback -->`) so future edits know to retry.
**Inline data visualizations:** Proactively add SVG sparklines next to numbers, mini-charts on dashboard slides, and small Mermaid diagrams on split slides even when not explicitly requested. A number with a sparkline next to it tells a better story than a number alone.
**When to skip images:** If surf isn't available, degrade gracefully — use CSS gradients and SVG decorations instead. Never error on missing surf. Pure structural or data-heavy decks (code reviews, table comparisons) may not need generated images.
## Compositional Variety
Consecutive slides must vary their spatial approach. Three centered slides in a row means push one off-axis.
**Composition patterns to alternate between:**
- Centered (title slides, quotes)
- Left-heavy: content on the left 60%, breathing room on the right
- Right-heavy: content on the right 60%, visual or whitespace on the left
- Edge-aligned: content pushed to bottom or top, large empty space opposite
- Split: two distinct panels filling the viewport
- Full-bleed: background dominates, minimal overlaid text
The agent should plan the slide sequence considering layout rhythm, not just content order. When outlining a deck, assign a composition to each slide before writing HTML.
## Presentation Readability
Slides get projected, screen-shared, viewed at distance. Design accordingly:
- **Minimum body text: 16px.** Nothing smaller except labels and captions.
- **One focal point per slide.** Not three competing elements.
- **Higher contrast than pages.** Dimmed text (`--text-dim`) should still be easily readable at distance — test against the background.
- **Nav chrome opacity.** Dots and progress bar must be visible on any slide background (light or dark) without being distracting. Use the backdrop blur or text-shadow approach from the Nav Chrome section.
- **Simpler Mermaid diagrams.** Max 8–10 nodes, 18px+ labels, 2px+ edges. The diagram should be readable without zoom at presentation distance. Zoom controls remain available for detail inspection.
## Content Density Limits
Each slide must fit in exactly 100dvh. If content exceeds these limits, the agent splits across multiple slides — never scrolls within a slide.
| Slide type | Max content |
|-----------|-------------|
| Title | 1 heading + 1 subtitle |
| Section Divider | 1 number + 1 heading + optional subhead |
| Content | 1 heading + 5–6 bullets (max 2 lines each) |
| Split | 1 heading + 2 panels, each follows its inner type's limits |
| Diagram | 1 heading + 1 Mermaid diagram (max 8–10 nodes) |
| Dashboard | 1 heading + 6 KPI cards. Hero values ≤6 chars (numbers, %, short labels). Longer strings belong in the label row. |
| Table | 1 heading + 8 rows; overflow paginates to next slide |
| Code | 1 heading + 10 lines of code |
| Quote | 1 short quote (~25 words / ~150 chars max) + 1 attribution. Longer quotes are content slides, not quote slides. |
| Full-Bleed | 1 heading + 1 subtitle over background |
## Responsive Height Breakpoints
Height-based scaling is more critical for slides than width. Each breakpoint progressively reduces padding, font sizes, and hides decorative elements.
```css
/* Compact viewports */
@media (max-height: 700px) {
.slide {
padding: clamp(24px, 4vh, 40px) clamp(32px, 6vw, 80px);
}
.slide__display { font-size: clamp(36px, 8vw, 72px); }
.slide--divider .slide__number { font-size: clamp(80px, 16vw, 160px); }
}
/* Small tablets / landscape phones */
@media (max-height: 600px) {
.slide__decor { display: none; } /* hide decorative SVGs */
.slide--quote { padding: clamp(32px, 6vh, 60px) clamp(40px, 8vw, 100px); }
.slide__quote-mark { display: none; }
}
/* Aggressive: landscape phones */
@media (max-height: 500px) {
.slide {
padding: clamp(16px, 3vh, 24px) clamp(24px, 5vw, 48px);
}
.deck-dots { display: none; } /* dots clutter tiny viewports */
.slide__display { font-size: clamp(28px, 7vw, 48px); }
}
/* Width breakpoint for grids */
@media (max-width: 768px) {
.slide--content .slide__inner { grid-template-columns: 1fr; }
.slide--content .slide__aside { display: none; }
.slide--split .slide__panels { grid-template-columns: 1fr; }
.slide--dashboard .slide__kpis { grid-template-columns: repeat(2, 1fr); }
}
```
## Curated Presets
Starting points the agent can riff on. Each defines a font pairing, palette, and background treatment. The agent adapts these to the content — different decks with the same preset should still feel distinct.
### Midnight Editorial
Deep navy, serif display, warm gold accents. Cinematic, premium. Dark-first.
```css
:root {
--font-body: 'Instrument Serif', Georgia, serif;
--font-mono: 'JetBrains Mono', 'SF Mono', monospace;
--bg: #0f1729;
--surface: #162040;
--surface2: #1d2b52;
--surface-elevated: #243362;
--border: rgba(200, 180, 140, 0.08);
--border-bright: rgba(200, 180, 140, 0.16);
--text: #e8e4d8;
--text-dim: #9a9484;
--accent: #d4a73a;
--accent-dim: rgba(212, 167, 58, 0.1);
--code-bg: #0a0f1e;
--code-text: #d4d0c4;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #faf8f2;
--surface: #ffffff;
--surface2: #f5f0e6;
--surface-elevated: #fffdf5;
--border: rgba(30, 30, 50, 0.08);
--border-bright: rgba(30, 30, 50, 0.16);
--text: #1a1814;
--text-dim: #7a7468;
--accent: #b8860b;
--accent-dim: rgba(184, 134, 11, 0.08);
--code-bg: #2a2520;
--code-text: #e8e4d8;
}
}
```
Background: radial gold glow at top center. Decorative corner marks in gold. Title slides use dramatic serif at max scale.
### Warm Signal
Cream paper, bold sans, terracotta/coral accents. Confident and modern. Light-first.
```css
:root {
--font-body: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-mono: 'Azeret Mono', 'SF Mono', monospace;
--bg: #faf6f0;
--surface: #ffffff;
--surface2: #f5ece0;
--surface-elevated: #fffdf5;
--border: rgba(60, 40, 20, 0.08);
--border-bright: rgba(60, 40, 20, 0.16);
--text: #2c2a25;
--text-dim: #7c756a;
--accent: #c2410c;
--accent-dim: rgba(194, 65, 12, 0.08);
--code-bg: #2c2520;
--code-text: #f5ece0;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1c1916;
--surface: #262220;
--surface2: #302b28;
--surface-elevated: #3a3430;
--border: rgba(200, 180, 160, 0.08);
--border-bright: rgba(200, 180, 160, 0.16);
--text: #f0e8dc;
--text-dim: #a09888;
--accent: #e85d2a;
--accent-dim: rgba(232, 93, 42, 0.1);
--code-bg: #141210;
--code-text: #f0e8dc;
}
}
```
Background: warm radial glow at bottom left. Terracotta accent borders on cards. Section divider numbers in ultra-light coral.
### Terminal Mono
Dark, monospace everything, green/cyan accents, faint grid. Developer-native. Dark-first.
```css
:root {
--font-body: 'Geist Mono', 'SF Mono', Consolas, monospace;
--font-mono: 'Geist Mono', 'SF Mono', Consolas, monospace;
--bg: #0a0e14;
--surface: #12161e;
--surface2: #1a1f2a;
--surface-elevated: #222836;
--border: rgba(80, 250, 123, 0.06);
--border-bright: rgba(80, 250, 123, 0.12);
--text: #c8d6e5;
--text-dim: #5a6a7a;
--accent: #50fa7b;
--accent-dim: rgba(80, 250, 123, 0.08);
--code-bg: #060a10;
--code-text: #c8d6e5;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f4f6f8;
--surface: #ffffff;
--surface2: #eaecf0;
--surface-elevated: #f8f9fa;
--border: rgba(0, 80, 40, 0.08);
--border-bright: rgba(0, 80, 40, 0.16);
--text: #1a2332;
--text-dim: #5a6a7a;
--accent: #0d7a3e;
--accent-dim: rgba(13, 122, 62, 0.08);
--code-bg: #1a2332;
--code-text: #c8d6e5;
}
}
```
Background: faint dot grid. Everything in mono. Title slides use large weight-400 mono instead of bold display. Code slides feel native.
### Swiss Clean
White, geometric sans, single bold accent, visible grid. Minimal and precise. Light-first.
```css
:root {
--font-body: 'DM Sans', system-ui, sans-serif;
--font-mono: 'Fira Code', 'SF Mono', monospace;
--bg: #ffffff;
--surface: #f8f8f8;
--surface2: #f0f0f0;
--surface-elevated: #ffffff;
--border: rgba(0, 0, 0, 0.08);
--border-bright: rgba(0, 0, 0, 0.16);
--text: #111111;
--text-dim: #666666;
--accent: #0055ff;
--accent-dim: rgba(0, 85, 255, 0.06);
--code-bg: #18181b;
--code-text: #e4e4e7;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111111;
--surface: #1a1a1a;
--surface2: #222222;
--surface-elevated: #2a2a2a;
--border: rgba(255, 255, 255, 0.08);
--border-bright: rgba(255, 255, 255, 0.16);
--text: #f0f0f0;
--text-dim: #888888;
--accent: #3b82f6;
--accent-dim: rgba(59, 130, 246, 0.08);
--code-bg: #0a0a0a;
--code-text: #e4e4e7;
}
}
```
Background: clean white or near-black, no gradients. Visible grid lines (the `--with-grid` pattern). Tight geometric layouts. Single accent color used sparingly for emphasis. Data-heavy and analytical content shines here.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Architecture Diagram — Reference Template</title>
<!--
Reference template for the visual-explainer skill: CSS Grid architecture layout.
Warm terracotta/sage palette — distinctly different from the teal (mermaid)
and rose (data-table) templates so agents absorb variety, not a single palette.
Key patterns demonstrated:
- Warm non-default palette (terracotta + sage, NOT indigo/violet)
- Depth tiers: hero (input sources), default (mid sections), recessed (callout)
- Asymmetric background atmosphere (off-center gradient mesh)
- Large heading (38px) for typographic contrast
- Section cards with colored accent borders and dot labels
- Vertical flow arrows between sections (inline SVG)
- Horizontal pipeline with step boxes and arrow separators
- Parallel branch within a pipeline
- Color-coded legend, three-column output row
- Staggered fade-in via --i CSS variable (works with interleaved elements)
- Reduced motion respect, responsive single-column fallback
-->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
/* ============ THEME ============ */
:root {
--font-body: 'IBM Plex Sans', system-ui, sans-serif;
--font-mono: 'IBM Plex Mono', 'SF Mono', Consolas, monospace;
/* Light theme — warm terracotta + sage palette */
--bg: #faf7f5;
--surface: #ffffff;
--surface2: #f5f0ec;
--surface-elevated: #fff9f5;
--border: rgba(0, 0, 0, 0.07);
--border-bright: rgba(0, 0, 0, 0.14);
--text: #292017;
--text-dim: #8a7e72;
--accent: #c2410c;
--accent-dim: rgba(194, 65, 12, 0.07);
--green: #4d7c0f;
--green-dim: rgba(77, 124, 15, 0.07);
--orange: #b45309;
--orange-dim: rgba(180, 83, 9, 0.07);
--sage: #65a30d;
--sage-dim: rgba(101, 163, 13, 0.07);
--teal: #0f766e;
--teal-dim: rgba(15, 118, 110, 0.07);
--plum: #9f1239;
--plum-dim: rgba(159, 18, 57, 0.07);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1412;
--surface: #231d1a;
--surface2: #2e2622;
--surface-elevated: #352d28;
--border: rgba(255, 255, 255, 0.06);
--border-bright: rgba(255, 255, 255, 0.12);
--text: #ede5dd;
--text-dim: #a69889;
--accent: #fb923c;
--accent-dim: rgba(251, 146, 60, 0.12);
--green: #a3e635;
--green-dim: rgba(163, 230, 53, 0.1);
--orange: #fbbf24;
--orange-dim: rgba(251, 191, 36, 0.1);
--sage: #bef264;
--sage-dim: rgba(190, 242, 100, 0.1);
--teal: #5eead4;
--teal-dim: rgba(94, 234, 212, 0.1);
--plum: #fda4af;
--plum-dim: rgba(253, 164, 175, 0.1);
}
}
/* ============ RESET + BASE ============ */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
background-image:
radial-gradient(ellipse at 20% 0%, var(--accent-dim) 0%, transparent 50%),
radial-gradient(ellipse at 80% 100%, var(--sage-dim) 0%, transparent 40%);
color: var(--text);
font-family: var(--font-body);
padding: 40px;
min-height: 100vh;
}
/* ============ ANIMATION ============ */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.section, .flow-arrow {
animation: fadeUp 0.4s ease-out both;
animation-delay: calc(var(--i, 0) * 0.06s);
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-delay: 0ms !important;
transition-duration: 0.01ms !important;
}
}
/* ============ TYPOGRAPHY ============ */
h1 {
font-size: 38px;
font-weight: 700;
letter-spacing: -1px;
margin-bottom: 6px;
text-wrap: balance;
}
.subtitle {
color: var(--text-dim);
font-size: 14px;
margin-bottom: 40px;
font-family: var(--font-mono);
}
/* ============ LAYOUT ============ */
.diagram {
display: grid;
grid-template-columns: 1fr;
gap: 24px;
max-width: 1100px;
margin: 0 auto;
}
/* ============ SECTION CARD ============ */
.section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px 24px;
}
.section--hero {
background: var(--surface-elevated);
border-color: color-mix(in srgb, var(--border) 50%, var(--accent) 50%);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
padding: 28px 32px;
}
.section--recessed {
background: var(--surface2);
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.04);
}
.section-label {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.5px;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.section-label .dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
/* Color variants */
.section--accent { border-color: var(--accent-dim); }
.section--accent .section-label { color: var(--accent); }
.section--accent .section-label .dot { background: var(--accent); }
.section--green { border-color: var(--green-dim); }
.section--green .section-label { color: var(--green); }
.section--green .section-label .dot { background: var(--green); }
.section--orange { border-color: var(--orange-dim); }
.section--orange .section-label { color: var(--orange); }
.section--orange .section-label .dot { background: var(--orange); }
.section--sage { border-color: var(--sage-dim); }
.section--sage .section-label { color: var(--sage); }
.section--sage .section-label .dot { background: var(--sage); }
.section--teal { border-color: var(--teal-dim); }
.section--teal .section-label { color: var(--teal); }
.section--teal .section-label .dot { background: var(--teal); }
.section--plum { border-color: var(--plum-dim); }
.section--plum .section-label { color: var(--plum); }
.section--plum .section-label .dot { background: var(--plum); }
/* ============ INNER GRID ============ */
.inner-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.inner-card {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 16px;
}
.inner-card .title {
font-weight: 600;
font-size: 13px;
margin-bottom: 4px;
}
.inner-card .desc {
color: var(--text-dim);
font-size: 12px;
line-height: 1.5;
}
.inner-card code {
font-family: var(--font-mono);
font-size: 11px;
background: var(--accent-dim);
color: var(--accent);
padding: 1px 5px;
border-radius: 3px;
}
/* ============ FLOW ARROW ============ */
.flow-arrow {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 12px;
padding: 4px 0;
}
.flow-arrow svg {
width: 20px;
height: 20px;
fill: none;
stroke: var(--border-bright);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
/* ============ PIPELINE ============ */
.pipeline {
display: flex;
gap: 0;
align-items: stretch;
overflow-x: auto;
padding-bottom: 4px;
}
.pipeline-step {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
min-width: 120px;
flex-shrink: 0;
text-align: center;
}
.pipeline-step .step-num {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
margin-bottom: 4px;
}
.pipeline-step .step-name {
font-size: 12px;
font-weight: 600;
margin-bottom: 3px;
}
.pipeline-step .step-detail {
font-size: 10px;
color: var(--text-dim);
line-height: 1.4;
}
/* Step color variants */
.pipeline-step--teal { border-color: var(--teal-dim); }
.pipeline-step--teal .step-num { color: var(--teal); }
.pipeline-step--sage { border-color: var(--sage-dim); }
.pipeline-step--sage .step-num { color: var(--sage); }
.pipeline-step--orange { border-color: var(--orange-dim); }
.pipeline-step--orange .step-num { color: var(--orange); }
.pipeline-step--green { border-color: var(--green-dim); }
.pipeline-step--green .step-num { color: var(--green); }
.pipeline-arrow {
display: flex;
align-items: center;
padding: 0 2px;
color: var(--border-bright);
font-size: 16px;
flex-shrink: 0;
}
.pipeline-parallel {
display: flex;
flex-direction: column;
gap: 4px;
}
/* ============ THREE COLUMN ROW ============ */
.three-col {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
}
/* ============ LIST ============ */
.node-list {
list-style: none;
font-size: 12px;
line-height: 1.8;
}
.node-list li {
padding-left: 14px;
position: relative;
}
.node-list li::before {
content: '›';
color: var(--text-dim);
font-weight: 600;
position: absolute;
left: 0;
}
.node-list code {
font-family: var(--font-mono);
font-size: 11px;
background: var(--accent-dim);
color: var(--accent);
padding: 1px 5px;
border-radius: 3px;
}
/* ============ CALLOUT ============ */
.callout {
background: var(--surface2);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: 0 8px 8px 0;
padding: 14px 18px;
font-size: 13px;
line-height: 1.6;
color: var(--text-dim);
}
.callout strong { color: var(--text); font-weight: 600; }
.callout code {
font-family: var(--font-mono);
font-size: 11px;
background: var(--accent-dim);
color: var(--accent);
padding: 1px 5px;
border-radius: 3px;
}
/* ============ LEGEND ============ */
.legend {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-dim);
font-family: var(--font-mono);
}
.legend-swatch {
width: 12px;
height: 12px;
border-radius: 3px;
}
/* ============ SOURCE PILLS ============ */
.sources {
display: flex;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
}
.source {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 18px;
font-family: var(--font-mono);
font-size: 13px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
transition: border-color 0.2s;
}
.source:hover { border-color: var(--border-bright); }
/* ============ RESPONSIVE ============ */
@media (max-width: 768px) {
body { padding: 20px; }
.inner-grid { grid-template-columns: 1fr; }
.three-col { grid-template-columns: 1fr; }
.pipeline { flex-wrap: wrap; gap: 6px; }
.pipeline-arrow { display: none; }
}
</style>
</head>
<body>
<h1>System Architecture</h1>
<p class="subtitle">reference template — architecture diagram pattern</p>
<div class="diagram">
<!-- Source pills row — hero depth for the entry point -->
<div class="section section--hero" style="--i:0">
<div class="section-label"><span class="dot" style="background:var(--text-dim)"></span> Input Sources</div>
<div class="sources">
<div class="source"><span>💬</span> Slack</div>
<div class="source"><span>🐙</span> GitHub</div>
<div class="source"><span>📧</span> Email</div>
</div>
</div>
<!-- Flow arrow -->
<div class="flow-arrow" style="--i:1">
<svg viewBox="0 0 20 20"><path d="M10 4 L10 16 M6 12 L10 16 L14 12"/></svg>
incoming events
</div>
<!-- Gateway section with inner grid -->
<div class="section section--accent" style="--i:2">
<div class="section-label"><span class="dot"></span> Gateway Layer</div>
<div class="inner-grid">
<div class="inner-card">
<div class="title">Router</div>
<div class="desc">Routes messages to agents via <code>resolveRoute()</code></div>
</div>
<div class="inner-card">
<div class="title">HTTP Server</div>
<div class="desc">Plugin routes + handlers for webhooks and API</div>
</div>
</div>
</div>
<div class="flow-arrow" style="--i:3">
<svg viewBox="0 0 20 20"><path d="M10 4 L10 16 M6 12 L10 16 L14 12"/></svg>
pipeline entry
</div>
<!-- Pipeline section -->
<div class="section section--green" style="--i:4">
<div class="section-label"><span class="dot"></span> Processing Pipeline</div>
<div class="legend" style="margin-bottom:14px">
<div class="legend-item"><div class="legend-swatch" style="background:var(--teal-dim);border:1px solid var(--teal)"></div>no LLM</div>
<div class="legend-item"><div class="legend-swatch" style="background:var(--sage-dim);border:1px solid var(--sage)"></div>LLM call</div>
<div class="legend-item"><div class="legend-swatch" style="background:var(--orange-dim);border:1px solid var(--orange)"></div>embedding</div>
<div class="legend-item"><div class="legend-swatch" style="background:var(--green-dim);border:1px solid var(--green)"></div>DB write</div>
</div>
<div class="pipeline">
<div class="pipeline-step pipeline-step--teal">
<div class="step-num">STEP 0</div>
<div class="step-name">Pre-filter</div>
<div class="step-detail">Allowlist, bots,<br>dedup check</div>
</div>
<div class="pipeline-arrow">→</div>
<div class="pipeline-step pipeline-step--sage">
<div class="step-num">STEP 1</div>
<div class="step-name">Relevance</div>
<div class="step-detail">Cheap LLM<br>boolean check</div>
</div>
<div class="pipeline-arrow">→</div>
<div class="pipeline-step pipeline-step--sage">
<div class="step-num">STEP 2</div>
<div class="step-name">Classify</div>
<div class="step-detail">JSON schema<br>validated output</div>
</div>
<div class="pipeline-arrow">→</div>
<div class="pipeline-parallel">
<div class="pipeline-step pipeline-step--orange">
<div class="step-num">STEP 3</div>
<div class="step-name">Embed</div>
<div class="step-detail">Vector embedding</div>
</div>
<div class="pipeline-step pipeline-step--teal">
<div class="step-num">STEP 5</div>
<div class="step-name">Enrich</div>
<div class="step-detail">User resolution</div>
</div>
</div>
<div class="pipeline-arrow">→</div>
<div class="pipeline-step pipeline-step--green">
<div class="step-num">STEP 4</div>
<div class="step-name">Cluster</div>
<div class="step-detail">Cosine similarity<br>+ INSERT</div>
</div>
</div>
</div>
<div class="flow-arrow" style="--i:5">
<svg viewBox="0 0 20 20"><path d="M10 4 L10 16 M6 12 L10 16 L14 12"/></svg>
stored and queryable
</div>
<!-- Database section -->
<div class="section section--orange" style="--i:6">
<div class="section-label"><span class="dot"></span> Database</div>
<div class="inner-grid">
<div class="inner-card">
<div class="title">feedback_items</div>
<div class="desc">Classification, embedding, cluster assignment, source dedup</div>
</div>
<div class="inner-card">
<div class="title">clusters</div>
<div class="desc">Centroid vectors, trends, ticket links, severity rollup</div>
</div>
</div>
</div>
<div class="flow-arrow" style="--i:7">
<svg viewBox="0 0 20 20"><path d="M10 4 L10 16 M6 12 L10 16 L14 12"/></svg>
consumed by
</div>
<!-- Three column output -->
<div class="three-col">
<div class="section section--sage" style="--i:8">
<div class="section-label"><span class="dot"></span> Agent Tools</div>
<ul class="node-list">
<li><code>search</code> semantic vector search</li>
<li><code>clusters</code> browse and filter</li>
<li><code>stats</code> aggregate metrics</li>
</ul>
</div>
<div class="section section--plum" style="--i:9">
<div class="section-label"><span class="dot"></span> Actions</div>
<ul class="node-list">
<li>Create tickets from clusters</li>
<li>Notify customers on ship</li>
<li>Generate release notes</li>
</ul>
</div>
<div class="section section--teal" style="--i:10">
<div class="section-label"><span class="dot"></span> Dashboard</div>
<ul class="node-list">
<li>Metrics overview</li>
<li>Feedback stream</li>
<li>NL chat interface</li>
</ul>
</div>
</div>
<!-- Callout — recessed depth for secondary info -->
<div class="callout section section--recessed" style="--i:11">
<strong>Multi-tenant</strong> — Each agent gets an isolated database at
<code>{agentDir}/intelligence/feedback.db</code> with per-agent config overlay
and credential isolation.
</div>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Requirements Audit — Reference Template</title>
<!--
Reference template for the visual-explainer skill: data tables.
Rose/cranberry palette — distinctly different from terracotta (architecture)
and teal (mermaid) templates so agents absorb variety.
Key patterns demonstrated:
- Rose/cranberry palette (NOT indigo/violet)
- KPI summary cards above the table (visual hook before the data)
- Collapsible <details> section (replaces static callout)
- Depth tiers: elevated KPIs, default table, collapsible for secondary
- Real <table> element with sticky header, alternating rows
- Status indicator badges (match, gap, partial)
- Text wrapping in wide columns, code references in cells
- Summary footer row with aggregate status
- Staggered row animation via --i variable
- Both light and dark themes via prefers-color-scheme
- Responsive horizontal scroll wrapper
-->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
/* ============ THEME ============ */
:root {
--font-body: 'Instrument Serif', 'Georgia', serif;
--font-mono: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
--font-sans: system-ui, -apple-system, sans-serif;
--bg: #fff5f5;
--surface: #ffffff;
--surface2: #fef0ee;
--surface-elevated: #fff8f7;
--border: rgba(0, 0, 0, 0.07);
--border-bright: rgba(0, 0, 0, 0.14);
--text: #1c1917;
--text-dim: #78716c;
--accent: #be123c;
--accent-dim: rgba(190, 18, 60, 0.06);
--green: #16a34a;
--green-dim: rgba(22, 163, 74, 0.08);
--red: #dc2626;
--red-dim: rgba(220, 38, 38, 0.08);
--orange: #d97706;
--orange-dim: rgba(217, 119, 6, 0.08);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a0a0a;
--surface: #231414;
--surface2: #2e1b1b;
--surface-elevated: #351f1f;
--border: rgba(255, 255, 255, 0.06);
--border-bright: rgba(255, 255, 255, 0.12);
--text: #fde2e2;
--text-dim: #c9a3a3;
--accent: #fb7185;
--accent-dim: rgba(251, 113, 133, 0.12);
--green: #4ade80;
--green-dim: rgba(74, 222, 128, 0.1);
--red: #f87171;
--red-dim: rgba(248, 113, 113, 0.1);
--orange: #fbbf24;
--orange-dim: rgba(251, 191, 36, 0.1);
}
}
/* ============ RESET + BASE ============ */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
background-image:
radial-gradient(ellipse at 30% 0%, var(--accent-dim) 0%, transparent 50%),
radial-gradient(ellipse at 70% 100%, var(--green-dim) 0%, transparent 40%);
color: var(--text);
font-family: var(--font-sans);
padding: 40px;
min-height: 100vh;
}
/* ============ ANIMATION ============ */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate {
animation: fadeUp 0.35s ease-out both;
animation-delay: calc(var(--i, 0) * 0.04s);
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-delay: 0ms !important;
transition-duration: 0.01ms !important;
}
}
/* ============ CONTAINER ============ */
.container {
max-width: 1000px;
margin: 0 auto;
}
/* ============ HEADER ============ */
h1 {
font-family: var(--font-body);
font-size: 32px;
font-weight: 400;
letter-spacing: -0.3px;
margin-bottom: 6px;
}
.subtitle {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 12px;
margin-bottom: 32px;
}
/* ============ LEGEND ============ */
.legend {
display: flex;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
}
.legend-swatch {
width: 10px;
height: 10px;
border-radius: 3px;
}
/* ============ TABLE WRAPPER ============ */
.table-wrap {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
margin-bottom: 24px;
}
.table-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* ============ TABLE ============ */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
line-height: 1.55;
}
/* Header */
.data-table thead {
position: sticky;
top: 0;
z-index: 2;
}
.data-table th {
background: var(--surface2);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.2px;
color: var(--text-dim);
text-align: left;
padding: 14px 16px;
border-bottom: 2px solid var(--border-bright);
white-space: nowrap;
}
/* Cells */
.data-table td {
padding: 14px 16px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
/* Wide text column */
.data-table .wide {
min-width: 220px;
max-width: 440px;
}
/* Alternating rows */
.data-table tbody tr:nth-child(even) {
background: var(--accent-dim);
}
/* Row hover + animation stagger */
.data-table tbody tr {
transition: background 0.15s ease;
animation: fadeUp 0.35s ease-out both;
animation-delay: calc(var(--i, 0) * 0.04s);
}
.data-table tbody tr:hover {
background: var(--border);
}
/* Code in cells */
.data-table code {
font-family: var(--font-mono);
font-size: 11px;
background: var(--accent-dim);
color: var(--accent);
padding: 1px 5px;
border-radius: 3px;
}
/* Secondary text */
.data-table small {
display: block;
color: var(--text-dim);
font-size: 11px;
margin-top: 3px;
}
/* ============ STATUS BADGES ============ */
.status {
display: inline-flex;
align-items: center;
gap: 5px;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
padding: 3px 10px;
border-radius: 6px;
white-space: nowrap;
letter-spacing: 0.3px;
}
.status--match {
background: var(--green-dim);
color: var(--green);
}
.status--gap {
background: var(--red-dim);
color: var(--red);
}
.status--partial {
background: var(--orange-dim);
color: var(--orange);
}
/* Dot before status text */
.status::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
/* ============ FOOTER ROW ============ */
.data-table tfoot td {
background: var(--surface2);
font-weight: 600;
font-family: var(--font-mono);
font-size: 12px;
border-top: 2px solid var(--border-bright);
border-bottom: none;
padding: 14px 16px;
}
/* ============ KPI SUMMARY ============ */
.kpi-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 14px;
margin-bottom: 20px;
}
.kpi-card {
background: var(--surface-elevated, var(--surface));
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.kpi-card__value {
font-family: var(--font-body);
font-size: 32px;
font-weight: 400;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.kpi-card__value--green { color: var(--green); }
.kpi-card__value--red { color: var(--red); }
.kpi-card__value--orange { color: var(--orange); }
.kpi-card__label {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.2px;
color: var(--text-dim);
margin-top: 6px;
}
/* ============ COLLAPSIBLE ============ */
details.collapsible {
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
details.collapsible summary {
padding: 14px 20px;
background: var(--surface);
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
transition: background 0.15s ease;
}
details.collapsible summary:hover {
background: var(--surface-elevated, var(--surface));
}
details.collapsible summary::-webkit-details-marker { display: none; }
details.collapsible summary::before {
content: '▸';
font-size: 11px;
color: var(--text-dim);
transition: transform 0.15s ease;
}
details.collapsible[open] summary::before {
transform: rotate(90deg);
}
details.collapsible .collapsible__body {
padding: 16px 20px;
border-top: 1px solid var(--border);
font-size: 13px;
line-height: 1.6;
color: var(--text-dim);
}
details.collapsible .collapsible__body strong {
color: var(--text);
font-weight: 600;
}
details.collapsible .collapsible__body code {
font-family: var(--font-mono);
font-size: 11px;
background: var(--accent-dim);
color: var(--accent);
padding: 1px 5px;
border-radius: 3px;
}
/* ============ RESPONSIVE ============ */
@media (max-width: 768px) {
body { padding: 16px; }
h1 { font-size: 24px; }
.data-table th,
.data-table td { padding: 10px 12px; }
}
</style>
</head>
<body>
<div class="container">
<h1 class="animate" style="--i:0">Requirements Audit</h1>
<p class="subtitle animate" style="--i:1">victoria's email vs. implementation plan — point-by-point review</p>
<div class="kpi-row animate" style="--i:2">
<div class="kpi-card">
<div class="kpi-card__value">14</div>
<div class="kpi-card__label">Items Reviewed</div>
</div>
<div class="kpi-card">
<div class="kpi-card__value kpi-card__value--green">13</div>
<div class="kpi-card__label">Match</div>
</div>
<div class="kpi-card">
<div class="kpi-card__value kpi-card__value--red">1</div>
<div class="kpi-card__label">Gap</div>
</div>
<div class="kpi-card">
<div class="kpi-card__value" style="color:var(--accent)">93%</div>
<div class="kpi-card__label">Coverage</div>
</div>
</div>
<div class="legend animate" style="--i:3">
<div class="legend-item"><div class="legend-swatch" style="background:var(--green)"></div> Match</div>
<div class="legend-item"><div class="legend-swatch" style="background:var(--red)"></div> Gap</div>
<div class="legend-item"><div class="legend-swatch" style="background:var(--orange)"></div> Partial</div>
</div>
<div class="table-wrap animate" style="--i:4">
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th class="wide">Request</th>
<th class="wide">Plan</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr style="--i:4">
<td class="wide">Template: <code>yoth-special-edition</code> only</td>
<td class="wide">Plan scopes everything to this template.</td>
<td><span class="status status--match">Match</span></td>
</tr>
<tr style="--i:5">
<td class="wide">EOD tomorrow (Friday), Tuesday AM launch</td>
<td class="wide">Plan header says same.</td>
<td><span class="status status--match">Match</span></td>
</tr>
<tr style="--i:6">
<td class="wide">Update BIS button label and pop up text</td>
<td class="wide">Button text + modal description configurable via settings.</td>
<td><span class="status status--match">Match</span></td>
</tr>
<tr style="--i:7">
<td class="wide">Use Stoq's API to trigger events</td>
<td class="wide">Uses <code>openInlineForm</code>, <code>openModal</code>, <code>removeInlineForm</code>.</td>
<td><span class="status status--match">Match</span></td>
</tr>
<tr style="--i:8">
<td class="wide">Custom button + modal with text settings in buy buttons block</td>
<td class="wide">Schema settings in <code>buy_buttons</code> block.</td>
<td><span class="status status--match">Match</span></td>
</tr>
<tr style="--i:9">
<td class="wide">Default values = current behavior when empty</td>
<td class="wide">Blank settings = Stoq default "Notify Me" behavior.</td>
<td><span class="status status--match">Match</span></td>
</tr>
<tr style="--i:10">
<td class="wide">Only display for OOS variants</td>
<td class="wide">DOM-based sold-out detection.</td>
<td><span class="status status--match">Match</span></td>
</tr>
<tr style="--i:11">
<td class="wide">Exclude products with <code>excludebis</code> tag</td>
<td class="wide">Checked in both PDP and PLP Liquid.</td>
<td><span class="status status--match">Match</span></td>
</tr>
<tr style="--i:12">
<td class="wide"><code>openInlineForm</code> to load Stoq form in modal</td>
<td class="wide">PDP modal uses <code>openInlineForm</code>.</td>
<td><span class="status status--match">Match</span></td>
</tr>
<tr style="--i:13">
<td class="wide">Updated Button Label: "Join the waitlist"</td>
<td class="wide">Pre-populated in template JSON.</td>
<td><span class="status status--match">Match</span></td>
</tr>
<tr style="--i:14">
<td class="wide">Updated Pop Up Text: "Sign up to be notified when we restock this or other embroidered styles."</td>
<td class="wide"><code>bis_modal_description</code> setting.</td>
<td><span class="status status--match">Match</span></td>
</tr>
<tr style="--i:15">
<td class="wide">Theme: Huha 2.0 - Giddy Up Collection D2C Launch</td>
<td class="wide">Clone ID <code>145580556374</code>.</td>
<td><span class="status status--match">Match</span></td>
</tr>
<tr style="--i:16">
<td class="wide">Changes made locally</td>
<td class="wide">Local dev + theme push.</td>
<td><span class="status status--match">Match</span></td>
</tr>
<tr style="--i:17">
<td class="wide">Run <code>stoq:restock-modal:submitted</code> when form is submitted</td>
<td class="wide">Not mentioned in plan.
<small>When using <code>openInlineForm</code> inside a custom modal, unclear if Stoq fires this event automatically or if manual dispatch is needed.</small>
</td>
<td><span class="status status--gap">Gap</span></td>
</tr>
</tbody>
<tfoot>
<tr>
<td>14 items reviewed</td>
<td></td>
<td>13 match · 1 gap</td>
</tr>
</tfoot>
</table>
</div>
</div>
<details class="collapsible animate" style="--i:19">
<summary>Gap Analysis Detail</summary>
<div class="collapsible__body">
<strong>Gap: <code>stoq:restock-modal:submitted</code> event.</strong>
Michael explicitly requests firing this event on form submission. The plan uses Stoq's <code>openInlineForm</code> inside a custom modal, but doesn't address whether Stoq dispatches this event automatically in that context. If other integrations (Klaviyo, analytics, theme JS) listen for it, missing it could silently break the submission pipeline. Recommend adding an explicit <code>dispatchEvent</code> call as a safety net.
</div>
</details>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CI/CD Pipeline — Reference Template</title>
<!--
Reference template for the visual-explainer skill: Mermaid diagrams.
Teal/cyan palette — distinctly different from terracotta (architecture)
and rose (data-table) templates so agents absorb variety.
Key patterns demonstrated:
- Teal/cyan palette (NOT indigo/violet)
- Dot-grid background atmosphere (different from radial gradient)
- Large heading (38px) for typographic contrast
- ESM import of Mermaid + @mermaid-js/layout-elk
- Mermaid theme: 'base' + full themeVariables, fontSize: 16px
- CSS overrides: .nodeLabel 16px, .edgeLabel 13px
- look: 'classic' for clean lines
- layout: 'elk' for better node positioning
- Zoom controls with scroll-to-zoom and drag-to-pan
- Both light and dark themes via prefers-color-scheme
- Staggered fade-in, reduced motion respect
-->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@400;500;600;700&family=Fragment+Mono:wght@400&display=swap" rel="stylesheet">
<style>
/* ============ THEME ============ */
:root {
--font-body: 'Bricolage Grotesque', system-ui, sans-serif;
--font-mono: 'Fragment Mono', 'SF Mono', Consolas, monospace;
--bg: #f0fdfa;
--surface: #ffffff;
--border: rgba(0, 0, 0, 0.07);
--text: #134e4a;
--text-dim: #5f8a85;
--primary: #0d9488;
--primary-dim: rgba(13, 148, 136, 0.08);
--secondary: #0369a1;
--tertiary: #d97706;
--danger: #dc2626;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #042f2e;
--surface: #0a3d3a;
--border: rgba(255, 255, 255, 0.08);
--text: #ccfbf1;
--text-dim: #5eead4;
--primary: #2dd4bf;
--primary-dim: rgba(45, 212, 191, 0.14);
--secondary: #38bdf8;
--tertiary: #fbbf24;
--danger: #f87171;
}
}
/* ============ RESET + BASE ============ */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background-color: var(--bg);
background-image: radial-gradient(circle, var(--border) 1px, transparent 1px);
background-size: 24px 24px;
color: var(--text);
font-family: var(--font-body);
padding: 40px;
min-height: 100vh;
}
/* ============ ANIMATION ============ */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.animate {
animation: fadeUp 0.4s ease-out both;
animation-delay: calc(var(--i, 0) * 0.06s);
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-delay: 0ms !important;
transition-duration: 0.01ms !important;
}
}
/* ============ LAYOUT ============ */
.container {
max-width: 1000px;
margin: 0 auto;
}
h1 {
font-size: 38px;
font-weight: 700;
letter-spacing: -1px;
margin-bottom: 6px;
text-wrap: balance;
}
.subtitle {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 12px;
margin-bottom: 32px;
}
.description {
font-size: 14px;
line-height: 1.7;
color: var(--text-dim);
margin-bottom: 24px;
max-width: 700px;
}
.description code {
font-family: var(--font-mono);
font-size: 12px;
background: var(--primary-dim);
color: var(--primary);
padding: 1px 5px;
border-radius: 3px;
}
/* ============ MERMAID CONTAINER ============ */
.mermaid-wrap {
position: relative;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 32px 24px;
overflow: auto;
margin-bottom: 24px;
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.zoom-controls {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 2px;
z-index: 10;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px;
}
.zoom-controls button {
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 14px;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s ease, color 0.15s ease;
}
.zoom-controls button:hover {
background: var(--border);
color: var(--text);
}
.mermaid-wrap { cursor: grab; }
.mermaid-wrap.is-panning { cursor: grabbing; user-select: none; }
/* ============ MULTI-DIAGRAM STRUCTURE ============ */
.diagram-shell {
position: relative;
}
.diagram-shell__hint {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
margin-bottom: 8px;
opacity: 0.7;
}
.mermaid-viewport {
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
min-height: 300px;
}
.mermaid-canvas {
position: absolute;
top: 0;
left: 0;
}
.zoom-label {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
padding: 0 6px;
white-space: nowrap;
}
/* ============ MERMAID SVG OVERRIDES ============ */
.mermaid .nodeLabel {
font-family: var(--font-body) !important;
font-size: 16px !important;
}
.mermaid .edgeLabel {
font-family: var(--font-mono) !important;
font-size: 13px !important;
}
.mermaid .node rect,
.mermaid .node circle,
.mermaid .node polygon {
stroke-width: 1.5px !important;
}
.mermaid .edge-pattern-solid {
stroke-width: 1.5px !important;
}
/* ============ LEGEND ============ */
.legend {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
}
.legend-swatch {
width: 12px;
height: 12px;
border-radius: 3px;
}
/* ============ CALLOUT ============ */
.callout {
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--primary);
border-radius: 0 10px 10px 0;
padding: 16px 20px;
font-size: 13px;
line-height: 1.6;
color: var(--text-dim);
margin-top: 24px;
}
.callout strong { color: var(--text); }
.callout code {
font-family: var(--font-mono);
font-size: 11px;
background: var(--primary-dim);
color: var(--primary);
padding: 1px 5px;
border-radius: 3px;
}
/* ============ RESPONSIVE ============ */
@media (max-width: 768px) {
body { padding: 16px; }
h1 { font-size: 22px; }
.mermaid-wrap { padding: 16px 12px; }
}
</style>
</head>
<body>
<div class="container">
<h1 class="animate" style="--i:0">CI/CD Pipeline</h1>
<p class="subtitle animate" style="--i:1">github actions → staging → production</p>
<p class="description animate" style="--i:2">
Every push to <code>main</code> triggers the full pipeline. Tests and linting run in parallel,
then the build step produces a Docker image. Staging deploys automatically; production requires
manual approval via a GitHub environment gate.
</p>
<section class="diagram-shell animate" style="--i:3">
<p class="diagram-shell__hint">
Ctrl/Cmd + wheel to zoom. Scroll to pan. Drag to pan when zoomed. Double-click to fit.
</p>
<div class="mermaid-wrap">
<div class="zoom-controls">
<button type="button" data-action="zoom-in" title="Zoom in">+</button>
<button type="button" data-action="zoom-out" title="Zoom out">−</button>
<button type="button" data-action="zoom-fit" title="Smart fit">↺</button>
<button type="button" data-action="zoom-one" title="1:1 zoom">1:1</button>
<button type="button" data-action="zoom-expand" title="Open full size">⛶</button>
<span class="zoom-label">Loading...</span>
</div>
<div class="mermaid-viewport">
<div class="mermaid mermaid-canvas"></div>
</div>
</div>
<script type="text/plain" class="diagram-source">
graph TD
A[Push to main] --> B{Branch?}
B -->|main| C[Run Tests]
B -->|feature| I[Run Tests]
I --> J[Preview Deploy]
C --> D[Lint + Type Check]
C --> E[Unit Tests]
C --> F[Integration Tests]
D --> G[Build Docker Image]
E --> G
F --> G
G --> H[Deploy to Staging]
H --> K{Smoke Tests Pass?}
K -->|Yes| L[Manual Approval]
K -->|No| M[Alert + Rollback]
L --> N[Deploy to Production]
N --> O[Health Check]
O --> P[Done]
</script>
</section>
<div class="legend animate" style="--i:4">
<div class="legend-item"><div class="legend-swatch" style="background:var(--primary)"></div> Automated step</div>
<div class="legend-item"><div class="legend-swatch" style="background:var(--tertiary)"></div> Decision gate</div>
<div class="legend-item"><div class="legend-swatch" style="background:var(--danger)"></div> Failure path</div>
<div class="legend-item"><div class="legend-swatch" style="background:var(--secondary)"></div> Success</div>
</div>
<div class="callout animate" style="--i:5">
<strong>ELK layout.</strong> The <code>layout: 'elk'</code> engine provides better node positioning
for complex graphs — it requires the separate <code>@mermaid-js/layout-elk</code> package (imported above).
Without it, Mermaid silently falls back to dagre.
</div>
</div>
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
import elkLayouts from 'https://cdn.jsdelivr.net/npm/@mermaid-js/layout-elk/dist/mermaid-layout-elk.esm.min.mjs';
const config = {
fitPadding: 28,
minHeight: 360,
maxHeightPx: 960,
maxHeightVh: 0.84,
maxInitialZoom: 1.8,
minZoom: 0.08,
maxZoom: 6.5,
zoomStep: 0.14,
readabilityFloor: 0.58
};
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
let activeDrag = null;
addEventListener('mousemove', (e) => activeDrag?.onMove(e));
addEventListener('mouseup', () => {
activeDrag?.onEnd();
activeDrag = null;
});
const isDark = matchMedia('(prefers-color-scheme: dark)').matches;
mermaid.registerLayoutLoaders(elkLayouts);
mermaid.initialize({
startOnLoad: false,
theme: 'base',
look: 'classic',
layout: 'elk',
themeVariables: {
fontFamily: "'Bricolage Grotesque', system-ui, sans-serif",
fontSize: '16px',
primaryColor: isDark ? '#115e59' : '#ccfbf1',
primaryBorderColor: isDark ? '#2dd4bf' : '#0d9488',
primaryTextColor: isDark ? '#ccfbf1' : '#134e4a',
secondaryColor: isDark ? '#0c4a6e' : '#e0f2fe',
secondaryBorderColor: isDark ? '#38bdf8' : '#0369a1',
secondaryTextColor: isDark ? '#ccfbf1' : '#134e4a',
tertiaryColor: isDark ? '#2e2618' : '#fffbeb',
tertiaryBorderColor: isDark ? '#fbbf24' : '#d97706',
tertiaryTextColor: isDark ? '#ccfbf1' : '#134e4a',
lineColor: isDark ? '#5eead4' : '#5f8a85',
noteBkgColor: isDark ? '#115e59' : '#fefce8',
noteTextColor: isDark ? '#ccfbf1' : '#134e4a',
noteBorderColor: isDark ? '#fbbf24' : '#d97706',
}
});
function initDiagram(shell) {
const wrap = shell.querySelector('.mermaid-wrap');
const viewport = shell.querySelector('.mermaid-viewport');
const canvas = shell.querySelector('.mermaid-canvas');
const source = shell.querySelector('.diagram-source');
const label = shell.querySelector('.zoom-label');
if (!wrap || !viewport || !canvas || !source || !label) {
console.error('initDiagram: missing required elements in', shell);
return;
}
let zoom = 1;
let fitMode = 'contain';
let panX = 0;
let panY = 0;
let svgW = 0;
let svgH = 0;
let sx = 0;
let sy = 0;
let spx = 0;
let spy = 0;
let touchDist = 0;
let touchCx = 0;
let touchCy = 0;
function constrainPan() {
const vpW = viewport.clientWidth;
const vpH = viewport.clientHeight;
const rW = svgW * zoom;
const rH = svgH * zoom;
const pad = config.fitPadding;
panX = (rW + pad * 2 <= vpW)
? (vpW - rW) / 2
: clamp(panX, vpW - rW - pad, pad);
panY = (rH + pad * 2 <= vpH)
? (vpH - rH) / 2
: clamp(panY, vpH - rH - pad, pad);
}
function applyTransform() {
const svg = canvas.querySelector('svg');
if (!svg || !svgW) return;
constrainPan();
svg.style.width = (svgW * zoom) + 'px';
svg.style.height = (svgH * zoom) + 'px';
canvas.style.transform = `translate(${panX}px, ${panY}px)`;
label.textContent = Math.round(zoom * 100) + '% \u2014 ' + fitMode;
}
function canPan() {
const rW = svgW * zoom;
const rH = svgH * zoom;
return rW + config.fitPadding * 2 > viewport.clientWidth
|| rH + config.fitPadding * 2 > viewport.clientHeight;
}
function computeSmartFit() {
const vpW = viewport.clientWidth;
const vpH = viewport.clientHeight;
const aW = Math.max(80, vpW - config.fitPadding * 2);
const aH = Math.max(80, vpH - config.fitPadding * 2);
const contain = Math.min(aW / svgW, aH / svgH);
let z = contain;
let mode = 'contain';
if (contain < config.readabilityFloor) {
const chartR = svgH / svgW;
const vpR = vpH / Math.max(vpW, 1);
if (chartR >= vpR) {
z = aW / svgW;
mode = 'width-priority';
} else {
z = aH / svgH;
mode = 'height-priority';
}
}
return { zoom: clamp(z, config.minZoom, config.maxInitialZoom), mode };
}
function fitDiagram() {
if (!svgW) return;
const fit = computeSmartFit();
zoom = fit.zoom;
fitMode = fit.mode;
panX = (viewport.clientWidth - svgW * zoom) / 2;
panY = (viewport.clientHeight - svgH * zoom) / 2;
applyTransform();
}
function setOneToOne() {
zoom = clamp(1, config.minZoom, config.maxZoom);
fitMode = '1:1';
panX = (viewport.clientWidth - svgW * zoom) / 2;
panY = (viewport.clientHeight - svgH * zoom) / 2;
applyTransform();
}
function zoomAround(factor, cx, cy) {
const next = clamp(zoom * factor, config.minZoom, config.maxZoom);
const ratio = next / zoom;
panX = cx - ratio * (cx - panX);
panY = cy - ratio * (cy - panY);
zoom = next;
fitMode = 'custom';
applyTransform();
}
function readSvgNaturalSize(svg) {
let w = 0;
let h = 0;
if (svg.viewBox?.baseVal?.width > 0) {
w = svg.viewBox.baseVal.width;
h = svg.viewBox.baseVal.height;
}
if (!w) {
w = parseFloat(svg.getAttribute('width')) || 0;
h = parseFloat(svg.getAttribute('height')) || 0;
}
if (!w) {
const b = svg.getBBox();
w = b.width;
h = b.height;
}
if (!w) {
const r = svg.getBoundingClientRect();
w = r.width || 1000;
h = r.height || 700;
}
if (!svg.getAttribute('viewBox')) {
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
}
return { w, h };
}
function setAdaptiveHeight() {
if (!svgW) return;
const usableW = Math.max(280, wrap.getBoundingClientRect().width - 2);
const idealH = (svgH / svgW) * usableW + config.fitPadding * 2;
const maxVp = Math.floor(innerHeight * config.maxHeightVh);
const hardMax = Math.min(config.maxHeightPx, Math.max(config.minHeight + 40, maxVp));
wrap.style.height = Math.round(clamp(idealH, config.minHeight, hardMax)) + 'px';
}
function openInNewTab() {
const svg = canvas.querySelector('svg');
if (!svg) return;
const clone = svg.cloneNode(true);
clone.style.width = '';
clone.style.height = '';
// Use the same isDark value that was used to render the Mermaid theme
// This ensures the background matches the baked-in SVG colors
const bg = isDark ? '#042f2e' : '#f0fdfa';
const html = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Diagram</title><style>
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
background:${bg};padding:40px;box-sizing:border-box}
svg{max-width:100%;max-height:90vh;height:auto}
</style></head><body>${clone.outerHTML}</body></html>`;
open(URL.createObjectURL(new Blob([html], { type: 'text/html' })), '_blank');
}
async function render() {
try {
const code = source.textContent.trim();
if (!code) {
label.textContent = 'Error: Empty source';
return;
}
const id = 'diagram-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
const { svg } = await mermaid.render(id, code);
const parsed = new DOMParser().parseFromString(svg, 'text/html');
const parsedSvg = parsed.body.querySelector('svg');
if (!parsedSvg) {
label.textContent = 'Error: No SVG';
return;
}
canvas.replaceChildren(document.adoptNode(parsedSvg));
const svgNode = canvas.querySelector('svg');
if (!svgNode) {
label.textContent = 'Error: No SVG';
return;
}
const size = readSvgNaturalSize(svgNode);
svgW = size.w;
svgH = size.h;
svgNode.removeAttribute('width');
svgNode.removeAttribute('height');
svgNode.style.maxWidth = 'none';
svgNode.style.display = 'block';
setAdaptiveHeight();
fitDiagram();
} catch (err) {
console.error('Mermaid render failed:', err);
label.textContent = 'Error: ' + (err.message || 'Render failed');
}
}
const actions = {
'zoom-in': () => zoomAround(1 + config.zoomStep, viewport.clientWidth / 2, viewport.clientHeight / 2),
'zoom-out': () => zoomAround(1 / (1 + config.zoomStep), viewport.clientWidth / 2, viewport.clientHeight / 2),
'zoom-fit': fitDiagram,
'zoom-one': setOneToOne,
'zoom-expand': openInNewTab
};
Object.entries(actions).forEach(([action, handler]) => {
wrap.querySelector(`[data-action="${action}"]`)?.addEventListener('click', handler);
});
viewport.addEventListener('dblclick', fitDiagram);
viewport.addEventListener('wheel', (e) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const rect = viewport.getBoundingClientRect();
const factor = e.deltaY < 0 ? 1 + config.zoomStep : 1 / (1 + config.zoomStep);
zoomAround(factor, e.clientX - rect.left, e.clientY - rect.top);
return;
}
if (canPan()) {
e.preventDefault();
panX -= e.deltaX;
panY -= e.deltaY;
applyTransform();
}
}, { passive: false });
viewport.addEventListener('mousedown', (e) => {
if (e.target.closest('.zoom-controls') || !canPan()) return;
wrap.classList.add('is-panning');
sx = e.clientX;
sy = e.clientY;
spx = panX;
spy = panY;
e.preventDefault();
activeDrag = {
onMove: (ev) => {
panX = spx + (ev.clientX - sx);
panY = spy + (ev.clientY - sy);
applyTransform();
},
onEnd: () => {
wrap.classList.remove('is-panning');
}
};
});
viewport.addEventListener('touchstart', (e) => {
if (e.touches.length === 1) {
sx = e.touches[0].clientX;
sy = e.touches[0].clientY;
spx = panX;
spy = panY;
} else if (e.touches.length === 2) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
touchDist = Math.sqrt(dx * dx + dy * dy);
const r = viewport.getBoundingClientRect();
touchCx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left;
touchCy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top;
}
}, { passive: true });
viewport.addEventListener('touchmove', (e) => {
if (e.touches.length === 1 && canPan()) {
if (touchDist > 0) {
sx = e.touches[0].clientX;
sy = e.touches[0].clientY;
spx = panX;
spy = panY;
touchDist = 0;
}
e.preventDefault();
panX = spx + (e.touches[0].clientX - sx);
panY = spy + (e.touches[0].clientY - sy);
applyTransform();
} else if (e.touches.length === 2 && touchDist > 0) {
e.preventDefault();
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const d = Math.sqrt(dx * dx + dy * dy);
zoomAround(d / touchDist, touchCx, touchCy);
touchDist = d;
}
}, { passive: false });
new ResizeObserver(() => {
if (svgW) {
setAdaptiveHeight();
fitDiagram();
}
}).observe(wrap);
render();
}
document.querySelectorAll('.diagram-shell').forEach(initDiagram);
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Gateway Redesign — Reference Slide Deck</title>
<!--
Reference template for the visual-explainer skill: slide decks.
Midnight Editorial preset — deep navy, serif display, warm gold accents.
Distinctly different from the terracotta (architecture), teal (mermaid),
and rose (data-table) templates so agents absorb variety.
Key patterns demonstrated:
- All 10 slide types in a cohesive narrative
- SlideEngine JS: keyboard/touch/wheel nav, progress bar, dots, counter, hints
- Cinematic transitions: fade + translateY + scale, staggered child reveals
- Per-slide background variation (gradient direction, accent glow position)
- Decorative SVG accents (corner marks, quote mark, divider)
- Typography scale: 120px display → 48px heading → 22px body → 14px label
- Compositional variety: centered, left-heavy, split, full-bleed
- Mermaid at presentation scale (18px labels, 2px edges, 8 nodes)
- Dark-first with light mode via prefers-color-scheme
- Responsive height breakpoints for projection and small viewports
- Nav chrome with backdrop blur for mixed-background visibility
- Event delegation: Mermaid zoom and table scroll don't trigger slide nav
- prefers-reduced-motion respected
-->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
/* ============ THEME: Midnight Editorial ============ */
:root {
--font-body: 'Instrument Serif', Georgia, serif;
--font-mono: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
--bg: #0f1729;
--surface: #162040;
--surface2: #1d2b52;
--surface-elevated: #243362;
--border: rgba(200, 180, 140, 0.08);
--border-bright: rgba(200, 180, 140, 0.16);
--text: #e8e4d8;
--text-dim: #9a9484;
--accent: #d4a73a;
--accent-dim: rgba(212, 167, 58, 0.1);
--code-bg: #0a0f1e;
--code-text: #d4d0c4;
--green: #4ade80;
--green-dim: rgba(74, 222, 128, 0.1);
--red: #f87171;
--red-dim: rgba(248, 113, 113, 0.1);
--blue: #60a5fa;
--blue-dim: rgba(96, 165, 250, 0.1);
}
@media (prefers-color-scheme: light) {
:root {
--bg: #faf8f2;
--surface: #ffffff;
--surface2: #f5f0e6;
--surface-elevated: #fffdf5;
--border: rgba(30, 30, 50, 0.08);
--border-bright: rgba(30, 30, 50, 0.16);
--text: #1a1814;
--text-dim: #7a7468;
--accent: #b8860b;
--accent-dim: rgba(184, 134, 11, 0.08);
--code-bg: #2a2520;
--code-text: #e8e4d8;
--green: #16a34a;
--green-dim: rgba(22, 163, 74, 0.08);
--red: #dc2626;
--red-dim: rgba(220, 38, 38, 0.08);
--blue: #2563eb;
--blue-dim: rgba(37, 99, 235, 0.08);
}
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font-body);
color: var(--text);
background: var(--bg);
overflow: hidden;
}
/* ============ DECK ENGINE ============ */
.deck {
height: 100dvh;
overflow-y: auto;
scroll-snap-type: y mandatory;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
.slide {
height: 100dvh;
scroll-snap-align: start;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
padding: clamp(40px, 6vh, 80px) clamp(40px, 8vw, 120px);
isolation: isolate;
opacity: 0;
transform: translateY(40px) scale(0.98);
transition:
opacity 0.6s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
.slide.visible {
opacity: 1;
transform: none;
}
.slide .reveal {
opacity: 0;
transform: translateY(20px);
transition:
opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.slide.visible .reveal { opacity: 1; transform: none; }
.slide.visible .reveal:nth-child(1) { transition-delay: 0.1s; }
.slide.visible .reveal:nth-child(2) { transition-delay: 0.2s; }
.slide.visible .reveal:nth-child(3) { transition-delay: 0.3s; }
.slide.visible .reveal:nth-child(4) { transition-delay: 0.4s; }
.slide.visible .reveal:nth-child(5) { transition-delay: 0.5s; }
.slide.visible .reveal:nth-child(6) { transition-delay: 0.6s; }
@media (prefers-reduced-motion: reduce) {
.slide, .slide .reveal {
opacity: 1 !important;
transform: none !important;
transition: none !important;
}
}
/* ============ NAV CHROME ============ */
.deck-progress {
position: fixed; top: 0; left: 0; height: 3px;
background: var(--accent); z-index: 100;
transition: width 0.3s ease; pointer-events: none;
}
.deck-dots {
position: fixed; right: clamp(12px, 2vw, 24px); top: 50%;
transform: translateY(-50%); display: flex; flex-direction: column;
gap: 8px; z-index: 100; padding: 8px;
background: color-mix(in srgb, var(--bg) 60%, transparent 40%);
border-radius: 20px;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
}
.deck-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--text-dim); opacity: 0.3; border: none; padding: 0;
cursor: pointer; transition: opacity 0.2s ease, transform 0.2s ease;
}
.deck-dot:hover { opacity: 0.6; }
.deck-dot.active { opacity: 1; transform: scale(1.5); background: var(--accent); }
.deck-counter {
position: fixed; bottom: clamp(12px, 2vh, 24px); right: clamp(12px, 2vw, 24px);
font-family: var(--font-mono); font-size: 12px; color: var(--text-dim);
z-index: 100; font-variant-numeric: tabular-nums;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.deck-hints {
position: fixed; bottom: clamp(12px, 2vh, 24px); left: 50%;
transform: translateX(-50%); font-family: var(--font-mono);
font-size: 11px; color: var(--text-dim); opacity: 0.6; z-index: 100;
transition: opacity 0.5s ease; white-space: nowrap;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.deck-hints.faded { opacity: 0; pointer-events: none; }
/* ============ SHARED SLIDE ELEMENTS ============ */
.slide__display {
font-size: clamp(48px, 10vw, 120px);
font-weight: 400;
letter-spacing: -2px;
line-height: 0.95;
text-wrap: balance;
}
.slide__heading {
font-size: clamp(28px, 5vw, 48px);
font-weight: 400;
letter-spacing: -0.5px;
line-height: 1.15;
text-wrap: balance;
}
.slide__body {
font-size: clamp(16px, 2.2vw, 22px);
line-height: 1.6;
color: var(--text-dim);
text-wrap: pretty;
}
.slide__subtitle {
font-family: var(--font-mono);
font-size: clamp(12px, 1.5vw, 18px);
color: var(--text-dim);
letter-spacing: 1px;
text-transform: uppercase;
}
.slide__label {
font-family: var(--font-mono);
font-size: clamp(10px, 1.2vw, 13px);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--accent);
margin-bottom: 12px;
}
/* ============ DECORATIVE SVG ============ */
.slide__decor {
position: absolute;
pointer-events: none;
z-index: 0;
}
/* ============ SLIDE TYPE: TITLE ============ */
.slide--title {
justify-content: center;
align-items: center;
text-align: center;
background-image: radial-gradient(ellipse at 50% 30%, var(--accent-dim) 0%, transparent 50%);
}
.slide--title .slide__display { color: var(--accent); }
/* ============ SLIDE TYPE: DIVIDER ============ */
.slide--divider { justify-content: center; }
.slide--divider .slide__number {
font-size: clamp(100px, 22vw, 260px);
font-weight: 200;
line-height: 0.85;
opacity: 0.06;
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -55%);
pointer-events: none;
font-variant-numeric: tabular-nums;
color: var(--accent);
}
/* ============ SLIDE TYPE: CONTENT ============ */
.slide--content .slide__inner {
display: grid;
grid-template-columns: 3fr 2fr;
gap: clamp(24px, 4vw, 60px);
align-items: center;
width: 100%;
}
.slide__bullets {
list-style: none;
padding: 0;
}
.slide__bullets li {
padding: 10px 0 10px 24px;
position: relative;
font-size: clamp(16px, 2vw, 22px);
line-height: 1.5;
color: var(--text-dim);
}
.slide__bullets li::before {
content: '';
position: absolute;
left: 0; top: 20px;
width: 6px; height: 6px;
border-radius: 50%;
background: var(--accent);
}
.slide__aside {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
}
/* ============ SLIDE TYPE: SPLIT ============ */
.slide--split { padding: 0; }
.slide--split .slide__panels {
display: grid;
grid-template-columns: 3fr 2fr;
height: 100%;
}
.slide--split .slide__panel {
padding: clamp(40px, 6vh, 80px) clamp(32px, 4vw, 60px);
display: flex;
flex-direction: column;
justify-content: center;
}
.slide--split .slide__panel--primary { background: var(--surface); }
.slide--split .slide__panel--secondary {
background: var(--surface2);
display: flex;
flex-direction: column;
gap: 12px;
}
/* ============ SLIDE TYPE: DIAGRAM ============ */
.slide--diagram {
padding: clamp(24px, 4vh, 48px) clamp(24px, 4vw, 60px);
}
.slide--diagram .slide__heading { margin-bottom: clamp(8px, 1.5vh, 20px); }
.mermaid-wrap {
position: relative;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
overflow: auto;
flex: 1;
min-height: 0;
display: flex;
justify-content: center;
align-items: center;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.mermaid-wrap::-webkit-scrollbar { width: 6px; height: 6px; }
.mermaid-wrap::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
.zoom-controls {
position: absolute; top: 8px; right: 8px;
display: flex; gap: 2px; z-index: 10;
background: var(--surface); border: 1px solid var(--border);
border-radius: 6px; padding: 2px;
}
.zoom-controls button {
width: 28px; height: 28px; border: none; background: transparent;
color: var(--text-dim); font-family: var(--font-mono); font-size: 14px;
cursor: pointer; border-radius: 4px;
display: flex; align-items: center; justify-content: center;
transition: background 0.15s, color 0.15s;
}
.zoom-controls button:hover { background: var(--border); color: var(--text); }
.mermaid-wrap { cursor: grab; }
.mermaid-wrap.is-panning { cursor: grabbing; user-select: none; }
.mermaid .nodeLabel { color: var(--text) !important; }
.mermaid .edgeLabel { color: var(--text-dim) !important; background-color: var(--bg) !important; }
.mermaid .edgeLabel rect { fill: var(--bg) !important; }
.slide--diagram .mermaid svg {
width: 100% !important;
height: auto !important;
max-width: 100% !important;
}
.slide--diagram .mermaid .nodeLabel { font-size: 18px !important; }
.slide--diagram .mermaid .edgeLabel { font-family: var(--font-mono) !important; font-size: 14px !important; }
.slide--diagram .mermaid .node rect,
.slide--diagram .mermaid .node circle,
.slide--diagram .mermaid .node polygon { stroke-width: 2px; }
.slide--diagram .mermaid .edge-pattern-solid { stroke-width: 2px; }
/* ============ SLIDE TYPE: DASHBOARD ============ */
.slide--dashboard .slide__kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(clamp(140px, 20vw, 220px), 1fr));
gap: clamp(12px, 2vw, 24px);
}
.slide__kpi {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: clamp(16px, 3vh, 32px) clamp(16px, 2vw, 24px);
min-width: 0;
overflow: hidden;
}
.slide__kpi-val {
font-size: clamp(36px, 6vw, 64px);
font-weight: 400;
letter-spacing: -1.5px;
line-height: 1.1;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.slide__kpi-label {
font-family: var(--font-mono);
font-size: clamp(9px, 1.2vw, 13px);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-dim);
margin-top: 8px;
}
.slide__kpi-trend {
font-family: var(--font-mono);
font-size: 12px;
margin-top: 4px;
}
/* ============ SLIDE TYPE: TABLE ============ */
.slide--table { padding: clamp(24px, 4vh, 48px) clamp(24px, 4vw, 60px); }
.table-wrap {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
flex: 1;
min-height: 0;
}
.table-scroll { overflow-x: auto; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th {
background: var(--surface2);
font-family: var(--font-mono);
font-size: clamp(10px, 1.3vw, 14px);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-dim);
text-align: left;
padding: clamp(10px, 1.5vh, 16px) clamp(14px, 2vw, 24px);
border-bottom: 2px solid var(--border-bright);
white-space: nowrap;
}
.data-table td {
padding: clamp(10px, 1.5vh, 16px) clamp(14px, 2vw, 24px);
border-bottom: 1px solid var(--border);
font-size: clamp(14px, 1.8vw, 20px);
vertical-align: top;
}
.data-table tbody tr:last-child td { border-bottom: none; }
.data-table tbody tr:nth-child(even) { background: var(--surface2); }
.data-table tbody tr { transition: background 0.15s; }
.data-table tbody tr:hover { background: var(--accent-dim); }
.data-table code {
font-family: var(--font-mono); font-size: 0.85em;
background: var(--accent-dim); color: var(--accent);
padding: 1px 5px; border-radius: 3px;
}
/* ============ SLIDE TYPE: CODE ============ */
.slide--code { align-items: center; }
.slide__code-block {
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: clamp(24px, 4vh, 48px) clamp(24px, 4vw, 48px);
max-width: 900px;
width: 100%;
position: relative;
}
.slide__code-filename {
position: absolute;
top: -12px; left: 24px;
font-family: var(--font-mono);
font-size: 11px; font-weight: 600;
padding: 4px 12px; border-radius: 4px;
background: var(--accent); color: var(--bg);
}
.slide__code-block pre { margin: 0; overflow-x: auto; }
.slide__code-block code {
font-family: var(--font-mono);
font-size: clamp(14px, 1.6vw, 18px);
line-height: 1.7;
color: var(--code-text);
}
.slide__code-block .hl { color: var(--accent); }
.slide__code-block .cm { color: var(--text-dim); }
/* ============ SLIDE TYPE: QUOTE ============ */
.slide--quote {
justify-content: center;
align-items: center;
text-align: center;
padding: clamp(60px, 10vh, 120px) clamp(60px, 12vw, 200px);
}
.slide__quote-mark {
font-size: clamp(80px, 14vw, 180px);
line-height: 0.5;
opacity: 0.06;
font-family: Georgia, serif;
pointer-events: none;
margin-bottom: -20px;
color: var(--accent);
}
.slide--quote blockquote {
font-size: clamp(24px, 4vw, 48px);
font-weight: 400;
line-height: 1.35;
font-style: italic;
}
.slide--quote cite {
font-family: var(--font-mono);
font-size: clamp(11px, 1.4vw, 14px);
font-style: normal;
margin-top: clamp(16px, 3vh, 32px);
display: block;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--text-dim);
}
/* ============ SLIDE TYPE: FULL-BLEED ============ */
.slide--bleed {
padding: 0;
justify-content: flex-end;
}
.slide__bg {
position: absolute; inset: 0;
background-size: cover; background-position: center;
z-index: 0;
}
.slide__bg--gradient {
background: linear-gradient(135deg, #1a0f3c 0%, #0f1729 40%, #162040 100%);
}
.slide__scrim {
position: absolute; inset: 0;
background: linear-gradient(to top, rgba(0,0,0,0.75) 0%, rgba(0,0,0,0.2) 40%, transparent 100%);
z-index: 1;
}
.slide--bleed .slide__content {
position: relative; z-index: 2;
padding: clamp(40px, 6vh, 80px) clamp(40px, 8vw, 120px);
color: #ffffff;
}
.slide--bleed .slide__heading { color: #ffffff; }
.slide--bleed .slide__subtitle { color: rgba(255,255,255,0.7); }
/* ============ RESPONSIVE ============ */
@media (max-height: 700px) {
.slide { padding: clamp(24px, 4vh, 40px) clamp(32px, 6vw, 80px); }
.slide__display { font-size: clamp(36px, 8vw, 72px); }
.slide--divider .slide__number { font-size: clamp(80px, 16vw, 160px); }
}
@media (max-height: 600px) {
.slide__decor { display: none; }
.slide--quote { padding: clamp(32px, 6vh, 60px) clamp(40px, 8vw, 100px); }
.slide__quote-mark { display: none; }
}
@media (max-height: 500px) {
.slide { padding: clamp(16px, 3vh, 24px) clamp(24px, 5vw, 48px); }
.deck-dots { display: none; }
.slide__display { font-size: clamp(28px, 7vw, 48px); }
}
@media (max-width: 768px) {
.slide--content .slide__inner { grid-template-columns: 1fr; }
.slide--content .slide__aside { display: none; }
.slide--split .slide__panels { grid-template-columns: 1fr; }
.slide--dashboard .slide__kpis { grid-template-columns: repeat(2, 1fr); }
}
</style>
</head>
<body>
<div class="deck">
<!-- SLIDE 1: TITLE -->
<section class="slide slide--title">
<svg class="slide__decor" style="top:0;right:0;" width="120" height="120" viewBox="0 0 120 120">
<line x1="120" y1="0" x2="120" y2="40" stroke="var(--accent)" stroke-width="2" opacity="0.15"/>
<line x1="80" y1="0" x2="120" y2="0" stroke="var(--accent)" stroke-width="2" opacity="0.15"/>
</svg>
<svg class="slide__decor" style="bottom:0;left:0;" width="120" height="120" viewBox="0 0 120 120">
<line x1="0" y1="80" x2="0" y2="120" stroke="var(--accent)" stroke-width="2" opacity="0.15"/>
<line x1="0" y1="120" x2="40" y2="120" stroke="var(--accent)" stroke-width="2" opacity="0.15"/>
</svg>
<div class="reveal">
<p class="slide__subtitle" style="margin-bottom:clamp(16px,2vh,32px);">Engineering Review — Q1 2026</p>
</div>
<h1 class="slide__display reveal">API Gateway Redesign</h1>
<div class="reveal">
<p class="slide__subtitle" style="margin-top:clamp(16px,2vh,32px);">From monolith proxy to edge-native routing</p>
</div>
</section>
<!-- SLIDE 2: SECTION DIVIDER -->
<section class="slide slide--divider" style="background-image:radial-gradient(ellipse at 80% 60%, var(--accent-dim) 0%, transparent 40%);">
<span class="slide__number">01</span>
<div>
<h2 class="slide__heading reveal">The Problem</h2>
<p class="slide__subtitle reveal" style="margin-top:12px;">Why the current gateway can't scale</p>
</div>
</section>
<!-- SLIDE 3: CONTENT (left-heavy asymmetric) -->
<section class="slide slide--content" style="background-image:radial-gradient(ellipse at 20% 80%, var(--accent-dim) 0%, transparent 45%);">
<div class="slide__inner">
<div>
<p class="slide__label reveal">Current State</p>
<h2 class="slide__heading reveal">Single Point of Failure</h2>
<ul class="slide__bullets">
<li class="reveal">All traffic routes through one Node.js process</li>
<li class="reveal">Rate limiting is per-instance, not distributed</li>
<li class="reveal">Auth validation adds 40ms per request</li>
<li class="reveal">No circuit breaking — cascade failures hit everything</li>
</ul>
</div>
<div class="slide__aside reveal">
<svg viewBox="0 0 160 160" width="160" height="160">
<circle cx="80" cy="80" r="60" fill="none" stroke="var(--red)" stroke-width="2" opacity="0.3"/>
<circle cx="80" cy="80" r="40" fill="none" stroke="var(--red)" stroke-width="1.5" opacity="0.2" stroke-dasharray="4 4"/>
<circle cx="80" cy="80" r="8" fill="var(--red)" opacity="0.4"/>
<text x="80" y="130" text-anchor="middle" font-family="var(--font-mono)" font-size="11" fill="var(--text-dim)">SINGLE PROCESS</text>
</svg>
</div>
</div>
</section>
<!-- SLIDE 4: SPLIT (before/after) -->
<section class="slide slide--split">
<div class="slide__panels">
<div class="slide__panel slide__panel--primary">
<p class="slide__label reveal" style="color:var(--red);">Before</p>
<h2 class="slide__heading reveal" style="font-size:clamp(22px,3.5vw,36px);">Monolith Proxy</h2>
<ul class="slide__bullets" style="margin-top:16px;">
<li class="reveal">Express.js middleware chain</li>
<li class="reveal">In-memory rate limit counters</li>
<li class="reveal">Synchronous JWT validation</li>
<li class="reveal">Manual upstream health checks</li>
</ul>
</div>
<div class="slide__panel slide__panel--secondary">
<p class="slide__label reveal" style="color:var(--green);">After</p>
<h2 class="slide__heading reveal" style="font-size:clamp(22px,3.5vw,36px);">Edge-Native</h2>
<ul class="slide__bullets" style="margin-top:16px;">
<li class="reveal">Cloudflare Workers at the edge</li>
<li class="reveal">Durable Objects for distributed state</li>
<li class="reveal">Async JWT with key caching</li>
<li class="reveal">Automatic circuit breakers</li>
</ul>
</div>
</div>
</section>
<!-- SLIDE 5: SECTION DIVIDER -->
<section class="slide slide--divider" style="background-image:radial-gradient(ellipse at 30% 40%, var(--accent-dim) 0%, transparent 40%);">
<span class="slide__number">02</span>
<div>
<h2 class="slide__heading reveal">Architecture</h2>
<p class="slide__subtitle reveal" style="margin-top:12px;">How the new system works</p>
</div>
</section>
<!-- SLIDE 6: DIAGRAM -->
<section class="slide slide--diagram">
<h2 class="slide__heading reveal">Request Flow</h2>
<div class="mermaid-wrap reveal">
<div class="zoom-controls">
<button onclick="zoomDiagram(this,1.2)" title="Zoom in">+</button>
<button onclick="zoomDiagram(this,0.8)" title="Zoom out">−</button>
<button onclick="resetZoom(this)" title="Reset">↺</button>
<button onclick="openDiagramFullscreen(this)" title="Open full size in new tab">⛶</button>
</div>
<pre class="mermaid">
graph LR
Client["Client"] --> Edge["Edge Worker"]
Edge --> Auth["Auth Cache"]
Edge --> RL["Rate Limiter<br/>Durable Object"]
Edge --> Router["Route Resolver"]
Router --> API["API Service"]
Router --> Static["Static Assets"]
API --> DB["Database"]
classDef primary fill:#d4a73a22,stroke:#d4a73a,stroke-width:2px
classDef secondary fill:#60a5fa22,stroke:#60a5fa,stroke-width:2px
classDef storage fill:#4ade8022,stroke:#4ade80,stroke-width:2px
class Client,Edge primary
class Auth,RL,Router secondary
class API,Static,DB storage
</pre>
</div>
</section>
<!-- SLIDE 7: DASHBOARD -->
<section class="slide slide--dashboard" style="background-image:radial-gradient(ellipse at 70% 30%, var(--accent-dim) 0%, transparent 40%);">
<h2 class="slide__heading reveal">Performance Impact</h2>
<div class="slide__kpis">
<div class="slide__kpi reveal">
<div class="slide__kpi-val" style="color:var(--accent);">12ms</div>
<div class="slide__kpi-label">P99 Latency</div>
<div class="slide__kpi-trend" style="color:var(--green);">↓ from 142ms</div>
</div>
<div class="slide__kpi reveal">
<div class="slide__kpi-val" style="color:var(--green);">99.97%</div>
<div class="slide__kpi-label">Uptime</div>
<div class="slide__kpi-trend" style="color:var(--green);">↑ from 99.2%</div>
</div>
<div class="slide__kpi reveal">
<div class="slide__kpi-val" style="color:var(--blue);">340</div>
<div class="slide__kpi-label">Edge Locations</div>
<div class="slide__kpi-trend" style="color:var(--text-dim);">global coverage</div>
</div>
<div class="slide__kpi reveal">
<div class="slide__kpi-val" style="color:var(--accent);">$0.02</div>
<div class="slide__kpi-label">Per 10K Requests</div>
<div class="slide__kpi-trend" style="color:var(--green);">↓ 68% cost reduction</div>
</div>
</div>
</section>
<!-- SLIDE 8: TABLE -->
<section class="slide slide--table">
<h2 class="slide__heading reveal">Migration Phases</h2>
<div class="table-wrap reveal" style="flex:1; min-height:0; margin-top:clamp(8px,1.5vh,20px);">
<div class="table-scroll">
<table class="data-table">
<thead><tr><th>Phase</th><th>Scope</th><th>Timeline</th><th>Risk</th></tr></thead>
<tbody>
<tr><td>1. Shadow mode</td><td>Mirror traffic to edge, compare responses</td><td>Week 1–2</td><td style="color:var(--green);">Low</td></tr>
<tr><td>2. Canary rollout</td><td>5% traffic to edge, monitor errors</td><td>Week 3</td><td style="color:var(--green);">Low</td></tr>
<tr><td>3. Gradual shift</td><td>25% → 50% → 75% traffic</td><td>Week 4–5</td><td style="color:var(--accent);">Medium</td></tr>
<tr><td>4. Full cutover</td><td>100% traffic, decommission old proxy</td><td>Week 6</td><td style="color:var(--accent);">Medium</td></tr>
<tr><td>5. Cleanup</td><td>Remove feature flags, archive old code</td><td>Week 7</td><td style="color:var(--green);">Low</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- SLIDE 9: CODE -->
<section class="slide slide--code" style="background-image:radial-gradient(ellipse at 50% 80%, var(--accent-dim) 0%, transparent 40%);">
<h2 class="slide__heading reveal" style="text-align:center;">Edge Worker Entry Point</h2>
<div class="slide__code-block reveal" style="margin-top:clamp(12px,2vh,24px);">
<span class="slide__code-filename">gateway.ts</span>
<pre><code><span class="hl">export default</span> {
<span class="hl">async fetch</span>(req: Request, env: Env) {
<span class="cm">// Auth check with edge-cached keys</span>
const identity = <span class="hl">await</span> verifyAuth(req, env);
<span class="cm">// Distributed rate limiting</span>
const limit = env.RATE_LIMITER.get(identity.id);
<span class="hl">if</span> (<span class="hl">await</span> limit.check()) <span class="hl">return</span> tooMany();
<span class="cm">// Route to upstream</span>
<span class="hl">return</span> route(req, env.SERVICES);
}
};</code></pre>
</div>
</section>
<!-- SLIDE 10: QUOTE -->
<section class="slide slide--quote" style="background-image:radial-gradient(ellipse at 50% 50%, var(--accent-dim) 0%, transparent 35%);">
<div class="slide__quote-mark reveal">“</div>
<blockquote class="reveal">
The fastest request is the one that never leaves the edge.
</blockquote>
<cite class="reveal">— Edge Computing Principle</cite>
</section>
<!-- SLIDE 11: FULL-BLEED -->
<section class="slide slide--bleed">
<div class="slide__bg slide__bg--gradient"></div>
<div class="slide__scrim"></div>
<div class="slide__content">
<p class="slide__label reveal" style="color:rgba(255,255,255,0.6);">Next Steps</p>
<h2 class="slide__heading reveal">Ship Shadow Mode This Week</h2>
<p class="slide__subtitle reveal" style="color:rgba(255,255,255,0.6); margin-top:12px;">Full cutover targeted for end of Q1</p>
</div>
</section>
</div><!-- /deck -->
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
mermaid.initialize({
startOnLoad: true,
theme: 'base',
look: 'classic',
themeVariables: {
primaryColor: isDark ? '#1d2b52' : '#fef3e0',
primaryBorderColor: isDark ? '#d4a73a' : '#b8860b',
primaryTextColor: isDark ? '#e8e4d8' : '#1a1814',
secondaryColor: isDark ? '#162040' : '#eff6ff',
secondaryBorderColor: isDark ? '#60a5fa' : '#2563eb',
secondaryTextColor: isDark ? '#e8e4d8' : '#1a1814',
tertiaryColor: isDark ? '#0f2620' : '#f0fdf4',
tertiaryBorderColor: isDark ? '#4ade80' : '#16a34a',
tertiaryTextColor: isDark ? '#e8e4d8' : '#1a1814',
lineColor: isDark ? '#9a9484' : '#7a7468',
fontSize: '18px',
fontFamily: 'var(--font-body)',
noteBkgColor: isDark ? '#1d2b52' : '#fef3e0',
noteTextColor: isDark ? '#e8e4d8' : '#1a1814',
noteBorderColor: isDark ? '#d4a73a' : '#b8860b',
}
});
function autoFit() {
document.querySelectorAll('.mermaid svg').forEach(function(svg) {
svg.removeAttribute('height');
svg.style.width = '100%';
svg.style.maxWidth = '100%';
svg.style.height = 'auto';
svg.parentElement.style.width = '100%';
});
document.querySelectorAll('.slide__kpi-val').forEach(function(el) {
if (el.scrollWidth > el.clientWidth) {
var s = el.clientWidth / el.scrollWidth;
el.style.transform = 'scale(' + s + ')';
el.style.transformOrigin = 'left top';
}
});
document.querySelectorAll('.slide--quote blockquote').forEach(function(el) {
var len = el.textContent.trim().length;
if (len > 100) {
var scale = Math.max(0.5, 100 / len);
var fs = parseFloat(getComputedStyle(el).fontSize);
el.style.fontSize = Math.max(16, Math.round(fs * scale)) + 'px';
}
});
}
mermaid.run().then(function() {
autoFit();
new SlideEngine();
});
</script>
<script>
// Mermaid zoom controls + click-to-expand
var INITIAL_ZOOM=1;
function zoomDiagram(b,f){var w=b.closest('.mermaid-wrap');var t=w.querySelector('.mermaid');var c=parseFloat(t.dataset.zoom||INITIAL_ZOOM);var n=Math.min(Math.max(c*f,0.5),5);t.dataset.zoom=n;t.style.zoom=n;}
function resetZoom(b){var w=b.closest('.mermaid-wrap');var t=w.querySelector('.mermaid');t.dataset.zoom=INITIAL_ZOOM;t.style.zoom=INITIAL_ZOOM;}
function openDiagramFullscreen(b){openMermaidInNewTab(b.closest('.mermaid-wrap'));}
function openMermaidInNewTab(w){var svg=w.querySelector('.mermaid svg');if(!svg)return;var clone=svg.cloneNode(true);clone.style.zoom='';clone.style.transform='';var styles=getComputedStyle(document.documentElement);var bg=styles.getPropertyValue('--bg').trim()||'#ffffff';var html='<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Diagram</title><style>body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:'+bg+';padding:40px;box-sizing:border-box}svg{max-width:100%;max-height:90vh;height:auto}</style></head><body>'+clone.outerHTML+'</body></html>';window.open(URL.createObjectURL(new Blob([html],{type:'text/html'})),'_blank');}
document.querySelectorAll('.mermaid-wrap').forEach(function(w){w.addEventListener('wheel',function(e){if(!e.ctrlKey&&!e.metaKey)return;e.preventDefault();var t=w.querySelector('.mermaid');var c=parseFloat(t.dataset.zoom||INITIAL_ZOOM);var f=e.deltaY<0?1.1:0.9;var n=Math.min(Math.max(c*f,0.5),5);t.dataset.zoom=n;t.style.zoom=n;},{passive:false});var sX,sY,sL,sT,sTime,didPan;w.addEventListener('mousedown',function(e){if(e.target.closest('.zoom-controls'))return;w.classList.add('is-panning');sX=e.clientX;sY=e.clientY;sL=w.scrollLeft;sT=w.scrollTop;sTime=Date.now();didPan=false;});window.addEventListener('mousemove',function(e){if(!w.classList.contains('is-panning'))return;var dx=e.clientX-sX,dy=e.clientY-sY;if(Math.abs(dx)>5||Math.abs(dy)>5)didPan=true;w.scrollLeft=sL-dx;w.scrollTop=sT-dy;});window.addEventListener('mouseup',function(){if(!w.classList.contains('is-panning'))return;w.classList.remove('is-panning');if(!didPan&&Date.now()-sTime<300)openMermaidInNewTab(w);});});
// SlideEngine
function SlideEngine(){
this.deck=document.querySelector('.deck');
this.slides=[].slice.call(document.querySelectorAll('.slide'));
this.current=0;
this.total=this.slides.length;
this.buildChrome();
this.bindEvents();
this.observe();
this.update();
}
SlideEngine.prototype.buildChrome=function(){
var bar=document.createElement('div');bar.className='deck-progress';document.body.appendChild(bar);this.bar=bar;
var dots=document.createElement('div');dots.className='deck-dots';var self=this;
this.slides.forEach(function(_,i){var d=document.createElement('button');d.className='deck-dot';d.title='Slide '+(i+1);d.onclick=function(){self.goTo(i);};dots.appendChild(d);});
document.body.appendChild(dots);this.dots=[].slice.call(dots.children);
var ctr=document.createElement('div');ctr.className='deck-counter';document.body.appendChild(ctr);this.counter=ctr;
var hints=document.createElement('div');hints.className='deck-hints';hints.textContent='\u2190 \u2192 or scroll to navigate';document.body.appendChild(hints);this.hints=hints;
this.hintTimer=setTimeout(function(){hints.classList.add('faded');},4000);
};
SlideEngine.prototype.bindEvents=function(){
var self=this;
document.addEventListener('keydown',function(e){
if(e.target.closest('.mermaid-wrap,.table-scroll,.code-scroll,input,textarea,[contenteditable]'))return;
if(['ArrowDown','ArrowRight',' ','PageDown'].indexOf(e.key)>-1){e.preventDefault();self.next();}
else if(['ArrowUp','ArrowLeft','PageUp'].indexOf(e.key)>-1){e.preventDefault();self.prev();}
else if(e.key==='Home'){e.preventDefault();self.goTo(0);}
else if(e.key==='End'){e.preventDefault();self.goTo(self.total-1);}
self.fadeHints();
});
var tY;
this.deck.addEventListener('touchstart',function(e){tY=e.touches[0].clientY;},{passive:true});
this.deck.addEventListener('touchend',function(e){var dy=tY-e.changedTouches[0].clientY;if(Math.abs(dy)>50){dy>0?self.next():self.prev();}});
};
SlideEngine.prototype.observe=function(){
var self=this;
var obs=new IntersectionObserver(function(entries){entries.forEach(function(entry){if(entry.isIntersecting){entry.target.classList.add('visible');self.current=self.slides.indexOf(entry.target);self.update();}});},{threshold:0.5});
this.slides.forEach(function(s){obs.observe(s);});
};
SlideEngine.prototype.goTo=function(i){this.slides[Math.max(0,Math.min(i,this.total-1))].scrollIntoView({behavior:'smooth'});};
SlideEngine.prototype.next=function(){if(this.current<this.total-1)this.goTo(this.current+1);};
SlideEngine.prototype.prev=function(){if(this.current>0)this.goTo(this.current-1);};
SlideEngine.prototype.update=function(){
this.bar.style.width=((this.current+1)/this.total*100)+'%';
var c=this.current;this.dots.forEach(function(d,i){d.classList.toggle('active',i===c);});
this.counter.textContent=(this.current+1)+' / '+this.total;
};
SlideEngine.prototype.fadeHints=function(){clearTimeout(this.hintTimer);this.hints.classList.add('faded');};
</script>
</body>
</html>