[
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Setup Bun\n        uses: oven-sh/setup-bun@v1\n        with:\n          bun-version: latest\n\n      - name: Install dependencies\n        run: bun install\n\n      - name: Run tests\n        run: bun test\n\n      - name: Type check\n        run: bun x tsc --noEmit\n"
  },
  {
    "path": ".github/workflows/publish.yml",
    "content": "name: Publish to npm\n\non:\n  release:\n    types: [published]\n\njobs:\n  publish:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Setup Bun\n        uses: oven-sh/setup-bun@v1\n        with:\n          bun-version: latest\n\n      - name: Setup Node.js (for npm publish)\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20'\n          registry-url: 'https://registry.npmjs.org'\n\n      - name: Install dependencies\n        run: bun install\n\n      - name: Run tests\n        run: bun test\n\n      - name: Publish to npm\n        run: npm publish --access public\n        env:\n          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": "# Dependencies\nnode_modules/\n\n# Build output\ndist/\nsite/\n*.tsbuildinfo\n\n# Generated files\nindex.html\n\n# IDE\n.idea/\n.vscode/\n*.swp\n*.swo\n.DS_Store\n\n# Logs\n*.log\nnpm-debug.log*\n\n# Environment\n.env\n.env.local\n.env.*.local\n\n# Test coverage\ncoverage/\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2026 Craft Docs\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n\n# beautiful-mermaid\n\n**Render Mermaid diagrams as beautiful SVGs or ASCII art**\n\nUltra-fast, fully themeable, zero DOM dependencies. Built for the AI era.\n\n![beautiful-mermaid sequence diagram example](hero.png)\n\n[![npm version](https://img.shields.io/npm/v/beautiful-mermaid.svg)](https://www.npmjs.com/package/beautiful-mermaid)\n[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)\n\n[**Live Demo & Samples**](https://agents.craft.do/mermaid)\n\n**[→ Use it live in Craft Agents](https://agents.craft.do)**\n\n</div>\n\n---\n\n## Why We Built This\n\nDiagrams are essential for AI-assisted programming. When you're working with an AI coding assistant, being able to visualize data flows, state machines, and system architecture—directly in your terminal or chat interface—makes complex concepts instantly graspable.\n\n[Mermaid](https://mermaid.js.org/) is the de facto standard for text-based diagrams. It's brilliant. But the default renderer has problems:\n\n- **Aesthetics** — Might be personal preference, but wished they looked more professional\n- **Complex theming** — Customizing colors requires wrestling with CSS classes\n- **No terminal output** — Can't render to ASCII for CLI tools\n- **Heavy dependencies** — Pulls in a lot of code for simple diagrams\n\nWe built `beautiful-mermaid` at [Craft](https://craft.do) to power diagrams in [Craft Agents](https://agents.craft.do). It's fast, beautiful, and works everywhere—from rich UIs to plain terminals.\n\n\nThe ASCII rendering engine is based on [mermaid-ascii](https://github.com/AlexanderGrooff/mermaid-ascii) by Alexander Grooff. We ported it from Go to TypeScript and extended it. Thank you Alexander for the excellent foundation! (And inspiration that this was possible.)\n\n## Features\n\n- **6 diagram types** — Flowcharts, State, Sequence, Class, ER, and XY Charts (bar, line, combined)\n- **Dual output** — SVG for rich UIs, ASCII/Unicode for terminals\n- **Synchronous rendering** — No async, no flash. Works with React `useMemo()`\n- **15 built-in themes** — And dead simple to add your own\n- **Full Shiki compatibility** — Use any VS Code theme directly\n- **Live theme switching** — CSS custom properties, no re-render needed\n- **Mono mode** — Beautiful diagrams from just 2 colors\n- **Zero DOM dependencies** — Pure TypeScript, works everywhere\n- **Ultra-fast** — Renders 100+ diagrams in under 500ms\n\n## Installation\n\n```bash\nnpm install beautiful-mermaid\n# or\nbun add beautiful-mermaid\n# or\npnpm add beautiful-mermaid\n```\n\n## Quick Start\n\n### SVG Output\n\n```typescript\nimport { renderMermaidSVG } from 'beautiful-mermaid'\n\nconst svg = renderMermaidSVG(`\n  graph TD\n    A[Start] --> B{Decision}\n    B -->|Yes| C[Action]\n    B -->|No| D[End]\n`)\n```\n\nRendering is **fully synchronous** — no `await`, no promises. The ELK.js layout engine runs synchronously via a FakeWorker bypass, so you get your SVG string instantly.\n\nNeed async? Use `renderMermaidSVGAsync()` — same output, returns a `Promise<string>`.\n\n### ASCII Output\n\n```typescript\nimport { renderMermaidASCII } from 'beautiful-mermaid'\n\nconst ascii = renderMermaidASCII(`graph LR; A --> B --> C`)\n```\n\n```\n┌───┐     ┌───┐     ┌───┐\n│   │     │   │     │   │\n│ A │────►│ B │────►│ C │\n│   │     │   │     │   │\n└───┘     └───┘     └───┘\n```\n\n---\n\n## React Integration\n\nBecause rendering is synchronous, you can use `useMemo()` for zero-flash diagram rendering:\n\n```tsx\nimport { renderMermaidSVG } from 'beautiful-mermaid'\n\nfunction MermaidDiagram({ code }: { code: string }) {\n  const { svg, error } = React.useMemo(() => {\n    try {\n      return {\n        svg: renderMermaidSVG(code, {\n          bg: 'var(--background)',\n          fg: 'var(--foreground)',\n          transparent: true,\n        }),\n        error: null,\n      }\n    } catch (err) {\n      return { svg: null, error: err instanceof Error ? err : new Error(String(err)) }\n    }\n  }, [code])\n\n  if (error) return <pre>{error.message}</pre>\n  return <div dangerouslySetInnerHTML={{ __html: svg! }} />\n}\n```\n\n**Why this works well:**\n- **No flash** — SVG is computed synchronously during render, not in a useEffect\n- **CSS variables** — Pass `var(--background)` etc. instead of hex colors. The SVG inherits from your app's CSS, so theme switches apply instantly without re-rendering\n- **Memoized** — Only re-renders when `code` changes\n\n---\n\n## Theming\n\nThe theming system is the heart of `beautiful-mermaid`. It's designed to be both powerful and dead simple.\n\n### The Two-Color Foundation\n\nEvery diagram needs just two colors: **background** (`bg`) and **foreground** (`fg`). That's it. From these two colors, the entire diagram is derived using `color-mix()`:\n\n```typescript\nconst svg = renderMermaidSVG(diagram, {\n  bg: '#1a1b26',  // Background\n  fg: '#a9b1d6',  // Foreground\n})\n```\n\nThis is **Mono Mode**—a coherent, beautiful diagram from just two colors. The system automatically derives:\n\n| Element | Derivation |\n|---------|------------|\n| Text | `--fg` at 100% |\n| Secondary text | `--fg` at 60% into `--bg` |\n| Edge labels | `--fg` at 40% into `--bg` |\n| Faint text | `--fg` at 25% into `--bg` |\n| Connectors | `--fg` at 50% into `--bg` |\n| Arrow heads | `--fg` at 85% into `--bg` |\n| Node fill | `--fg` at 3% into `--bg` |\n| Group header | `--fg` at 5% into `--bg` |\n| Inner strokes | `--fg` at 12% into `--bg` |\n| Node stroke | `--fg` at 20% into `--bg` |\n\n### Enriched Mode\n\nFor richer themes, you can provide optional \"enrichment\" colors that override specific derivations:\n\n```typescript\nconst svg = renderMermaidSVG(diagram, {\n  bg: '#1a1b26',\n  fg: '#a9b1d6',\n  // Optional enrichment:\n  line: '#3d59a1',    // Edge/connector color\n  accent: '#7aa2f7',  // Arrow heads, highlights\n  muted: '#565f89',   // Secondary text, labels\n  surface: '#292e42', // Node fill tint\n  border: '#3d59a1',  // Node stroke\n})\n```\n\nIf an enrichment color isn't provided, it falls back to the `color-mix()` derivation. This means you can provide just the colors you care about.\n\n### CSS Custom Properties = Live Switching\n\nAll colors are CSS custom properties on the `<svg>` element. This means you can switch themes instantly without re-rendering:\n\n```javascript\n// Switch theme by updating CSS variables\nsvg.style.setProperty('--bg', '#282a36')\nsvg.style.setProperty('--fg', '#f8f8f2')\n// The entire diagram updates immediately\n```\n\nFor React apps, pass CSS variable references instead of hex values:\n\n```typescript\nconst svg = renderMermaidSVG(diagram, {\n  bg: 'var(--background)',\n  fg: 'var(--foreground)',\n  accent: 'var(--accent)',\n  transparent: true,\n})\n// Theme switches apply automatically via CSS cascade — no re-render needed\n```\n\n### Built-in Themes\n\n15 carefully curated themes ship out of the box:\n\n| Theme | Type | Background | Accent |\n|-------|------|------------|--------|\n| `zinc-light` | Light | `#FFFFFF` | Derived |\n| `zinc-dark` | Dark | `#18181B` | Derived |\n| `tokyo-night` | Dark | `#1a1b26` | `#7aa2f7` |\n| `tokyo-night-storm` | Dark | `#24283b` | `#7aa2f7` |\n| `tokyo-night-light` | Light | `#d5d6db` | `#34548a` |\n| `catppuccin-mocha` | Dark | `#1e1e2e` | `#cba6f7` |\n| `catppuccin-latte` | Light | `#eff1f5` | `#8839ef` |\n| `nord` | Dark | `#2e3440` | `#88c0d0` |\n| `nord-light` | Light | `#eceff4` | `#5e81ac` |\n| `dracula` | Dark | `#282a36` | `#bd93f9` |\n| `github-light` | Light | `#ffffff` | `#0969da` |\n| `github-dark` | Dark | `#0d1117` | `#4493f8` |\n| `solarized-light` | Light | `#fdf6e3` | `#268bd2` |\n| `solarized-dark` | Dark | `#002b36` | `#268bd2` |\n| `one-dark` | Dark | `#282c34` | `#c678dd` |\n\n```typescript\nimport { renderMermaidSVG, THEMES } from 'beautiful-mermaid'\n\nconst svg = renderMermaidSVG(diagram, THEMES['tokyo-night'])\n```\n\n### Adding Your Own Theme\n\nCreating a theme is trivial. At minimum, just provide `bg` and `fg`:\n\n```typescript\nconst myTheme = {\n  bg: '#0f0f0f',\n  fg: '#e0e0e0',\n}\n\nconst svg = renderMermaidSVG(diagram, myTheme)\n```\n\nWant richer colors? Add any of the optional enrichments:\n\n```typescript\nconst myRichTheme = {\n  bg: '#0f0f0f',\n  fg: '#e0e0e0',\n  accent: '#ff6b6b',  // Pop of color for arrows\n  muted: '#666666',   // Subdued labels\n}\n```\n\n### Full Shiki Compatibility\n\nUse **any VS Code theme** directly via Shiki integration. This gives you access to hundreds of community themes:\n\n```typescript\nimport { getSingletonHighlighter } from 'shiki'\nimport { renderMermaidSVG, fromShikiTheme } from 'beautiful-mermaid'\n\n// Load any theme from Shiki's registry\nconst highlighter = await getSingletonHighlighter({\n  themes: ['vitesse-dark', 'rose-pine', 'material-theme-darker']\n})\n\n// Extract diagram colors from the theme\nconst colors = fromShikiTheme(highlighter.getTheme('vitesse-dark'))\n\nconst svg = renderMermaidSVG(diagram, colors)\n```\n\nThe `fromShikiTheme()` function intelligently maps VS Code editor colors to diagram roles:\n\n| Editor Color | Diagram Role |\n|--------------|--------------|\n| `editor.background` | `bg` |\n| `editor.foreground` | `fg` |\n| `editorLineNumber.foreground` | `line` |\n| `focusBorder` / keyword token | `accent` |\n| comment token | `muted` |\n| `editor.selectionBackground` | `surface` |\n| `editorWidget.border` | `border` |\n\n---\n\n## Supported Diagrams\n\n### Flowcharts\n\n```\ngraph TD\n  A[Start] --> B{Decision}\n  B -->|Yes| C[Process]\n  B -->|No| D[End]\n  C --> D\n```\n\nAll directions supported: `TD` (top-down), `LR` (left-right), `BT` (bottom-top), `RL` (right-left).\n\n### State Diagrams\n\n```\nstateDiagram-v2\n  [*] --> Idle\n  Idle --> Processing: start\n  Processing --> Complete: done\n  Complete --> [*]\n```\n\n### Sequence Diagrams\n\n```\nsequenceDiagram\n  Alice->>Bob: Hello Bob!\n  Bob-->>Alice: Hi Alice!\n  Alice->>Bob: How are you?\n  Bob-->>Alice: Great, thanks!\n```\n\n### Class Diagrams\n\n```\nclassDiagram\n  Animal <|-- Duck\n  Animal <|-- Fish\n  Animal: +int age\n  Animal: +String gender\n  Animal: +isMammal() bool\n  Duck: +String beakColor\n  Duck: +swim()\n  Duck: +quack()\n```\n\n### ER Diagrams\n\n```\nerDiagram\n  CUSTOMER ||--o{ ORDER : places\n  ORDER ||--|{ LINE_ITEM : contains\n  PRODUCT ||--o{ LINE_ITEM : \"is in\"\n```\n\n### Inline Edge Styling\n\nUse `linkStyle` to override edge colors and stroke widths — just like [Mermaid's linkStyle](https://mermaid.js.org/syntax/flowchart.html#styling-links):\n\n```\ngraph TD\n  A --> B --> C\n  linkStyle 0 stroke:#ff0000,stroke-width:2px\n  linkStyle default stroke:#888888\n```\n\n|             Syntax              |                 Effect                 |\n| ------------------------------- | -------------------------------------- |\n| `linkStyle 0 stroke:#f00`       | Style a single edge by index (0-based) |\n| `linkStyle 0,2 stroke:#f00`     | Style multiple edges at once           |\n| `linkStyle default stroke:#888` | Default style applied to all edges     |\n\nIndex-specific styles override the default. Supported properties: `stroke`, `stroke-width`.\n\nWorks in both flowcharts and state diagrams.\n\n### XY Charts\n\nBar charts, line charts, and combinations — using Mermaid's `xychart-beta` syntax.\n\n**Bar chart:**\n\n```\nxychart-beta\n    title \"Monthly Revenue\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun]\n    y-axis \"Revenue ($K)\" 0 --> 500\n    bar [180, 250, 310, 280, 350, 420]\n```\n\n**Line chart:**\n\n```\nxychart-beta\n    title \"User Growth\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun]\n    line [1200, 1800, 2500, 3100, 3800, 4500]\n```\n\n**Combined bar + line:**\n\n```\nxychart-beta\n    title \"Sales with Trend\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun]\n    bar [300, 380, 280, 450, 350, 520]\n    line [300, 330, 320, 353, 352, 395]\n```\n\n**Horizontal orientation:**\n\n```\nxychart-beta horizontal\n    title \"Language Popularity\"\n    x-axis [Python, JavaScript, Java, Go, Rust]\n    bar [30, 25, 20, 12, 8]\n```\n\n**Axis configuration:**\n\n- Categorical x-axis: `x-axis [A, B, C]`\n- Numeric x-axis range: `x-axis 0 --> 100`\n- Axis titles: `x-axis \"Category\" [A, B, C]`\n- Y-axis range: `y-axis \"Score\" 0 --> 100`\n\n**Multi-series:** Add multiple `bar` and/or `line` declarations. Each series gets a distinct color from a monochromatic palette derived from the theme's accent color.\n\n### XY Chart Styling\n\nThe chart renderer follows a clean, minimal design philosophy inspired by Apple and Craft:\n\n- **Dot grid** — A subtle dot pattern fills the plot area instead of traditional solid grid lines\n- **Rounded bars** — All bar corners are rounded for a modern, polished look\n- **Smooth curves** — Line series use natural cubic spline interpolation, producing mathematically smooth curves through all data points (not straight segments or staircase steps)\n- **Floating labels** — No visible axis lines or tick marks; labels float freely for a clutter-free aesthetic\n- **Drop-shadow lines** — Each line series has a subtle shadow beneath it for depth\n- **Monochromatic palette** — Series 0 uses the theme's accent color; additional series get darker/lighter shades of the same hue with subtle hue drift, adapting automatically to light or dark backgrounds\n- **Interactive tooltips** — When rendered with `interactive: true`, hovering over bars or data points shows value tooltips. Multi-line tooltips appear when multiple series share an x-position\n- **Sparse line dots** — Lines with 12 or fewer data points show data point dots by default for readability\n- **Full theme support** — All 15 built-in themes (and custom themes) apply to charts. The accent color drives the entire series color palette\n- **Live theme switching** — Chart series colors are CSS custom properties (`--xychart-color-N`), so theme changes apply instantly without re-rendering\n\n---\n\n## ASCII Output\n\nFor terminal environments, CLI tools, or anywhere you need plain text, render to ASCII or Unicode box-drawing characters:\n\n```typescript\nimport { renderMermaidASCII } from 'beautiful-mermaid'\n\n// Unicode mode (default) — prettier box drawing\nconst unicode = renderMermaidASCII(`graph LR; A --> B`)\n\n// Pure ASCII mode — maximum compatibility\nconst ascii = renderMermaidASCII(`graph LR; A --> B`, { useAscii: true })\n```\n\n**Unicode output:**\n```\n┌───┐     ┌───┐\n│   │     │   │\n│ A │────►│ B │\n│   │     │   │\n└───┘     └───┘\n```\n\n**ASCII output:**\n```\n+---+     +---+\n|   |     |   |\n| A |---->| B |\n|   |     |   |\n+---+     +---+\n```\n\n### ASCII Options\n\n```typescript\nrenderMermaidASCII(diagram, {\n  useAscii: false,      // true = ASCII, false = Unicode (default)\n  paddingX: 5,          // Horizontal spacing between nodes\n  paddingY: 5,          // Vertical spacing between nodes\n  boxBorderPadding: 1,  // Padding inside node boxes\n  colorMode: 'auto',    // 'none' | 'auto' | 'ansi16' | 'ansi256' | 'truecolor' | 'html'\n  theme: { ... },       // Partial<AsciiTheme> — override default colors\n})\n```\n\n### ASCII XY Charts\n\nXY charts render to ASCII with dedicated chart-drawing characters:\n\n- **Bar charts** — `█` blocks (Unicode) or `#` (ASCII mode)\n- **Line charts** — Staircase routing with rounded corners: `╭╮╰╯│─` (Unicode) or `+|-` (ASCII)\n- **Multi-series** — Each series gets a distinct ANSI color from the theme's accent palette\n- **Legends** — Automatically shown when multiple series are present\n- **Horizontal charts** — Fully supported with categories on the y-axis\n\n---\n\n## API Reference\n\n### `renderMermaidSVG(text, options?): string`\n\nRender a Mermaid diagram to SVG. Synchronous. Auto-detects diagram type.\n\n**Parameters:**\n- `text` — Mermaid source code\n- `options` — Optional `RenderOptions` object\n\n**RenderOptions:**\n\n| Option | Type | Default | Description |\n|--------|------|---------|-------------|\n| `bg` | `string` | `#FFFFFF` | Background color (or CSS variable) |\n| `fg` | `string` | `#27272A` | Foreground color (or CSS variable) |\n| `line` | `string?` | — | Edge/connector color |\n| `accent` | `string?` | — | Arrow heads, highlights |\n| `muted` | `string?` | — | Secondary text, labels |\n| `surface` | `string?` | — | Node fill tint |\n| `border` | `string?` | — | Node stroke color |\n| `font` | `string` | `Inter` | Font family |\n| `transparent` | `boolean` | `false` | Render with transparent background |\n| `padding` | `number` | `40` | Canvas padding in px |\n| `nodeSpacing` | `number` | `24` | Horizontal spacing between sibling nodes |\n| `layerSpacing` | `number` | `40` | Vertical spacing between layers |\n| `componentSpacing` | `number` | `24` | Spacing between disconnected components |\n| `thoroughness` | `number` | `3` | Crossing minimization trials (1-7, higher = better but slower) |\n| `interactive` | `boolean` | `false` | Enable hover tooltips on XY chart bars and data points |\n\n**XY Charts:** Diagrams starting with `xychart-beta` are auto-detected — no separate function needed. The `accent` color option drives the chart series color palette.\n\n### `renderMermaidSVGAsync(text, options?): Promise<string>`\n\nAsync version of `renderMermaidSVG()`. Same output, returns a `Promise<string>`. Useful in async server handlers or data loaders.\n\n### `renderMermaidASCII(text, options?): string`\n\nRender a Mermaid diagram to ASCII/Unicode text. Synchronous.\n\n**AsciiRenderOptions:**\n\n| Option | Type | Default | Description |\n|--------|------|---------|-------------|\n| `useAscii` | `boolean` | `false` | Use ASCII instead of Unicode |\n| `paddingX` | `number` | `5` | Horizontal node spacing |\n| `paddingY` | `number` | `5` | Vertical node spacing |\n| `boxBorderPadding` | `number` | `1` | Inner box padding |\n| `colorMode` | `string` | `'auto'` | `'none'`, `'auto'`, `'ansi16'`, `'ansi256'`, `'truecolor'`, or `'html'` |\n| `theme` | `Partial<AsciiTheme>` | — | Override default colors for ASCII output |\n\n### `parseMermaid(text): MermaidGraph`\n\nParse Mermaid source into a structured graph object (for custom processing).\n\n### `fromShikiTheme(theme): DiagramColors`\n\nExtract diagram colors from a Shiki theme object.\n\n### `THEMES: Record<string, DiagramColors>`\n\nObject containing all 15 built-in themes.\n\n### `DEFAULTS: { bg: string, fg: string }`\n\nDefault colors (`#FFFFFF` / `#27272A`).\n\n---\n\n## Attribution\n\nThe ASCII rendering engine is based on [mermaid-ascii](https://github.com/AlexanderGrooff/mermaid-ascii) by Alexander Grooff. We ported it from Go to TypeScript and extended it with:\n\n- Sequence diagram support\n- Class diagram support\n- ER diagram support\n- Unicode box-drawing characters\n- Configurable spacing and padding\n\nThank you Alexander for the excellent foundation!\n\n---\n\n## License\n\nMIT — see [LICENSE](LICENSE) for details.\n\n---\n\n<div align=\"center\">\n\nBuilt with care by the team at [Craft](https://craft.do)\n\n</div>\n"
  },
  {
    "path": "bench.ts",
    "content": "/**\n * Performance benchmark for beautiful-mermaid.\n *\n * Runs all sample definitions through both renderers (SVG + ASCII) in Bun\n * and prints a table with per-sample timing and aggregate stats.\n *\n * Usage: bun run packages/mermaid/bench.ts\n */\n\nimport { samples } from './samples-data.ts'\nimport { renderMermaid } from './src/index.ts'\nimport { renderMermaidAscii } from './src/ascii/index.ts'\n\n// ============================================================================\n// Types\n// ============================================================================\n\ninterface Result {\n  index: number\n  title: string\n  category: string\n  svgMs: number\n  asciiMs: number\n  svgError: string | null\n  asciiError: string | null\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\n/** Pad/truncate a string to exactly `width` characters, right-aligned if numeric. */\nfunction col(value: string, width: number, align: 'left' | 'right' = 'left'): string {\n  const truncated = value.length > width ? value.slice(0, width - 1) + '\\u2026' : value\n  return align === 'right' ? truncated.padStart(width) : truncated.padEnd(width)\n}\n\nfunction fmtMs(ms: number): string {\n  return ms.toFixed(1)\n}\n\n// ============================================================================\n// Main\n// ============================================================================\n\nconst results: Result[] = []\nconst totalStart = performance.now()\n\nconsole.log(`\\nbeautiful-mermaid — Benchmark (${samples.length} samples)`)\nconsole.log('═'.repeat(90))\nconsole.log(\n  `${col('#', 4, 'right')}  ${col('Title', 38)}  ${col('Category', 15)}  ${col('SVG (ms)', 10, 'right')}  ${col('ASCII (ms)', 10, 'right')}  ${col('Total', 10, 'right')}`,\n)\nconsole.log('─'.repeat(90))\n\nfor (let i = 0; i < samples.length; i++) {\n  const sample = samples[i]!\n  const category = sample.category ?? 'Other'\n  let svgMs = 0\n  let asciiMs = 0\n  let svgError: string | null = null\n  let asciiError: string | null = null\n\n  // Render SVG (async — uses dagre layout for flowcharts/state/class/ER)\n  try {\n    const t0 = performance.now()\n    await renderMermaid(sample.source, sample.options)\n    svgMs = performance.now() - t0\n  } catch (err) {\n    svgError = String(err)\n    svgMs = -1\n  }\n\n  // Render ASCII (sync — custom text layout, no dagre)\n  try {\n    const t0 = performance.now()\n    renderMermaidAscii(sample.source)\n    asciiMs = performance.now() - t0\n  } catch (err) {\n    asciiError = String(err)\n    asciiMs = -1\n  }\n\n  const totalMs = (svgMs >= 0 ? svgMs : 0) + (asciiMs >= 0 ? asciiMs : 0)\n\n  results.push({ index: i, title: sample.title, category, svgMs, asciiMs, svgError, asciiError })\n\n  // Print row\n  const svgStr = svgMs >= 0 ? fmtMs(svgMs) : 'ERR'\n  const asciiStr = asciiMs >= 0 ? fmtMs(asciiMs) : 'N/A'\n  console.log(\n    `${col(String(i + 1), 4, 'right')}  ${col(sample.title, 38)}  ${col(category, 15)}  ${col(svgStr, 10, 'right')}  ${col(asciiStr, 10, 'right')}  ${col(fmtMs(totalMs), 10, 'right')}`,\n  )\n}\n\nconst totalElapsed = performance.now() - totalStart\n\n// ============================================================================\n// Aggregates\n// ============================================================================\n\nconsole.log('═'.repeat(90))\n\nconst svgTimes = results.filter(r => r.svgMs >= 0).map(r => r.svgMs)\nconst asciiTimes = results.filter(r => r.asciiMs >= 0).map(r => r.asciiMs)\nconst svgTotal = svgTimes.reduce((a, b) => a + b, 0)\nconst asciiTotal = asciiTimes.reduce((a, b) => a + b, 0)\n\nconsole.log(`Total: ${fmtMs(totalElapsed)}ms (SVG: ${fmtMs(svgTotal)}ms, ASCII: ${fmtMs(asciiTotal)}ms)`)\nconsole.log(`Average: ${fmtMs((svgTotal + asciiTotal) / results.length)}ms per sample`)\n\n// Find slowest SVG and ASCII\nif (svgTimes.length > 0) {\n  const slowestSvg = results.filter(r => r.svgMs >= 0).sort((a, b) => b.svgMs - a.svgMs)[0]!\n  console.log(`Slowest SVG:   #${slowestSvg.index + 1} ${slowestSvg.title} (${fmtMs(slowestSvg.svgMs)}ms)`)\n}\nif (asciiTimes.length > 0) {\n  const slowestAscii = results.filter(r => r.asciiMs >= 0).sort((a, b) => b.asciiMs - a.asciiMs)[0]!\n  console.log(`Slowest ASCII: #${slowestAscii.index + 1} ${slowestAscii.title} (${fmtMs(slowestAscii.asciiMs)}ms)`)\n}\n\n// Report errors\nconst svgErrors = results.filter(r => r.svgError)\nconst asciiErrors = results.filter(r => r.asciiError)\nif (svgErrors.length > 0) {\n  console.log(`\\nSVG errors (${svgErrors.length}):`)\n  for (const r of svgErrors) {\n    console.log(`  #${r.index + 1} ${r.title}: ${r.svgError}`)\n  }\n}\nif (asciiErrors.length > 0) {\n  console.log(`\\nASCII unsupported (${asciiErrors.length}):`)\n  for (const r of asciiErrors) {\n    console.log(`  #${r.index + 1} ${r.title}`)\n  }\n}\n\n// Category breakdown\nconsole.log('\\n── By Category ──')\nconst catMap = new Map<string, Result[]>()\nfor (const r of results) {\n  if (!catMap.has(r.category)) catMap.set(r.category, [])\n  catMap.get(r.category)!.push(r)\n}\nfor (const [cat, catResults] of catMap) {\n  const catSvg = catResults.filter(r => r.svgMs >= 0).reduce((a, r) => a + r.svgMs, 0)\n  const catAscii = catResults.filter(r => r.asciiMs >= 0).reduce((a, r) => a + r.asciiMs, 0)\n  console.log(`  ${col(cat, 16)} ${col(String(catResults.length), 3, 'right')} samples  SVG: ${col(fmtMs(catSvg), 8, 'right')}ms  ASCII: ${col(fmtMs(catAscii), 8, 'right')}ms  Total: ${col(fmtMs(catSvg + catAscii), 8, 'right')}ms`)\n}\n\nconsole.log()\n"
  },
  {
    "path": "dev.ts",
    "content": "/**\n * Development server with live reload for mermaid samples.\n *\n * Usage: bun run packages/mermaid/dev.ts\n *\n * - Runs `index.ts` to generate index.html on startup\n * - Watches `src/` and `index.ts` for file changes\n * - On change, rebuilds index.html and notifies browsers via SSE\n * - Serves index.html with an injected live-reload script\n *\n * This avoids manually re-running the build and refreshing the browser —\n * just save a file and the page updates automatically.\n */\n\nimport { watch } from 'fs'\nimport { join } from 'path'\n\nconst PORT = 3456\nconst ROOT = import.meta.dir\n\n// ============================================================================\n// Build management\n// ============================================================================\n\nlet building = false\nconst sseClients = new Set<ReadableStreamDefaultController>()\n\nasync function rebuild(): Promise<void> {\n  if (building) return\n  building = true\n  console.log('\\x1b[36m[dev]\\x1b[0m Rebuilding samples...')\n  const t0 = performance.now()\n\n  const proc = Bun.spawn(['bun', 'run', join(ROOT, 'index.ts')], {\n    cwd: ROOT,\n    stdout: 'inherit',\n    stderr: 'inherit',\n  })\n  await proc.exited\n\n  const ms = (performance.now() - t0).toFixed(0)\n  if (proc.exitCode === 0) {\n    console.log(`\\x1b[32m[dev]\\x1b[0m Rebuilt in ${ms}ms`)\n    // Notify all connected browsers to reload\n    for (const client of sseClients) {\n      try {\n        client.enqueue('data: reload\\n\\n')\n      } catch {\n        sseClients.delete(client)\n      }\n    }\n  } else {\n    console.error(`\\x1b[31m[dev]\\x1b[0m Build failed (exit ${proc.exitCode})`)\n  }\n  building = false\n}\n\n// ============================================================================\n// File watching — debounced to coalesce rapid saves\n// ============================================================================\n\nlet debounce: Timer | null = null\nfunction onFileChange(_event: string, filename: string | null): void {\n  // Ignore index.html itself (it's the output, not a source)\n  if (filename === 'index.html') return\n  if (debounce) clearTimeout(debounce)\n  debounce = setTimeout(() => {\n    console.log(`\\x1b[90m[dev]\\x1b[0m Change detected${filename ? `: ${filename}` : ''}`)\n    rebuild()\n  }, 150)\n}\n\n// Watch the entire mermaid package for changes (excludes index.html output)\nwatch(ROOT, { recursive: true }, onFileChange)\n\n// ============================================================================\n// HTTP server\n// ============================================================================\n\n// Initial build before starting the server\nawait rebuild()\n\nconsole.log(`\\x1b[36m[dev]\\x1b[0m Server running at \\x1b[1mhttp://localhost:${PORT}\\x1b[0m`)\nconsole.log(`\\x1b[36m[dev]\\x1b[0m Watching for changes in src/ and index.ts\\n`)\n\nBun.serve({\n  port: PORT,\n  async fetch(req) {\n    const url = new URL(req.url)\n\n    // SSE endpoint — browsers connect here to receive reload signals\n    if (url.pathname === '/__dev_events') {\n      let controller!: ReadableStreamDefaultController\n      const stream = new ReadableStream({\n        start(c) {\n          controller = c\n          sseClients.add(controller)\n        },\n        cancel() {\n          sseClients.delete(controller)\n        },\n      })\n      return new Response(stream, {\n        headers: {\n          'Content-Type': 'text/event-stream',\n          'Cache-Control': 'no-cache',\n          Connection: 'keep-alive',\n        },\n      })\n    }\n\n    // Serve index.html with injected live-reload script\n    const file = Bun.file(join(ROOT, 'index.html'))\n    if (!(await file.exists())) {\n      return new Response('index.html not found — build may have failed', { status: 404 })\n    }\n\n    let html = await file.text()\n\n    // Inject live-reload client before </body>\n    html = html.replace(\n      '</body>',\n      `  <script>\n    // Live reload — SSE connection to dev server.\n    // When the server signals a rebuild, the page reloads automatically.\n    // If the connection drops (server restarting), it reconnects with backoff.\n    ;(function() {\n      function connect() {\n        var es = new EventSource('/__dev_events');\n        es.onmessage = function(e) {\n          if (e.data === 'reload') location.reload();\n        };\n        es.onerror = function() {\n          es.close();\n          setTimeout(connect, 500);\n        };\n      }\n      connect();\n    })();\n  </script>\n</body>`,\n    )\n\n    return new Response(html, {\n      headers: { 'Content-Type': 'text/html' },\n    })\n  },\n})\n"
  },
  {
    "path": "index.ts",
    "content": "/**\n * Generates index.html showcasing all beautiful-mermaid rendering capabilities.\n *\n * Usage: bun run index.ts\n *\n * This file doubles as a **visual test suite** — every supported feature,\n * shape, edge type, block construct, and theme variant is exercised by at\n * least one sample. If a rendering change causes regressions, it will be\n * visible in the generated HTML.\n *\n * The generated HTML is **dynamic** — it includes a bundled copy of the\n * mermaid renderer and renders all diagrams client-side in real time,\n * showing progressive loading and per-diagram render timing.\n *\n * Sample definitions live in samples-data.ts (shared with bench.ts).\n */\n\nimport { samples } from './samples-data.ts'\nimport { THEMES } from './src/theme.ts'\nimport { createHighlighter } from 'shiki'\n\n// ============================================================================\n// HTML generation — dynamic version\n//\n// Instead of pre-rendering SVGs at build time, we:\n//   1. Bundle the mermaid renderer for the browser via Bun.build()\n//   2. Embed sample definitions as inline JSON\n//   3. Emit client-side JS that renders each diagram on page load\n// ============================================================================\n\nfunction escapeHtml(text: string): string {\n  return text\n    .replace(/&/g, '&amp;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n    .replace(/\"/g, '&quot;')\n}\n\n/** Convert markdown-style backtick spans to <code> tags in description text. */\nfunction formatDescription(text: string): string {\n  return text.replace(/`([^`]+)`/g, '<code>$1</code>')\n}\n\n/** Human-readable labels for theme keys */\nconst THEME_LABELS: Record<string, string> = {\n  'zinc-dark': 'Zinc Dark',\n  'tokyo-night': 'Tokyo Night',\n  'tokyo-night-storm': 'Tokyo Storm',\n  'tokyo-night-light': 'Tokyo Light',\n  'catppuccin-mocha': 'Catppuccin',\n  'catppuccin-latte': 'Latte',\n  'nord': 'Nord',\n  'nord-light': 'Nord Light',\n  'dracula': 'Dracula',\n  'github-light': 'GitHub',\n  'github-dark': 'GitHub Dark',\n  'solarized-light': 'Solarized',\n  'solarized-dark': 'Solar Dark',\n  'one-dark': 'One Dark',\n}\n\nasync function generateHtml(): Promise<string> {\n  // Step 0: Create Shiki highlighter for mermaid syntax highlighting in source panels.\n  // We use 'github-light' as the base theme — its hex colors get overridden by CSS\n  // color-mix() rules derived from --t-fg / --t-bg so tokens adapt to any theme.\n  const highlighter = await createHighlighter({\n    langs: ['mermaid'],\n    themes: ['github-light'],\n  })\n\n  // Step 1: Bundle the mermaid renderer for the browser\n  const buildResult = await Bun.build({\n    entrypoints: [new URL('./src/browser.ts', import.meta.url).pathname],\n    target: 'browser',\n    format: 'esm',\n    minify: true,\n  })\n  if (!buildResult.success) {\n    console.error('Bundle build failed:', buildResult.logs)\n    process.exit(1)\n  }\n  const bundleJs = await buildResult.outputs[0]!.text()\n  console.log(`Browser bundle: ${(bundleJs.length / 1024).toFixed(1)} KB`)\n\n  // Step 2: Build sample JSON (only serializable fields needed by client)\n  const samplesJson = JSON.stringify(samples.map(s => ({\n    title: s.title,\n    description: s.description,\n    source: s.source,\n    category: s.category ?? 'Other',\n    options: s.options ?? {},\n  })))\n\n  // Step 3: Group samples by category for TOC (done at build time since it's static)\n  const categories = new Map<string, number[]>()\n  samples.forEach((sample, i) => {\n    const cat = sample.category ?? 'Other'\n    if (!categories.has(cat)) categories.set(cat, [])\n    categories.get(cat)!.push(i)\n  })\n\n  const categoryBadgeColors: Record<string, string> = {\n    Flowchart: '#3b82f6',\n    State: '#8b5cf6',\n    Sequence: '#10b981',\n    Class: '#f59e0b',\n    ER: '#ef4444',\n    'XY Chart': '#f97316',\n    'Theme Showcase': '#06b6d4',\n  }\n\n  // Map category names to the title prefixes they use, so we can strip duplicates in the ToC\n  const categoryPrefixes: Record<string, string> = {\n    'State': 'State: ',\n    'Sequence': 'Sequence: ',\n    'Class': 'Class: ',\n    'ER': 'ER: ',\n    'XY Chart': 'XY: ',\n    'Theme Showcase': 'Theme: ',\n  }\n\n  // Build mapping from original index to display number (excluding Hero samples)\n  const heroCount = samples.filter(s => s.category === 'Hero').length\n  const displayNum = (i: number) => i + 1 - heroCount\n\n  const tocSections = [...categories.entries()]\n    .filter(([cat]) => cat !== 'Hero') // Skip Hero from TOC\n    .map(([cat, indices]) => {\n    const badgeColor = categoryBadgeColors[cat] ?? '#71717a'\n    const prefix = categoryPrefixes[cat]\n    const items = indices.map(i => {\n      let title = samples[i]!.title\n      // Strip the category prefix from the title since it's already under the category heading\n      if (prefix && title.startsWith(prefix)) title = title.slice(prefix.length)\n      return `<li><a href=\"#sample-${i}\"><span class=\"toc-num\">${displayNum(i)}.</span> ${escapeHtml(title)}</a></li>`\n    }).join('\\n            ')\n    return `\n        <div class=\"toc-category\">\n          <h3>${escapeHtml(cat)} (${indices.length} samples)</h3>\n          <ol start=\"${displayNum(indices[0]!)}\">\n            ${items}\n          </ol>\n        </div>`\n  }).join('\\n')\n\n  // Step 3b: Build theme selector pills (build-time so we include swatches)\n  // Only show Default, Dracula, and Solarized inline; rest go in \"More\" dropdown\n  const VISIBLE_THEMES = new Set(['dracula', 'solarized-light'])\n\n  function buildThemePill(key: string, colors: { bg: string; fg: string }, active = false): string {\n    const isDark = parseInt(colors.bg.replace('#', '').slice(0, 2), 16) < 0x80\n    const shadow = isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'\n    const label = key === '' ? 'Default' : (THEME_LABELS[key] ?? key)\n    const activeClass = active ? ' active' : ''\n    return `<button class=\"theme-pill shadow-minimal${activeClass}\" data-theme=\"${key}\"><span class=\"theme-swatch\" style=\"background:${colors.bg};box-shadow:inset 0 0 0 1px ${shadow}\"></span>${escapeHtml(label)}</button>`\n  }\n\n  const themeEntries = Object.entries(THEMES)\n  // Visible inline pills: Default + Dracula + Solarized\n  const visiblePills = [\n    '<button class=\"theme-pill shadow-minimal active\" data-theme=\"\"><span class=\"theme-swatch\" style=\"background:#FFFFFF;box-shadow:inset 0 0 0 1px rgba(0,0,0,0.1)\"></span>Default</button>',\n    ...themeEntries\n      .filter(([key]) => VISIBLE_THEMES.has(key))\n      .map(([key, colors]) => buildThemePill(key, colors)),\n  ]\n  // All themes go in the dropdown (including Default, Dracula, Solarized)\n  const allDropdownPills = [\n    buildThemePill('', { bg: '#FFFFFF', fg: '#27272A' }, true),\n    ...themeEntries.map(([key, colors]) => buildThemePill(key, colors)),\n  ]\n  const totalThemes = allDropdownPills.length\n\n  const themePillsHtml = `\n    <div class=\"theme-pills-inline\">\n      ${visiblePills.join('\\n      ')}\n    </div>\n    <div class=\"theme-more-wrapper\">\n      <button class=\"theme-pill shadow-minimal\" id=\"theme-more-btn\">${totalThemes} Themes</button>\n      <div class=\"theme-more-dropdown shadow-modal-small\" id=\"theme-more-dropdown\">\n        ${allDropdownPills.join('\\n        ')}\n      </div>\n    </div>`\n\n  // Step 4: Pre-highlight all sample sources with Shiki (build-time only, zero runtime cost).\n  // The mermaid TextMate grammar requires a fenced code block prefix to tokenize properly\n  // (see https://github.com/shikijs/shiki/issues/973), so we wrap each source with\n  // ```mermaid ... ``` and then strip those fence lines from the output HTML.\n  // Source panels always use github-dark — Shiki's inline colors are used directly.\n  const highlightedSources = samples.map(sample => {\n    const fenced = '```mermaid\\n' + sample.source.trim() + '\\n```'\n    const html = highlighter.codeToHtml(fenced, {\n      lang: 'mermaid',\n      theme: 'github-light',\n    })\n    // Strip the first line (```mermaid) and last line (```) from the output\n    return html.replace(\n      /(<code>)<span class=\"line\">.*?<\\/span>\\n/,  // first line\n      '$1'\n    ).replace(\n      /\\n<span class=\"line\">.*?<\\/span>(<\\/code>)/, // last line\n      '$1'\n    )\n  })\n\n  // Step 5: Build sample card HTML shells (SVG + ASCII are empty, filled client-side)\n  // data-sample-bg stores the per-sample background for \"Default\" mode restoration.\n  // Hero samples get special full-width SVG-only treatment and are placed before \"Samples\" heading.\n  const heroCards: string[] = []\n  const regularCards: string[] = []\n\n  samples.forEach((sample, i) => {\n    const bg = sample.options?.bg ?? ''\n    const isHero = sample.category === 'Hero'\n\n    if (isHero) {\n      // Hero sample: full-width SVG only, no header/source/ASCII panels\n      heroCards.push(`\n    <section class=\"sample sample-hero\" id=\"sample-${i}\">\n      <div class=\"hero-diagram-panel\" id=\"svg-panel-${i}\" data-sample-bg=\"${bg}\">\n        <div class=\"svg-container\" id=\"svg-${i}\">\n          <div class=\"loading-spinner\"></div>\n        </div>\n      </div>\n    </section>`)\n    } else {\n      regularCards.push(`\n    <section class=\"sample\" id=\"sample-${i}\">\n      <div class=\"sample-header\">\n        <h2>${escapeHtml(sample.title)}</h2>\n        <p class=\"description\">${formatDescription(sample.description)}</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\" id=\"source-panel-${i}\">\n          ${highlightedSources[i]}\n          ${sample.options ? `<div class=\"options\"><strong>Options:</strong> <code>${escapeHtml(JSON.stringify(sample.options))}</code></div>` : ''}\n          <button class=\"edit-btn\" data-sample=\"${i}\">Edit</button>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-${i}\" data-sample-bg=\"${bg}\">\n          <div class=\"svg-container\" id=\"svg-${i}\">\n            <div class=\"loading-spinner\"></div>\n          </div>\n        </div>\n        <div class=\"ascii-panel\" id=\"ascii-panel-${i}\">\n          <pre class=\"ascii-output\"><code id=\"ascii-${i}\">Rendering\\u2026</code></pre>\n        </div>\n      </div>\n    </section>`)\n    }\n  })\n\n  const heroCardsHtml = heroCards.join('\\n')\n  const regularCardsHtml = regularCards.join('\\n')\n\n  // ============================================================================\n  // Step 5: Assemble full HTML\n  // ============================================================================\n\n  return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n  <meta name=\"theme-color\" id=\"theme-color-meta\" content=\"#f9f9fa\" />\n  <title>Beautiful Mermaid — Mermaid Rendering, Made Beautiful</title>\n  <meta name=\"description\" content=\"Open source diagram rendering library built for the AI era. Ultra-fast, fully themeable, outputs to SVG and ASCII. Supports Flowchart, State, Sequence, Class, and ER diagrams.\" />\n  <link rel=\"icon\" type=\"image/svg+xml\" href=\"/mermaid/favicon.svg\" />\n  <link rel=\"icon\" type=\"image/x-icon\" href=\"/mermaid/favicon.ico\" />\n  <link rel=\"apple-touch-icon\" href=\"/mermaid/apple-touch-icon.png\" />\n  <meta property=\"og:title\" content=\"Beautiful Mermaid\" />\n  <meta property=\"og:description\" content=\"Open source diagram rendering library built for the AI era. Ultra-fast, fully themeable, outputs to SVG and ASCII.\" />\n  <meta property=\"og:image\" content=\"https://agents.craft.do/mermaid/og-image.png\" />\n  <meta property=\"og:type\" content=\"website\" />\n  <meta property=\"og:url\" content=\"https://agents.craft.do/mermaid\" />\n  <meta name=\"twitter:card\" content=\"summary_large_image\" />\n  <meta name=\"twitter:title\" content=\"Beautiful Mermaid\" />\n  <meta name=\"twitter:description\" content=\"Mermaid rendering, made beautiful. Ultra-fast, fully themeable, outputs to SVG and ASCII.\" />\n  <meta name=\"twitter:image\" content=\"https://agents.craft.do/mermaid/og-image.png\" />\n  <!-- Plausible Analytics -->\n  <script defer data-domain=\"agents.craft.do/mermaid\" src=\"https://plausible.io/js/script.js\"></script>\n  <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n  <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\n  <link href=\"https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap\" rel=\"stylesheet\" />\n  <style>\n    /* -- Reset & base -- */\n    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n\n    /* -----------------------------------------------------------------\n     * CSS custom property theming\n     *\n     * --t-bg and --t-fg drive the entire page color scheme.\n     * All other colors are derived via color-mix(). When a theme is\n     * selected from the pill bar, JS updates these two variables on\n     * <body> — and the whole page adapts instantly.\n     * ----------------------------------------------------------------- */\n    body {\n      --t-bg: #FFFFFF;\n      --t-fg: #27272A;\n      --t-accent: #3b82f6;\n      --foreground-rgb: 39, 39, 42;\n      --accent-rgb: 59, 130, 246;\n      --shadow-border-opacity: 0.08;\n      --shadow-blur-opacity: 0.06;\n      --theme-bar-bg: #f9f9fa;  /* Mixed bg for theme bar and top gradient — updated by JS on theme change */\n\n      font-family: 'Geist', system-ui, -apple-system, sans-serif;\n      background: color-mix(in srgb, var(--t-fg) 4%, var(--t-bg));\n      color: var(--t-fg);\n      line-height: 1.6;\n      margin: 0;\n      transition: background 0.2s, color 0.2s;\n      -webkit-font-smoothing: antialiased;\n      -moz-osx-font-smoothing: grayscale;\n    }\n    .content-wrapper {\n      max-width: 1440px;\n      margin: 0 auto;\n      padding: 2rem;\n      padding-top: 0;\n    }\n    @media (max-width: 768px) {\n      .content-wrapper {\n        padding: 1rem;\n        padding-top: 0;\n      }\n    }\n    @media (min-width: 1000px) {\n      .content-wrapper {\n        padding: 3rem;\n        padding-top: 0;\n      }\n    }\n\n    /* -- Scroll fade gradients (GPU accelerated) -- */\n    body::before,\n    body::after {\n      content: '';\n      position: fixed;\n      left: 0;\n      right: 0;\n      height: 64px;\n      pointer-events: none;\n      z-index: 1000;\n      will-change: transform;\n    }\n    body::before {\n      top: 0;\n      background: linear-gradient(to bottom, var(--theme-bar-bg) 0%, transparent 100%);\n    }\n    body::after {\n      bottom: 0;\n      background: linear-gradient(to top, var(--theme-bar-bg) 0%, transparent 100%);\n    }\n\n    /* -- Theme selector bar (full-width, sits outside .content-wrapper) -- */\n    .theme-bar {\n      position: sticky;\n      top: 0;\n      z-index: 1001;\n      background: transparent;\n      padding: 0.5rem 2rem;\n      display: flex;\n      align-items: center;\n      gap: 0.75rem;\n      overflow: visible;\n    }\n    @media (max-width: 768px) {\n      .theme-bar {\n        padding: 0.5rem 1rem;\n      }\n    }\n    .theme-label {\n      font-size: 0.7rem;\n      font-weight: 600;\n      text-transform: uppercase;\n      letter-spacing: 0.06em;\n      color: color-mix(in srgb, var(--t-fg) 35%, var(--t-bg));\n      white-space: nowrap;\n    }\n    .theme-pills {\n      display: flex;\n      gap: 0.3rem;\n      overflow: visible;\n      padding: 4px;\n      margin: -4px;\n      margin-left: auto;\n      position: relative;\n      z-index: 2;\n    }\n    .theme-pills-inline {\n      display: flex;\n      gap: 0.3rem;\n    }\n    /* Hide inline theme pills on smaller screens, show only \"15 Themes\" dropdown */\n    @media (max-width: 1024px) {\n      .theme-pills-inline {\n        display: none;\n      }\n    }\n    .theme-pill {\n      display: flex;\n      align-items: center;\n      height: 30px;\n      gap: 8px;\n      padding: 0 14px 0 12px;\n      border: none;\n      border-radius: 8px;\n      background: color-mix(in srgb, var(--t-bg) 97%, var(--t-fg));\n      color: color-mix(in srgb, var(--t-fg) 80%, var(--t-bg));\n      font-size: 12px;\n      font-weight: 500;\n      font-family: inherit;\n      cursor: pointer;\n      white-space: nowrap;\n      transition: color 0.15s, background 0.15s, box-shadow 0.2s, transform 0.1s;\n    }\n    .theme-pill:hover {\n      color: var(--t-fg);\n      background: color-mix(in srgb, var(--t-bg) 92%, var(--t-fg));\n    }\n    .theme-pill.active {\n      color: var(--t-fg);\n      background: var(--t-bg);\n      font-weight: 600;\n    }\n    .theme-pill:active {\n      transform: translateY(0.5px);\n    }\n    .theme-swatch {\n      display: inline-block;\n      width: 14px;\n      height: 14px;\n      border-radius: 50%;\n      flex-shrink: 0;\n    }\n\n    /* -- \"More\" dropdown for overflow themes -- */\n    .theme-more-wrapper {\n      position: relative;\n    }\n    .theme-more-dropdown {\n      display: none;\n      position: absolute;\n      top: calc(100% + 6px);\n      right: 0;\n      background: var(--t-bg);\n      border-radius: 12px;\n      padding: 6px;\n      flex-direction: column;\n      gap: 2px;\n      min-width: 160px;\n      z-index: 1002;\n    }\n    .theme-more-dropdown.open {\n      display: flex;\n    }\n    .theme-more-dropdown .theme-pill {\n      width: 100%;\n      justify-content: flex-start;\n      background: transparent;\n      box-shadow: none;\n    }\n    .theme-more-dropdown .theme-pill:hover {\n      background: color-mix(in srgb, var(--t-bg) 92%, var(--t-fg));\n    }\n    /* Active pill in dropdown gets bg + shadow-minimal (same as inline pills) */\n    .theme-more-dropdown .theme-pill.active,\n    .theme-more-dropdown .theme-pill.shadow-tinted {\n      background: var(--t-bg);\n      box-shadow:\n        rgba(0, 0, 0, 0) 0px 0px 0px 0px,\n        rgba(0, 0, 0, 0) 0px 0px 0px 0px,\n        rgba(var(--foreground-rgb), 0.06) 0px 0px 0px 1px,\n        rgba(0, 0, 0, var(--shadow-blur-opacity)) 0px 1px 1px -0.5px,\n        rgba(0, 0, 0, var(--shadow-blur-opacity)) 0px 3px 3px -1.5px;\n    }\n\n    /* -- Brand badge (left-aligned in theme bar) -- */\n    .brand-badge-wrapper {\n      position: relative;\n    }\n    .brand-badge {\n      display: flex;\n      align-items: center;\n      height: 30px;\n      gap: 6px;\n      padding: 0 12px;\n      border: none;\n      border-radius: 8px;\n      background: color-mix(in srgb, var(--t-bg) 97%, var(--t-fg));\n      color: color-mix(in srgb, var(--t-fg) 80%, var(--t-bg));\n      font-size: 12px;\n      font-weight: 400;\n      font-family: inherit;\n      white-space: nowrap;\n      cursor: pointer;\n      transition: color 0.15s, background 0.15s, box-shadow 0.2s, transform 0.1s;\n    }\n    .brand-badge:hover {\n      color: var(--t-fg);\n      background: color-mix(in srgb, var(--t-bg) 92%, var(--t-fg));\n    }\n    .brand-badge.active {\n      color: var(--t-fg);\n      background: var(--t-bg);\n    }\n    .brand-badge:active {\n      transform: translateY(0.5px);\n    }\n    .brand-logo {\n      width: 14px;\n      height: 14px;\n      flex-shrink: 0;\n    }\n\n    /* -- Brand dropdown -- */\n    .brand-dropdown {\n      display: none;\n      position: absolute;\n      top: calc(100% + 6px);\n      left: 0;\n      background: var(--t-bg);\n      border-radius: 12px;\n      padding: 6px;\n      flex-direction: column;\n      gap: 2px;\n      width: max-content;\n      z-index: 1002;\n    }\n    .brand-dropdown.open {\n      display: flex;\n    }\n    .brand-dropdown-item {\n      display: flex;\n      align-items: center;\n      height: 34px;\n      gap: 8px;\n      padding: 0 12px;\n      border-radius: 8px;\n      background: transparent;\n      color: var(--t-fg);\n      text-decoration: none;\n      font-size: 13px;\n      font-weight: 400;\n      transition: background 0.15s;\n    }\n    .brand-dropdown-item:hover {\n      background: color-mix(in srgb, var(--t-bg) 92%, var(--t-fg));\n    }\n    .brand-dropdown-logo {\n      flex-shrink: 0;\n    }\n    .brand-dropdown-item .tagline {\n      color: color-mix(in srgb, var(--t-fg) 50%, var(--t-bg));\n      font-weight: 300;\n      margin-left: 0.25rem;\n    }\n    .brand-dropdown-item .tagline::before {\n      content: '·';\n      margin-right: 0.25rem;\n    }\n\n    /* -- Contents button (screen-centered via absolute positioning) -- */\n    .contents-btn {\n      position: absolute;\n      left: 50%;\n      transform: translateX(-50%);\n      display: flex;\n      align-items: center;\n      height: 30px;\n      gap: 6px;\n      padding: 0 12px;\n      border: none;\n      border-radius: 8px;\n      background: color-mix(in srgb, var(--t-bg) 97%, var(--t-fg));\n      color: color-mix(in srgb, var(--t-fg) 80%, var(--t-bg));\n      font-size: 12px;\n      font-weight: 500;\n      font-family: inherit;\n      cursor: pointer;\n      white-space: nowrap;\n      transition: color 0.15s, background 0.15s, box-shadow 0.2s, transform 0.1s;\n    }\n    .contents-btn:hover {\n      color: var(--t-fg);\n      background: color-mix(in srgb, var(--t-bg) 92%, var(--t-fg));\n    }\n    .contents-btn.active {\n      color: var(--t-fg);\n      background: var(--t-bg);\n    }\n    .contents-btn:active {\n      transform: translateX(-50%) translateY(0.5px);\n    }\n    .contents-btn svg {\n      width: 14px;\n      height: 14px;\n      flex-shrink: 0;\n    }\n    /* Hide contents button on smaller screens */\n    @media (max-width: 1024px) {\n      .contents-btn,\n      .mega-menu {\n        display: none !important;\n      }\n    }\n    /* -- Craft shadow + radius utilities -- */\n    .rounded-6px { border-radius: 6px; }\n    .shadow-minimal {\n      box-shadow:\n        rgba(0, 0, 0, 0) 0px 0px 0px 0px,\n        rgba(0, 0, 0, 0) 0px 0px 0px 0px,\n        rgba(var(--foreground-rgb), 0.06) 0px 0px 0px 1px,\n        rgba(0, 0, 0, var(--shadow-blur-opacity)) 0px 1px 1px -0.5px,\n        rgba(0, 0, 0, var(--shadow-blur-opacity)) 0px 3px 3px -1.5px;\n    }\n    .shadow-modal-small {\n      box-shadow:\n        rgba(0, 0, 0, 0) 0px 0px 0px 0px,\n        rgba(0, 0, 0, 0) 0px 0px 0px 0px,\n        rgba(var(--foreground-rgb), 0.06) 0px 0px 0px 1px,\n        rgba(0, 0, 0, calc(var(--shadow-blur-opacity) * 0.67)) 0px 1px 1px -0.5px,\n        rgba(0, 0, 0, calc(var(--shadow-blur-opacity) * 0.67)) 0px 3px 3px 0px,\n        rgba(0, 0, 0, calc(var(--shadow-blur-opacity) * 0.33)) 0px 6px 6px 0px,\n        rgba(0, 0, 0, calc(var(--shadow-blur-opacity) * 0.33)) 0px 12px 12px 0px,\n        rgba(0, 0, 0, calc(var(--shadow-blur-opacity) * 0.33)) 0px 24px 24px 0px;\n    }\n    .shadow-tinted {\n      --shadow-color: 0, 0, 0;\n      box-shadow:\n        rgba(var(--shadow-color), 0) 0px 0px 0px 0px,\n        rgba(var(--shadow-color), 0) 0px 0px 0px 0px,\n        rgba(var(--shadow-color), calc(var(--shadow-border-opacity) * 1.5)) 0px 0px 0px 1px,\n        rgba(var(--shadow-color), var(--shadow-border-opacity)) 0px 1px 1px -0.5px,\n        rgba(var(--shadow-color), var(--shadow-blur-opacity)) 0px 3px 3px -1.5px,\n        rgba(var(--shadow-color), calc(var(--shadow-blur-opacity) * 0.67)) 0px 6px 6px -3px;\n    }\n\n    /* -- Mega menu dropdown (x-centered with Contents button) -- */\n    .mega-menu {\n      display: none;\n      position: absolute;\n      top: calc(100% + 6px);\n      left: 50%;\n      transform: translateX(-50%);\n      max-width: min(1180px, calc(100vw - 2rem));\n      width: max-content;\n      background: var(--t-bg);\n      border-radius: 12px;\n      padding: 1.5rem 2rem;\n      max-height: 70vh;\n      overflow-y: auto;\n      overflow-x: hidden;\n      z-index: 998;\n    }\n    .mega-menu.open {\n      display: block;\n    }\n    .toc-grid {\n      columns: 4;\n      column-gap: 2rem;\n    }\n    @media (max-width: 1200px) {\n      .toc-grid {\n        columns: 3;\n      }\n    }\n    .toc-category {\n      display: inline-block;\n      width: 100%;\n      margin: 0;\n      padding-bottom: 1rem;\n    }\n    .toc-category h3 {\n      font-size: 0.85rem;\n      font-weight: 600;\n      margin: 0 0 0.5rem 0;\n      color: var(--t-fg);\n      white-space: nowrap;\n      overflow: hidden;\n      text-overflow: ellipsis;\n    }\n    .toc-category ol {\n      padding: 0;\n      margin: 0;\n      list-style: none;\n      font-size: 0.8rem;\n    }\n    .toc-category li {\n      margin-bottom: 0.15rem;\n      white-space: nowrap;\n      overflow: hidden;\n      text-overflow: ellipsis;\n    }\n    .toc-category a { color: var(--t-fg); text-decoration: none; }\n    .toc-category a:hover { text-decoration: underline; }\n    .toc-num { color: color-mix(in srgb, var(--t-fg) 30%, var(--t-bg)); }\n\n    /* -- Sample card -- */\n    .sample {\n      background: var(--t-bg);\n      margin-bottom: 2rem;\n      overflow: hidden;\n    }\n\n    /* -- Hero sample (full-width SVG showcase, above Samples heading) -- */\n    .sample-hero {\n      margin-bottom: 0;\n      background: transparent;\n    }\n    .hero-diagram-panel {\n      padding: 1rem 0;\n      display: flex;\n      justify-content: center;\n      align-items: center;\n      background: transparent;\n    }\n    .hero-diagram-panel .svg-container {\n      width: 100%;\n      max-width: 100%;\n    }\n    .hero-diagram-panel .svg-container svg {\n      width: 100%;\n      height: auto;\n    }\n\n    .sample-header {\n      padding: 1.25rem 1.5rem;\n      max-width: 48rem;\n      border-bottom: 1px solid color-mix(in srgb, var(--t-fg) 5%, var(--t-bg));\n    }\n    .sample-header h2 {\n      font-size: 1.5rem;\n      font-weight: 500;\n      color: var(--t-fg);\n    }\n    .description {\n      color: color-mix(in srgb, var(--t-fg) 50%, var(--t-bg));\n      font-size: 1rem;\n      font-weight: 400;\n      margin-top: 0.1rem;\n    }\n    .description code {\n      font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;\n      font-size: 0.875em;\n      color: color-mix(in srgb, var(--t-fg) 85%, var(--t-bg));\n      background: color-mix(in srgb, var(--t-fg) 6%, var(--t-bg));\n      padding: 0.15rem 0.4rem;\n      border-radius: 3px;\n    }\n\n    .sample-content {\n      display: grid;\n      grid-template-columns:\n        minmax(200px, 1fr)\n        minmax(250px, 2fr)\n        minmax(250px, 2fr);\n      min-height: 420px;\n    }\n    @media (max-width: 900px) {\n      .sample-content { grid-template-columns: 1fr; }\n      .ascii-panel { border-left: none !important; border-top: 1px solid color-mix(in srgb, var(--t-fg) 5%, var(--t-bg)) !important; }\n    }\n\n    /* -- Source panel -- */\n    .source-panel {\n      position: relative;\n      padding: 0.75rem 1.5rem;\n      border-right: 1px solid color-mix(in srgb, var(--t-fg) 5%, var(--t-bg));\n      min-width: 0;      /* grid child: allow shrinking below content width */\n      overflow-y: auto;\n    }\n    .source-panel h3 {\n      font-size: 0.8rem;\n      font-weight: 600;\n      text-transform: uppercase;\n      letter-spacing: 0.05em;\n      color: color-mix(in srgb, var(--t-fg) 35%, var(--t-bg));\n      margin-bottom: 0.75rem;\n    }\n    .source-panel pre {\n      padding: 1rem;\n      font-size: 0.8rem;\n      line-height: 1.5;\n      overflow-x: auto;\n      white-space: pre-wrap;\n      word-break: break-word;\n    }\n    .source-panel code {\n      font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;\n    }\n\n    /* -- Shiki syntax highlighting overrides --\n     * Shiki outputs inline style=\"color:#hex\" per token. We override these with\n     * color-mix() rules derived from --t-fg / --t-bg so tokens adapt to any theme.\n     * The hex values below are from the github-light Shiki theme used at build time. */\n    .source-panel {\n      background: color-mix(in srgb, var(--t-fg) 1.5%, var(--t-bg));\n    }\n    .source-panel .shiki {\n      background: transparent !important;\n      padding: 0.5rem 0;\n      font-size: 0.8rem;\n      line-height: 1.5;\n      overflow-x: auto;\n      white-space: pre-wrap;\n      word-break: break-word;\n      margin: 0;\n    }\n    .source-panel .shiki code {\n      background: transparent;\n      font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;\n    }\n    /* Default text */\n    .source-panel .shiki,\n    .source-panel .shiki span[style*=\"#24292e\"],\n    .source-panel .shiki span[style*=\"#24292E\"] {\n      color: color-mix(in srgb, var(--t-fg) 70%, var(--t-bg)) !important;\n    }\n    /* Keywords: graph, subgraph, end, participant, -->, classDef, brackets */\n    .source-panel .shiki span[style*=\"#D73A49\"],\n    .source-panel .shiki span[style*=\"#d73a49\"] {\n      color: color-mix(in srgb, var(--t-fg) 90%, var(--t-bg)) !important;\n      font-weight: 500;\n    }\n    /* Direction labels, subgraph names */\n    .source-panel .shiki span[style*=\"#6F42C1\"],\n    .source-panel .shiki span[style*=\"#6f42c1\"] {\n      color: color-mix(in srgb, var(--t-fg) 65%, var(--t-bg)) !important;\n    }\n    /* Node IDs */\n    .source-panel .shiki span[style*=\"#E36209\"],\n    .source-panel .shiki span[style*=\"#e36209\"] {\n      color: color-mix(in srgb, var(--t-fg) 75%, var(--t-bg)) !important;\n    }\n    /* Strings, labels, message text */\n    .source-panel .shiki span[style*=\"#032F62\"],\n    .source-panel .shiki span[style*=\"#032f62\"] {\n      color: color-mix(in srgb, var(--t-fg) 50%, var(--t-bg)) !important;\n    }\n    .options {\n      margin-top: 0.75rem;\n      font-size: 0.8rem;\n      color: color-mix(in srgb, var(--t-fg) 50%, var(--t-bg));\n    }\n    .options code {\n      background: color-mix(in srgb, var(--t-fg) 6%, var(--t-bg));\n      padding: 0.15rem 0.4rem;\n      border-radius: 3px;\n      font-size: 0.75rem;\n    }\n\n    /* -- Edit button (subtle text link, bottom-left of source panel) -- */\n    .edit-btn {\n      position: absolute;\n      bottom: 0.75rem;\n      left: 1.5rem;\n      background: none;\n      border: none;\n      padding: 0;\n      font-size: 0.75rem;\n      font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;\n      color: color-mix(in srgb, var(--t-fg) 35%, var(--t-bg));\n      cursor: pointer;\n      text-decoration: none;\n      transition: color 0.15s;\n    }\n    .edit-btn:hover {\n      color: var(--t-fg);\n      text-decoration: underline;\n    }\n\n    /* -- Edit dialog overlay -- */\n    .edit-overlay {\n      display: none;\n      position: fixed;\n      inset: 0;\n      z-index: 2000;\n      background: rgba(0, 0, 0, 0.4);\n      backdrop-filter: blur(4px);\n      align-items: center;\n      justify-content: center;\n    }\n    .edit-overlay.open { display: flex; }\n    .edit-dialog {\n      background: var(--t-bg);\n      border-radius: 16px;\n      width: min(680px, calc(100vw - 3rem));\n      max-height: calc(100vh - 4rem);\n      display: flex;\n      flex-direction: column;\n      overflow: hidden;\n    }\n    .edit-dialog-header {\n      display: flex;\n      align-items: center;\n      justify-content: space-between;\n      padding: 1rem 1.25rem;\n      border-bottom: 1px solid color-mix(in srgb, var(--t-fg) 8%, var(--t-bg));\n    }\n    .edit-dialog-title {\n      font-size: 0.95rem;\n      font-weight: 600;\n      color: var(--t-fg);\n    }\n    .edit-dialog-close {\n      background: none;\n      border: none;\n      font-size: 1.25rem;\n      color: color-mix(in srgb, var(--t-fg) 40%, var(--t-bg));\n      cursor: pointer;\n      padding: 0 0.25rem;\n      line-height: 1;\n    }\n    .edit-dialog-close:hover { color: var(--t-fg); }\n    .edit-dialog-textarea {\n      flex: 1;\n      min-height: 300px;\n      max-height: 60vh;\n      margin: 0;\n      padding: 1rem 1.25rem;\n      border: none;\n      outline: none;\n      resize: none;\n      background: color-mix(in srgb, var(--t-fg) 2%, var(--t-bg));\n      color: var(--t-fg);\n      font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;\n      font-size: 0.8rem;\n      line-height: 1.5;\n      white-space: pre;\n      tab-size: 2;\n    }\n    .edit-dialog-footer {\n      display: flex;\n      justify-content: flex-end;\n      gap: 0.5rem;\n      padding: 0.75rem 1.25rem;\n      border-top: 1px solid color-mix(in srgb, var(--t-fg) 8%, var(--t-bg));\n    }\n    .edit-dialog-btn {\n      padding: 0.5rem 1rem;\n      border-radius: 8px;\n      border: none;\n      font-size: 0.8rem;\n      font-weight: 500;\n      font-family: inherit;\n      cursor: pointer;\n      transition: opacity 0.15s;\n    }\n    .edit-dialog-btn:hover { opacity: 0.85; }\n    .edit-dialog-cancel {\n      background: color-mix(in srgb, var(--t-fg) 8%, var(--t-bg));\n      color: var(--t-fg);\n    }\n    .edit-dialog-save {\n      background: var(--t-fg);\n      color: var(--t-bg);\n    }\n\n    /* -- SVG panel -- */\n    .svg-panel {\n      padding: 1.25rem 1.5rem;\n      display: flex;\n      flex-direction: column;\n      min-width: 0;      /* grid child: allow shrinking below content width */\n      /* Background set dynamically: matches the SVG --bg in default mode,\n         or the global theme bg when a theme is active. */\n    }\n    .svg-panel h3 {\n      font-size: 0.8rem;\n      font-weight: 600;\n      text-transform: uppercase;\n      letter-spacing: 0.05em;\n      color: color-mix(in srgb, var(--t-fg) 35%, var(--t-bg));\n      margin-bottom: 0.75rem;\n    }\n    .svg-container {\n      flex: 1;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      min-height: 0;     /* flex child: allow shrinking to fit */\n    }\n    .svg-container svg {\n      max-width: 100%;\n      max-height: 100%;  /* scale down to fit both axes */\n      height: auto;\n    }\n\n    /* -- ASCII panel -- */\n    .ascii-panel {\n      padding: 1.25rem 1.5rem;\n      border-left: 1px solid color-mix(in srgb, var(--t-fg) 5%, var(--t-bg));\n      display: flex;\n      flex-direction: column;\n      align-items: center;\n      justify-content: center;\n      min-width: 0;      /* grid child: allow shrinking below content width */\n    }\n    .ascii-panel h3 {\n      font-size: 0.8rem;\n      font-weight: 600;\n      text-transform: uppercase;\n      letter-spacing: 0.05em;\n      color: color-mix(in srgb, var(--t-fg) 35%, var(--t-bg));\n      margin-bottom: 0.75rem;\n    }\n    .ascii-output {\n      padding: 1rem;\n      font-size: 0.7rem;\n      line-height: 1.3;\n      overflow-x: auto;   /* horizontal scroll only */\n      overflow-y: hidden;  /* scale to height, no vertical scroll */\n      white-space: pre;\n      flex: 1;\n      max-width: 100%;\n      font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;\n    }\n\n    /* -- Loading spinner -- */\n    .loading-spinner {\n      width: 24px;\n      height: 24px;\n      border: 2px solid color-mix(in srgb, var(--t-fg) 12%, var(--t-bg));\n      border-top-color: color-mix(in srgb, var(--t-fg) 35%, var(--t-bg));\n      border-radius: 50%;\n      animation: spin 0.8s linear infinite;\n    }\n    @keyframes spin {\n      to { transform: rotate(360deg); }\n    }\n\n    /* -- Timing badge -- */\n    .timing {\n      font-size: 0.7rem;\n      font-weight: 400;\n      color: color-mix(in srgb, var(--t-fg) 30%, var(--t-bg));\n      margin-left: 0.5rem;\n      text-transform: none;\n      letter-spacing: normal;\n    }\n\n    /* -- Error state -- */\n    .render-error {\n      color: #ef4444;\n      font-size: 0.85rem;\n      font-family: 'JetBrains Mono', monospace;\n      white-space: pre-wrap;\n      word-break: break-word;\n    }\n\n    /* -- Hero header section -- */\n    .hero-header {\n      max-width: 1440px;\n      margin: 0 auto;\n      padding: 6rem 2rem 2rem;\n      text-align: left;\n    }\n    @media (min-width: 1000px) {\n      .hero-header {\n        padding: 6rem 3rem 2rem;\n      }\n    }\n    .hero-title {\n      font-size: 2.25rem;\n      font-weight: 800;\n      line-height: 1.2;\n      margin: 0 0 0.25rem;\n      color: var(--t-fg);\n    }\n    .hero-tagline {\n      font-size: 1rem;\n      font-weight: 500;\n      color: color-mix(in srgb, var(--t-fg) 50%, var(--t-bg));\n      margin: 0 0 1rem;\n    }\n    .hero-description {\n      font-size: 0.95rem;\n      line-height: 1.6;\n      color: color-mix(in srgb, var(--t-fg) 70%, var(--t-bg));\n      margin: 0 0 1.5rem;\n      max-width: 680px;\n    }\n    .hero-description a {\n      color: var(--t-fg);\n      text-decoration: underline;\n      text-underline-offset: 2px;\n    }\n    .hero-description a:hover {\n      color: var(--t-accent);\n    }\n    .hero-buttons {\n      display: flex;\n      flex-wrap: wrap;\n      align-items: flex-start;\n      gap: 0.5rem;\n    }\n    @media (max-width: 768px) {\n      .hero-buttons {\n        flex-direction: column;\n      }\n    }\n    .hero-btn {\n      display: inline-flex;\n      align-items: center;\n      gap: 0.5rem;\n      padding: 0.75rem 1.25rem;\n      font-size: 0.875rem;\n      font-weight: 500;\n      border-radius: 12px;\n      text-decoration: none;\n      transition: opacity 0.15s, transform 0.1s;\n      cursor: pointer;\n      border: none;\n      font-family: inherit;\n    }\n    .hero-btn:hover {\n      opacity: 0.9;\n    }\n    .hero-btn:active {\n      transform: translateY(0.5px);\n    }\n    .hero-btn-primary {\n      background: var(--t-fg);\n      color: var(--t-bg);\n      box-shadow:\n        rgba(0, 0, 0, 0) 0px 0px 0px 0px,\n        rgba(0, 0, 0, 0) 0px 0px 0px 0px,\n        rgba(0, 0, 0, 0.1) 0px 1px 3px 0px,\n        rgba(0, 0, 0, 0.1) 0px 1px 2px -1px;\n    }\n    .hero-btn-secondary {\n      background: var(--t-bg);\n      color: var(--t-fg);\n      box-shadow:\n        rgba(0, 0, 0, 0) 0px 0px 0px 0px,\n        rgba(0, 0, 0, 0) 0px 0px 0px 0px,\n        rgba(var(--foreground-rgb), 0.06) 0px 0px 0px 1px,\n        rgba(0, 0, 0, 0.1) 0px 1px 3px 0px,\n        rgba(0, 0, 0, 0.1) 0px 1px 2px -1px;\n    }\n    .hero-btn svg {\n      width: 16px;\n      height: 16px;\n    }\n    .hero-description code {\n      font-family: 'JetBrains Mono', 'Fira Code', monospace;\n      font-size: 0.85em;\n      background: color-mix(in srgb, var(--t-fg) 8%, var(--t-bg));\n      padding: 0.15em 0.4em;\n      border-radius: 4px;\n    }\n\n    /* -- Hero meta (below buttons) -- */\n    .hero-meta {\n      margin-top: 1.25rem;\n    }\n    .hero-meta .meta {\n      font-size: 0.85rem;\n      color: color-mix(in srgb, var(--t-fg) 40%, var(--t-bg));\n      margin: 0.15rem 0;\n    }\n\n    .hero-meta .meta a {\n      color: color-mix(in srgb, var(--t-fg) 50%, var(--t-bg));\n      text-decoration: underline;\n      text-underline-offset: 2px;\n    }\n    .hero-meta .meta a:hover {\n      color: var(--t-fg);\n    }\n\n    /* -- Section title -- */\n    .section-title {\n      font-size: 1.875rem;\n      font-weight: 800;\n      line-height: 1.2;\n      margin: 0;\n      padding: 2.5rem 0 1.5rem;\n      color: var(--t-fg);\n    }\n\n    /* -- Footer -- */\n    .site-footer {\n      position: relative;\n      z-index: 10;\n      padding: 1.5rem 2rem 2rem;\n      display: flex;\n      align-items: center;\n      justify-content: space-between;\n      max-width: 1440px;\n      width: 100%;\n      margin: 0 auto;\n      font-size: 12px;\n      color: color-mix(in srgb, var(--t-fg) 50%, var(--t-bg));\n    }\n    @media (min-width: 1000px) {\n      .site-footer {\n        padding: 1.5rem 3rem 2rem;\n      }\n    }\n    .footer-links {\n      display: flex;\n      align-items: center;\n      gap: 1rem;\n    }\n    .footer-links a {\n      color: color-mix(in srgb, var(--t-fg) 50%, var(--t-bg));\n      text-decoration: none;\n      transition: color 0.15s;\n    }\n    .footer-links a:hover {\n      color: var(--t-fg);\n    }\n    .footer-links svg {\n      width: 1.25rem;\n      height: 1.25rem;\n      display: block;\n    }\n  </style>\n</head>\n<body>\n  <!-- Safari 26+ reads title bar color from the topmost fixed element's background.\n       This invisible 1px div provides a real DOM element for Safari to detect. -->\n  <div id=\"safari-theme-color\" style=\"position:fixed;top:0;left:0;right:0;height:1px;background:var(--theme-bar-bg);z-index:9999;pointer-events:none;\"></div>\n\n  <!-- Navigation + theme bar -->\n  <div class=\"theme-bar\" id=\"theme-bar\">\n    <div class=\"brand-badge-wrapper\">\n      <button class=\"brand-badge shadow-minimal\" id=\"brand-badge-btn\"><svg class=\"brand-logo\" viewBox=\"0 0 299 300\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M137.879,300.001 L137.875,300.001 C62.3239,300.001 0.966154,239.232 0.0117188,163.908 L2.56478e-10,162.126 L137.879,162.126 L137.879,300.001 Z\" fill=\"#06367A\"/><path d=\"M137.879,0 L137.875,0 C61.729,0 0,61.729 0,137.875 L0,137.878 L137.879,137.878 L137.879,0 Z\" fill=\"#FF51FF\"/><path d=\"M160.558,137.883 L160.561,137.883 C236.707,137.883 298.436,76.1537 298.436,0.00758561 L298.436,0.00562043 L160.558,0.00562043 L160.558,137.883 Z\" fill=\"#007CFF\"/><path d=\"M160.558,162.123 L160.561,162.123 C236.112,162.123 297.471,222.891 298.426,298.216 L298.436,299.998 L160.558,299.998 L160.558,162.123 Z\" fill=\"#0A377B\"/></svg><span><strong>Beautiful Mermaid</strong> by Craft</span></button>\n      <div class=\"brand-dropdown shadow-modal-small\" id=\"brand-dropdown\">\n        <a href=\"https://agents.craft.do\" class=\"brand-dropdown-item\" target=\"_blank\" rel=\"noopener\">\n          <svg width=\"18\" height=\"18\" class=\"brand-dropdown-logo\" style=\"margin-left: -4px;\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><g transform=\"translate(3.4502, 3)\" fill=\"#9570BE\"><path d=\"M3.17890888,3.6 L3.17890888,0 L16,0 L16,3.6 L3.17890888,3.6 Z M9.642,7.2 L9.64218223,10.8 L0,10.8 L0,3.6 L16,3.6 L16,7.2 L9.642,7.2 Z M3.17890888,18 L3.178,14.4 L0,14.4 L0,10.8 L16,10.8 L16,18 L3.17890888,18 Z\" fill-rule=\"nonzero\"></path></g></svg>\n          <span style=\"margin-left: -2px;\">Craft Agents<span class=\"tagline\">Simply mind-blowing</span></span>\n        </a>\n        <a href=\"https://craft.do\" class=\"brand-dropdown-item\" target=\"_blank\" rel=\"noopener\">\n          <svg width=\"12\" height=\"12\" class=\"brand-dropdown-logo\" viewBox=\"0 0 299 300\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M137.879 300L137.875 300.001C62.3239 300.001 0.966154 239.232 0.0117188 163.908L2.56478e-10 162.126H137.879V300Z\" fill=\"currentColor\"/><path d=\"M137.879 0.000976562L137.875 0C61.729 6.6569e-06 0.000194275 61.729 0 137.875L2.56478e-10 137.878L137.879 137.878L137.879 0.000976562Z\" fill=\"currentColor\"/><path d=\"M160.558 137.882L160.561 137.883C236.707 137.882 298.436 76.1537 298.436 0.00758561V0.00563248L160.558 0.00562043L160.558 137.882Z\" fill=\"currentColor\"/><path d=\"M160.558 162.124L160.561 162.123C236.112 162.123 297.471 222.891 298.426 298.216L298.436 299.998H160.558V162.124Z\" fill=\"currentColor\"/></svg>\n          <span>Craft Docs<span class=\"tagline\">Amazing Notes &amp; Docs</span></span>\n        </a>\n      </div>\n    </div>\n    <button class=\"contents-btn shadow-minimal\" id=\"contents-btn\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"><line x1=\"3\" y1=\"4\" x2=\"13\" y2=\"4\"/><line x1=\"3\" y1=\"8\" x2=\"13\" y2=\"8\"/><line x1=\"3\" y1=\"12\" x2=\"10\" y2=\"12\"/></svg>Contents</button>\n    <div class=\"theme-pills\" id=\"theme-pills\">\n      ${themePillsHtml}\n    </div>\n    <div class=\"mega-menu shadow-modal-small\" id=\"mega-menu\">\n      <div class=\"toc-grid\">\n        ${tocSections}\n      </div>\n    </div>\n  </div>\n\n  <!-- Hero header section -->\n  <header class=\"hero-header\">\n    <h1 class=\"hero-title\">Beautiful Mermaid</h1>\n    <p class=\"hero-tagline\">Mermaid Rendering, made beautiful.</p>\n    <p class=\"hero-description\">\n      An open source library for rendering diagrams, designed for the age of AI: <a href=\"https://www.npmjs.com/package/beautiful-mermaid\" target=\"_blank\" rel=\"noopener\"><code>beautiful-mermaid</code></a>.\n      Ultra-fast, fully themeable, and outputs to both SVG and ASCII.<br>\n      Built by the team at <a href=\"https://craft.do\" target=\"_blank\" rel=\"noopener\">Craft</a> — because diagrams deserve great design too.\n    </p>\n    <div class=\"hero-buttons\">\n      <a href=\"https://agents.craft.do\" target=\"_blank\" rel=\"noopener\" class=\"hero-btn hero-btn-primary\">\n        <svg viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><g transform=\"translate(3.4502, 3)\" fill=\"currentColor\"><path d=\"M3.17890888,3.6 L3.17890888,0 L16,0 L16,3.6 L3.17890888,3.6 Z M9.642,7.2 L9.64218223,10.8 L0,10.8 L0,3.6 L16,3.6 L16,7.2 L9.642,7.2 Z M3.17890888,18 L3.178,14.4 L0,14.4 L0,10.8 L16,10.8 L16,18 L3.17890888,18 Z\" fill-rule=\"nonzero\"></path></g></svg>\n        Use in Craft Agents\n      </a>\n      <a href=\"https://github.com/lukilabs/beautiful-mermaid\" target=\"_blank\" rel=\"noopener\" class=\"hero-btn hero-btn-secondary\">\n        <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z\"/></svg>\n        GitHub\n      </a>\n      <button type=\"button\" class=\"hero-btn hero-btn-secondary\" id=\"random-theme-btn\">\n        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"16 3 21 3 21 8\"/><line x1=\"4\" y1=\"20\" x2=\"21\" y2=\"3\"/><polyline points=\"21 16 21 21 16 21\"/><line x1=\"15\" y1=\"15\" x2=\"21\" y2=\"21\"/><line x1=\"4\" y1=\"4\" x2=\"9\" y2=\"9\"/></svg>\n        Random Theme\n      </button>\n    </div>\n    <div class=\"hero-meta\">\n      <p class=\"meta\" id=\"total-timing\">Rendering ${samples.length * 2} samples\\u2026</p>\n      <div class=\"meta\">ASCII rendering based on <a href=\"https://github.com/AlexanderGrooff/mermaid-ascii\" target=\"_blank\" rel=\"noopener\">Mermaid-ASCII</a></div>\n      <div class=\"meta\">Early preview — actively evolving</div>\n    </div>\n  </header>\n\n  <div class=\"content-wrapper\">\n\n${heroCardsHtml}\n\n  <h2 class=\"section-title\">Samples</h2>\n\n${regularCardsHtml}\n\n  <!-- Bundled mermaid renderer — exposes window.__mermaid -->\n  <script type=\"module\">\n${bundleJs}\n\n  // ============================================================================\n  // Client-side rendering + theme switching\n  // ============================================================================\n\n  var samples = ${samplesJson};\n  var THEMES = window.__mermaid.THEMES;\n  var renderMermaid = window.__mermaid.renderMermaidSVGAsync;\n  var renderMermaidAscii = window.__mermaid.renderMermaidASCII;\n  var diagramColorsToAsciiTheme = window.__mermaid.diagramColorsToAsciiTheme;\n  var getSeriesColor = window.__mermaid.getSeriesColor;\n  var CHART_ACCENT_FALLBACK = window.__mermaid.CHART_ACCENT_FALLBACK;\n\n  var totalTimingEl = document.getElementById('total-timing');\n\n  // -- Theme state --\n  // Stores each SVG element's original inline style attribute (from initial render)\n  // so we can restore per-sample colors when switching back to \"Default\".\n  var originalSvgStyles = [];\n\n  function hexToRgb(hex) {\n    if (!hex || typeof hex !== 'string') return null;\n    var value = hex.trim();\n    if (value[0] === '#') value = value.slice(1);\n    if (value.length === 3) {\n      value = value[0] + value[0] + value[1] + value[1] + value[2] + value[2];\n    }\n    if (value.length !== 6) return null;\n    var intValue = parseInt(value, 16);\n    if (Number.isNaN(intValue)) return null;\n    return {\n      r: (intValue >> 16) & 255,\n      g: (intValue >> 8) & 255,\n      b: intValue & 255,\n    };\n  }\n\n  function setShadowVars(theme) {\n    var body = document.body;\n    var fg = theme ? theme.fg : '#27272A';\n    var bg = theme ? theme.bg : '#FFFFFF';\n    var accent = theme ? (theme.accent || '#3b82f6') : '#3b82f6';\n    var fgRgb = hexToRgb(fg) || { r: 39, g: 39, b: 42 };\n    var bgRgb = hexToRgb(bg) || { r: 255, g: 255, b: 255 };\n    var accentRgb = hexToRgb(accent) || { r: 59, g: 130, b: 246 };\n    var brightness = (bgRgb.r * 299 + bgRgb.g * 587 + bgRgb.b * 114) / 1000;\n    var darkMode = brightness < 140;\n\n    body.style.setProperty('--foreground-rgb', fgRgb.r + ', ' + fgRgb.g + ', ' + fgRgb.b);\n    body.style.setProperty('--accent-rgb', accentRgb.r + ', ' + accentRgb.g + ', ' + accentRgb.b);\n    body.style.setProperty('--shadow-border-opacity', darkMode ? '0.15' : '0.08');\n    body.style.setProperty('--shadow-blur-opacity', darkMode ? '0.12' : '0.06');\n  }\n\n  // Update <meta name=\"theme-color\"> so Safari 26+ title bar matches the page.\n  // Computes color-mix(in srgb, fg 4%, bg) in JS since browsers may not\n  // reliably re-evaluate CSS color-mix() for the meta tag.\n  function updateThemeColor(fg, bg) {\n    var fgRgb = hexToRgb(fg) || { r: 39, g: 39, b: 42 };\n    var bgRgb = hexToRgb(bg) || { r: 255, g: 255, b: 255 };\n    // Mix: 4% foreground, 96% background (matches body CSS)\n    var r = Math.round(bgRgb.r * 0.96 + fgRgb.r * 0.04);\n    var g = Math.round(bgRgb.g * 0.96 + fgRgb.g * 0.04);\n    var b = Math.round(bgRgb.b * 0.96 + fgRgb.b * 0.04);\n    var hex = '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);\n    document.getElementById('theme-color-meta').setAttribute('content', hex);\n    // Update --theme-bar-bg on body so gradients update instantly\n    document.body.style.setProperty('--theme-bar-bg', hex);\n    // Force Safari 26+ to re-read title bar color by updating the invisible fixed div\n    // and triggering a reflow (display toggle + offsetHeight read)\n    var safariDiv = document.getElementById('safari-theme-color');\n    safariDiv.style.background = hex;\n    safariDiv.style.display = 'none';\n    void safariDiv.offsetHeight;\n    safariDiv.style.display = '';\n  }\n\n  // ----------------------------------------------------------------\n  // Apply a named theme (or '' for Default) to the entire page.\n  //\n  // This is instant — no re-rendering needed. SVGs use CSS custom\n  // properties internally, so updating --bg/--fg on the <svg> tag\n  // re-paints all nodes, edges, text, and backgrounds via color-mix().\n  // ----------------------------------------------------------------\n  function applyTheme(themeKey) {\n    var theme = themeKey ? THEMES[themeKey] : null;\n    var body = document.body;\n\n    // 1. Update body CSS variables — the entire page derives from these\n    if (theme) {\n      body.style.setProperty('--t-bg', theme.bg);\n      body.style.setProperty('--t-fg', theme.fg);\n      body.style.setProperty('--t-accent', theme.accent || '#3b82f6');\n    } else {\n      body.style.setProperty('--t-bg', '#FFFFFF');\n      body.style.setProperty('--t-fg', '#27272A');\n      body.style.setProperty('--t-accent', '#3b82f6');\n    }\n    setShadowVars(theme);\n    updateThemeColor(theme ? theme.fg : '#27272A', theme ? theme.bg : '#FFFFFF');\n\n    // 2. Update all rendered SVG elements' CSS variables\n    var svgs = document.querySelectorAll('.svg-container svg');\n    for (var j = 0; j < svgs.length; j++) {\n      var svgEl = svgs[j];\n      if (theme) {\n        // Override with the global theme colors\n        svgEl.style.setProperty('--bg', theme.bg);\n        svgEl.style.setProperty('--fg', theme.fg);\n        // Set enrichment variables if provided, else remove so SVG\n        // internal color-mix() fallbacks activate\n        var enrichment = ['line', 'accent', 'muted', 'surface', 'border'];\n        for (var k = 0; k < enrichment.length; k++) {\n          var prop = enrichment[k];\n          if (theme[prop]) svgEl.style.setProperty('--' + prop, theme[prop]);\n          else svgEl.style.removeProperty('--' + prop);\n        }\n        // Recompute xychart series color vars from the new accent\n        var maxColor = parseInt(svgEl.getAttribute('data-xychart-colors') || '-1', 10);\n        if (maxColor >= 0) {\n          var accent = theme.accent || CHART_ACCENT_FALLBACK;\n          svgEl.style.setProperty('--xychart-color-0', accent);\n          for (var ci = 1; ci <= maxColor; ci++) {\n            svgEl.style.setProperty('--xychart-color-' + ci, getSeriesColor(ci, accent, theme.bg));\n          }\n        }\n      } else {\n        // Restore original inline style from initial render\n        if (originalSvgStyles[j] !== undefined) {\n          svgEl.setAttribute('style', originalSvgStyles[j]);\n        }\n      }\n    }\n\n    // 3. Update SVG panel backgrounds to match (skip hero panels - keep transparent)\n    for (var j = 0; j < samples.length; j++) {\n      var panel = document.getElementById('svg-panel-' + j);\n      if (!panel) continue;\n      // Skip hero panels - they stay transparent\n      if (panel.classList.contains('hero-diagram-panel')) continue;\n      if (theme) {\n        panel.style.background = theme.bg;\n      } else {\n        // Default mode: use the per-sample bg (or clear for page default)\n        var sampleBg = panel.getAttribute('data-sample-bg');\n        panel.style.background = sampleBg || '';\n      }\n    }\n\n    // 4. Re-render ASCII panels with new theme colors\n    var asciiTheme = theme ? diagramColorsToAsciiTheme(theme) : null;\n    for (var j = 0; j < samples.length; j++) {\n      var asciiEl = document.getElementById('ascii-' + j);\n      if (!asciiEl) continue;\n      try {\n        asciiEl.innerHTML = renderMermaidAscii(\n          samples[j].source,\n          asciiTheme ? { theme: asciiTheme } : {}\n        );\n      } catch (e) { /* keep existing content */ }\n    }\n\n    // 5. Update active pill\n    var pills = document.querySelectorAll('.theme-pill');\n    for (var j = 0; j < pills.length; j++) {\n      var isActive = pills[j].getAttribute('data-theme') === themeKey;\n      pills[j].classList.toggle('active', isActive);\n      pills[j].classList.toggle('shadow-tinted', isActive);\n    }\n\n    // 6. Persist selection\n    if (themeKey) {\n      localStorage.setItem('mermaid-theme', themeKey);\n    } else {\n      localStorage.removeItem('mermaid-theme');\n    }\n  }\n\n  // -- Set up theme pill click handlers --\n  document.getElementById('theme-pills').addEventListener('click', function(e) {\n    var pill = e.target.closest('.theme-pill');\n    if (!pill || pill.id === 'theme-more-btn') return;\n    applyTheme(pill.getAttribute('data-theme') || '');\n    // Close \"More\" dropdown if a theme was picked from it\n    var dd = document.getElementById('theme-more-dropdown');\n    if (dd && dd.classList.contains('open')) dd.classList.remove('open');\n  });\n\n  // -- \"More\" themes dropdown (direct listener, same pattern as Contents) --\n  var moreBtn = document.getElementById('theme-more-btn');\n  var moreDropdown = document.getElementById('theme-more-dropdown');\n\n  if (moreBtn && moreDropdown) {\n    moreBtn.addEventListener('click', function(e) {\n      e.stopPropagation();\n      moreDropdown.classList.toggle('open');\n    });\n\n    // Close on outside click\n    document.addEventListener('click', function(e) {\n      if (!moreDropdown.classList.contains('open')) return;\n      if (!e.target.closest('.theme-more-wrapper')) {\n        moreDropdown.classList.remove('open');\n      }\n    });\n\n    // Close on Escape\n    document.addEventListener('keydown', function(e) {\n      if (e.key === 'Escape' && moreDropdown.classList.contains('open')) {\n        moreDropdown.classList.remove('open');\n      }\n    });\n  }\n\n  // -- Random theme button --\n  var randomThemeBtn = document.getElementById('random-theme-btn');\n  var themeKeys = Object.keys(THEMES);\n  var currentThemeKey = localStorage.getItem('mermaid-theme') || '';\n\n  if (randomThemeBtn) {\n    randomThemeBtn.addEventListener('click', function() {\n      // Filter out the current theme so we never pick the same one\n      var availableKeys = themeKeys.filter(function(k) { return k !== currentThemeKey; });\n      // Also include default ('') if not currently selected\n      if (currentThemeKey !== '') availableKeys.push('');\n      // Pick a random theme\n      var randomIndex = Math.floor(Math.random() * availableKeys.length);\n      var newThemeKey = availableKeys[randomIndex];\n      currentThemeKey = newThemeKey;\n      applyTheme(newThemeKey);\n    });\n  }\n\n  // -- Brand dropdown --\n  var brandBtn = document.getElementById('brand-badge-btn');\n  var brandDropdown = document.getElementById('brand-dropdown');\n\n  if (brandBtn && brandDropdown) {\n    brandBtn.addEventListener('click', function(e) {\n      e.stopPropagation();\n      var isOpen = brandDropdown.classList.toggle('open');\n      brandBtn.classList.toggle('active', isOpen);\n      brandBtn.classList.toggle('shadow-tinted', isOpen);\n    });\n\n    // Close on outside click\n    document.addEventListener('click', function(e) {\n      if (!brandDropdown.classList.contains('open')) return;\n      if (!e.target.closest('.brand-badge-wrapper')) {\n        brandDropdown.classList.remove('open');\n        brandBtn.classList.remove('active');\n        brandBtn.classList.remove('shadow-tinted');\n      }\n    });\n\n    // Close on Escape\n    document.addEventListener('keydown', function(e) {\n      if (e.key === 'Escape' && brandDropdown.classList.contains('open')) {\n        brandDropdown.classList.remove('open');\n        brandBtn.classList.remove('active');\n        brandBtn.classList.remove('shadow-tinted');\n      }\n    });\n  }\n\n  // -- Mega menu (Contents dropdown) --\n  var contentsBtn = document.getElementById('contents-btn');\n  var megaMenu = document.getElementById('mega-menu');\n\n  contentsBtn.addEventListener('click', function(e) {\n    e.stopPropagation();\n    var isOpen = megaMenu.classList.toggle('open');\n    contentsBtn.classList.toggle('active', isOpen);\n    contentsBtn.classList.toggle('shadow-tinted', isOpen);\n  });\n\n  // Close on clicking a ToC link (smooth scroll to target)\n  megaMenu.addEventListener('click', function(e) {\n    var link = e.target.closest('a');\n    if (!link) return;\n    e.preventDefault();\n    megaMenu.classList.remove('open');\n    contentsBtn.classList.remove('active');\n    contentsBtn.classList.remove('shadow-tinted');\n    var target = document.querySelector(link.getAttribute('href'));\n    if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });\n  });\n\n  // Close on outside click\n  document.addEventListener('click', function(e) {\n    if (!megaMenu.classList.contains('open')) return;\n    if (!e.target.closest('.mega-menu') && !e.target.closest('.contents-btn')) {\n      megaMenu.classList.remove('open');\n      contentsBtn.classList.remove('active');\n      contentsBtn.classList.remove('shadow-tinted');\n    }\n  });\n\n  // Close on Escape\n  document.addEventListener('keydown', function(e) {\n    if (e.key === 'Escape' && megaMenu.classList.contains('open')) {\n      megaMenu.classList.remove('open');\n      contentsBtn.classList.remove('active');\n      contentsBtn.classList.remove('shadow-tinted');\n    }\n  });\n\n  // -- Restore saved theme immediately (before rendering begins) --\n  var savedTheme = localStorage.getItem('mermaid-theme');\n  if (savedTheme && THEMES[savedTheme]) {\n    // Apply page-level CSS variables right away to avoid flash\n    document.body.style.setProperty('--t-bg', THEMES[savedTheme].bg);\n    document.body.style.setProperty('--t-fg', THEMES[savedTheme].fg);\n    document.body.style.setProperty('--t-accent', THEMES[savedTheme].accent || '#3b82f6');\n    setShadowVars(THEMES[savedTheme]);\n    updateThemeColor(THEMES[savedTheme].fg, THEMES[savedTheme].bg);\n    // Mark the correct pill as active\n    var pills = document.querySelectorAll('.theme-pill');\n    for (var j = 0; j < pills.length; j++) {\n      var isActive = pills[j].getAttribute('data-theme') === savedTheme;\n      pills[j].classList.toggle('active', isActive);\n      pills[j].classList.toggle('shadow-tinted', isActive);\n    }\n  } else {\n    setShadowVars(null);\n  }\n\n  // ============================================================================\n  // Progressive rendering — render each diagram sequentially\n  // ============================================================================\n\n  var totalStart = performance.now();\n\n  for (var i = 0; i < samples.length; i++) {\n    var sample = samples[i];\n    var svgContainer = document.getElementById('svg-' + i);\n    var asciiContainer = document.getElementById('ascii-' + i);\n    var svgPanel = document.getElementById('svg-panel-' + i);\n\n    // Render SVG — wrapped in a timeout guard so a stalled layout\n    // doesn't block all remaining diagrams from rendering.\n    try {\n      var svg = await renderMermaid(sample.source, sample.options);\n      svgContainer.innerHTML = svg;\n\n      // Store the SVG's original inline style for Default mode restoration\n      var svgEl = svgContainer.querySelector('svg');\n      if (svgEl) {\n        originalSvgStyles.push(svgEl.getAttribute('style') || '');\n\n        // If a global theme is active, immediately override the SVG's variables\n        if (savedTheme && THEMES[savedTheme]) {\n          var th = THEMES[savedTheme];\n          svgEl.style.setProperty('--bg', th.bg);\n          svgEl.style.setProperty('--fg', th.fg);\n          var enrichment = ['line', 'accent', 'muted', 'surface', 'border'];\n          for (var k = 0; k < enrichment.length; k++) {\n            if (th[enrichment[k]]) svgEl.style.setProperty('--' + enrichment[k], th[enrichment[k]]);\n            else svgEl.style.removeProperty('--' + enrichment[k]);\n          }\n          // Recompute xychart series color vars from the saved theme's accent\n          var maxColor = parseInt(svgEl.getAttribute('data-xychart-colors') || '-1', 10);\n          if (maxColor >= 0) {\n            var accent = th.accent || CHART_ACCENT_FALLBACK;\n            svgEl.style.setProperty('--xychart-color-0', accent);\n            for (var ci = 1; ci <= maxColor; ci++) {\n              svgEl.style.setProperty('--xychart-color-' + ci, getSeriesColor(ci, accent, th.bg));\n            }\n          }\n        }\n      } else {\n        originalSvgStyles.push('');\n      }\n\n      // Set panel background to match the SVG (skip for hero panels - keep transparent)\n      var isHeroPanel = svgPanel.classList.contains('hero-diagram-panel');\n      if (!isHeroPanel) {\n        if (savedTheme && THEMES[savedTheme]) {\n          svgPanel.style.background = THEMES[savedTheme].bg;\n        } else {\n          var sampleBg = svgPanel.getAttribute('data-sample-bg');\n          if (sampleBg) svgPanel.style.background = sampleBg;\n        }\n      }\n    } catch (err) {\n      svgContainer.innerHTML = '<div class=\"render-error\">SVG Error: ' + escapeHtml(String(err)) + '</div>';\n      originalSvgStyles.push('');\n    }\n\n    // Hero samples don't have ASCII panels\n    if (asciiContainer) {\n      try {\n        var asciiOpts = savedTheme && THEMES[savedTheme]\n          ? { theme: diagramColorsToAsciiTheme(THEMES[savedTheme]) }\n          : {};\n        asciiContainer.innerHTML = renderMermaidAscii(sample.source, asciiOpts);\n      } catch (e) {\n        asciiContainer.textContent = '(ASCII not supported for this diagram type)';\n      }\n    }\n\n  }\n\n  // Done — show total time\n  var totalMs = (performance.now() - totalStart).toFixed(0);\n  totalTimingEl.textContent = (samples.length * 2) + ' samples (SVG+ASCII) rendered in ' + totalMs + ' ms';\n\n  function escapeHtml(text) {\n    return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\n  }\n\n  // ============================================================================\n  // Edit dialog — open, close, save & re-render\n  // ============================================================================\n\n  var editOverlay = document.getElementById('edit-overlay');\n  var editTextarea = document.getElementById('edit-dialog-textarea');\n  var editSaveBtn = document.getElementById('edit-dialog-save');\n  var editCancelBtn = document.getElementById('edit-dialog-cancel');\n  var editCloseBtn = document.getElementById('edit-dialog-close');\n  var editingSampleIndex = -1;\n\n  function openEditDialog(index) {\n    editingSampleIndex = index;\n    editTextarea.value = samples[index].source;\n    editOverlay.classList.add('open');\n    editTextarea.focus();\n  }\n\n  function closeEditDialog() {\n    editOverlay.classList.remove('open');\n    editingSampleIndex = -1;\n  }\n\n  async function saveAndRender() {\n    var index = editingSampleIndex;\n    if (index < 0) return;\n    var source = editTextarea.value;\n    samples[index].source = source;\n\n    // Close dialog immediately so user sees results rendering\n    closeEditDialog();\n\n    // Update source panel with plain text (Shiki not available at runtime)\n    var sourcePanel = document.getElementById('source-panel-' + index);\n    if (sourcePanel) {\n      var shikiEl = sourcePanel.querySelector('.shiki');\n      if (shikiEl) {\n        shikiEl.innerHTML = '<code>' + escapeHtml(source) + '</code>';\n      }\n    }\n\n    // Re-render SVG (async — renderMermaid returns a Promise)\n    var svgContainer = document.getElementById('svg-' + index);\n    try {\n      var svg = await renderMermaid(source, samples[index].options);\n      svgContainer.innerHTML = svg;\n      var svgEl = svgContainer.querySelector('svg');\n      if (svgEl) {\n        originalSvgStyles[index] = svgEl.getAttribute('style') || '';\n        var activeTheme = localStorage.getItem('mermaid-theme');\n        if (activeTheme && THEMES[activeTheme]) {\n          var th = THEMES[activeTheme];\n          svgEl.style.setProperty('--bg', th.bg);\n          svgEl.style.setProperty('--fg', th.fg);\n          var enrichment = ['line', 'accent', 'muted', 'surface', 'border'];\n          for (var k = 0; k < enrichment.length; k++) {\n            if (th[enrichment[k]]) svgEl.style.setProperty('--' + enrichment[k], th[enrichment[k]]);\n            else svgEl.style.removeProperty('--' + enrichment[k]);\n          }\n          // Recompute xychart series color vars\n          var maxColor = parseInt(svgEl.getAttribute('data-xychart-colors') || '-1', 10);\n          if (maxColor >= 0) {\n            var accent = th.accent || CHART_ACCENT_FALLBACK;\n            svgEl.style.setProperty('--xychart-color-0', accent);\n            for (var ci = 1; ci <= maxColor; ci++) {\n              svgEl.style.setProperty('--xychart-color-' + ci, getSeriesColor(ci, accent, th.bg));\n            }\n          }\n        }\n      }\n    } catch (err) {\n      svgContainer.innerHTML = '<div class=\"render-error\">' + escapeHtml(String(err)) + '</div>';\n    }\n\n    // Re-render ASCII\n    var asciiContainer = document.getElementById('ascii-' + index);\n    if (asciiContainer) {\n      try {\n        var activeThemeKey = localStorage.getItem('mermaid-theme');\n        var editAsciiOpts = activeThemeKey && THEMES[activeThemeKey]\n          ? { theme: diagramColorsToAsciiTheme(THEMES[activeThemeKey]) }\n          : {};\n        asciiContainer.innerHTML = renderMermaidAscii(source, editAsciiOpts);\n      } catch (e) {\n        asciiContainer.textContent = '(ASCII error: ' + e.message + ')';\n      }\n    }\n  }\n\n  // Event listeners\n  document.addEventListener('click', function(e) {\n    var btn = e.target.closest('.edit-btn');\n    if (btn) openEditDialog(parseInt(btn.dataset.sample, 10));\n  });\n  editSaveBtn.addEventListener('click', saveAndRender);\n  editCancelBtn.addEventListener('click', closeEditDialog);\n  editCloseBtn.addEventListener('click', closeEditDialog);\n  editOverlay.addEventListener('click', function(e) {\n    if (e.target === editOverlay) closeEditDialog();\n  });\n  document.addEventListener('keydown', function(e) {\n    if (!editOverlay.classList.contains('open')) return;\n    if (e.key === 'Escape') closeEditDialog();\n    if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') saveAndRender();\n  });\n\n  </script>\n\n  <!-- Edit dialog (shared single instance) -->\n  <div class=\"edit-overlay\" id=\"edit-overlay\">\n    <div class=\"edit-dialog shadow-modal-small\">\n      <div class=\"edit-dialog-header\">\n        <span class=\"edit-dialog-title\">Edit Diagram</span>\n        <button class=\"edit-dialog-close\" id=\"edit-dialog-close\">&times;</button>\n      </div>\n      <textarea class=\"edit-dialog-textarea\" id=\"edit-dialog-textarea\"\n        spellcheck=\"false\" autocomplete=\"off\" autocorrect=\"off\"></textarea>\n      <div class=\"edit-dialog-footer\">\n        <button class=\"edit-dialog-btn edit-dialog-cancel\" id=\"edit-dialog-cancel\">Cancel</button>\n        <button class=\"edit-dialog-btn edit-dialog-save\" id=\"edit-dialog-save\">Save &amp; Render</button>\n      </div>\n    </div>\n  </div>\n\n  </div><!-- .content-wrapper -->\n\n  <footer class=\"site-footer\">\n    <span>&copy; 2026 Craft Docs Limited, Inc. All rights reserved.</span>\n    <div class=\"footer-links\">\n      <a href=\"mailto:agents@craft.do\">Contact</a>\n      <a href=\"https://github.com/lukilabs/beautiful-mermaid\" target=\"_blank\" rel=\"noopener noreferrer\">\n        <svg viewBox=\"0 0 24 24\" fill=\"currentColor\">\n          <path d=\"M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z\"/>\n        </svg>\n      </a>\n      <a href=\"https://x.com/craftdocs\" target=\"_blank\" rel=\"noopener noreferrer\">\n        <svg viewBox=\"0 0 24 24\" fill=\"currentColor\">\n          <path d=\"M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z\"/>\n        </svg>\n      </a>\n    </div>\n  </footer>\n</body>\n</html>`\n}\n\n// ============================================================================\n// Main\n// ============================================================================\n\nconst html = await generateHtml()\nconst outPath = new URL('./index.html', import.meta.url).pathname\nawait Bun.write(outPath, html)\nconsole.log(`Written to ${outPath} (${(html.length / 1024).toFixed(1)} KB)`)\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"beautiful-mermaid\",\n  \"version\": \"1.1.3\",\n  \"license\": \"MIT\",\n  \"description\": \"Render Mermaid diagrams as beautiful SVGs or ASCII art. Ultra-fast, fully themeable, zero DOM dependencies.\",\n  \"type\": \"module\",\n  \"main\": \"dist/index.js\",\n  \"types\": \"dist/index.d.ts\",\n  \"exports\": {\n    \".\": {\n      \"bun\": \"./src/index.ts\",\n      \"import\": \"./dist/index.js\",\n      \"types\": \"./dist/index.d.ts\"\n    }\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/lukilabs/beautiful-mermaid\"\n  },\n  \"homepage\": \"https://github.com/lukilabs/beautiful-mermaid\",\n  \"bugs\": {\n    \"url\": \"https://github.com/lukilabs/beautiful-mermaid/issues\"\n  },\n  \"keywords\": [\n    \"mermaid\",\n    \"diagram\",\n    \"svg\",\n    \"ascii\",\n    \"flowchart\",\n    \"sequence-diagram\",\n    \"class-diagram\",\n    \"er-diagram\",\n    \"xychart\",\n    \"state-diagram\",\n    \"visualization\",\n    \"theming\"\n  ],\n  \"author\": \"Craft Docs\",\n  \"files\": [\"src/\", \"dist/\", \"LICENSE\", \"README.md\"],\n  \"scripts\": {\n    \"test\": \"bun test src/__tests__/\",\n    \"samples\": \"bun run index.ts\",\n    \"dev\": \"bun run dev.ts\",\n    \"bench\": \"bun run bench.ts\",\n    \"build\": \"tsup\",\n    \"build:samples\": \"bun run index.ts && mkdir -p site && mv index.html site/ && cp -r public/* site/\",\n    \"deploy\": \"bun run build:samples && wrangler pages deploy site --project-name craft-agents-mermaid\",\n    \"xychart-test\": \"bun run xychart-test.ts\",\n    \"prepublishOnly\": \"npm run build\"\n  },\n  \"dependencies\": {\n    \"elkjs\": \"^0.11.0\",\n    \"entities\": \"^7.0.1\"\n  },\n  \"devDependencies\": {\n    \"@types/bun\": \"^1.3.9\",\n    \"shiki\": \"^3.19.0\",\n    \"tsup\": \"^8.5.1\",\n    \"typescript\": \"^5.9.3\"\n  }\n}\n"
  },
  {
    "path": "samples-data.ts",
    "content": "/**\n * Sample definitions for the beautiful-mermaid visual test suite.\n *\n * Shared by:\n *   - index.ts     — generates the HTML visual test page\n *   - bench.ts     — runs performance benchmarks in Bun (no browser)\n *   - dev.ts       — dev server with live reload\n *\n * Every supported feature, shape, edge type, block construct, and theme\n * variant is exercised by at least one sample.\n */\n\nexport interface Sample {\n  title: string\n  description: string\n  source: string\n  /** Optional category tag for grouping in the Table of Contents */\n  category?: string\n  options?: { bg?: string; fg?: string; line?: string; accent?: string; muted?: string; surface?: string; border?: string; font?: string; padding?: number; transparent?: boolean; interactive?: boolean }\n}\n\nexport const samples: Sample[] = [\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  HERO — Showcase diagram\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'Beautiful Mermaid',\n    category: 'Hero',\n    description: 'Mermaid rendering, made beautiful.',\n    source: `stateDiagram-v2\n    direction LR\n    [*] --> Input\n    Input --> Parse: DSL\n    Parse --> Layout: AST\n    Layout --> SVG: Vector\n    Layout --> ASCII: Text\n    SVG --> Theme\n    ASCII --> Theme\n    Theme --> Output\n    Output --> [*]`,\n    options: { transparent: true },\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  FLOWCHART — Shapes\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'Simple Flow',\n    category: 'Flowchart',\n    description: 'Basic linear flow with three nodes connected by solid arrows.',\n    source: `graph TD\n  A[Start] --> B[Process] --> C[End]`,\n  },\n  {\n    title: 'Original Node Shapes',\n    category: 'Flowchart',\n    description: 'Rectangle, rounded, diamond, stadium, and circle.',\n    source: `graph LR\n  A[Rectangle] --> B(Rounded)\n  B --> C{Diamond}\n  C --> D([Stadium])\n  D --> E((Circle))`,\n  },\n  {\n    title: 'Batch 1 Shapes',\n    category: 'Flowchart',\n    description: 'Subroutine `[[text]]`, double circle `(((text)))`, and hexagon `{{text}}`.',\n    source: `graph LR\n  A[[Subroutine]] --> B(((Double Circle)))\n  B --> C{{Hexagon}}`,\n  },\n  {\n    title: 'Batch 2 Shapes',\n    category: 'Flowchart',\n    description: 'Cylinder `[(text)]`, asymmetric `>text]`, trapezoid `[/text\\\\]`, and inverse trapezoid `[\\\\text/]`.',\n    source: `graph LR\n  A[(Database)] --> B>Flag Shape]\n  B --> C[/Wider Bottom\\\\]\n  C --> D[\\\\Wider Top/]`,\n  },\n  {\n    title: 'All 12 Flowchart Shapes',\n    category: 'Flowchart',\n    description: 'Every supported flowchart shape in a single diagram.',\n    source: `graph LR\n  A[Rectangle] --> B(Rounded)\n  B --> C{Diamond}\n  C --> D([Stadium])\n  D --> E((Circle))\n  E --> F[[Subroutine]]\n  F --> G(((Double Circle)))\n  G --> H{{Hexagon}}\n  H --> I[(Database)]\n  I --> J>Flag]\n  J --> K[/Trapezoid\\\\]\n  K --> L[\\\\Inverse Trap/]`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  FLOWCHART — Edges\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'All Edge Styles',\n    category: 'Flowchart',\n    description: 'Solid, dotted, and thick arrows with labels.',\n    source: `graph TD\n  A[Source] -->|solid| B[Target 1]\n  A -.->|dotted| C[Target 2]\n  A ==>|thick| D[Target 3]`,\n  },\n  {\n    title: 'No-Arrow Edges',\n    category: 'Flowchart',\n    description: 'Lines without arrowheads: solid `---`, dotted `-.-`, thick `===`.',\n    source: `graph TD\n  A[Node 1] ---|related| B[Node 2]\n  B -.- C[Node 3]\n  C === D[Node 4]`,\n  },\n  {\n    title: 'Text-Embedded Labels',\n    category: 'Flowchart',\n    description: 'Using `-- label -->` syntax instead of `-->|label|` for edge labels.',\n    source: `flowchart TD\n  A(Start) --> B{Is it sunny?}\n  B -- Yes --> C[Go to the park]\n  B -- No --> D[Stay indoors]\n  C --> E[Finish]\n  D --> E`,\n  },\n  {\n    title: 'Bidirectional Arrows',\n    category: 'Flowchart',\n    description: 'Arrows in both directions: `<-->`, `<-.->`, `<==>`.',\n    source: `graph LR\n  A[Client] <-->|sync| B[Server]\n  B <-.->|heartbeat| C[Monitor]\n  C <==>|data| D[Storage]`,\n  },\n  {\n    title: 'Parallel Links (&)',\n    category: 'Flowchart',\n    description: 'Using `&` to create multiple edges from/to groups of nodes.',\n    source: `graph TD\n  A[Input] & B[Config] --> C[Processor]\n  C --> D[Output] & E[Log]`,\n  },\n  {\n    title: 'Chained Edges',\n    category: 'Flowchart',\n    description: 'A long chain of nodes demonstrating edge chaining syntax.',\n    source: `graph LR\n  A[Step 1] --> B[Step 2] --> C[Step 3] --> D[Step 4] --> E[Step 5]`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  FLOWCHART — Edge Styling (linkStyle)\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'linkStyle: Color-Coded Edges',\n    category: 'Flowchart',\n    description: 'Using `linkStyle` to color specific edges by index (0-based).',\n    source: `graph TD\n  A[Start] --> B{Decision}\n  B -->|Yes| C[Accept]\n  B -->|No| D[Reject]\n  C --> E[Done]\n  D --> E\n  linkStyle 0 stroke:#7aa2f7,stroke-width:3px\n  linkStyle 1 stroke:#9ece6a,stroke-width:2px\n  linkStyle 2 stroke:#f7768e,stroke-width:2px\n  linkStyle default stroke:#565f89`,\n  },\n  {\n    title: 'linkStyle: Default + Override',\n    category: 'Flowchart',\n    description: 'Default edge style with index-specific overrides for critical paths.',\n    source: `graph LR\n  A[Request] --> B[Auth]\n  B --> C[Process]\n  C --> D[Response]\n  B --> E[Reject]\n  linkStyle default stroke:#6b7280,stroke-width:1px\n  linkStyle 0,1,2 stroke:#22c55e,stroke-width:2px\n  linkStyle 3 stroke:#ef4444,stroke-width:3px`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  FLOWCHART — Directions\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'Direction: Left-Right (LR)',\n    category: 'Flowchart',\n    description: 'Horizontal layout flowing left to right.',\n    source: `graph LR\n  A[Input] --> B[Transform] --> C[Output]`,\n  },\n  {\n    title: 'Direction: Bottom-Top (BT)',\n    category: 'Flowchart',\n    description: 'Vertical layout flowing from bottom to top.',\n    source: `graph BT\n  A[Foundation] --> B[Layer 2] --> C[Top]`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  FLOWCHART — Subgraphs\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'Subgraphs',\n    category: 'Flowchart',\n    description: 'Grouped nodes inside labeled subgraph containers.',\n    source: `graph TD\n  subgraph Frontend\n    A[React App] --> B[State Manager]\n  end\n  subgraph Backend\n    C[API Server] --> D[Database]\n  end\n  B --> C`,\n  },\n  {\n    title: 'Nested Subgraphs',\n    category: 'Flowchart',\n    description: 'Subgraphs inside subgraphs for hierarchical grouping.',\n    source: `graph TD\n  subgraph Cloud\n    subgraph us-east [US East Region]\n      A[Web Server] --> B[App Server]\n    end\n    subgraph us-west [US West Region]\n      C[Web Server] --> D[App Server]\n    end\n  end\n  E[Load Balancer] --> A\n  E --> C`,\n  },\n  {\n    title: 'Subgraph Direction Override',\n    category: 'Flowchart',\n    description: 'Using `direction LR` inside a subgraph while the outer graph flows TD.',\n    source: `graph TD\n  subgraph pipeline [Processing Pipeline]\n    direction LR\n    A[Input] --> B[Parse] --> C[Transform] --> D[Output]\n  end\n  E[Source] --> A\n  D --> F[Sink]`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  FLOWCHART — Styling\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: '::: Class Shorthand',\n    category: 'Flowchart',\n    description: 'Assigning classes with `:::` syntax directly on node definitions.',\n    source: `graph TD\n  A[Normal]:::default --> B[Highlighted]:::highlight --> C[Error]:::error\n  classDef default fill:#f4f4f5,stroke:#a1a1aa\n  classDef highlight fill:#fbbf24,stroke:#d97706\n  classDef error fill:#ef4444,stroke:#dc2626`,\n  },\n  {\n    title: 'Inline Style Overrides',\n    category: 'Flowchart',\n    description: 'Using `style` statements to override node fill and stroke colors.',\n    source: `graph TD\n  A[Default] --> B[Custom Colors] --> C[Another Custom]\n  style B fill:#3b82f6,stroke:#1d4ed8,color:#ffffff\n  style C fill:#10b981,stroke:#059669`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  FLOWCHART — Real-World Diagrams\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'CI/CD Pipeline',\n    category: 'Flowchart',\n    description: 'A realistic CI/CD pipeline with decision points, feedback loops, and deployment stages.',\n    source: `graph TD\n  subgraph ci [CI Pipeline]\n    A[Push Code] --> B{Tests Pass?}\n    B -->|Yes| C[Build Image]\n    B -->|No| D[Fix & Retry]\n    D -.-> A\n  end\n  C --> E([Deploy Staging])\n  E --> F{QA Approved?}\n  F -->|Yes| G((Production))\n  F -->|No| D`,\n  },\n  {\n    title: 'System Architecture',\n    category: 'Flowchart',\n    description: 'A microservices architecture with multiple services and data stores.',\n    source: `graph LR\n  subgraph clients [Client Layer]\n    A([Web App]) --> B[API Gateway]\n    C([Mobile App]) --> B\n  end\n  subgraph services [Service Layer]\n    B --> D[Auth Service]\n    B --> E[User Service]\n    B --> F[Order Service]\n  end\n  subgraph data [Data Layer]\n    D --> G[(Auth DB)]\n    E --> H[(User DB)]\n    F --> I[(Order DB)]\n    F --> J([Message Queue])\n  end`,\n  },\n  {\n    title: 'Decision Tree',\n    category: 'Flowchart',\n    description: 'A branching decision flowchart with multiple outcomes.',\n    source: `graph TD\n  A{Is it raining?} -->|Yes| B{Have umbrella?}\n  A -->|No| C([Go outside])\n  B -->|Yes| D([Go with umbrella])\n  B -->|No| E{Is it heavy?}\n  E -->|Yes| F([Stay inside])\n  E -->|No| G([Run for it])`,\n  },\n  {\n    title: 'Git Branching Workflow',\n    category: 'Flowchart',\n    description: 'A git flow showing feature branches, PRs, and release cycle.',\n    source: `graph LR\n  A[main] --> B[develop]\n  B --> C[feature/auth]\n  B --> D[feature/ui]\n  C --> E{PR Review}\n  D --> E\n  E -->|approved| B\n  B --> F[release/1.0]\n  F --> G{Tests?}\n  G -->|pass| A\n  G -->|fail| F`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  STATE DIAGRAMS\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'Basic State Diagram',\n    category: 'State',\n    description: 'A simple `stateDiagram-v2` with start/end pseudostates and transitions.',\n    source: `stateDiagram-v2\n  [*] --> Idle\n  Idle --> Active : start\n  Active --> Idle : cancel\n  Active --> Done : complete\n  Done --> [*]`,\n  },\n  {\n    title: 'State: Composite States',\n    category: 'State',\n    description: 'Nested composite states with inner transitions.',\n    source: `stateDiagram-v2\n  [*] --> Idle\n  Idle --> Processing : submit\n  state Processing {\n    parse --> validate\n    validate --> execute\n  }\n  Processing --> Complete : done\n  Processing --> Error : fail\n  Error --> Idle : retry\n  Complete --> [*]`,\n  },\n  {\n    title: 'State: Connection Lifecycle',\n    category: 'State',\n    description: 'TCP-like connection state machine with multiple states.',\n    source: `stateDiagram-v2\n  [*] --> Closed\n  Closed --> Connecting : connect\n  Connecting --> Connected : success\n  Connecting --> Closed : timeout\n  Connected --> Disconnecting : close\n  Connected --> Reconnecting : error\n  Reconnecting --> Connected : success\n  Reconnecting --> Closed : max_retries\n  Disconnecting --> Closed : done\n  Closed --> [*]`,\n  },\n\n  {\n    title: 'State: CJK State Names',\n    category: 'State',\n    description: 'State diagram using Chinese characters for state names.',\n    source: `stateDiagram-v2\n  [*] --> 空闲\n  空闲 --> 处理中 : 提交\n  处理中 --> 完成 : 成功\n  处理中 --> 错误 : 失败\n  错误 --> 空闲 : 重试\n  完成 --> [*]`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  SEQUENCE DIAGRAMS — Core Features\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'Sequence: Basic Messages',\n    category: 'Sequence',\n    description: 'Simple request/response between two participants.',\n    source: `sequenceDiagram\n  Alice->>Bob: Hello Bob!\n  Bob-->>Alice: Hi Alice!`,\n  },\n  {\n    title: 'Sequence: Participant Aliases',\n    category: 'Sequence',\n    description: 'Using `participant ... as ...` for compact diagram IDs with readable labels.',\n    source: `sequenceDiagram\n  participant A as Alice\n  participant B as Bob\n  participant C as Charlie\n  A->>B: Hello\n  B->>C: Forward\n  C-->>A: Reply`,\n  },\n  {\n    title: 'Sequence: Actor Stick Figures',\n    category: 'Sequence',\n    description: 'Using `actor` instead of `participant` renders stick figures instead of boxes.',\n    source: `sequenceDiagram\n  actor U as User\n  participant S as System\n  participant DB as Database\n  U->>S: Click button\n  S->>DB: Query\n  DB-->>S: Results\n  S-->>U: Display`,\n  },\n  {\n    title: 'Sequence: Arrow Types',\n    category: 'Sequence',\n    description: 'All arrow types: solid `->>` and dashed `-->>` with filled arrowheads, open arrows `-)` .',\n    source: `sequenceDiagram\n  A->>B: Solid arrow (sync)\n  B-->>A: Dashed arrow (return)\n  A-)B: Open arrow (async)\n  B--)A: Open dashed arrow`,\n  },\n  {\n    title: 'Sequence: Activation Boxes',\n    category: 'Sequence',\n    description: 'Using `+` and `-` to show when participants are active.',\n    source: `sequenceDiagram\n  participant C as Client\n  participant S as Server\n  C->>+S: Request\n  S->>+S: Process\n  S->>-S: Done\n  S-->>-C: Response`,\n  },\n  {\n    title: 'Sequence: Self-Messages',\n    category: 'Sequence',\n    description: 'A participant sending a message to itself (displayed as a loop arrow).',\n    source: `sequenceDiagram\n  participant S as Server\n  S->>S: Internal process\n  S->>S: Validate\n  S-->>S: Log`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  SEQUENCE DIAGRAMS — Blocks\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'Sequence: Loop Block',\n    category: 'Sequence',\n    description: 'A `loop` construct wrapping repeated message exchanges.',\n    source: `sequenceDiagram\n  participant C as Client\n  participant S as Server\n  C->>S: Connect\n  loop Every 30s\n    C->>S: Heartbeat\n    S-->>C: Ack\n  end\n  C->>S: Disconnect`,\n  },\n  {\n    title: 'Sequence: Alt/Else Block',\n    category: 'Sequence',\n    description: 'Conditional branching with `alt` (if) and `else` blocks.',\n    source: `sequenceDiagram\n  participant C as Client\n  participant S as Server\n  C->>S: Login\n  alt Valid credentials\n    S-->>C: 200 OK\n  else Invalid\n    S-->>C: 401 Unauthorized\n  else Account locked\n    S-->>C: 403 Forbidden\n  end`,\n  },\n  {\n    title: 'Sequence: Opt Block',\n    category: 'Sequence',\n    description: 'Optional block — executes only if condition is met.',\n    source: `sequenceDiagram\n  participant A as App\n  participant C as Cache\n  participant DB as Database\n  A->>C: Get data\n  C-->>A: Cache miss\n  opt Cache miss\n    A->>DB: Query\n    DB-->>A: Results\n    A->>C: Store in cache\n  end`,\n  },\n  {\n    title: 'Sequence: Par Block',\n    category: 'Sequence',\n    description: 'Parallel execution with `par`/`and` constructs.',\n    source: `sequenceDiagram\n  participant C as Client\n  participant A as AuthService\n  participant U as UserService\n  participant O as OrderService\n  C->>A: Authenticate\n  par Fetch user data\n    A->>U: Get profile\n  and Fetch orders\n    A->>O: Get orders\n  end\n  A-->>C: Combined response`,\n  },\n  {\n    title: 'Sequence: Critical Block',\n    category: 'Sequence',\n    description: 'Critical section that must complete atomically.',\n    source: `sequenceDiagram\n  participant A as App\n  participant DB as Database\n  A->>DB: BEGIN\n  critical Transaction\n    A->>DB: UPDATE accounts\n    A->>DB: INSERT log\n  end\n  A->>DB: COMMIT`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  SEQUENCE DIAGRAMS — Notes\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'Sequence: Notes (Right/Left/Over)',\n    category: 'Sequence',\n    description: 'Notes positioned to the right, left, or over participants.',\n    source: `sequenceDiagram\n  participant A as Alice\n  participant B as Bob\n  Note left of A: Alice prepares\n  A->>B: Hello\n  Note right of B: Bob thinks\n  B-->>A: Reply\n  Note over A,B: Conversation complete`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  SEQUENCE DIAGRAMS — Complex / Real-World\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'Sequence: OAuth 2.0 Flow',\n    category: 'Sequence',\n    description: 'Full OAuth 2.0 authorization code flow with token exchange.',\n    source: `sequenceDiagram\n  actor U as User\n  participant App as Client App\n  participant Auth as Auth Server\n  participant API as Resource API\n  U->>App: Click Login\n  App->>Auth: Authorization request\n  Auth->>U: Login page\n  U->>Auth: Credentials\n  Auth-->>App: Authorization code\n  App->>Auth: Exchange code for token\n  Auth-->>App: Access token\n  App->>API: Request + token\n  API-->>App: Protected resource\n  App-->>U: Display data`,\n  },\n  {\n    title: 'Sequence: Database Transaction',\n    category: 'Sequence',\n    description: 'Multi-step database transaction with rollback handling.',\n    source: `sequenceDiagram\n  participant C as Client\n  participant S as Server\n  participant DB as Database\n  C->>S: POST /transfer\n  S->>DB: BEGIN\n  S->>DB: Debit account A\n  alt Success\n    S->>DB: Credit account B\n    S->>DB: INSERT audit_log\n    S->>DB: COMMIT\n    S-->>C: 200 OK\n  else Insufficient funds\n    S->>DB: ROLLBACK\n    S-->>C: 400 Bad Request\n  end`,\n  },\n  {\n    title: 'Sequence: Microservice Orchestration',\n    category: 'Sequence',\n    description: 'Complex multi-service flow with parallel calls and error handling.',\n    source: `sequenceDiagram\n  participant G as Gateway\n  participant A as Auth\n  participant U as Users\n  participant O as Orders\n  participant N as Notify\n  G->>A: Validate token\n  A-->>G: Valid\n  par Fetch data\n    G->>U: Get user\n    U-->>G: User data\n  and\n    G->>O: Get orders\n    O-->>G: Order list\n  end\n  G->>N: Send notification\n  N-->>G: Queued\n  Note over G: Aggregate response`,\n  },\n  {\n    title: 'Sequence: Self-Messages with Notes',\n    category: 'Sequence',\n    description: 'Self-referencing messages inside alt blocks with notes — tests that notes clear self-message loops and stack without overlapping.',\n    source: `sequenceDiagram\n  participant User\n  participant Main as Main Process\n  participant Renderer\n  participant Timer as 3s Fallback Timer\n  User->>Main: CMD+W\n  Main->>Main: event.preventDefault()\n  Main->>Renderer: WINDOW_CLOSE_REQUESTED\n  Main->>Timer: Start 3s timer\n  alt Multiple panels\n    Renderer->>Renderer: closePanel(focusedId)\n    Note over Renderer: Panel removed\n    Note over Renderer: No confirmCloseWindow!\n    Timer-->>Main: 3s elapsed → window.destroy()\n  else Single panel\n    Renderer->>Renderer: closePanel(lastId)\n    Note over Renderer: Stack becomes []\n    Renderer->>Renderer: Auto-select fires → new panel created!\n    Note over Renderer: Panel reopens\n    Timer-->>Main: 3s elapsed → window.destroy()\n  end`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  CLASS DIAGRAMS — Core Features\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'Class: Basic Class',\n    category: 'Class',\n    description: 'A single class with attributes and methods, rendered as a 3-compartment box.',\n    source: `classDiagram\n  class Animal {\n    +String name\n    +int age\n    +eat() void\n    +sleep() void\n  }`,\n  },\n  {\n    title: 'Class: Visibility Markers',\n    category: 'Class',\n    description: 'All four visibility levels: `+` (public), `-` (private), `#` (protected), `~` (package).',\n    source: `classDiagram\n  class User {\n    +String name\n    -String password\n    #int internalId\n    ~String packageField\n    +login() bool\n    -hashPassword() String\n    #validate() void\n    ~notify() void\n  }`,\n  },\n  {\n    title: 'Class: Interface Annotation',\n    category: 'Class',\n    description: 'Using `<<interface>>` annotation above the class name.',\n    source: `classDiagram\n  class Serializable {\n    <<interface>>\n    +serialize() String\n    +deserialize(data) void\n  }`,\n  },\n  {\n    title: 'Class: Abstract Annotation',\n    category: 'Class',\n    description: 'Using `<<abstract>>` annotation for abstract classes.',\n    source: `classDiagram\n  class Shape {\n    <<abstract>>\n    +String color\n    +area() double\n    +draw() void\n  }`,\n  },\n  {\n    title: 'Class: Enum Annotation',\n    category: 'Class',\n    description: 'Using `<<enumeration>>` annotation for enum types.',\n    source: `classDiagram\n  class Status {\n    <<enumeration>>\n    ACTIVE\n    INACTIVE\n    PENDING\n    DELETED\n  }`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  CLASS DIAGRAMS — Relationships\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'Class: Inheritance (<|--)',\n    category: 'Class',\n    description: 'Inheritance relationship rendered with a hollow triangle marker.',\n    source: `classDiagram\n  class Animal {\n    +String name\n    +eat() void\n  }\n  class Dog {\n    +String breed\n    +bark() void\n  }\n  class Cat {\n    +bool isIndoor\n    +meow() void\n  }\n  Animal <|-- Dog\n  Animal <|-- Cat`,\n  },\n  {\n    title: 'Class: Composition (*--)',\n    category: 'Class',\n    description: 'Composition — \"owns\" relationship with filled diamond marker.',\n    source: `classDiagram\n  class Car {\n    +String model\n    +start() void\n  }\n  class Engine {\n    +int horsepower\n    +rev() void\n  }\n  Car *-- Engine`,\n  },\n  {\n    title: 'Class: Aggregation (o--)',\n    category: 'Class',\n    description: 'Aggregation — \"has\" relationship with hollow diamond marker.',\n    source: `classDiagram\n  class University {\n    +String name\n  }\n  class Department {\n    +String faculty\n  }\n  University o-- Department`,\n  },\n  {\n    title: 'Class: Association (-->)',\n    category: 'Class',\n    description: 'Basic association — simple directed arrow.',\n    source: `classDiagram\n  class Customer {\n    +String name\n  }\n  class Order {\n    +int orderId\n  }\n  Customer --> Order`,\n  },\n  {\n    title: 'Class: Dependency (..>)',\n    category: 'Class',\n    description: 'Dependency — dashed line with open arrow.',\n    source: `classDiagram\n  class Service {\n    +process() void\n  }\n  class Repository {\n    +find() Object\n  }\n  Service ..> Repository`,\n  },\n  {\n    title: 'Class: Realization (..|>)',\n    category: 'Class',\n    description: 'Realization — dashed line with hollow triangle (implements interface).',\n    source: `classDiagram\n  class Flyable {\n    <<interface>>\n    +fly() void\n  }\n  class Bird {\n    +fly() void\n    +sing() void\n  }\n  Bird ..|> Flyable`,\n  },\n  {\n    title: 'Class: All 6 Relationship Types',\n    category: 'Class',\n    description: 'Every relationship type in a single diagram for comparison.',\n    source: `classDiagram\n  A <|-- B : inheritance\n  C *-- D : composition\n  E o-- F : aggregation\n  G --> H : association\n  I ..> J : dependency\n  K ..|> L : realization`,\n  },\n  {\n    title: 'Class: Relationship Labels',\n    category: 'Class',\n    description: 'Labeled relationships between classes with descriptive text.',\n    source: `classDiagram\n  class Teacher {\n    +String name\n  }\n  class Student {\n    +String name\n  }\n  class Course {\n    +String title\n  }\n  Teacher --> Course : teaches\n  Student --> Course : enrolled in`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  CLASS DIAGRAMS — Complex / Real-World\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'Class: Design Pattern — Observer',\n    category: 'Class',\n    description: 'The Observer (publish-subscribe) design pattern with interface + concrete implementations.',\n    source: `classDiagram\n  class Subject {\n    <<interface>>\n    +attach(Observer) void\n    +detach(Observer) void\n    +notify() void\n  }\n  class Observer {\n    <<interface>>\n    +update() void\n  }\n  class EventEmitter {\n    -List~Observer~ observers\n    +attach(Observer) void\n    +detach(Observer) void\n    +notify() void\n  }\n  class Logger {\n    +update() void\n  }\n  class Alerter {\n    +update() void\n  }\n  Subject <|.. EventEmitter\n  Observer <|.. Logger\n  Observer <|.. Alerter\n  EventEmitter --> Observer`,\n  },\n  {\n    title: 'Class: MVC Architecture',\n    category: 'Class',\n    description: 'Model-View-Controller pattern showing relationships between layers.',\n    source: `classDiagram\n  class Model {\n    -data Map\n    +getData() Map\n    +setData(key, val) void\n    +notify() void\n  }\n  class View {\n    -model Model\n    +render() void\n    +update() void\n  }\n  class Controller {\n    -model Model\n    -view View\n    +handleInput(event) void\n    +updateModel(data) void\n  }\n  Controller --> Model : updates\n  Controller --> View : refreshes\n  View --> Model : reads\n  Model ..> View : notifies`,\n  },\n  {\n    title: 'Class: Full Hierarchy',\n    category: 'Class',\n    description: 'A complete class hierarchy with abstract base, interfaces, and concrete classes.',\n    source: `classDiagram\n  class Animal {\n    <<abstract>>\n    +String name\n    +int age\n    +eat() void\n    +sleep() void\n  }\n  class Mammal {\n    +bool warmBlooded\n    +nurse() void\n  }\n  class Bird {\n    +bool canFly\n    +layEggs() void\n  }\n  class Dog {\n    +String breed\n    +bark() void\n  }\n  class Cat {\n    +bool isIndoor\n    +purr() void\n  }\n  class Parrot {\n    +String vocabulary\n    +speak() void\n  }\n  Animal <|-- Mammal\n  Animal <|-- Bird\n  Mammal <|-- Dog\n  Mammal <|-- Cat\n  Bird <|-- Parrot`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  ER DIAGRAMS — Core Features\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'ER: Basic Relationship',\n    category: 'ER',\n    description: 'A simple one-to-many relationship between two entities.',\n    source: `erDiagram\n  CUSTOMER ||--o{ ORDER : places`,\n  },\n  {\n    title: 'ER: Entity with Attributes',\n    category: 'ER',\n    description: 'An entity with typed attributes and `PK`/`FK`/`UK` key badges.',\n    source: `erDiagram\n  CUSTOMER {\n    int id PK\n    string name\n    string email UK\n    date created_at\n  }`,\n  },\n  {\n    title: 'ER: Attribute Keys (PK, FK, UK)',\n    category: 'ER',\n    description: 'All three key constraint types rendered as badges.',\n    source: `erDiagram\n  ORDER {\n    int id PK\n    int customer_id FK\n    string invoice_number UK\n    decimal total\n    date order_date\n    string status\n  }`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  ER DIAGRAMS — Cardinality Types\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'ER: Exactly One to Exactly One (||--||)',\n    category: 'ER',\n    description: 'One-to-one mandatory relationship.',\n    source: `erDiagram\n  PERSON ||--|| PASSPORT : has`,\n  },\n  {\n    title: 'ER: Exactly One to Zero-or-Many (||--o{)',\n    category: 'ER',\n    description: 'Classic one-to-many optional relationship (crow\\'s foot).',\n    source: `erDiagram\n  CUSTOMER ||--o{ ORDER : places`,\n  },\n  {\n    title: 'ER: Zero-or-One to One-or-Many (|o--|{)',\n    category: 'ER',\n    description: 'Optional on one side, at-least-one on the other.',\n    source: `erDiagram\n  SUPERVISOR |o--|{ EMPLOYEE : manages`,\n  },\n  {\n    title: 'ER: One-or-More to Zero-or-Many (}|--o{)',\n    category: 'ER',\n    description: 'At-least-one to zero-or-many relationship.',\n    source: `erDiagram\n  TEACHER }|--o{ COURSE : teaches`,\n  },\n  {\n    title: 'ER: All Cardinality Types',\n    category: 'ER',\n    description: 'Every cardinality combination in one diagram.',\n    source: `erDiagram\n  A ||--|| B : one-to-one\n  C ||--o{ D : one-to-many\n  E |o--|{ F : opt-to-many\n  G }|--o{ H : many-to-many`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  ER DIAGRAMS — Line Styles\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'ER: Identifying (Solid) Relationship',\n    category: 'ER',\n    description: 'Solid line indicating an identifying relationship (child depends on parent for identity).',\n    source: `erDiagram\n  ORDER ||--|{ LINE_ITEM : contains`,\n  },\n  {\n    title: 'ER: Non-Identifying (Dashed) Relationship',\n    category: 'ER',\n    description: 'Dashed line indicating a non-identifying relationship.',\n    source: `erDiagram\n  USER ||..o{ LOG_ENTRY : generates\n  USER ||..o{ SESSION : opens`,\n  },\n  {\n    title: 'ER: Mixed Identifying & Non-Identifying',\n    category: 'ER',\n    description: 'Both solid and dashed lines in the same diagram.',\n    source: `erDiagram\n  ORDER ||--|{ LINE_ITEM : contains\n  ORDER ||..o{ SHIPMENT : ships-via\n  PRODUCT ||--o{ LINE_ITEM : includes\n  PRODUCT ||..o{ REVIEW : receives`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  ER DIAGRAMS — Complex / Real-World\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'ER: E-Commerce Schema',\n    category: 'ER',\n    description: 'Full e-commerce database schema with customers, orders, products, and line items.',\n    source: `erDiagram\n  CUSTOMER {\n    int id PK\n    string name\n    string email UK\n  }\n  ORDER {\n    int id PK\n    date created\n    int customer_id FK\n  }\n  PRODUCT {\n    int id PK\n    string name\n    float price\n  }\n  LINE_ITEM {\n    int id PK\n    int order_id FK\n    int product_id FK\n    int quantity\n  }\n  CUSTOMER ||--o{ ORDER : places\n  ORDER ||--|{ LINE_ITEM : contains\n  PRODUCT ||--o{ LINE_ITEM : includes`,\n  },\n  {\n    title: 'ER: Blog Platform Schema',\n    category: 'ER',\n    description: 'Blog system with users, posts, comments, and tags.',\n    source: `erDiagram\n  USER {\n    int id PK\n    string username UK\n    string email UK\n    date joined\n  }\n  POST {\n    int id PK\n    string title\n    text content\n    int author_id FK\n    date published\n  }\n  COMMENT {\n    int id PK\n    text body\n    int post_id FK\n    int user_id FK\n    date created\n  }\n  TAG {\n    int id PK\n    string name UK\n  }\n  USER ||--o{ POST : writes\n  USER ||--o{ COMMENT : authors\n  POST ||--o{ COMMENT : has\n  POST }|--o{ TAG : tagged-with`,\n  },\n  {\n    title: 'ER: School Management Schema',\n    category: 'ER',\n    description: 'School system with students, teachers, courses, and enrollments.',\n    source: `erDiagram\n  STUDENT {\n    int id PK\n    string name\n    date dob\n    string grade\n  }\n  TEACHER {\n    int id PK\n    string name\n    string department\n  }\n  COURSE {\n    int id PK\n    string title\n    int teacher_id FK\n    int credits\n  }\n  ENROLLMENT {\n    int id PK\n    int student_id FK\n    int course_id FK\n    string semester\n    float grade\n  }\n  TEACHER ||--o{ COURSE : teaches\n  STUDENT ||--o{ ENROLLMENT : enrolled\n  COURSE ||--o{ ENROLLMENT : has`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  XY CHARTS (xychart-beta)\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'XY: Simple Bar Chart',\n    category: 'XY Chart',\n    description: 'Basic bar chart with categorical x-axis.',\n    source: `xychart-beta\n    title \"Product Sales\"\n    x-axis [Widgets, Gadgets, Gizmos, Doodads, Thingamajigs]\n    bar [150, 230, 180, 95, 310]`,\n    options: { interactive: true },\n  },\n  {\n    title: 'XY: Line Chart',\n    category: 'XY Chart',\n    description: 'Line chart showing revenue growth over years.',\n    source: `xychart-beta\n    title \"Revenue Growth\"\n    x-axis [2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025]\n    line [320, 420, 540, 680, 820, 950, 1080, 1200]`,\n    options: { interactive: true },\n  },\n  {\n    title: 'XY: Bar and Line Overlay',\n    category: 'XY Chart',\n    description: 'Bars with a line overlay and both axis titles.',\n    source: `xychart-beta\n    title \"Monthly Revenue\"\n    x-axis \"Month\" [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]\n    y-axis \"Revenue (USD)\" 0 --> 10000\n    bar [4200, 5000, 5800, 6200, 5500, 7000, 7800, 7200, 8400, 8100, 9000, 9200]\n    line [4200, 5000, 5800, 6200, 5500, 7000, 7800, 7200, 8400, 8100, 9000, 9200]`,\n    options: { interactive: true },\n  },\n  {\n    title: 'XY: Horizontal Bars',\n    category: 'XY Chart',\n    description: 'Horizontal bar chart showing language popularity.',\n    source: `xychart-beta horizontal\n    title \"Language Popularity\"\n    x-axis [Python, JavaScript, Java, Go, Rust]\n    bar [30, 25, 20, 12, 8]`,\n    options: { interactive: true },\n  },\n  {\n    title: 'XY: Multiple Bar Series',\n    category: 'XY Chart',\n    description: 'Two bar series comparing years side by side.',\n    source: `xychart-beta\n    title \"2023 vs 2024 Sales\"\n    x-axis [Q1, Q2, Q3, Q4]\n    bar [200, 250, 300, 280]\n    bar [230, 280, 320, 350]`,\n    options: { interactive: true },\n  },\n  {\n    title: 'XY: Dual Lines',\n    category: 'XY Chart',\n    description: 'Two lines comparing planned vs actual values.',\n    source: `xychart-beta\n    title \"Planned vs Actual\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug]\n    line [100, 145, 190, 240, 280, 320, 360, 400]\n    line [90, 130, 185, 235, 275, 340, 380, 420]`,\n    options: { interactive: true },\n  },\n  {\n    title: 'XY: Numeric X-Axis',\n    category: 'XY Chart',\n    description: 'Line chart using a numeric x-axis range.',\n    source: `xychart-beta\n    title \"Distribution Curve\"\n    x-axis 0 --> 100\n    line [4, 7, 13, 21, 31, 43, 58, 71, 84, 91, 95, 91, 84, 71, 58, 43, 31, 21, 13, 7, 4]`,\n    options: { interactive: true },\n  },\n  {\n    title: 'XY: 12-Month Dataset',\n    category: 'XY Chart',\n    description: 'Full year monthly data with bar and trend line.',\n    source: `xychart-beta\n    title \"Monthly Active Users (2024)\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]\n    y-axis \"Users\" 0 --> 30000\n    bar [12000, 13500, 15200, 16800, 18500, 20100, 19800, 21500, 23000, 24200, 25800, 28000]\n    line [12000, 13500, 15200, 16800, 18500, 20100, 19800, 21500, 23000, 24200, 25800, 28000]`,\n    options: { interactive: true },\n  },\n  {\n    title: 'XY: Horizontal Combined',\n    category: 'XY Chart',\n    description: 'Horizontal chart with both bars and a trend line.',\n    source: `xychart-beta horizontal\n    title \"Budget vs Actual\"\n    x-axis [Eng, Sales, Marketing, Product, Ops, HR, Finance, Legal]\n    bar [500, 350, 250, 200, 150, 120, 100, 80]\n    line [480, 380, 230, 180, 160, 110, 95, 75]`,\n    options: { interactive: true },\n  },\n  {\n    title: 'XY: Sprint Burndown',\n    category: 'XY Chart',\n    description: 'Sprint burndown chart with actual and ideal lines.',\n    source: `xychart-beta\n    title \"Sprint Burndown\"\n    x-axis [D1, D2, D3, D4, D5, D6, D7, D8, D9, D10]\n    y-axis \"Story Points\" 0 --> 80\n    line [72, 65, 58, 50, 45, 38, 30, 22, 12, 0]\n    line [72, 65, 58, 50, 43, 36, 29, 22, 14, 0]`,\n    options: { interactive: true },\n  },\n]\n"
  },
  {
    "path": "src/__tests__/ascii-edge-styles.test.ts",
    "content": "// ============================================================================\n// ASCII edge style tests — dotted and thick line rendering\n// ============================================================================\n\nimport { describe, it, expect } from 'bun:test'\nimport { renderMermaidAscii } from '../ascii/index.ts'\n\ndescribe('ASCII edge styles', () => {\n  describe('solid edges (default)', () => {\n    it('renders solid edges with ─ in unicode mode', () => {\n      const result = renderMermaidAscii(`\n        graph LR\n          A --> B\n      `)\n      expect(result).toContain('─')\n      expect(result).not.toContain('┄')\n      expect(result).not.toContain('━')\n    })\n\n    it('renders solid edges with - in ascii mode', () => {\n      const result = renderMermaidAscii(`\n        graph LR\n          A --> B\n      `, { useAscii: true })\n      expect(result).toContain('-')\n    })\n  })\n\n  describe('dotted edges (-.->)', () => {\n    it('renders dotted edges with ┄ in unicode mode', () => {\n      const result = renderMermaidAscii(`\n        graph LR\n          A -.-> B\n      `)\n      // Should contain dotted horizontal line character\n      expect(result).toContain('┄')\n    })\n\n    it('renders dotted edges with . in ascii mode', () => {\n      const result = renderMermaidAscii(`\n        graph LR\n          A -.-> B\n      `, { useAscii: true })\n      // Should contain dots for dotted lines\n      expect(result).toContain('.')\n    })\n\n    it('renders dotted vertical edges with ┆ in unicode mode', () => {\n      const result = renderMermaidAscii(`\n        graph TD\n          A -.-> B\n      `)\n      // Should contain dotted vertical line character\n      expect(result).toContain('┆')\n    })\n\n    it('renders dotted vertical edges with : in ascii mode', () => {\n      const result = renderMermaidAscii(`\n        graph TD\n          A -.-> B\n      `, { useAscii: true })\n      // Should contain colons for dotted vertical lines\n      expect(result).toContain(':')\n    })\n\n    it('renders dotted edges with labels', () => {\n      const result = renderMermaidAscii(`\n        graph LR\n          A -.->|optional| B\n      `)\n      expect(result).toContain('┄')\n      expect(result).toContain('optional')\n    })\n  })\n\n  describe('thick edges (==>)', () => {\n    it('renders thick edges with ━ in unicode mode', () => {\n      const result = renderMermaidAscii(`\n        graph LR\n          A ==> B\n      `)\n      // Should contain thick horizontal line character\n      expect(result).toContain('━')\n    })\n\n    it('renders thick edges with = in ascii mode', () => {\n      const result = renderMermaidAscii(`\n        graph LR\n          A ==> B\n      `, { useAscii: true })\n      // Should contain equals for thick lines\n      expect(result).toContain('=')\n    })\n\n    it('renders thick vertical edges with ┃ in unicode mode', () => {\n      const result = renderMermaidAscii(`\n        graph TD\n          A ==> B\n      `)\n      // Should contain thick vertical line character\n      expect(result).toContain('┃')\n    })\n  })\n\n  describe('mixed edge styles', () => {\n    it('renders different styles in the same diagram', () => {\n      const result = renderMermaidAscii(`\n        graph LR\n          A --> B\n          B -.-> C\n          C ==> D\n      `)\n      // Should have all three line types\n      expect(result).toContain('─')  // solid\n      expect(result).toContain('┄')  // dotted\n      expect(result).toContain('━')  // thick\n    })\n\n    it('renders mixed styles in ascii mode', () => {\n      const result = renderMermaidAscii(`\n        graph LR\n          A --> B\n          B -.-> C\n          C ==> D\n      `, { useAscii: true })\n      // Note: ASCII mode uses - for solid, . for dotted, = for thick\n      // We just check that the diagram renders without error\n      expect(result).toContain('A')\n      expect(result).toContain('B')\n      expect(result).toContain('C')\n      expect(result).toContain('D')\n    })\n  })\n})\n"
  },
  {
    "path": "src/__tests__/ascii-multiline.test.ts",
    "content": "import { describe, it, expect } from 'bun:test'\nimport { renderMermaidAscii } from '../ascii/index.ts'\n\ndescribe('ASCII multi-line labels', () => {\n  describe('flowchart nodes', () => {\n    it('renders multi-line node labels', () => {\n      const ascii = renderMermaidAscii('graph TD\\n  A[Line1<br>Line2]', { useAscii: false })\n      expect(ascii).toContain('Line1')\n      expect(ascii).toContain('Line2')\n      // Lines should be on different rows\n      const lines = ascii.split('\\n')\n      const line1Row = lines.findIndex(l => l.includes('Line1'))\n      const line2Row = lines.findIndex(l => l.includes('Line2'))\n      expect(line2Row).toBeGreaterThan(line1Row)\n    })\n\n    it('handles 3+ line labels', () => {\n      const ascii = renderMermaidAscii('graph TD\\n  A[A<br>B<br>C]', { useAscii: false })\n      expect(ascii).toContain('A')\n      expect(ascii).toContain('B')\n      expect(ascii).toContain('C')\n      // Verify vertical ordering\n      const lines = ascii.split('\\n')\n      const aRow = lines.findIndex(l => l.includes('A') && !l.includes('─') && !l.includes('-'))\n      const bRow = lines.findIndex(l => l.includes('B'))\n      const cRow = lines.findIndex(l => l.includes('C'))\n      expect(bRow).toBeGreaterThan(aRow)\n      expect(cRow).toBeGreaterThan(bRow)\n    })\n\n    it('renders in ASCII mode (not Unicode)', () => {\n      const ascii = renderMermaidAscii('graph TD\\n  A[Line1<br>Line2]', { useAscii: true })\n      expect(ascii).toContain('Line1')\n      expect(ascii).toContain('Line2')\n      // Should use ASCII box characters\n      expect(ascii).toContain('+')\n      expect(ascii).toContain('-')\n    })\n  })\n\n  describe('flowchart edge labels', () => {\n    it('renders multi-line edge labels', () => {\n      const ascii = renderMermaidAscii('graph TD\\n  A --> B\\n  A -->|Line1<br>Line2| C', { useAscii: false })\n      expect(ascii).toContain('Line1')\n      expect(ascii).toContain('Line2')\n    })\n  })\n\n  describe('flowchart subgraph labels', () => {\n    it('renders multi-line subgraph labels', () => {\n      const ascii = renderMermaidAscii(`graph TD\n        subgraph sg [Group<br>Header]\n          A[Node]\n        end\n      `, { useAscii: false })\n      expect(ascii).toContain('Group')\n      expect(ascii).toContain('Header')\n    })\n  })\n\n  describe('sequence diagram', () => {\n    it('renders multi-line actor labels', () => {\n      const ascii = renderMermaidAscii(`sequenceDiagram\n        participant A as Actor<br>One\n        A->>A: msg\n      `, { useAscii: false })\n      expect(ascii).toContain('Actor')\n      expect(ascii).toContain('One')\n    })\n\n    it('renders multi-line message labels', () => {\n      const ascii = renderMermaidAscii(`sequenceDiagram\n        participant A\n        participant B\n        A->>B: Line1<br>Line2\n      `, { useAscii: false })\n      expect(ascii).toContain('Line1')\n      expect(ascii).toContain('Line2')\n    })\n\n    it('preserves existing note multi-line support', () => {\n      const ascii = renderMermaidAscii(`sequenceDiagram\n        participant A\n        A->>A: self\n        Note over A: Note line 1<br>Note line 2\n      `, { useAscii: false })\n      expect(ascii).toContain('Note line 1')\n      expect(ascii).toContain('Note line 2')\n    })\n  })\n\n  describe('class diagram', () => {\n    it('renders multi-line class names', () => {\n      const ascii = renderMermaidAscii(`classDiagram\n        class MyClass[\"Long<br>Name\"]\n      `, { useAscii: false })\n      expect(ascii).toContain('Long')\n      expect(ascii).toContain('Name')\n    })\n\n    it('renders multi-line relationship labels', () => {\n      const ascii = renderMermaidAscii(`classDiagram\n        A --> B : uses<br>implements\n      `, { useAscii: false })\n      expect(ascii).toContain('uses')\n      expect(ascii).toContain('implements')\n    })\n  })\n\n  describe('ER diagram', () => {\n    it('renders multi-line entity names', () => {\n      const ascii = renderMermaidAscii(`erDiagram\n        \"Entity<br>Name\" {\n          string id\n        }\n      `, { useAscii: false })\n      expect(ascii).toContain('Entity')\n      expect(ascii).toContain('Name')\n    })\n\n    it('renders multi-line relationship labels', () => {\n      const ascii = renderMermaidAscii(`erDiagram\n        A ||--o{ B : \"has<br>many\"\n      `, { useAscii: false })\n      expect(ascii).toContain('has')\n      expect(ascii).toContain('many')\n    })\n  })\n\n  describe('edge cases', () => {\n    it('handles empty lines from consecutive <br>', () => {\n      const ascii = renderMermaidAscii('graph TD\\n  A[Line1<br><br>Line3]', { useAscii: false })\n      expect(ascii).toContain('Line1')\n      expect(ascii).toContain('Line3')\n    })\n\n    it('handles single-line labels (no <br>)', () => {\n      const ascii = renderMermaidAscii('graph TD\\n  A[SingleLine]', { useAscii: false })\n      expect(ascii).toContain('SingleLine')\n    })\n\n    it('handles very long lines', () => {\n      const long = 'A'.repeat(30)\n      const ascii = renderMermaidAscii(`graph TD\\n  A[${long}<br>Short]`, { useAscii: false })\n      expect(ascii).toContain(long)\n      expect(ascii).toContain('Short')\n    })\n\n    it('handles mixed short and long lines', () => {\n      const ascii = renderMermaidAscii('graph TD\\n  A[Short<br>VeryLongSecondLine<br>Med]', { useAscii: false })\n      expect(ascii).toContain('Short')\n      expect(ascii).toContain('VeryLongSecondLine')\n      expect(ascii).toContain('Med')\n    })\n  })\n\n  describe('multiline-utils functions', () => {\n    it('splitLines splits on newlines', () => {\n      // Test through the rendering pipeline\n      const ascii = renderMermaidAscii('graph TD\\n  A[One<br>Two<br>Three]', { useAscii: false })\n      const lines = ascii.split('\\n')\n      // All three words should appear on separate lines\n      expect(lines.some(l => l.includes('One'))).toBe(true)\n      expect(lines.some(l => l.includes('Two'))).toBe(true)\n      expect(lines.some(l => l.includes('Three'))).toBe(true)\n    })\n\n    it('maxLineWidth uses longest line for box sizing', () => {\n      // Box should be wide enough for the longest line\n      const ascii = renderMermaidAscii('graph TD\\n  A[X<br>LongLine<br>Y]', { useAscii: false })\n      // The box should contain LongLine without truncation\n      expect(ascii).toContain('LongLine')\n    })\n  })\n})\n"
  },
  {
    "path": "src/__tests__/ascii.test.ts",
    "content": "/**\n * Golden-file tests for the ASCII/Unicode renderer.\n *\n * Ported from AlexanderGrooff/mermaid-ascii cmd/graph_test.go.\n * Each .txt file contains mermaid input above a `---` separator\n * and the expected ASCII/Unicode output below it.\n *\n * Test data: 44 ASCII files + 22 Unicode files = 66 total.\n */\nimport { describe, it, expect } from 'bun:test'\nimport { renderMermaidAscii } from '../ascii/index.ts'\nimport { hasDiagonalLines, DIAGONAL_CHARS } from '../ascii/validate.ts'\nimport { readdirSync, readFileSync } from 'node:fs'\nimport { join } from 'node:path'\n\n// ============================================================================\n// Test case parser — matches Go's testutil.ReadTestCase format\n// ============================================================================\n\ninterface TestCase {\n  mermaid: string\n  expected: string\n  paddingX: number\n  paddingY: number\n}\n\n/**\n * Parse a golden test file into its components.\n * Format:\n *   [paddingX=N]     (optional)\n *   [paddingY=N]     (optional)\n *   <mermaid code>\n *   ---\n *   <expected output>\n */\nfunction parseTestCase(content: string): TestCase {\n  const tc: TestCase = { mermaid: '', expected: '', paddingX: 5, paddingY: 5 }\n  const lines = content.split('\\n')\n  const paddingRegex = /^(?:padding([xy]))\\s*=\\s*(\\d+)\\s*$/i\n\n  let inMermaid = true\n  let mermaidStarted = false\n  const mermaidLines: string[] = []\n  const expectedLines: string[] = []\n\n  for (const line of lines) {\n    if (line === '---') {\n      inMermaid = false\n      continue\n    }\n\n    if (inMermaid) {\n      const trimmed = line.trim()\n\n      // Before mermaid code starts, parse padding directives and skip blanks\n      if (!mermaidStarted) {\n        if (trimmed === '') continue\n        const match = trimmed.match(paddingRegex)\n        if (match) {\n          const value = parseInt(match[2]!, 10)\n          if (match[1]!.toLowerCase() === 'x') {\n            tc.paddingX = value\n          } else {\n            tc.paddingY = value\n          }\n          continue\n        }\n      }\n\n      mermaidStarted = true\n      mermaidLines.push(line)\n    } else {\n      expectedLines.push(line)\n    }\n  }\n\n  tc.mermaid = mermaidLines.join('\\n') + '\\n'\n\n  // Strip final trailing newline (matches Go's strings.TrimSuffix(expected, \"\\n\"))\n  let expected = expectedLines.join('\\n')\n  if (expected.endsWith('\\n')) {\n    expected = expected.slice(0, -1)\n  }\n  tc.expected = expected\n\n  return tc\n}\n\n// ============================================================================\n// Whitespace normalization — matches Go's testutil.NormalizeWhitespace\n// ============================================================================\n\n/**\n * Normalize whitespace for comparison:\n * - Trim trailing spaces from each line\n * - Remove leading/trailing blank lines\n */\nfunction normalizeWhitespace(s: string): string {\n  const lines = s.split('\\n')\n  let normalized = lines.map(l => l.trimEnd())\n\n  // Remove leading blank lines\n  while (normalized.length > 0 && normalized[0] === '') {\n    normalized.shift()\n  }\n  // Remove trailing blank lines\n  while (normalized.length > 0 && normalized[normalized.length - 1] === '') {\n    normalized.pop()\n  }\n\n  return normalized.join('\\n')\n}\n\n/** Replace spaces with middle dots for clearer diff output. */\nfunction visualizeWhitespace(s: string): string {\n  return s.replaceAll(' ', '·')\n}\n\n// ============================================================================\n// Test runner — dynamically loads all golden files from testdata directories\n// ============================================================================\n\nfunction runGoldenTests(dir: string, useAscii: boolean): void {\n  const files = readdirSync(dir).filter(f => f.endsWith('.txt')).sort()\n\n  for (const file of files) {\n    const testName = file.replace('.txt', '')\n\n    it(testName, () => {\n      const content = readFileSync(join(dir, file), 'utf-8')\n      const tc = parseTestCase(content)\n\n      const actual = renderMermaidAscii(tc.mermaid, {\n        useAscii,\n        paddingX: tc.paddingX,\n        paddingY: tc.paddingY,\n      })\n\n      const normalizedExpected = normalizeWhitespace(tc.expected)\n      const normalizedActual = normalizeWhitespace(actual)\n\n      if (normalizedExpected !== normalizedActual) {\n        const expectedVis = visualizeWhitespace(normalizedExpected)\n        const actualVis = visualizeWhitespace(normalizedActual)\n        expect(actualVis).toBe(expectedVis)\n      }\n    })\n  }\n}\n\n// ============================================================================\n// Test suites\n// ============================================================================\n\nconst testdataDir = join(import.meta.dir, 'testdata')\n\ndescribe('ASCII rendering', () => {\n  runGoldenTests(join(testdataDir, 'ascii'), true)\n})\n\ndescribe('Unicode rendering', () => {\n  runGoldenTests(join(testdataDir, 'unicode'), false)\n})\n\n// ============================================================================\n// Config behavior tests — ported from Go's TestGraphUseAsciiConfig\n// ============================================================================\n\ndescribe('Config behavior', () => {\n  const mermaidInput = 'graph LR\\nA --> B'\n\n  it('ASCII and Unicode outputs should differ', () => {\n    const asciiOutput = renderMermaidAscii(mermaidInput, { useAscii: true })\n    const unicodeOutput = renderMermaidAscii(mermaidInput, { useAscii: false })\n    expect(asciiOutput).not.toBe(unicodeOutput)\n  })\n\n  it('ASCII output should not contain Unicode box-drawing characters', () => {\n    const output = renderMermaidAscii(mermaidInput, { useAscii: true })\n    expect(output).not.toContain('┌')\n    expect(output).not.toContain('─')\n    expect(output).not.toContain('│')\n  })\n\n  it('Unicode output should contain Unicode box-drawing characters', () => {\n    const output = renderMermaidAscii(mermaidInput, { useAscii: false })\n    const hasUnicode = output.includes('┌') || output.includes('─') || output.includes('│')\n    expect(hasUnicode).toBe(true)\n  })\n})\n\n// ============================================================================\n// Diagonal validation — ensures all edges use orthogonal Manhattan routing\n// ============================================================================\n\ndescribe('Diagonal validation', () => {\n  const asciiDir = join(testdataDir, 'ascii')\n  const unicodeDir = join(testdataDir, 'unicode')\n\n  it('ASCII output should never contain diagonal characters', () => {\n    // Test all ASCII golden files\n    const files = readdirSync(asciiDir).filter((f) => f.endsWith('.txt'))\n    for (const file of files) {\n      const content = readFileSync(join(asciiDir, file), 'utf-8')\n      const { mermaid, paddingX, paddingY } = parseTestCase(content)\n      const output = renderMermaidAscii(mermaid, {\n        useAscii: true,\n        boxBorderPadding: paddingX,\n        paddingY: paddingY,\n      })\n\n      // Check for diagonal characters\n      for (const char of DIAGONAL_CHARS.ascii) {\n        expect(output).not.toContain(char)\n      }\n    }\n  })\n\n  it('Unicode output should never contain diagonal characters', () => {\n    // Test all Unicode golden files\n    const files = readdirSync(unicodeDir).filter((f) => f.endsWith('.txt'))\n    for (const file of files) {\n      const content = readFileSync(join(unicodeDir, file), 'utf-8')\n      const { mermaid, paddingX, paddingY } = parseTestCase(content)\n      const output = renderMermaidAscii(mermaid, {\n        useAscii: false,\n        boxBorderPadding: paddingX,\n        paddingY: paddingY,\n      })\n\n      // Check for diagonal characters\n      for (const char of DIAGONAL_CHARS.unicode) {\n        expect(output).not.toContain(char)\n      }\n    }\n  })\n\n  it('hasDiagonalLines utility correctly detects diagonal characters', () => {\n    // Should detect ASCII diagonals\n    expect(hasDiagonalLines('A / B')).toBe(true)\n    expect(hasDiagonalLines('A \\\\ B')).toBe(true)\n\n    // Should detect Unicode diagonals\n    expect(hasDiagonalLines('A ╱ B')).toBe(true)\n    expect(hasDiagonalLines('A ╲ B')).toBe(true)\n\n    // Should not flag clean output\n    expect(hasDiagonalLines('┌───┐\\n│ A │\\n└───┘')).toBe(false)\n    expect(hasDiagonalLines('+---+\\n| A |\\n+---+')).toBe(false)\n  })\n})\n"
  },
  {
    "path": "src/__tests__/class-arrow-directions.test.ts",
    "content": "/**\n * Comprehensive tests for class diagram arrow directions.\n *\n * Ensures all relationship types have correctly oriented arrows:\n * - Inheritance/Realization: hollow triangles point toward parent/interface\n * - Association/Dependency: filled arrows point from source to target\n * - Composition/Aggregation: diamonds are omnidirectional\n */\n\nimport { describe, test, expect } from 'bun:test'\nimport { renderMermaidAscii } from '../ascii/index.ts'\n\ndescribe('Class Diagram Arrow Directions', () => {\n\n  // ============================================================================\n  // INHERITANCE (<|--)\n  // ============================================================================\n\n  describe('Inheritance (<|--)', () => {\n    test('parent above child - triangle points UP toward parent', () => {\n      const diagram = `classDiagram\n        Animal <|-- Dog`\n      const result = renderMermaidAscii(diagram)\n\n      // Should contain upward triangle\n      expect(result).toContain('△')\n      expect(result).not.toContain('▽')\n\n      // Parent should be above child\n      const lines = result.split('\\n')\n      const animalLine = lines.findIndex(l => l.includes('Animal'))\n      const dogLine = lines.findIndex(l => l.includes('Dog'))\n      expect(animalLine).toBeLessThan(dogLine)\n    })\n\n    test('multiple inheritance creates separate arrows', () => {\n      const diagram = `classDiagram\n        Animal <|-- Dog\n        Animal <|-- Cat\n        Dog <|-- Puppy`\n      const result = renderMermaidAscii(diagram)\n\n      // Animal should be at top, then Dog/Cat, then Puppy\n      const lines = result.split('\\n')\n      const animalLine = lines.findIndex(l => l.includes('Animal'))\n      const dogLine = lines.findIndex(l => l.includes('Dog'))\n      const catLine = lines.findIndex(l => l.includes('Cat'))\n      const puppyLine = lines.findIndex(l => l.includes('Puppy'))\n\n      expect(animalLine).toBeLessThan(dogLine)\n      expect(animalLine).toBeLessThan(catLine)\n      expect(dogLine).toBeLessThan(puppyLine)\n    })\n\n    test('multi-level inheritance - all triangles point UP', () => {\n      const diagram = `classDiagram\n        Animal <|-- Mammal\n        Mammal <|-- Dog`\n      const result = renderMermaidAscii(diagram)\n\n      // Verify ordering: Animal > Mammal > Dog (top to bottom)\n      const lines = result.split('\\n')\n      const animalLine = lines.findIndex(l => l.includes('Animal'))\n      const mammalLine = lines.findIndex(l => l.includes('Mammal'))\n      const dogLine = lines.findIndex(l => l.includes('Dog'))\n\n      expect(animalLine).toBeLessThan(mammalLine)\n      expect(mammalLine).toBeLessThan(dogLine)\n\n      // All triangles should point up\n      expect(result.match(/△/g)?.length).toBe(2)\n    })\n\n    test('multiple inheritance from same parent', () => {\n      const diagram = `classDiagram\n        Animal <|-- Dog\n        Animal <|-- Cat`\n      const result = renderMermaidAscii(diagram)\n\n      // Animal should be above both children\n      const lines = result.split('\\n')\n      const animalLine = lines.findIndex(l => l.includes('Animal'))\n      const dogLine = lines.findIndex(l => l.includes('Dog'))\n      const catLine = lines.findIndex(l => l.includes('Cat'))\n\n      expect(animalLine).toBeLessThan(dogLine)\n      expect(animalLine).toBeLessThan(catLine)\n\n      // Should have at least one triangle pointing up (may merge visually)\n      expect(result).toContain('△')\n    })\n\n    test('ASCII mode uses ^ for upward triangle', () => {\n      const diagram = `classDiagram\n        Animal <|-- Dog`\n      const result = renderMermaidAscii(diagram, { useAscii: true })\n\n      expect(result).toContain('^')\n      expect(result).not.toContain('v')\n    })\n  })\n\n  // ============================================================================\n  // ASSOCIATION (-->)\n  // ============================================================================\n\n  describe('Association (-->)', () => {\n    test('source above target - arrow points DOWN', () => {\n      const diagram = `classDiagram\n        Person --> Address`\n      const result = renderMermaidAscii(diagram)\n\n      // Should contain downward arrow\n      expect(result).toContain('▼')\n      expect(result).not.toContain('▲')\n\n      // Person should be above Address\n      const lines = result.split('\\n')\n      const personLine = lines.findIndex(l => l.includes('Person'))\n      const addressLine = lines.findIndex(l => l.includes('Address'))\n      expect(personLine).toBeLessThan(addressLine)\n    })\n\n    test('multiple associations from same source', () => {\n      const diagram = `classDiagram\n        Person --> Address\n        Person --> Phone`\n      const result = renderMermaidAscii(diagram)\n\n      // Person should be above both targets\n      const lines = result.split('\\n')\n      const personLine = lines.findIndex(l => l.includes('Person'))\n      const addressLine = lines.findIndex(l => l.includes('Address'))\n      const phoneLine = lines.findIndex(l => l.includes('Phone'))\n\n      expect(personLine).toBeLessThan(addressLine)\n      expect(personLine).toBeLessThan(phoneLine)\n    })\n\n    test('chain of associations', () => {\n      const diagram = `classDiagram\n        A --> B\n        B --> C`\n      const result = renderMermaidAscii(diagram)\n\n      // A > B > C ordering\n      const lines = result.split('\\n')\n      const aLine = lines.findIndex(l => l.includes('│ A │'))\n      const bLine = lines.findIndex(l => l.includes('│ B │'))\n      const cLine = lines.findIndex(l => l.includes('│ C │'))\n\n      expect(aLine).toBeLessThan(bLine)\n      expect(bLine).toBeLessThan(cLine)\n\n      // Both arrows point down\n      expect(result.match(/▼/g)?.length).toBe(2)\n    })\n\n    test('ASCII mode uses v for downward arrow', () => {\n      const diagram = `classDiagram\n        Person --> Address`\n      const result = renderMermaidAscii(diagram, { useAscii: true })\n\n      expect(result).toContain('v')\n      expect(result).not.toContain('^')\n    })\n  })\n\n  // ============================================================================\n  // DEPENDENCY (..>)\n  // ============================================================================\n\n  describe('Dependency (..>)', () => {\n    test('source above target - arrow points DOWN', () => {\n      const diagram = `classDiagram\n        Client ..> Server`\n      const result = renderMermaidAscii(diagram)\n\n      expect(result).toContain('▼')\n      expect(result).not.toContain('▲')\n\n      const lines = result.split('\\n')\n      const clientLine = lines.findIndex(l => l.includes('Client'))\n      const serverLine = lines.findIndex(l => l.includes('Server'))\n      expect(clientLine).toBeLessThan(serverLine)\n    })\n\n    test('multiple dependencies', () => {\n      const diagram = `classDiagram\n        Client ..> Server\n        Client ..> Database`\n      const result = renderMermaidAscii(diagram)\n\n      const lines = result.split('\\n')\n      const clientLine = lines.findIndex(l => l.includes('Client'))\n      const serverLine = lines.findIndex(l => l.includes('Server'))\n      const dbLine = lines.findIndex(l => l.includes('Database'))\n\n      expect(clientLine).toBeLessThan(serverLine)\n      expect(clientLine).toBeLessThan(dbLine)\n    })\n\n    test('ASCII mode uses v for downward arrow', () => {\n      const diagram = `classDiagram\n        Client ..> Server`\n      const result = renderMermaidAscii(diagram, { useAscii: true })\n\n      expect(result).toContain('v')\n    })\n  })\n\n  // ============================================================================\n  // REALIZATION (..|>)\n  // ============================================================================\n\n  describe('Realization (..|>)', () => {\n    test('interface above implementation - triangle points UP', () => {\n      // Circle ..|> Shape means \"Circle implements Shape\"\n      // Shape (interface/parent) should be placed ABOVE Circle (implementation/child)\n      const diagram = `classDiagram\n        Circle ..|> Shape`\n      const result = renderMermaidAscii(diagram)\n\n      // Shape (interface) should be above Circle (implementation)\n      const lines = result.split('\\n')\n      const shapeLine = lines.findIndex(l => l.includes('Shape'))\n      const circleLine = lines.findIndex(l => l.includes('Circle'))\n      expect(shapeLine).toBeLessThan(circleLine)\n      expect(result).toContain('△')\n    })\n\n    test('realization with <|.. syntax (marker at from end)', () => {\n      // Shape <|.. Circle means \"Circle implements Shape\" (same as Circle ..|> Shape)\n      const diagram = `classDiagram\n        Shape <|.. Circle`\n      const result = renderMermaidAscii(diagram)\n\n      // Shape (interface) should be above Circle (implementation)\n      const lines = result.split('\\n')\n      const shapeLine = lines.findIndex(l => l.includes('Shape'))\n      const circleLine = lines.findIndex(l => l.includes('Circle'))\n      expect(shapeLine).toBeLessThan(circleLine)\n      expect(result).toContain('△')\n    })\n\n    test('multiple implementations', () => {\n      // Circle and Square both implement Shape\n      const diagram = `classDiagram\n        Circle ..|> Shape\n        Square ..|> Shape`\n      const result = renderMermaidAscii(diagram)\n\n      // Shape (interface) above both implementations\n      const lines = result.split('\\n')\n      const shapeLine = lines.findIndex(l => l.includes('Shape'))\n      const circleLine = lines.findIndex(l => l.includes('Circle'))\n      const squareLine = lines.findIndex(l => l.includes('Square'))\n\n      expect(shapeLine).toBeLessThan(circleLine)\n      expect(shapeLine).toBeLessThan(squareLine)\n      // At least one triangle (may merge visually if same connection point)\n      expect(result).toContain('△')\n    })\n  })\n\n  // ============================================================================\n  // COMPOSITION & AGGREGATION (omnidirectional diamonds)\n  // ============================================================================\n\n  describe('Composition (*--) and Aggregation (o--)', () => {\n    test('composition - diamond is omnidirectional', () => {\n      const diagram = `classDiagram\n        Car *-- Engine`\n      const result = renderMermaidAscii(diagram)\n\n      // Should contain filled diamond\n      expect(result).toContain('◆')\n    })\n\n    test('aggregation - hollow diamond is omnidirectional', () => {\n      const diagram = `classDiagram\n        Team o-- Player`\n      const result = renderMermaidAscii(diagram)\n\n      // Should contain hollow diamond\n      expect(result).toContain('◇')\n    })\n  })\n\n  // ============================================================================\n  // MIXED SCENARIOS\n  // ============================================================================\n\n  describe('Mixed Relationship Scenarios', () => {\n    test('all 6 relationship types together', () => {\n      const diagram = `classDiagram\n        A <|-- B : inheritance\n        C *-- D : composition\n        E o-- F : aggregation\n        G --> H : association\n        I ..> J : dependency\n        K ..|> L : realization`\n      const result = renderMermaidAscii(diagram)\n\n      // Upward triangles for inheritance and realization\n      expect(result.match(/△/g)?.length).toBe(2)\n\n      // Downward arrows for association and dependency\n      expect(result.match(/▼/g)?.length).toBe(2)\n\n      // Diamonds for composition and aggregation\n      expect(result).toContain('◆')\n      expect(result).toContain('◇')\n    })\n\n    test('inheritance with association - different arrow directions', () => {\n      const diagram = `classDiagram\n        Animal <|-- Dog\n        Dog --> Food`\n      const result = renderMermaidAscii(diagram)\n\n      // Should have both up triangle (inheritance) and down arrow (association)\n      expect(result).toContain('△')\n      expect(result).toContain('▼')\n    })\n\n    test('circular reference creates valid layout', () => {\n      const diagram = `classDiagram\n        A --> B\n        B --> C\n        C ..> A`\n      const result = renderMermaidAscii(diagram)\n\n      // Cycles may create mixed arrow directions (up and down) to avoid overlaps\n      // Just verify arrows are present and classes are rendered\n      const hasUpArrow = result.includes('▲')\n      const hasDownArrow = result.includes('▼')\n      expect(hasUpArrow || hasDownArrow).toBe(true)\n      expect(result).toContain('│ A │')\n      expect(result).toContain('│ B │')\n      expect(result).toContain('│ C │')\n    })\n  })\n\n  // ============================================================================\n  // ASCII vs UNICODE CONSISTENCY\n  // ============================================================================\n\n  describe('ASCII and Unicode Mode Consistency', () => {\n    test('same diagram produces consistent layouts in both modes', () => {\n      const diagram = `classDiagram\n        Animal <|-- Dog\n        Person --> Address`\n\n      const unicode = renderMermaidAscii(diagram)\n      const ascii = renderMermaidAscii(diagram, { useAscii: true })\n\n      // Both should have same node ordering\n      const unicodeLines = unicode.split('\\n')\n      const asciiLines = ascii.split('\\n')\n\n      const uAnimal = unicodeLines.findIndex(l => l.includes('Animal'))\n      const uDog = unicodeLines.findIndex(l => l.includes('Dog'))\n      const aPerson = asciiLines.findIndex(l => l.includes('Person'))\n      const aAddress = asciiLines.findIndex(l => l.includes('Address'))\n\n      expect(uAnimal).toBeLessThan(uDog)\n      expect(aPerson).toBeLessThan(aAddress)\n\n      // Unicode has △ and ▼, ASCII has ^ and v\n      expect(unicode).toContain('△')\n      expect(unicode).toContain('▼')\n      expect(ascii).toContain('^')\n      expect(ascii).toContain('v')\n    })\n  })\n\n  // ============================================================================\n  // EDGE CASES\n  // ============================================================================\n\n  describe('Edge Cases', () => {\n    test('single inheritance relationship', () => {\n      const diagram = `classDiagram\n        A <|-- B`\n      const result = renderMermaidAscii(diagram)\n\n      expect(result).toContain('△')\n      const lines = result.split('\\n')\n      const aLine = lines.findIndex(l => l.includes('│ A │'))\n      const bLine = lines.findIndex(l => l.includes('│ B │'))\n      expect(aLine).toBeLessThan(bLine)\n    })\n\n    test('classes with members maintain arrow directions', () => {\n      const diagram = `classDiagram\n        class Animal {\n          +String name\n          +eat() void\n        }\n        class Dog {\n          +bark() void\n        }\n        Animal <|-- Dog`\n      const result = renderMermaidAscii(diagram)\n\n      expect(result).toContain('△')\n      const lines = result.split('\\n')\n      const animalLine = lines.findIndex(l => l.includes('Animal'))\n      const dogLine = lines.findIndex(l => l.includes('Dog'))\n      expect(animalLine).toBeLessThan(dogLine)\n    })\n  })\n})\n"
  },
  {
    "path": "src/__tests__/class-integration.test.ts",
    "content": "/**\n * Integration tests for class diagrams — end-to-end parse → layout → render.\n */\nimport { describe, it, expect } from 'bun:test'\nimport { renderMermaidSVG } from '../index.ts'\n\ndescribe('renderMermaidSVG – class diagrams', () => {\n  it('renders a basic class diagram to valid SVG', () => {\n    const svg = renderMermaidSVG(`classDiagram\n      class Animal {\n        +String name\n        +eat() void\n      }`)\n    expect(svg).toContain('<svg')\n    expect(svg).toContain('</svg>')\n    expect(svg).toContain('Animal')\n    expect(svg).toContain('name')\n    expect(svg).toContain('eat')\n  })\n\n  it('renders class with annotation', () => {\n    const svg = renderMermaidSVG(`classDiagram\n      class Flyable {\n        <<interface>>\n        +fly() void\n      }`)\n    expect(svg).toContain('interface')\n    expect(svg).toContain('Flyable')\n    expect(svg).toContain('fly')\n  })\n\n  it('renders inheritance relationship with triangle marker', () => {\n    const svg = renderMermaidSVG(`classDiagram\n      Animal <|-- Dog`)\n    expect(svg).toContain('Animal')\n    expect(svg).toContain('Dog')\n    // Inheritance uses a hollow triangle marker\n    expect(svg).toContain('cls-inherit')\n  })\n\n  it('renders composition with filled diamond', () => {\n    const svg = renderMermaidSVG(`classDiagram\n      Car *-- Engine`)\n    expect(svg).toContain('cls-composition')\n  })\n\n  it('renders aggregation with hollow diamond', () => {\n    const svg = renderMermaidSVG(`classDiagram\n      University o-- Department`)\n    expect(svg).toContain('cls-aggregation')\n  })\n\n  it('renders dependency with dashed line', () => {\n    const svg = renderMermaidSVG(`classDiagram\n      Service ..> Repository`)\n    expect(svg).toContain('stroke-dasharray')\n    expect(svg).toContain('cls-arrow')\n  })\n\n  it('renders realization with dashed line and triangle', () => {\n    const svg = renderMermaidSVG(`classDiagram\n      Bird ..|> Flyable`)\n    expect(svg).toContain('stroke-dasharray')\n    expect(svg).toContain('cls-inherit')\n  })\n\n  it('renders relationship labels', () => {\n    const svg = renderMermaidSVG(`classDiagram\n      Customer --> Order : places`)\n    expect(svg).toContain('places')\n  })\n\n  it('renders class compartments with divider lines', () => {\n    const svg = renderMermaidSVG(`classDiagram\n      class Animal {\n        +String name\n        +eat() void\n      }`)\n    // Should have horizontal divider lines between compartments\n    const lines = svg.match(/<line /g) ?? []\n    // At least 2 dividers (header-attrs, attrs-methods)\n    expect(lines.length).toBeGreaterThanOrEqual(2)\n  })\n\n  it('renders with dark colors', () => {\n    const svg = renderMermaidSVG(`classDiagram\n      class A {\n        +x int\n      }`, { bg: '#18181B', fg: '#FAFAFA' })\n    expect(svg).toContain('--bg:#18181B')\n  })\n\n  it('renders a complete class hierarchy', () => {\n    const svg = renderMermaidSVG(`classDiagram\n      class Animal {\n        <<abstract>>\n        +String name\n        +eat() void\n      }\n      class Dog {\n        +String breed\n        +bark() void\n      }\n      class Cat {\n        +bool isIndoor\n        +meow() void\n      }\n      Animal <|-- Dog\n      Animal <|-- Cat`)\n    expect(svg).toContain('Animal')\n    expect(svg).toContain('Dog')\n    expect(svg).toContain('Cat')\n    expect(svg).toContain('abstract')\n  })\n})\n"
  },
  {
    "path": "src/__tests__/class-parser.test.ts",
    "content": "/**\n * Tests for the class diagram parser.\n *\n * Covers: class blocks, attributes, methods, visibility, annotations,\n * relationships (all 6 types), cardinality, labels, inline attributes.\n */\nimport { describe, it, expect } from 'bun:test'\nimport { parseClassDiagram } from '../class/parser.ts'\n\n/** Helper to parse — preprocesses text the same way index.ts does */\nfunction parse(text: string) {\n  const lines = text.split('\\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%'))\n  return parseClassDiagram(lines)\n}\n\n// ============================================================================\n// Class definitions\n// ============================================================================\n\ndescribe('parseClassDiagram – class definitions', () => {\n  it('parses a class block with attributes and methods', () => {\n    const d = parse(`classDiagram\n      class Animal {\n        +String name\n        +int age\n        +eat() void\n        +sleep()\n      }`)\n    expect(d.classes).toHaveLength(1)\n    expect(d.classes[0]!.id).toBe('Animal')\n    expect(d.classes[0]!.attributes).toHaveLength(2)\n    expect(d.classes[0]!.methods).toHaveLength(2)\n  })\n\n  it('parses attribute visibility (+ - # ~)', () => {\n    const d = parse(`classDiagram\n      class MyClass {\n        +String publicField\n        -int privateField\n        #double protectedField\n        ~bool packageField\n      }`)\n    expect(d.classes[0]!.attributes[0]!.visibility).toBe('+')\n    expect(d.classes[0]!.attributes[1]!.visibility).toBe('-')\n    expect(d.classes[0]!.attributes[2]!.visibility).toBe('#')\n    expect(d.classes[0]!.attributes[3]!.visibility).toBe('~')\n  })\n\n  it('parses method with return type', () => {\n    const d = parse(`classDiagram\n      class Calc {\n        +add(a, b) int\n      }`)\n    expect(d.classes[0]!.methods[0]!.name).toBe('add')\n    expect(d.classes[0]!.methods[0]!.type).toBe('int')\n  })\n\n  it('parses annotation <<interface>>', () => {\n    const d = parse(`classDiagram\n      class Flyable {\n        <<interface>>\n        +fly() void\n      }`)\n    expect(d.classes[0]!.annotation).toBe('interface')\n    expect(d.classes[0]!.methods).toHaveLength(1)\n  })\n\n  it('parses inline annotation syntax', () => {\n    const d = parse(`classDiagram\n      class Shape { <<abstract>> }`)\n    expect(d.classes[0]!.annotation).toBe('abstract')\n  })\n\n  it('parses standalone class declaration', () => {\n    const d = parse(`classDiagram\n      class EmptyClass`)\n    expect(d.classes).toHaveLength(1)\n    expect(d.classes[0]!.id).toBe('EmptyClass')\n  })\n\n  it('auto-creates classes from relationships', () => {\n    const d = parse(`classDiagram\n      Animal <|-- Dog`)\n    expect(d.classes).toHaveLength(2)\n    expect(d.classes.find(c => c.id === 'Animal')).toBeDefined()\n    expect(d.classes.find(c => c.id === 'Dog')).toBeDefined()\n  })\n})\n\n// ============================================================================\n// Inline attributes\n// ============================================================================\n\ndescribe('parseClassDiagram – inline attributes', () => {\n  it('parses inline attribute: ClassName : +Type name', () => {\n    const d = parse(`classDiagram\n      class Animal\n      Animal : +String name\n      Animal : +int age`)\n    const cls = d.classes.find(c => c.id === 'Animal')!\n    expect(cls.attributes).toHaveLength(2)\n    expect(cls.attributes[0]!.name).toBe('name')\n  })\n})\n\n// ============================================================================\n// Relationships\n// ============================================================================\n\ndescribe('parseClassDiagram – relationships', () => {\n  it('parses inheritance: <|-- (marker at from)', () => {\n    const d = parse(`classDiagram\n      Animal <|-- Dog`)\n    expect(d.relationships).toHaveLength(1)\n    expect(d.relationships[0]!.type).toBe('inheritance')\n    expect(d.relationships[0]!.from).toBe('Animal')\n    expect(d.relationships[0]!.to).toBe('Dog')\n    expect(d.relationships[0]!.markerAt).toBe('from')\n  })\n\n  it('parses composition: *-- (marker at from)', () => {\n    const d = parse(`classDiagram\n      Car *-- Engine`)\n    expect(d.relationships[0]!.type).toBe('composition')\n    expect(d.relationships[0]!.markerAt).toBe('from')\n  })\n\n  it('parses aggregation: o-- (marker at from)', () => {\n    const d = parse(`classDiagram\n      University o-- Department`)\n    expect(d.relationships[0]!.type).toBe('aggregation')\n    expect(d.relationships[0]!.markerAt).toBe('from')\n  })\n\n  it('parses association: --> (marker at to)', () => {\n    const d = parse(`classDiagram\n      Customer --> Order`)\n    expect(d.relationships[0]!.type).toBe('association')\n    expect(d.relationships[0]!.markerAt).toBe('to')\n  })\n\n  it('parses dependency: ..> (marker at to)', () => {\n    const d = parse(`classDiagram\n      Service ..> Repository`)\n    expect(d.relationships[0]!.type).toBe('dependency')\n    expect(d.relationships[0]!.markerAt).toBe('to')\n  })\n\n  it('parses realization: ..|> (marker at to)', () => {\n    const d = parse(`classDiagram\n      Bird ..|> Flyable`)\n    expect(d.relationships[0]!.type).toBe('realization')\n    expect(d.relationships[0]!.markerAt).toBe('to')\n  })\n\n  // --- Reversed arrow variants ---\n\n  it('parses reversed realization: <|.. (marker at from)', () => {\n    const d = parse(`classDiagram\n      Flyable <|.. Bird`)\n    expect(d.relationships[0]!.type).toBe('realization')\n    expect(d.relationships[0]!.from).toBe('Flyable')\n    expect(d.relationships[0]!.to).toBe('Bird')\n    expect(d.relationships[0]!.markerAt).toBe('from')\n  })\n\n  it('parses reversed composition: --* (marker at to)', () => {\n    const d = parse(`classDiagram\n      Engine --* Car`)\n    expect(d.relationships[0]!.type).toBe('composition')\n    expect(d.relationships[0]!.from).toBe('Engine')\n    expect(d.relationships[0]!.to).toBe('Car')\n    expect(d.relationships[0]!.markerAt).toBe('to')\n  })\n\n  it('parses reversed aggregation: --o (marker at to)', () => {\n    const d = parse(`classDiagram\n      Department --o University`)\n    expect(d.relationships[0]!.type).toBe('aggregation')\n    expect(d.relationships[0]!.from).toBe('Department')\n    expect(d.relationships[0]!.to).toBe('University')\n    expect(d.relationships[0]!.markerAt).toBe('to')\n  })\n\n  it('parses relationship with label', () => {\n    const d = parse(`classDiagram\n      Customer --> Order : places`)\n    expect(d.relationships[0]!.label).toBe('places')\n  })\n\n  it('parses relationship with cardinality', () => {\n    const d = parse(`classDiagram\n      Customer \"1\" --> \"*\" Order : places`)\n    expect(d.relationships[0]!.fromCardinality).toBe('1')\n    expect(d.relationships[0]!.toCardinality).toBe('*')\n  })\n\n  it('handles multiple relationships', () => {\n    const d = parse(`classDiagram\n      Animal <|-- Dog\n      Animal <|-- Cat\n      Dog *-- Leg`)\n    expect(d.relationships).toHaveLength(3)\n  })\n})\n\n// ============================================================================\n// Full diagram\n// ============================================================================\n\ndescribe('parseClassDiagram – full diagram', () => {\n  it('parses a complete class hierarchy', () => {\n    const d = parse(`classDiagram\n      class Animal {\n        <<abstract>>\n        +String name\n        +eat() void\n        +sleep() void\n      }\n      class Dog {\n        +String breed\n        +bark() void\n      }\n      class Cat {\n        +bool isIndoor\n        +meow() void\n      }\n      Animal <|-- Dog\n      Animal <|-- Cat`)\n\n    expect(d.classes).toHaveLength(3)\n    expect(d.relationships).toHaveLength(2)\n    const animal = d.classes.find(c => c.id === 'Animal')!\n    expect(animal.annotation).toBe('abstract')\n    expect(animal.attributes).toHaveLength(1)\n    expect(animal.methods).toHaveLength(2)\n  })\n})\n"
  },
  {
    "path": "src/__tests__/edge-approach-direction.test.ts",
    "content": "/**\n * Edge Approach Direction Tests\n *\n * Verifies that edges approach target nodes from the correct direction:\n * - Edges entering from TOP should have a vertical final segment (coming from above)\n * - Edges entering from BOTTOM should have a vertical final segment (coming from below)\n * - Edges entering from LEFT should have a horizontal final segment (coming from left)\n * - Edges entering from RIGHT should have a horizontal final segment (coming from right)\n *\n * This prevents visual artifacts where an arrow points to the top of a node\n * but approaches horizontally, creating an awkward bend at the arrowhead.\n */\n\nimport { describe, it, expect } from 'bun:test'\nimport { parseMermaid } from '../parser.ts'\nimport { layoutGraphSync } from '../layout.ts'\n\ninterface Point {\n  x: number\n  y: number\n}\n\n/**\n * Determine if a segment is primarily vertical (dy > dx).\n */\nfunction isVerticalSegment(p1: Point, p2: Point, tolerance = 1): boolean {\n  const dx = Math.abs(p2.x - p1.x)\n  const dy = Math.abs(p2.y - p1.y)\n  return dy > dx || dx < tolerance\n}\n\n/**\n * Determine if a segment is primarily horizontal (dx > dy).\n */\nfunction isHorizontalSegment(p1: Point, p2: Point, tolerance = 1): boolean {\n  const dx = Math.abs(p2.x - p1.x)\n  const dy = Math.abs(p2.y - p1.y)\n  return dx > dy || dy < tolerance\n}\n\n/**\n * Get the final segment of an edge (last two points).\n */\nfunction getFinalSegment(points: Point[]): { p1: Point; p2: Point } | null {\n  if (points.length < 2) return null\n  return {\n    p1: points[points.length - 2]!,\n    p2: points[points.length - 1]!,\n  }\n}\n\n/**\n * Get the first segment of an edge (first two points).\n */\nfunction getFirstSegment(points: Point[]): { p1: Point; p2: Point } | null {\n  if (points.length < 2) return null\n  return {\n    p1: points[0]!,\n    p2: points[1]!,\n  }\n}\n\n/**\n * Determine which side of a node a point is on.\n */\nfunction getApproachSide(\n  point: Point,\n  nodeX: number,\n  nodeY: number,\n  nodeWidth: number,\n  nodeHeight: number\n): 'top' | 'bottom' | 'left' | 'right' {\n  const cx = nodeX + nodeWidth / 2\n  const cy = nodeY + nodeHeight / 2\n  const dx = point.x - cx\n  const dy = point.y - cy\n\n  // Check which edge the point is closest to\n  const distTop = Math.abs(point.y - nodeY)\n  const distBottom = Math.abs(point.y - (nodeY + nodeHeight))\n  const distLeft = Math.abs(point.x - nodeX)\n  const distRight = Math.abs(point.x - (nodeX + nodeWidth))\n\n  const minDist = Math.min(distTop, distBottom, distLeft, distRight)\n\n  if (minDist === distTop) return 'top'\n  if (minDist === distBottom) return 'bottom'\n  if (minDist === distLeft) return 'left'\n  return 'right'\n}\n\n// ============================================================================\n// Test Cases\n// ============================================================================\n\ndescribe('Edge Approach Direction', () => {\n  describe('TD layout - edges should approach targets vertically from top', () => {\n    it('simple two-node vertical: final segment should be vertical', () => {\n      const parsed = parseMermaid(`graph TD\n        A --> B`)\n      const positioned = layoutGraphSync(parsed, {})\n\n      const edge = positioned.edges.find(\n        (e) => e.source === 'A' && e.target === 'B'\n      )\n      expect(edge).toBeDefined()\n\n      const finalSeg = getFinalSegment(edge!.points)\n      expect(finalSeg).not.toBeNull()\n\n      // Edge enters B from top, so final segment should be vertical\n      expect(isVerticalSegment(finalSeg!.p1, finalSeg!.p2)).toBe(true)\n    })\n\n    it('fan-out pattern: multiple edges to one target should all be vertical', () => {\n      // This is the exact pattern from the screenshot\n      const parsed = parseMermaid(`graph TD\n        Input --> Processor\n        Config --> Processor`)\n      const positioned = layoutGraphSync(parsed, {})\n\n      const processorNode = positioned.nodes.find((n) => n.id === 'Processor')\n      expect(processorNode).toBeDefined()\n\n      // Both edges should approach Processor with vertical final segments\n      for (const edge of positioned.edges) {\n        if (edge.target === 'Processor') {\n          const finalSeg = getFinalSegment(edge.points)\n          expect(finalSeg).not.toBeNull()\n\n          // The final segment should be vertical (approaching top edge)\n          const isVertical = isVerticalSegment(finalSeg!.p1, finalSeg!.p2)\n          expect(isVertical).toBe(true)\n        }\n      }\n    })\n\n    it('fan-in and fan-out pattern: all vertical approaches', () => {\n      // Full pattern from the screenshot\n      const parsed = parseMermaid(`graph TD\n        Input --> Processor\n        Config --> Processor\n        Processor --> Output\n        Processor --> Log`)\n      const positioned = layoutGraphSync(parsed, {})\n\n      // Check all edges\n      for (const edge of positioned.edges) {\n        const targetNode = positioned.nodes.find((n) => n.id === edge.target)\n        expect(targetNode).toBeDefined()\n\n        const finalSeg = getFinalSegment(edge.points)\n        expect(finalSeg).not.toBeNull()\n\n        // Determine which side the edge approaches\n        const approachSide = getApproachSide(\n          finalSeg!.p2,\n          targetNode!.x,\n          targetNode!.y,\n          targetNode!.width,\n          targetNode!.height\n        )\n\n        // For top/bottom approach, final segment must be vertical\n        // For left/right approach, final segment must be horizontal\n        if (approachSide === 'top' || approachSide === 'bottom') {\n          expect(isVerticalSegment(finalSeg!.p1, finalSeg!.p2)).toBe(true)\n        } else {\n          expect(isHorizontalSegment(finalSeg!.p1, finalSeg!.p2)).toBe(true)\n        }\n      }\n    })\n  })\n\n  describe('LR layout - edges should approach targets horizontally', () => {\n    it('simple two-node horizontal: final segment should be horizontal', () => {\n      const parsed = parseMermaid(`graph LR\n        A --> B`)\n      const positioned = layoutGraphSync(parsed, {})\n\n      const edge = positioned.edges.find(\n        (e) => e.source === 'A' && e.target === 'B'\n      )\n      expect(edge).toBeDefined()\n\n      const finalSeg = getFinalSegment(edge!.points)\n      expect(finalSeg).not.toBeNull()\n\n      // Edge enters B from left, so final segment should be horizontal\n      expect(isHorizontalSegment(finalSeg!.p1, finalSeg!.p2)).toBe(true)\n    })\n\n    it('fan-out pattern in LR: final segments should be horizontal', () => {\n      const parsed = parseMermaid(`graph LR\n        Input --> Processor\n        Config --> Processor`)\n      const positioned = layoutGraphSync(parsed, {})\n\n      for (const edge of positioned.edges) {\n        if (edge.target === 'Processor') {\n          const finalSeg = getFinalSegment(edge.points)\n          expect(finalSeg).not.toBeNull()\n          expect(isHorizontalSegment(finalSeg!.p1, finalSeg!.p2)).toBe(true)\n        }\n      }\n    })\n  })\n\n  describe('Source exit direction matches side', () => {\n    it('TD layout: edges should exit source vertically from bottom', () => {\n      const parsed = parseMermaid(`graph TD\n        A --> B\n        A --> C`)\n      const positioned = layoutGraphSync(parsed, {})\n\n      for (const edge of positioned.edges) {\n        if (edge.source === 'A') {\n          const firstSeg = getFirstSegment(edge.points)\n          expect(firstSeg).not.toBeNull()\n\n          // Edge exits A from bottom, so first segment should be vertical\n          expect(isVerticalSegment(firstSeg!.p1, firstSeg!.p2)).toBe(true)\n        }\n      }\n    })\n\n    it('LR layout: edges should exit source horizontally from right', () => {\n      const parsed = parseMermaid(`graph LR\n        A --> B\n        A --> C`)\n      const positioned = layoutGraphSync(parsed, {})\n\n      for (const edge of positioned.edges) {\n        if (edge.source === 'A') {\n          const firstSeg = getFirstSegment(edge.points)\n          expect(firstSeg).not.toBeNull()\n\n          // Edge exits A from right, so first segment should be horizontal\n          expect(isHorizontalSegment(firstSeg!.p1, firstSeg!.p2)).toBe(true)\n        }\n      }\n    })\n  })\n\n  describe('Diamond shapes - approach direction preserved', () => {\n    it('edges to diamond should approach with correct direction', () => {\n      const parsed = parseMermaid(`graph TD\n        A --> B{Decision}\n        B -->|Yes| C\n        B -->|No| D`)\n      const positioned = layoutGraphSync(parsed, {})\n\n      // Edge A → B should approach B's top vertically\n      const edgeAB = positioned.edges.find(\n        (e) => e.source === 'A' && e.target === 'B'\n      )\n      expect(edgeAB).toBeDefined()\n\n      const finalSegAB = getFinalSegment(edgeAB!.points)\n      expect(finalSegAB).not.toBeNull()\n      expect(isVerticalSegment(finalSegAB!.p1, finalSegAB!.p2)).toBe(true)\n    })\n\n    it('edges should terminate at diamond vertices, not bounding box', () => {\n      // In TD layout, edges approaching from above should hit the top vertex,\n      // and edges leaving downward should start from the bottom vertex.\n      const parsed = parseMermaid(`graph TD\n        A[Start] --> B{Decision}\n        B --> C[End]`)\n      const positioned = layoutGraphSync(parsed, {})\n\n      const diamond = positioned.nodes.find(n => n.id === 'B')\n      expect(diamond).toBeDefined()\n      expect(diamond!.shape).toBe('diamond')\n\n      // Calculate diamond vertices\n      const cx = diamond!.x + diamond!.width / 2\n      const topY = diamond!.y\n      const bottomY = diamond!.y + diamond!.height\n\n      // Edge A → B should end at the diamond's top vertex\n      const edgeAB = positioned.edges.find(e => e.source === 'A' && e.target === 'B')\n      expect(edgeAB).toBeDefined()\n      const endPointAB = edgeAB!.points[edgeAB!.points.length - 1]!\n      expect(endPointAB.x).toBeCloseTo(cx, 0)\n      expect(endPointAB.y).toBeCloseTo(topY, 0)\n\n      // Edge B → C should start from the diamond's bottom vertex\n      const edgeBC = positioned.edges.find(e => e.source === 'B' && e.target === 'C')\n      expect(edgeBC).toBeDefined()\n      const startPointBC = edgeBC!.points[0]!\n      expect(startPointBC.x).toBeCloseTo(cx, 0)\n      expect(startPointBC.y).toBeCloseTo(bottomY, 0)\n    })\n  })\n})\n"
  },
  {
    "path": "src/__tests__/er-integration.test.ts",
    "content": "/**\n * Integration tests for ER diagrams — end-to-end parse → layout → render.\n */\nimport { describe, it, expect } from 'bun:test'\nimport { renderMermaidSVG } from '../index.ts'\n\ndescribe('renderMermaidSVG – ER diagrams', () => {\n  it('renders a basic ER diagram to valid SVG', () => {\n    const svg = renderMermaidSVG(`erDiagram\n      CUSTOMER ||--o{ ORDER : places`)\n    expect(svg).toContain('<svg')\n    expect(svg).toContain('</svg>')\n    expect(svg).toContain('CUSTOMER')\n    expect(svg).toContain('ORDER')\n    expect(svg).toContain('places')\n  })\n\n  it('renders entity with attributes', () => {\n    const svg = renderMermaidSVG(`erDiagram\n      CUSTOMER {\n        int id PK\n        string name\n        string email UK\n      }`)\n    expect(svg).toContain('CUSTOMER')\n    expect(svg).toContain('id')\n    expect(svg).toContain('name')\n    expect(svg).toContain('email')\n    // PK/UK key badges\n    expect(svg).toContain('PK')\n    expect(svg).toContain('UK')\n  })\n\n  it('renders relationship lines between entities', () => {\n    const svg = renderMermaidSVG(`erDiagram\n      A ||--o{ B : has`)\n    // Should have polyline for the relationship\n    expect(svg).toContain('<polyline')\n  })\n\n  it('renders crow\\'s foot cardinality markers', () => {\n    const svg = renderMermaidSVG(`erDiagram\n      CUSTOMER ||--o{ ORDER : places`)\n    // Crow's foot markers are rendered as lines\n    const lineCount = (svg.match(/<line /g) ?? []).length\n    // Entity divider lines + cardinality markers\n    expect(lineCount).toBeGreaterThan(2)\n  })\n\n  it('renders non-identifying (dashed) relationships', () => {\n    const svg = renderMermaidSVG(`erDiagram\n      USER ||..o{ LOG : generates`)\n    expect(svg).toContain('stroke-dasharray')\n  })\n\n  it('renders relationship labels with background pills', () => {\n    const svg = renderMermaidSVG(`erDiagram\n      A ||--o{ B : places`)\n    expect(svg).toContain('places')\n    // Background pill behind label\n    expect(svg).toContain('rx=\"2\"')\n  })\n\n  it('renders with dark colors', () => {\n    const svg = renderMermaidSVG(`erDiagram\n      A ||--|| B : links`, { bg: '#18181B', fg: '#FAFAFA' })\n    expect(svg).toContain('--bg:#18181B')\n  })\n\n  it('renders entity boxes with header and attribute rows', () => {\n    const svg = renderMermaidSVG(`erDiagram\n      USER {\n        int id PK\n        string name\n        string email\n      }`)\n    // Should have rectangles for entity box and header\n    const rectCount = (svg.match(/<rect /g) ?? []).length\n    expect(rectCount).toBeGreaterThanOrEqual(2) // outer box + header\n  })\n\n  it('renders a complete e-commerce schema', () => {\n    const svg = renderMermaidSVG(`erDiagram\n      CUSTOMER {\n        int id PK\n        string name\n        string email UK\n      }\n      ORDER {\n        int id PK\n        date created\n        int customer_id FK\n      }\n      PRODUCT {\n        int id PK\n        string name\n        float price\n      }\n      CUSTOMER ||--o{ ORDER : places\n      ORDER ||--|{ LINE_ITEM : contains\n      PRODUCT ||--o{ LINE_ITEM : includes`)\n    expect(svg).toContain('CUSTOMER')\n    expect(svg).toContain('ORDER')\n    expect(svg).toContain('PRODUCT')\n    expect(svg).toContain('LINE_ITEM')\n    expect(svg).toContain('places')\n    expect(svg).toContain('contains')\n    expect(svg).toContain('includes')\n  })\n})\n\n// ============================================================================\n// Label positioning tests — verify that relationship labels sit ON the\n// polyline path, not floating in space. The renderer's midpoint() computes\n// the arc-length midpoint of the relationship polyline. These tests parse\n// the SVG output to verify positioning for both straight and multi-segment\n// (orthogonal, bent) paths.\n// ============================================================================\n\n/** Extract entity box rects from SVG: returns Map<label, {x, y, width, height, rightEdge}> */\nfunction extractEntityBoxes(svg: string): Map<string, { x: number; y: number; width: number; height: number; rightEdge: number }> {\n  const boxes = new Map<string, { x: number; y: number; width: number; height: number; rightEdge: number }>()\n\n  // Entity header text: <text x=\"...\" y=\"...\" ... font-weight=\"700\" ...>LABEL</text>\n  const headerPattern = /<text x=\"([\\d.]+)\" y=\"([\\d.]+)\"[^>]*font-weight=\"700\"[^>]*>([^<]+)<\\/text>/g\n  let match\n  while ((match = headerPattern.exec(svg)) !== null) {\n    const centerX = parseFloat(match[1]!)\n    const label = match[3]!\n\n    // Find the corresponding outer rect that contains this text.\n    const rectPattern = /<rect x=\"([\\d.]+)\" y=\"([\\d.]+)\" width=\"([\\d.]+)\" height=\"([\\d.]+)\" rx=\"0\" ry=\"0\"/g\n    let rectMatch\n    while ((rectMatch = rectPattern.exec(svg)) !== null) {\n      const rx = parseFloat(rectMatch[1]!)\n      const ry = parseFloat(rectMatch[2]!)\n      const rw = parseFloat(rectMatch[3]!)\n      const rh = parseFloat(rectMatch[4]!)\n      if (centerX >= rx && centerX <= rx + rw) {\n        boxes.set(label, { x: rx, y: ry, width: rw, height: rh, rightEdge: rx + rw })\n        break\n      }\n    }\n  }\n\n  return boxes\n}\n\n/** Extract relationship label positions from SVG: returns Map<label, {x, y}> */\nfunction extractLabelPositions(svg: string): Map<string, { x: number; y: number }> {\n  const labels = new Map<string, { x: number; y: number }>()\n  // Relationship labels use font-size=\"11\" font-weight=\"400\" — match flexibly\n  // regardless of attribute order\n  const labelPattern = /<text x=\"([\\d.]+)\" y=\"([\\d.]+)\"[^>]*font-size=\"11\"[^>]*font-weight=\"400\"[^>]*>([^<]+)<\\/text>/g\n  let match\n  while ((match = labelPattern.exec(svg)) !== null) {\n    labels.set(match[3]!, { x: parseFloat(match[1]!), y: parseFloat(match[2]!) })\n  }\n  return labels\n}\n\n/** Extract polyline paths from SVG: returns array of point arrays */\nfunction extractPolylines(svg: string): Array<Array<{ x: number; y: number }>> {\n  const polylines: Array<Array<{ x: number; y: number }>> = []\n  // Match polylines with points attribute anywhere in the tag\n  const pattern = /<polyline[^>]*points=\"([^\"]+)\"[^>]*>/g\n  let match\n  while ((match = pattern.exec(svg)) !== null) {\n    const points = match[1]!.split(' ').map(p => {\n      const [x, y] = p.split(',')\n      return { x: parseFloat(x!), y: parseFloat(y!) }\n    })\n    polylines.push(points)\n  }\n  return polylines\n}\n\n/**\n * Check if a point lies on (or very near) a polyline path.\n * Computes the minimum distance from the point to any segment of the polyline.\n * Returns the minimum distance in pixels.\n */\nfunction distanceToPolyline(point: { x: number; y: number }, polyline: Array<{ x: number; y: number }>): number {\n  let minDist = Infinity\n  for (let i = 1; i < polyline.length; i++) {\n    const a = polyline[i - 1]!\n    const b = polyline[i]!\n    const dist = pointToSegmentDist(point, a, b)\n    if (dist < minDist) minDist = dist\n  }\n  return minDist\n}\n\n/** Distance from point P to line segment AB */\nfunction pointToSegmentDist(p: { x: number; y: number }, a: { x: number; y: number }, b: { x: number; y: number }): number {\n  const dx = b.x - a.x\n  const dy = b.y - a.y\n  const lenSq = dx * dx + dy * dy\n  if (lenSq === 0) return Math.sqrt((p.x - a.x) ** 2 + (p.y - a.y) ** 2)\n  // Project P onto AB, clamped to [0,1]\n  const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq))\n  const projX = a.x + t * dx\n  const projY = a.y + t * dy\n  return Math.sqrt((p.x - projX) ** 2 + (p.y - projY) ** 2)\n}\n\n/**\n * Find the polyline closest to a label position.\n * Returns the minimum distance from the label to any polyline.\n */\nfunction closestPolylineDistance(label: { x: number; y: number }, polylines: Array<Array<{ x: number; y: number }>>): number {\n  let minDist = Infinity\n  for (const pl of polylines) {\n    const dist = distanceToPolyline(label, pl)\n    if (dist < minDist) minDist = dist\n  }\n  return minDist\n}\n\n// ─── Straight-line label positioning ────────────────────────────────────────\n\ndescribe('renderMermaidSVG – ER label positioning (straight lines)', () => {\n  it('label is between the two entity boxes horizontally', () => {\n    const svg = renderMermaidSVG(`erDiagram\n      TEACHER }|--o{ COURSE : teaches`)\n\n    const boxes = extractEntityBoxes(svg)\n    const labels = extractLabelPositions(svg)\n\n    const teacher = boxes.get('TEACHER')!\n    const course = boxes.get('COURSE')!\n    const label = labels.get('teaches')!\n\n    // Label x should be between the two entity box edges\n    const leftEdge = Math.min(teacher.rightEdge, course.rightEdge)\n    const rightEdge = Math.max(teacher.x, course.x)\n    expect(label.x).toBeGreaterThan(leftEdge)\n    expect(label.x).toBeLessThan(rightEdge)\n  })\n\n  it('label has minimum clearance from entity box edges', () => {\n    const svg = renderMermaidSVG(`erDiagram\n      A ||--o{ B : links`)\n\n    const boxes = extractEntityBoxes(svg)\n    const labels = extractLabelPositions(svg)\n\n    const boxA = boxes.get('A')!\n    const boxB = boxes.get('B')!\n    const label = labels.get('links')!\n\n    const minClearance = 10\n    const leftBox = boxA.x < boxB.x ? boxA : boxB\n    const rightBox = boxA.x < boxB.x ? boxB : boxA\n\n    expect(label.x - leftBox.rightEdge).toBeGreaterThanOrEqual(minClearance)\n    expect(rightBox.x - label.x).toBeGreaterThanOrEqual(minClearance)\n  })\n\n  it('label is approximately at the horizontal midpoint of the gap', () => {\n    const svg = renderMermaidSVG(`erDiagram\n      CUSTOMER ||--o{ ORDER : places`)\n\n    const boxes = extractEntityBoxes(svg)\n    const labels = extractLabelPositions(svg)\n\n    const customer = boxes.get('CUSTOMER')!\n    const order = boxes.get('ORDER')!\n    const label = labels.get('places')!\n\n    const leftBox = customer.x < order.x ? customer : order\n    const rightBox = customer.x < order.x ? order : customer\n    const gapMidpoint = (leftBox.rightEdge + rightBox.x) / 2\n\n    expect(Math.abs(label.x - gapMidpoint)).toBeLessThan(15)\n  })\n\n  it('label sits on (or very near) its relationship polyline', () => {\n    const svg = renderMermaidSVG(`erDiagram\n      A ||--o{ B : connects`)\n\n    const labels = extractLabelPositions(svg)\n    const polylines = extractPolylines(svg)\n    const label = labels.get('connects')!\n\n    // Label should be within 2px of its closest polyline segment\n    const dist = closestPolylineDistance(label, polylines)\n    expect(dist).toBeLessThan(2)\n  })\n})\n\n// ─── Multi-entity diagrams with orthogonal routing ──────────────────────────\n\ndescribe('renderMermaidSVG – ER label positioning (multi-segment paths)', () => {\n  it('all labels in a multi-relationship diagram sit near a polyline', () => {\n    const svg = renderMermaidSVG(`erDiagram\n      ORDER ||--|{ LINE_ITEM : contains\n      ORDER ||..o{ SHIPMENT : ships-via\n      PRODUCT ||--o{ LINE_ITEM : includes\n      PRODUCT ||..o{ REVIEW : receives`)\n\n    const labels = extractLabelPositions(svg)\n    const polylines = extractPolylines(svg)\n\n    // Every relationship label should be found\n    for (const name of ['contains', 'ships-via', 'includes', 'receives']) {\n      expect(labels.has(name)).toBe(true)\n    }\n\n    // Every label should be within 2px of a polyline segment\n    for (const [, pos] of labels) {\n      const dist = closestPolylineDistance(pos, polylines)\n      expect(dist).toBeLessThan(2)\n    }\n  })\n\n  it('non-identifying relationship labels also sit on their dashed polylines', () => {\n    const svg = renderMermaidSVG(`erDiagram\n      USER ||..o{ LOG_ENTRY : generates\n      USER ||..o{ SESSION : opens`)\n\n    const labels = extractLabelPositions(svg)\n    const polylines = extractPolylines(svg)\n\n    expect(labels.has('generates')).toBe(true)\n    expect(labels.has('opens')).toBe(true)\n\n    for (const [, pos] of labels) {\n      const dist = closestPolylineDistance(pos, polylines)\n      expect(dist).toBeLessThan(2)\n    }\n  })\n\n  it('label on vertical segment has x matching the segment x', () => {\n    const svg = renderMermaidSVG(`erDiagram\n      ORDER ||--|{ LINE_ITEM : contains\n      ORDER ||..o{ SHIPMENT : ships-via\n      PRODUCT ||--o{ LINE_ITEM : includes\n      PRODUCT ||..o{ REVIEW : receives`)\n\n    const labels = extractLabelPositions(svg)\n    const polylines = extractPolylines(svg)\n\n    // For each label, find the closest polyline and verify it's near a segment\n    for (const [, pos] of labels) {\n      const dist = closestPolylineDistance(pos, polylines)\n      expect(dist).toBeLessThan(2)\n    }\n  })\n\n  it('labels in e-commerce schema all sit on their polylines', () => {\n    const svg = renderMermaidSVG(`erDiagram\n      CUSTOMER ||--o{ ORDER : places\n      ORDER ||--|{ LINE_ITEM : contains\n      PRODUCT ||--o{ LINE_ITEM : includes`)\n\n    const labels = extractLabelPositions(svg)\n    const polylines = extractPolylines(svg)\n\n    expect(labels.size).toBe(3)\n    for (const [, pos] of labels) {\n      const dist = closestPolylineDistance(pos, polylines)\n      expect(dist).toBeLessThan(2)\n    }\n  })\n\n  it('label is not at the endpoint of any polyline', () => {\n    const svg = renderMermaidSVG(`erDiagram\n      A ||--o{ B : links`)\n\n    const labels = extractLabelPositions(svg)\n    const polylines = extractPolylines(svg)\n    const label = labels.get('links')!\n\n    for (const pl of polylines) {\n      const start = pl[0]!\n      const end = pl[pl.length - 1]!\n      const distToStart = Math.sqrt((label.x - start.x) ** 2 + (label.y - start.y) ** 2)\n      const distToEnd = Math.sqrt((label.x - end.x) ** 2 + (label.y - end.y) ** 2)\n      // At least one endpoint should be far from the label (>5px)\n      expect(Math.min(distToStart, distToEnd)).toBeGreaterThan(5)\n    }\n  })\n\n  it('multiple labels in same diagram have distinct positions', () => {\n    const svg = renderMermaidSVG(`erDiagram\n      CUSTOMER ||--o{ ORDER : places\n      ORDER ||--|{ LINE_ITEM : contains\n      PRODUCT ||--o{ LINE_ITEM : includes`)\n\n    const labels = extractLabelPositions(svg)\n    const positions = [...labels.values()]\n\n    // Each label should have a unique position (no two labels at same x,y)\n    for (let i = 0; i < positions.length; i++) {\n      for (let j = i + 1; j < positions.length; j++) {\n        const dx = positions[i]!.x - positions[j]!.x\n        const dy = positions[i]!.y - positions[j]!.y\n        const dist = Math.sqrt(dx * dx + dy * dy)\n        expect(dist).toBeGreaterThan(10) // at least 10px apart\n      }\n    }\n  })\n\n  it('label background pill also sits on the polyline', () => {\n    const svg = renderMermaidSVG(`erDiagram\n      A ||--o{ B : test`)\n\n    const labels = extractLabelPositions(svg)\n    const polylines = extractPolylines(svg)\n    const label = labels.get('test')!\n\n    // Find the background pill rect (rx=\"2\" ry=\"2\" near the label position)\n    const pillPattern = /<rect x=\"([\\d.]+)\" y=\"([\\d.]+)\" width=\"([\\d.]+)\" height=\"([\\d.]+)\" rx=\"2\" ry=\"2\"/g\n    let pillMatch\n    let foundPill = false\n    while ((pillMatch = pillPattern.exec(svg)) !== null) {\n      const px = parseFloat(pillMatch[1]!)\n      const pw = parseFloat(pillMatch[3]!)\n      const pillCenter = px + pw / 2\n      // Check if this pill is for our label (center within 1px of label x)\n      if (Math.abs(pillCenter - label.x) < 1) {\n        foundPill = true\n        // Pill center should also be on the polyline\n        const pillPos = { x: pillCenter, y: label.y }\n        const dist = closestPolylineDistance(pillPos, polylines)\n        expect(dist).toBeLessThan(2)\n      }\n    }\n    expect(foundPill).toBe(true)\n  })\n})\n"
  },
  {
    "path": "src/__tests__/er-parser.test.ts",
    "content": "/**\n * Tests for the ER diagram parser.\n *\n * Covers: entity definitions, attribute parsing (types, names, keys, comments),\n * relationships with all cardinality types, identifying/non-identifying lines.\n */\nimport { describe, it, expect } from 'bun:test'\nimport { parseErDiagram } from '../er/parser.ts'\n\n/** Helper to parse — preprocesses text the same way index.ts does */\nfunction parse(text: string) {\n  const lines = text.split('\\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%'))\n  return parseErDiagram(lines)\n}\n\n// ============================================================================\n// Entity definitions\n// ============================================================================\n\ndescribe('parseErDiagram – entity definitions', () => {\n  it('parses an entity with attributes', () => {\n    const d = parse(`erDiagram\n      CUSTOMER {\n        string name\n        int age\n        string email\n      }`)\n    expect(d.entities).toHaveLength(1)\n    expect(d.entities[0]!.id).toBe('CUSTOMER')\n    expect(d.entities[0]!.attributes).toHaveLength(3)\n    expect(d.entities[0]!.attributes[0]!.type).toBe('string')\n    expect(d.entities[0]!.attributes[0]!.name).toBe('name')\n  })\n\n  it('parses attributes with PK key', () => {\n    const d = parse(`erDiagram\n      USER {\n        int id PK\n        string name\n      }`)\n    expect(d.entities[0]!.attributes[0]!.keys).toContain('PK')\n  })\n\n  it('parses attributes with FK key', () => {\n    const d = parse(`erDiagram\n      ORDER {\n        int id PK\n        int customer_id FK\n      }`)\n    expect(d.entities[0]!.attributes[1]!.keys).toContain('FK')\n  })\n\n  it('parses attributes with UK key', () => {\n    const d = parse(`erDiagram\n      USER {\n        string email UK\n      }`)\n    expect(d.entities[0]!.attributes[0]!.keys).toContain('UK')\n  })\n\n  it('parses attributes with comment', () => {\n    const d = parse(`erDiagram\n      USER {\n        string email UK \"user email address\"\n      }`)\n    expect(d.entities[0]!.attributes[0]!.comment).toBe('user email address')\n  })\n\n  it('parses multiple entities', () => {\n    const d = parse(`erDiagram\n      CUSTOMER {\n        int id PK\n        string name\n      }\n      ORDER {\n        int id PK\n        date created\n      }`)\n    expect(d.entities).toHaveLength(2)\n  })\n\n  it('auto-creates entities from relationships', () => {\n    const d = parse(`erDiagram\n      CUSTOMER ||--o{ ORDER : places`)\n    expect(d.entities).toHaveLength(2)\n    expect(d.entities.find(e => e.id === 'CUSTOMER')).toBeDefined()\n    expect(d.entities.find(e => e.id === 'ORDER')).toBeDefined()\n  })\n})\n\n// ============================================================================\n// Relationships\n// ============================================================================\n\ndescribe('parseErDiagram – relationships', () => {\n  it('parses exactly-one to zero-or-many: ||--o{', () => {\n    const d = parse(`erDiagram\n      CUSTOMER ||--o{ ORDER : places`)\n    expect(d.relationships).toHaveLength(1)\n    expect(d.relationships[0]!.entity1).toBe('CUSTOMER')\n    expect(d.relationships[0]!.entity2).toBe('ORDER')\n    expect(d.relationships[0]!.cardinality1).toBe('one')\n    expect(d.relationships[0]!.cardinality2).toBe('zero-many')\n    expect(d.relationships[0]!.label).toBe('places')\n    expect(d.relationships[0]!.identifying).toBe(true)\n  })\n\n  it('parses zero-or-one to one-or-more: |o--|{', () => {\n    const d = parse(`erDiagram\n      A |o--|{ B : connects`)\n    expect(d.relationships[0]!.cardinality1).toBe('zero-one')\n    expect(d.relationships[0]!.cardinality2).toBe('many')\n  })\n\n  it('parses exactly-one to exactly-one: ||--||', () => {\n    const d = parse(`erDiagram\n      PERSON ||--|| PASSPORT : has`)\n    expect(d.relationships[0]!.cardinality1).toBe('one')\n    expect(d.relationships[0]!.cardinality2).toBe('one')\n  })\n\n  it('parses non-identifying relationship (dotted): ||..o{', () => {\n    const d = parse(`erDiagram\n      USER ||..o{ LOG : generates`)\n    expect(d.relationships[0]!.identifying).toBe(false)\n  })\n\n  it('parses one-or-more to zero-or-many: }|--o{', () => {\n    const d = parse(`erDiagram\n      PRODUCT }|--o{ TAG : has`)\n    expect(d.relationships[0]!.cardinality1).toBe('many')\n    expect(d.relationships[0]!.cardinality2).toBe('zero-many')\n  })\n\n  it('handles multiple relationships', () => {\n    const d = parse(`erDiagram\n      CUSTOMER ||--o{ ORDER : places\n      ORDER ||--|{ LINE_ITEM : contains\n      PRODUCT ||--o{ LINE_ITEM : appears_in`)\n    expect(d.relationships).toHaveLength(3)\n  })\n})\n\n// ============================================================================\n// Full diagram\n// ============================================================================\n\ndescribe('parseErDiagram – full diagram', () => {\n  it('parses a complete e-commerce schema', () => {\n    const d = parse(`erDiagram\n      CUSTOMER {\n        int id PK\n        string name\n        string email UK\n      }\n      ORDER {\n        int id PK\n        date created\n        int customer_id FK\n      }\n      LINE_ITEM {\n        int id PK\n        int quantity\n        int order_id FK\n        int product_id FK\n      }\n      PRODUCT {\n        int id PK\n        string name\n        float price\n      }\n      CUSTOMER ||--o{ ORDER : places\n      ORDER ||--|{ LINE_ITEM : contains\n      PRODUCT ||--o{ LINE_ITEM : includes`)\n\n    expect(d.entities).toHaveLength(4)\n    expect(d.relationships).toHaveLength(3)\n\n    const customer = d.entities.find(e => e.id === 'CUSTOMER')!\n    expect(customer.attributes).toHaveLength(3)\n    expect(customer.attributes[0]!.keys).toContain('PK')\n    expect(customer.attributes[2]!.keys).toContain('UK')\n\n    const lineItem = d.entities.find(e => e.id === 'LINE_ITEM')!\n    expect(lineItem.attributes.filter(a => a.keys.includes('FK'))).toHaveLength(2)\n  })\n})\n"
  },
  {
    "path": "src/__tests__/integration.test.ts",
    "content": "/**\n * Integration tests for the full renderMermaidSVG pipeline.\n *\n * These tests exercise parse → layout → render end-to-end.\n * They use the synchronous ELK.js-based rendering pipeline.\n *\n * Covers: original features, Batch 1 (new shapes), Batch 2 (edges, styles),\n * and Batch 3 (state diagrams).\n */\nimport { describe, it, expect } from 'bun:test'\nimport { renderMermaidSVG } from '../index.ts'\n\n// ============================================================================\n// Basic rendering\n// ============================================================================\n\ndescribe('renderMermaidSVG – basic', () => {\n  it('renders a simple graph to valid SVG', () => {\n    const svg = renderMermaidSVG('graph TD\\n  A --> B')\n    expect(svg).toContain('<svg xmlns=\"http://www.w3.org/2000/svg\"')\n    expect(svg).toContain('</svg>')\n    // Should contain both nodes\n    expect(svg).toContain('>A</text>')\n    expect(svg).toContain('>B</text>')\n  })\n\n  it('renders a graph with labeled nodes', () => {\n    const svg = renderMermaidSVG('graph TD\\n  A[Start] --> B[End]')\n    expect(svg).toContain('>Start</text>')\n    expect(svg).toContain('>End</text>')\n  })\n\n  it('renders edges with labels', () => {\n    const svg = renderMermaidSVG('graph TD\\n  A -->|Yes| B')\n    expect(svg).toContain('>Yes</text>')\n  })\n})\n\n// ============================================================================\n// Options\n// ============================================================================\n\ndescribe('renderMermaidSVG – options', () => {\n  it('applies dark colors', () => {\n    const svg = renderMermaidSVG('graph TD\\n  A --> B', { bg: '#18181B', fg: '#FAFAFA' })\n    expect(svg).toContain('--bg:#18181B')\n  })\n\n  it('applies default light colors', () => {\n    const svg = renderMermaidSVG('graph TD\\n  A --> B')\n    expect(svg).toContain('--bg:#FFFFFF')\n  })\n\n  it('applies custom font', () => {\n    const svg = renderMermaidSVG('graph TD\\n  A --> B', { font: 'JetBrains Mono' })\n    expect(svg).toContain(\"'JetBrains Mono'\")\n  })\n\n  it('respects padding option', () => {\n    const small = renderMermaidSVG('graph TD\\n  A --> B', { padding: 10 })\n    const large = renderMermaidSVG('graph TD\\n  A --> B', { padding: 80 })\n    const getWidth = (svg: string) => {\n      const match = svg.match(/width=\"([\\d.]+)\"/)\n      return match ? Number(match[1]) : 0\n    }\n    expect(getWidth(large)).toBeGreaterThan(getWidth(small))\n  })\n})\n\n// ============================================================================\n// Complex diagrams\n// ============================================================================\n\ndescribe('renderMermaidSVG – complex diagrams', () => {\n  it('renders all original node shapes', () => {\n    const svg = renderMermaidSVG(`graph TD\n      A[Rectangle] --> B(Rounded)\n      B --> C{Diamond}\n      C --> D([Stadium])\n      D --> E((Circle))`)\n\n    expect(svg).toContain('>Rectangle</text>')\n    expect(svg).toContain('>Rounded</text>')\n    expect(svg).toContain('>Diamond</text>')\n    expect(svg).toContain('>Stadium</text>')\n    expect(svg).toContain('>Circle</text>')\n    expect(svg).toContain('<polygon')\n    expect(svg).toContain('<circle')\n  })\n\n  it('renders all edge styles', () => {\n    const svg = renderMermaidSVG(`graph TD\n      A -->|solid| B\n      B -.->|dotted| C\n      C ==>|thick| D`)\n\n    expect(svg).toContain('>solid</text>')\n    expect(svg).toContain('>dotted</text>')\n    expect(svg).toContain('>thick</text>')\n    expect(svg).toContain('stroke-dasharray=\"4 4\"')\n  })\n\n  it('renders subgraphs', () => {\n    const svg = renderMermaidSVG(`graph TD\n      subgraph Backend\n        A[API] --> B[DB]\n      end\n      C[Client] --> A`)\n\n    expect(svg).toContain('>Backend</text>')\n    expect(svg).toContain('>API</text>')\n    expect(svg).toContain('>DB</text>')\n    expect(svg).toContain('>Client</text>')\n  })\n\n  it('renders a complex real-world diagram', () => {\n    const svg = renderMermaidSVG(`graph TD\n      subgraph ci [CI Pipeline]\n        A[Push Code] --> B{Tests Pass?}\n        B -->|Yes| C[Build Docker]\n        B -->|No| D[Fix & Retry]\n        D --> A\n      end\n      C --> E([Deploy to Staging])\n      E --> F{QA Approved?}\n      F -->|Yes| G((Production))\n      F -->|No| D`)\n\n    expect(svg).toContain('<svg')\n    expect(svg).toContain('</svg>')\n    expect(svg).toContain('>CI Pipeline</text>')\n    expect(svg).toContain('>Push Code</text>')\n    expect(svg).toContain('>Tests Pass?</text>')\n    expect(svg).toContain('>Yes</text>')\n    expect(svg).toContain('>No</text>')\n    expect(svg).toContain('>Production</text>')\n  })\n\n  it('renders different directions', () => {\n    const lr = renderMermaidSVG('graph LR\\n  A --> B --> C')\n    const td = renderMermaidSVG('graph TD\\n  A --> B --> C')\n\n    const getDimensions = (svg: string) => {\n      const w = svg.match(/width=\"([\\d.]+)\"/)\n      const h = svg.match(/height=\"([\\d.]+)\"/)\n      return { width: Number(w?.[1] ?? 0), height: Number(h?.[1] ?? 0) }\n    }\n\n    const lrDims = getDimensions(lr)\n    const tdDims = getDimensions(td)\n\n    expect(lrDims.width).toBeGreaterThan(tdDims.width)\n    expect(tdDims.height).toBeGreaterThan(lrDims.height)\n  })\n})\n\n// ============================================================================\n// Batch 1: New shapes (end-to-end)\n// ============================================================================\n\ndescribe('renderMermaidSVG – Batch 1 shapes', () => {\n  it('renders subroutine shape with inner vertical lines', () => {\n    const svg = renderMermaidSVG('graph TD\\n  A[[Subroutine]] --> B')\n    expect(svg).toContain('>Subroutine</text>')\n    expect(svg).toContain('<line') // inner vertical lines\n  })\n\n  it('renders double circle with two <circle> elements', () => {\n    const svg = renderMermaidSVG('graph TD\\n  A(((Important))) --> B')\n    expect(svg).toContain('>Important</text>')\n    const circleCount = (svg.match(/<circle/g) ?? []).length\n    expect(circleCount).toBeGreaterThanOrEqual(2)\n  })\n\n  it('renders hexagon as a polygon', () => {\n    const svg = renderMermaidSVG('graph TD\\n  A{{Decision}} --> B')\n    expect(svg).toContain('>Decision</text>')\n    expect(svg).toContain('<polygon')\n  })\n})\n\n// ============================================================================\n// Batch 2: New shapes and edge features (end-to-end)\n// ============================================================================\n\ndescribe('renderMermaidSVG – Batch 2 shapes', () => {\n  it('renders cylinder / database', () => {\n    const svg = renderMermaidSVG('graph TD\\n  A[(Database)] --> B')\n    expect(svg).toContain('>Database</text>')\n    expect(svg).toContain('<ellipse') // cylinder cap\n  })\n\n  it('renders asymmetric / flag', () => {\n    const svg = renderMermaidSVG('graph TD\\n  A>Flag Shape] --> B')\n    expect(svg).toContain('>Flag Shape</text>')\n    expect(svg).toContain('<polygon')\n  })\n\n  it('renders trapezoid shapes', () => {\n    const svg = renderMermaidSVG('graph TD\\n  A[/Wider Bottom\\\\] --> B[\\\\Wider Top/]')\n    expect(svg).toContain('>Wider Bottom</text>')\n    expect(svg).toContain('>Wider Top</text>')\n  })\n})\n\ndescribe('renderMermaidSVG – Batch 2 edge features', () => {\n  it('renders no-arrow edges', () => {\n    const svg = renderMermaidSVG('graph TD\\n  A --- B')\n    expect(svg).toContain('<polyline')\n    // No marker-end for no-arrow edges\n    expect(svg).not.toContain('marker-end')\n  })\n\n  it('renders bidirectional arrows', () => {\n    const svg = renderMermaidSVG('graph TD\\n  A <--> B')\n    expect(svg).toContain('marker-end=\"url(#arrowhead)\"')\n    expect(svg).toContain('marker-start=\"url(#arrowhead-start)\"')\n  })\n\n  it('renders parallel links with &', () => {\n    const svg = renderMermaidSVG('graph TD\\n  A & B --> C')\n    // Should have node labels for A, B, and C\n    expect(svg).toContain('>A</text>')\n    expect(svg).toContain('>B</text>')\n    expect(svg).toContain('>C</text>')\n    // Should have 2 edges (A→C and B→C)\n    const polylines = (svg.match(/<polyline/g) ?? []).length\n    expect(polylines).toBe(2)\n  })\n\n  it('applies inline style overrides', () => {\n    const svg = renderMermaidSVG(`graph TD\n      A[Red Node] --> B\n      style A fill:#ff0000,stroke:#cc0000`)\n    expect(svg).toContain('fill=\"#ff0000\"')\n    expect(svg).toContain('stroke=\"#cc0000\"')\n  })\n})\n\n// ============================================================================\n// Batch 3: State diagrams (end-to-end)\n// ============================================================================\n\ndescribe('renderMermaidSVG – state diagrams', () => {\n  it('renders a basic state diagram', () => {\n    const svg = renderMermaidSVG(`stateDiagram-v2\n      [*] --> Idle\n      Idle --> Active : start\n      Active --> Done`)\n\n    expect(svg).toContain('<svg')\n    expect(svg).toContain('</svg>')\n    expect(svg).toContain('>Idle</text>')\n    expect(svg).toContain('>Active</text>')\n    expect(svg).toContain('>Done</text>')\n    expect(svg).toContain('>start</text>')\n  })\n\n  it('renders start pseudostate as filled circle', () => {\n    const svg = renderMermaidSVG(`stateDiagram-v2\n      [*] --> Ready`)\n    // Start pseudostate: filled circle with stroke=\"none\"\n    expect(svg).toContain('stroke=\"none\"')\n    expect(svg).toContain('<circle')\n  })\n\n  it('renders end pseudostate as bullseye', () => {\n    const svg = renderMermaidSVG(`stateDiagram-v2\n      Done --> [*]`)\n    // End pseudostate: two circles (outer ring + inner filled)\n    const circleCount = (svg.match(/<circle/g) ?? []).length\n    expect(circleCount).toBeGreaterThanOrEqual(2)\n  })\n\n  it('renders composite state with inner nodes', () => {\n    const svg = renderMermaidSVG(`stateDiagram-v2\n      state Processing {\n        parse --> validate\n        validate --> execute\n      }\n      [*] --> Processing`)\n\n    expect(svg).toContain('>Processing</text>')\n    expect(svg).toContain('>parse</text>')\n    expect(svg).toContain('>validate</text>')\n    expect(svg).toContain('>execute</text>')\n  })\n\n  it('renders full state diagram lifecycle', () => {\n    const svg = renderMermaidSVG(`stateDiagram-v2\n      [*] --> Idle\n      Idle --> Processing : submit\n      state Processing {\n        parse --> validate\n        validate --> execute\n      }\n      Processing --> Complete : done\n      Complete --> [*]`)\n\n    expect(svg).toContain('<svg')\n    expect(svg).toContain('</svg>')\n    expect(svg).toContain('>Idle</text>')\n    expect(svg).toContain('>Complete</text>')\n    expect(svg).toContain('>Processing</text>')\n    expect(svg).toContain('>submit</text>')\n    expect(svg).toContain('>done</text>')\n  })\n\n  it('cycle edge labels do not overlap (Running ↔ Paused)', () => {\n    const svg = renderMermaidSVG(`stateDiagram-v2\n      [*] --> Ready\n      Ready --> Running : start\n      Running --> Paused : pause\n      Paused --> Running : resume\n      Running --> Stopped : stop\n      Stopped --> [*]`)\n\n    // Extract all label pill <rect> elements (rx=\"2\" distinguishes them from node rects)\n    const pillPattern = /<rect x=\"([^\"]+)\" y=\"([^\"]+)\" width=\"([^\"]+)\" height=\"([^\"]+)\" rx=\"2\"/g\n    const pills: { x: number; y: number; w: number; h: number }[] = []\n    let match: RegExpExecArray | null\n    while ((match = pillPattern.exec(svg)) !== null) {\n      pills.push({\n        x: parseFloat(match[1]!),\n        y: parseFloat(match[2]!),\n        w: parseFloat(match[3]!),\n        h: parseFloat(match[4]!),\n      })\n    }\n\n    // There should be at least 3 edge label pills (start, pause, resume, stop)\n    expect(pills.length).toBeGreaterThanOrEqual(3)\n\n    // Verify no pair of label pills overlap\n    for (let i = 0; i < pills.length; i++) {\n      for (let j = i + 1; j < pills.length; j++) {\n        const a = pills[i]!\n        const b = pills[j]!\n        const overlapX = a.x < b.x + b.w && a.x + a.w > b.x\n        const overlapY = a.y < b.y + b.h && a.y + a.h > b.y\n        expect(\n          overlapX && overlapY,\n          `Label pills ${i} (x=${a.x},y=${a.y},w=${a.w},h=${a.h}) and ${j} (x=${b.x},y=${b.y},w=${b.w},h=${b.h}) overlap`\n        ).toBe(false)\n      }\n    }\n  })\n})\n\n// ============================================================================\n// Source order and deduplication\n// ============================================================================\n\ndescribe('renderMermaidSVG – source order', () => {\n  it('does not duplicate composite state nodes in SVG', () => {\n    const svg = renderMermaidSVG(`stateDiagram-v2\n      [*] --> Idle\n      Idle --> Processing : submit\n      state Processing {\n        parse --> validate\n        validate --> execute\n      }\n      Processing --> Complete : done\n      Complete --> [*]`)\n\n    // \"Processing\" should appear exactly once as a group label, not also as a standalone node.\n    const processingLabels = (svg.match(/>Processing<\\/text>/g) ?? []).length\n    expect(processingLabels).toBe(1)\n  })\n\n  it('renders subgraph-first diagrams with subgraph at top in TD layout', () => {\n    const svg = renderMermaidSVG(`graph TD\n      subgraph ci [CI Pipeline]\n        A[Push Code] --> B{Tests Pass?}\n        B -->|Yes| C[Build Image]\n      end\n      C --> D([Deploy])\n      D --> E{QA?}\n      E -->|Yes| F((Production))`)\n\n    // Verify all elements render (no crashes from source order changes)\n    expect(svg).toContain('>CI Pipeline</text>')\n    expect(svg).toContain('>Push Code</text>')\n    expect(svg).toContain('>Deploy</text>')\n    expect(svg).toContain('>Production</text>')\n  })\n})\n\n// ============================================================================\n// Edge cases: self-loops, empty subgraphs, nesting depth\n// ============================================================================\n\ndescribe('renderMermaidSVG – edge cases', () => {\n  it('renders a self-loop (source === target)', () => {\n    const svg = renderMermaidSVG(`graph TD\n      A[Node] --> A`)\n\n    expect(svg).toContain('<svg')\n    expect(svg).toContain('>Node</text>')\n    // Should have at least one edge polyline\n    expect(svg).toContain('<polyline')\n  })\n\n  it('renders a self-loop with label', () => {\n    const svg = renderMermaidSVG(`graph TD\n      A[Retry] -->|again| A`)\n\n    expect(svg).toContain('>Retry</text>')\n    expect(svg).toContain('>again</text>')\n  })\n\n  it('renders an empty subgraph without crashing', () => {\n    const svg = renderMermaidSVG(`graph TD\n      subgraph Empty\n      end\n      A --> B`)\n\n    expect(svg).toContain('<svg')\n    expect(svg).toContain('>Empty</text>')\n    expect(svg).toContain('>A</text>')\n    expect(svg).toContain('>B</text>')\n  })\n\n  it('renders edges targeting an empty subgraph', () => {\n    const svg = renderMermaidSVG(`graph TD\n      subgraph S [Empty Group]\n      end\n      A --> S\n      S --> B`)\n\n    expect(svg).toContain('<svg')\n    expect(svg).toContain('>Empty Group</text>')\n    expect(svg).toContain('>A</text>')\n    expect(svg).toContain('>B</text>')\n  })\n\n  it('renders a single-node subgraph', () => {\n    const svg = renderMermaidSVG(`graph TD\n      subgraph Single\n        A[Only Node]\n      end\n      B --> A`)\n\n    expect(svg).toContain('>Single</text>')\n    expect(svg).toContain('>Only Node</text>')\n    expect(svg).toContain('>B</text>')\n  })\n\n  it('renders 3-level nested subgraphs', () => {\n    const svg = renderMermaidSVG(`graph TD\n      subgraph Level1 [Outer]\n        subgraph Level2 [Middle]\n          subgraph Level3 [Inner]\n            A[Deep Node] --> B[Also Deep]\n          end\n        end\n      end\n      C[Outside] --> A`)\n\n    expect(svg).toContain('>Outer</text>')\n    expect(svg).toContain('>Middle</text>')\n    expect(svg).toContain('>Inner</text>')\n    expect(svg).toContain('>Deep Node</text>')\n    expect(svg).toContain('>Also Deep</text>')\n    expect(svg).toContain('>Outside</text>')\n  })\n\n  it('renders 3-level nested composite states', () => {\n    const svg = renderMermaidSVG(`stateDiagram-v2\n      [*] --> Active\n      state Active {\n        state Processing {\n          state Validating {\n            check --> verify\n          }\n        }\n      }\n      Active --> [*]`)\n\n    expect(svg).toContain('<svg')\n    expect(svg).toContain('>Active</text>')\n    expect(svg).toContain('>Processing</text>')\n    expect(svg).toContain('>Validating</text>')\n    expect(svg).toContain('>check</text>')\n    expect(svg).toContain('>verify</text>')\n  })\n})\n\n// ============================================================================\n// All new shapes in one diagram (end-to-end stress test)\n// ============================================================================\n\ndescribe('renderMermaidSVG – all shapes combined', () => {\n  it('renders a diagram with all 12 flowchart shapes', () => {\n    const svg = renderMermaidSVG(`graph LR\n      A[Rectangle] --> B(Rounded)\n      B --> C{Diamond}\n      C --> D([Stadium])\n      D --> E((Circle))\n      E --> F[[Subroutine]]\n      F --> G(((DoubleCircle)))\n      G --> H{{Hexagon}}\n      H --> I[(Cylinder)]\n      I --> J>Flag]\n      J --> K[/Trapezoid\\\\]\n      K --> L[\\\\TrapAlt/]`)\n\n    // Verify every label renders\n    for (const label of ['Rectangle', 'Rounded', 'Diamond', 'Stadium', 'Circle',\n      'Subroutine', 'DoubleCircle', 'Hexagon', 'Cylinder', 'Flag', 'Trapezoid', 'TrapAlt']) {\n      expect(svg).toContain(`>${label}</text>`)\n    }\n\n    // Verify SVG validity\n    expect(svg).toContain('<svg')\n    expect(svg).toContain('</svg>')\n  })\n})\n"
  },
  {
    "path": "src/__tests__/layout-disconnected.test.ts",
    "content": "/**\n * Integration tests for disconnected component layout.\n *\n * These tests verify that the full layout pipeline correctly handles\n * graphs with multiple disconnected components (subgraphs or nodes\n * with no edges connecting them).\n *\n * The key invariant: disconnected components should NEVER overlap.\n */\nimport { describe, it, expect } from 'bun:test'\nimport { renderMermaidSync, parseMermaid } from '../index.ts'\nimport { layoutGraphSync } from '../layout.ts'\n\n// ============================================================================\n// Test helpers\n// ============================================================================\n\n/** Check if two rectangles overlap */\nfunction rectanglesOverlap(\n  r1: { x: number; y: number; width: number; height: number },\n  r2: { x: number; y: number; width: number; height: number }\n): boolean {\n  return !(\n    r1.x + r1.width <= r2.x ||   // r1 is left of r2\n    r2.x + r2.width <= r1.x ||   // r2 is left of r1\n    r1.y + r1.height <= r2.y ||  // r1 is above r2\n    r2.y + r2.height <= r1.y     // r2 is above r1\n  )\n}\n\n/** Get bounding box from positioned elements */\nfunction getBoundingBox(items: Array<{ x: number; y: number; width: number; height: number }>) {\n  if (items.length === 0) return { x: 0, y: 0, width: 0, height: 0 }\n\n  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity\n  for (const item of items) {\n    minX = Math.min(minX, item.x)\n    minY = Math.min(minY, item.y)\n    maxX = Math.max(maxX, item.x + item.width)\n    maxY = Math.max(maxY, item.y + item.height)\n  }\n\n  return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }\n}\n\n// ============================================================================\n// Two disconnected subgraphs (the original bug)\n// ============================================================================\n\ndescribe('layoutGraph – two disconnected subgraphs', () => {\n  it('renders without overlap in LR direction', () => {\n    const source = `graph LR\n      subgraph Today [Today]\n        A[AI Response] --> B[Markdown]\n        B --> C[User reads]\n        C --> D[User acts]\n      end\n\n      subgraph Tomorrow [Next Wave]\n        E[AI Response] --> F[Widget]\n        F --> G[User acts]\n      end`\n\n    const parsed = parseMermaid(source)\n    const result = layoutGraphSync(parsed)\n\n    // Find the two top-level groups\n    const today = result.groups.find(g => g.label === 'Today')\n    const tomorrow = result.groups.find(g => g.label === 'Next Wave')\n\n    expect(today).toBeDefined()\n    expect(tomorrow).toBeDefined()\n\n    // They should NOT overlap\n    expect(rectanglesOverlap(today!, tomorrow!)).toBe(false)\n  })\n\n  it('renders without overlap in TD direction', () => {\n    const source = `graph TD\n      subgraph Today [Today]\n        A --> B --> C\n      end\n\n      subgraph Tomorrow [Tomorrow]\n        D --> E --> F\n      end`\n\n    const parsed = parseMermaid(source)\n    const result = layoutGraphSync(parsed)\n\n    const today = result.groups.find(g => g.label === 'Today')\n    const tomorrow = result.groups.find(g => g.label === 'Tomorrow')\n\n    expect(today).toBeDefined()\n    expect(tomorrow).toBeDefined()\n    expect(rectanglesOverlap(today!, tomorrow!)).toBe(false)\n  })\n\n  it('respects direction for stacking (LR = vertical)', () => {\n    // Perpendicular stacking: LR flows horizontally → stack vertically\n    const source = `graph LR\n      subgraph S1 [First]\n        A --> B\n      end\n      subgraph S2 [Second]\n        C --> D\n      end`\n\n    const parsed = parseMermaid(source)\n    const result = layoutGraphSync(parsed)\n\n    const s1 = result.groups.find(g => g.label === 'First')!\n    const s2 = result.groups.find(g => g.label === 'Second')!\n\n    // In LR mode, subgraphs should be stacked vertically (perpendicular to flow)\n    // One should be above the other\n    const isVerticallyArranged =\n      (s1.y + s1.height <= s2.y) || (s2.y + s2.height <= s1.y)\n\n    expect(isVerticallyArranged).toBe(true)\n  })\n\n  it('respects direction for stacking (TD = horizontal)', () => {\n    // Perpendicular stacking: TD flows vertically → stack horizontally\n    const source = `graph TD\n      subgraph S1 [First]\n        A --> B\n      end\n      subgraph S2 [Second]\n        C --> D\n      end`\n\n    const parsed = parseMermaid(source)\n    const result = layoutGraphSync(parsed)\n\n    const s1 = result.groups.find(g => g.label === 'First')!\n    const s2 = result.groups.find(g => g.label === 'Second')!\n\n    // ELK may arrange disconnected components in various ways\n    // The key requirement is that they don't overlap\n    const noOverlap =\n      (s1.x + s1.width <= s2.x) || (s2.x + s2.width <= s1.x) ||\n      (s1.y + s1.height <= s2.y) || (s2.y + s2.height <= s1.y)\n\n    expect(noOverlap).toBe(true)\n  })\n})\n\n// ============================================================================\n// Three+ disconnected components\n// ============================================================================\n\ndescribe('layoutGraph – multiple disconnected components', () => {\n  it('renders three disconnected subgraphs without overlap', () => {\n    const source = `graph LR\n      subgraph A [Alpha]\n        A1 --> A2\n      end\n      subgraph B [Beta]\n        B1 --> B2\n      end\n      subgraph C [Gamma]\n        C1 --> C2\n      end`\n\n    const parsed = parseMermaid(source)\n    const result = layoutGraphSync(parsed)\n\n    const alpha = result.groups.find(g => g.label === 'Alpha')!\n    const beta = result.groups.find(g => g.label === 'Beta')!\n    const gamma = result.groups.find(g => g.label === 'Gamma')!\n\n    // No pair should overlap\n    expect(rectanglesOverlap(alpha, beta)).toBe(false)\n    expect(rectanglesOverlap(beta, gamma)).toBe(false)\n    expect(rectanglesOverlap(alpha, gamma)).toBe(false)\n  })\n\n  it('renders five disconnected nodes without overlap', () => {\n    // Five completely isolated nodes\n    const source = `graph LR\n      A[Node A]\n      B[Node B]\n      C[Node C]\n      D[Node D]\n      E[Node E]`\n\n    const parsed = parseMermaid(source)\n    const result = layoutGraphSync(parsed)\n\n    // All nodes should exist\n    expect(result.nodes.length).toBe(5)\n\n    // No pair of nodes should overlap\n    for (let i = 0; i < result.nodes.length; i++) {\n      for (let j = i + 1; j < result.nodes.length; j++) {\n        const overlap = rectanglesOverlap(result.nodes[i]!, result.nodes[j]!)\n        expect(\n          overlap,\n          `Nodes ${result.nodes[i]!.id} and ${result.nodes[j]!.id} overlap`\n        ).toBe(false)\n      }\n    }\n  })\n})\n\n// ============================================================================\n// Mixed: connected + disconnected\n// ============================================================================\n\ndescribe('layoutGraph – mixed connected and disconnected', () => {\n  it('renders two connected subgraphs + one disconnected', () => {\n    const source = `graph LR\n      subgraph Frontend [Frontend]\n        FE1 --> FE2\n      end\n      subgraph Backend [Backend]\n        BE1 --> BE2\n      end\n      subgraph Isolated [Isolated]\n        I1 --> I2\n      end\n      FE2 --> BE1`\n\n    const parsed = parseMermaid(source)\n    const result = layoutGraphSync(parsed)\n\n    const frontend = result.groups.find(g => g.label === 'Frontend')!\n    const backend = result.groups.find(g => g.label === 'Backend')!\n    const isolated = result.groups.find(g => g.label === 'Isolated')!\n\n    // None should overlap\n    expect(rectanglesOverlap(frontend, backend)).toBe(false)\n    expect(rectanglesOverlap(backend, isolated)).toBe(false)\n    expect(rectanglesOverlap(frontend, isolated)).toBe(false)\n  })\n\n  it('renders connected nodes + isolated node', () => {\n    const source = `graph LR\n      A --> B --> C\n      D[Isolated]`\n\n    const parsed = parseMermaid(source)\n    const result = layoutGraphSync(parsed)\n\n    const nodeD = result.nodes.find(n => n.id === 'D')!\n    const connectedNodes = result.nodes.filter(n => n.id !== 'D')\n\n    // Isolated node should not overlap with any connected node\n    for (const node of connectedNodes) {\n      expect(\n        rectanglesOverlap(nodeD, node),\n        `Node D overlaps with node ${node.id}`\n      ).toBe(false)\n    }\n  })\n})\n\n// ============================================================================\n// Layout quality preservation\n// ============================================================================\n\ndescribe('layoutGraph – quality preservation', () => {\n  it('each component looks identical to standalone rendering', () => {\n    // Render a single subgraph standalone\n    const standalone = `graph LR\n      subgraph S [Section]\n        A --> B --> C\n      end`\n\n    const standaloneParsed = parseMermaid(standalone)\n    const standaloneResult = layoutGraphSync(standaloneParsed)\n\n    // Render the same subgraph as part of a disconnected graph\n    const combined = `graph LR\n      subgraph S [Section]\n        A --> B --> C\n      end\n      subgraph Other [Other]\n        X --> Y\n      end`\n\n    const combinedParsed = parseMermaid(combined)\n    const combinedResult = layoutGraphSync(combinedParsed)\n\n    // The \"Section\" group should have the same dimensions\n    const standaloneGroup = standaloneResult.groups.find(g => g.label === 'Section')!\n    const combinedGroup = combinedResult.groups.find(g => g.label === 'Section')!\n\n    expect(combinedGroup.width).toBe(standaloneGroup.width)\n    expect(combinedGroup.height).toBe(standaloneGroup.height)\n  })\n})\n\n// ============================================================================\n// Edge cases\n// ============================================================================\n\ndescribe('layoutGraph – disconnected edge cases', () => {\n  it('handles single node as its own component', () => {\n    const source = `graph LR\n      A --> B\n      C[Isolated]`\n\n    const parsed = parseMermaid(source)\n    const result = layoutGraphSync(parsed)\n\n    expect(result.nodes.length).toBe(3)\n\n    // All nodes positioned without overlap\n    for (let i = 0; i < result.nodes.length; i++) {\n      for (let j = i + 1; j < result.nodes.length; j++) {\n        expect(rectanglesOverlap(result.nodes[i]!, result.nodes[j]!)).toBe(false)\n      }\n    }\n  })\n\n  it('handles empty subgraph with disconnected nodes', () => {\n    const source = `graph LR\n      subgraph Empty\n      end\n      A --> B`\n\n    const parsed = parseMermaid(source)\n    const result = layoutGraphSync(parsed)\n\n    expect(result.groups.length).toBe(1)\n    expect(result.nodes.length).toBe(2)\n  })\n\n  it('handles subgraph containing entire component', () => {\n    const source = `graph LR\n      subgraph Component1\n        A --> B --> C\n      end\n      D --> E`\n\n    const parsed = parseMermaid(source)\n    const result = layoutGraphSync(parsed)\n\n    const group = result.groups.find(g => g.label === 'Component1')!\n    const nodeD = result.nodes.find(n => n.id === 'D')!\n    const nodeE = result.nodes.find(n => n.id === 'E')!\n\n    // Group and D/E nodes should not overlap\n    expect(rectanglesOverlap(group, nodeD)).toBe(false)\n    expect(rectanglesOverlap(group, nodeE)).toBe(false)\n  })\n})\n\n// ============================================================================\n// Full render tests (SVG output)\n// ============================================================================\n\ndescribe('renderMermaid – disconnected components', () => {\n  it('renders two disconnected subgraphs to valid SVG', () => {\n    const source = `graph LR\n      subgraph Today [Today]\n        A --> B\n      end\n      subgraph Tomorrow [Tomorrow]\n        C --> D\n      end`\n\n    const svg = renderMermaidSync(source)\n\n    expect(svg).toContain('<svg')\n    expect(svg).toContain('</svg>')\n    expect(svg).toContain('>Today</text>')\n    expect(svg).toContain('>Tomorrow</text>')\n    expect(svg).toContain('>A</text>')\n    expect(svg).toContain('>B</text>')\n    expect(svg).toContain('>C</text>')\n    expect(svg).toContain('>D</text>')\n  })\n\n  it('renders isolated nodes to valid SVG', () => {\n    const source = `graph LR\n      A[First]\n      B[Second]\n      C[Third]`\n\n    const svg = renderMermaidSync(source)\n\n    expect(svg).toContain('<svg')\n    expect(svg).toContain('</svg>')\n    expect(svg).toContain('>First</text>')\n    expect(svg).toContain('>Second</text>')\n    expect(svg).toContain('>Third</text>')\n  })\n})\n"
  },
  {
    "path": "src/__tests__/linkstyle.test.ts",
    "content": "import { describe, it, expect } from 'bun:test'\nimport { parseMermaid } from '../parser.ts'\nimport { renderMermaidSVG } from '../index.ts'\n\ndescribe('linkStyle – parser', () => {\n  it('parses linkStyle with single index', () => {\n    const g = parseMermaid('graph TD\\n  A --> B\\n  linkStyle 0 stroke:#ff0000,stroke-width:2px')\n    expect(g.linkStyles.get(0)).toEqual({ stroke: '#ff0000', 'stroke-width': '2px' })\n  })\n\n  it('parses linkStyle with comma-separated indices', () => {\n    const g = parseMermaid('graph TD\\n  A --> B\\n  B --> C\\n  linkStyle 0,1 stroke:#00ff00')\n    expect(g.linkStyles.get(0)).toEqual({ stroke: '#00ff00' })\n    expect(g.linkStyles.get(1)).toEqual({ stroke: '#00ff00' })\n  })\n\n  it('parses linkStyle default', () => {\n    const g = parseMermaid('graph TD\\n  A --> B\\n  linkStyle default stroke:#888888,stroke-width:3px')\n    expect(g.linkStyles.get('default')).toEqual({ stroke: '#888888', 'stroke-width': '3px' })\n  })\n\n  it('later linkStyle overrides earlier for same index', () => {\n    const g = parseMermaid('graph TD\\n  A --> B\\n  linkStyle 0 stroke:#ff0000\\n  linkStyle 0 stroke:#00ff00')\n    expect(g.linkStyles.get(0)).toEqual({ stroke: '#00ff00' })\n  })\n\n  it('ignores linkStyle lines silently (no crash) when index out of range', () => {\n    const g = parseMermaid('graph TD\\n  A --> B\\n  linkStyle 99 stroke:#ff0000')\n    expect(g.linkStyles.get(99)).toEqual({ stroke: '#ff0000' })\n    expect(g.edges).toHaveLength(1)\n  })\n\n  it('strips trailing semicolons from style values', () => {\n    const g = parseMermaid('graph TD\\n  A --> B\\n  linkStyle 0 stroke:#ff0000,stroke-width:4px;')\n    expect(g.linkStyles.get(0)).toEqual({ stroke: '#ff0000', 'stroke-width': '4px' })\n  })\n})\n\ndescribe('linkStyle – state diagram parser', () => {\n  it('parses linkStyle in state diagrams', () => {\n    const g = parseMermaid('stateDiagram-v2\\n  A --> B\\n  linkStyle 0 stroke:#ff0000')\n    expect(g.linkStyles.get(0)).toEqual({ stroke: '#ff0000' })\n  })\n\n  it('parses linkStyle default in state diagrams', () => {\n    const g = parseMermaid('stateDiagram-v2\\n  A --> B\\n  B --> C\\n  linkStyle default stroke:#888')\n    expect(g.linkStyles.get('default')).toEqual({ stroke: '#888' })\n  })\n})\n\ndescribe('linkStyle – SVG integration', () => {\n  it('applies linkStyle stroke color to SVG edge', () => {\n    const svg = renderMermaidSVG('graph TD\\n  A --> B\\n  linkStyle 0 stroke:#ff0000')\n    expect(svg).toContain('stroke=\"#ff0000\"')\n  })\n\n  it('applies linkStyle stroke-width to SVG edge', () => {\n    const svg = renderMermaidSVG('graph TD\\n  A --> B\\n  linkStyle 0 stroke-width:3px')\n    expect(svg).toContain('stroke-width=\"3px\"')\n  })\n\n  it('applies linkStyle default to all edges', () => {\n    const svg = renderMermaidSVG('graph TD\\n  A --> B\\n  B --> C\\n  linkStyle default stroke:#00ff00')\n    const matches = svg.match(/stroke=\"#00ff00\"/g)\n    expect(matches).not.toBeNull()\n    expect(matches!.length).toBeGreaterThanOrEqual(2)\n  })\n\n  it('index-specific linkStyle overrides default', () => {\n    const svg = renderMermaidSVG(\n      'graph TD\\n  A --> B\\n  B --> C\\n  linkStyle default stroke:#888\\n  linkStyle 0 stroke:#ff0000'\n    )\n    expect(svg).toContain('stroke=\"#ff0000\"')\n    expect(svg).toContain('stroke=\"#888\"')\n  })\n\n  it('arrowhead color matches custom stroke color', () => {\n    const svg = renderMermaidSVG('graph TD\\n  A --> B\\n  linkStyle 0 stroke:#ff0000')\n    // Should have a color-specific marker def (# is hex-encoded to \"23\")\n    expect(svg).toContain('id=\"arrowhead-23ff0000\"')\n    expect(svg).toContain('fill=\"#ff0000\"')\n    // Edge should reference the colored marker\n    expect(svg).toContain('marker-end=\"url(#arrowhead-23ff0000)\"')\n  })\n\n  it('escapes XSS injection in stroke value', () => {\n    const svg = renderMermaidSVG('graph TD\\n  A --> B\\n  linkStyle 0 stroke:red\" onmouseover=\"alert(1)')\n    // Quotes must be escaped — no attribute breakout\n    expect(svg).not.toContain('stroke=\"red\" onmouseover')\n    expect(svg).toContain('stroke=\"red&quot; onmouseover=&quot;alert(1)\"')\n  })\n\n  it('trailing semicolons do not leak into SVG attributes', () => {\n    const svg = renderMermaidSVG('graph TD\\n  A --> B\\n  linkStyle 0 stroke:#ff0000,stroke-width:4px;')\n    expect(svg).toContain('stroke-width=\"4px\"')\n    expect(svg).not.toContain('stroke-width=\"4px;\"')\n  })\n})\n"
  },
  {
    "path": "src/__tests__/multiline-labels.test.ts",
    "content": "/**\n * Tests for multi-line label support via <br> tags.\n *\n * Covers:\n * - Parser: normalization of <br>, <br/>, <br /> to \\n\n * - Text metrics: measureMultilineText() for width/height calculation\n * - Layout: estimateNodeSize() with multi-line labels\n * - Renderer: <tspan> generation for node and edge labels\n * - Integration: full SVG output with multi-line labels\n */\nimport { describe, it, expect } from 'bun:test'\nimport { parseMermaid } from '../parser.ts'\nimport { parseSequenceDiagram } from '../sequence/parser.ts'\nimport { parseClassDiagram } from '../class/parser.ts'\nimport { parseErDiagram } from '../er/parser.ts'\nimport { measureMultilineText, LINE_HEIGHT_RATIO, measureTextWidth } from '../text-metrics.ts'\nimport { renderMermaid } from '../index.ts'\nimport { normalizeBrTags, stripFormattingTags } from '../multiline-utils.ts'\n\n// ============================================================================\n// Parser: <br> tag normalization\n// ============================================================================\n\ndescribe('parseMermaid – <br> tag normalization', () => {\n  describe('node labels', () => {\n    it('normalizes <br> to newline', () => {\n      const g = parseMermaid('graph TD\\n  A[Line1<br>Line2]')\n      expect(g.nodes.get('A')!.label).toBe('Line1\\nLine2')\n    })\n\n    it('normalizes <br/> to newline', () => {\n      const g = parseMermaid('graph TD\\n  A[Line1<br/>Line2]')\n      expect(g.nodes.get('A')!.label).toBe('Line1\\nLine2')\n    })\n\n    it('normalizes <br /> (with space) to newline', () => {\n      const g = parseMermaid('graph TD\\n  A[Line1<br />Line2]')\n      expect(g.nodes.get('A')!.label).toBe('Line1\\nLine2')\n    })\n\n    it('is case-insensitive (<BR>, <Br>, <bR>)', () => {\n      const g1 = parseMermaid('graph TD\\n  A[Line1<BR>Line2]')\n      const g2 = parseMermaid('graph TD\\n  B[Line1<Br>Line2]')\n      const g3 = parseMermaid('graph TD\\n  C[Line1<bR/>Line2]')\n\n      expect(g1.nodes.get('A')!.label).toBe('Line1\\nLine2')\n      expect(g2.nodes.get('B')!.label).toBe('Line1\\nLine2')\n      expect(g3.nodes.get('C')!.label).toBe('Line1\\nLine2')\n    })\n\n    it('handles multiple <br> tags', () => {\n      const g = parseMermaid('graph TD\\n  A[One<br>Two<br/>Three<br />Four]')\n      expect(g.nodes.get('A')!.label).toBe('One\\nTwo\\nThree\\nFour')\n    })\n\n    it('handles <br> with various node shapes', () => {\n      const g = parseMermaid(`graph TD\n        A[Rect<br>Label]\n        B(Round<br>Label)\n        C{Diamond<br>Label}\n        D([Stadium<br>Label])\n      `)\n      expect(g.nodes.get('A')!.label).toBe('Rect\\nLabel')\n      expect(g.nodes.get('B')!.label).toBe('Round\\nLabel')\n      expect(g.nodes.get('C')!.label).toBe('Diamond\\nLabel')\n      expect(g.nodes.get('D')!.label).toBe('Stadium\\nLabel')\n    })\n  })\n\n  describe('edge labels', () => {\n    it('normalizes <br> in edge labels', () => {\n      const g = parseMermaid('graph TD\\n  A -->|First<br>Second| B')\n      expect(g.edges[0]!.label).toBe('First\\nSecond')\n    })\n\n    it('normalizes <br/> in edge labels', () => {\n      const g = parseMermaid('graph TD\\n  A -->|Line1<br/>Line2| B')\n      expect(g.edges[0]!.label).toBe('Line1\\nLine2')\n    })\n  })\n\n  describe('subgraph labels', () => {\n    it('normalizes <br> in subgraph labels (bracket syntax)', () => {\n      const g = parseMermaid(`graph TD\n        subgraph sg1 [Group<br>Header]\n          A[Node]\n        end\n      `)\n      expect(g.subgraphs[0]!.label).toBe('Group\\nHeader')\n    })\n\n    it('normalizes <br> in subgraph labels (plain syntax)', () => {\n      const g = parseMermaid(`graph TD\n        subgraph Line1<br>Line2\n          A[Node]\n        end\n      `)\n      expect(g.subgraphs[0]!.label).toBe('Line1\\nLine2')\n    })\n  })\n\n  describe('state diagram labels', () => {\n    it('normalizes <br> in state alias labels', () => {\n      const g = parseMermaid(`stateDiagram-v2\n        state \"First<br>Second\" as s1\n        [*] --> s1\n      `)\n      expect(g.nodes.get('s1')!.label).toBe('First\\nSecond')\n    })\n\n    it('normalizes <br> in state description labels', () => {\n      const g = parseMermaid(`stateDiagram-v2\n        s1 : First<br>Second\n        [*] --> s1\n      `)\n      expect(g.nodes.get('s1')!.label).toBe('First\\nSecond')\n    })\n\n    it('normalizes <br> in transition labels', () => {\n      const g = parseMermaid(`stateDiagram-v2\n        s1 --> s2 : Event<br>Action\n      `)\n      expect(g.edges[0]!.label).toBe('Event\\nAction')\n    })\n  })\n\n  describe('sequence diagram labels', () => {\n    it('normalizes <br> in participant alias labels', () => {\n      const lines = ['sequenceDiagram', 'participant A as First<br>Line', 'A->>A: test']\n      const diagram = parseSequenceDiagram(lines)\n      expect(diagram.actors[0]!.label).toBe('First\\nLine')\n    })\n\n    it('normalizes <br> in message labels', () => {\n      const lines = ['sequenceDiagram', 'A->>B: Hello<br>World']\n      const diagram = parseSequenceDiagram(lines)\n      expect(diagram.messages[0]!.label).toBe('Hello\\nWorld')\n    })\n\n    it('normalizes <br> in note text', () => {\n      const lines = ['sequenceDiagram', 'A->>B: Hello', 'Note over A,B: First<br>Second']\n      const diagram = parseSequenceDiagram(lines)\n      expect(diagram.notes[0]!.text).toBe('First\\nSecond')\n    })\n\n    it('normalizes <br> in block labels', () => {\n      const lines = ['sequenceDiagram', 'A->>B: Hello', 'loop Every<br>30s', 'A->>B: Ping', 'end']\n      const diagram = parseSequenceDiagram(lines)\n      expect(diagram.blocks[0]!.label).toBe('Every\\n30s')\n    })\n\n    it('normalizes <br> in divider labels', () => {\n      const lines = ['sequenceDiagram', 'A->>B: Hello', 'alt First<br>case', 'A->>B: a', 'else Second<br>case', 'A->>B: b', 'end']\n      const diagram = parseSequenceDiagram(lines)\n      expect(diagram.blocks[0]!.dividers[0]!.label).toBe('Second\\ncase')\n    })\n  })\n\n  describe('class diagram labels', () => {\n    it('normalizes <br> in relationship labels', () => {\n      const lines = ['classDiagram', 'A --> B : uses<br>internally']\n      const diagram = parseClassDiagram(lines)\n      expect(diagram.relationships[0]!.label).toBe('uses\\ninternally')\n    })\n\n    it('normalizes <br> in fromCardinality labels', () => {\n      const lines = ['classDiagram', 'A \"one<br>to\" --> B']\n      const diagram = parseClassDiagram(lines)\n      expect(diagram.relationships[0]!.fromCardinality).toBe('one\\nto')\n    })\n\n    it('normalizes <br> in toCardinality labels', () => {\n      const lines = ['classDiagram', 'A --> \"many<br>items\" B']\n      const diagram = parseClassDiagram(lines)\n      expect(diagram.relationships[0]!.toCardinality).toBe('many\\nitems')\n    })\n  })\n\n  describe('ER diagram labels', () => {\n    it('normalizes <br> in relationship labels', () => {\n      const lines = ['erDiagram', 'CUSTOMER ||--o{ ORDER : places<br>orders']\n      const diagram = parseErDiagram(lines)\n      expect(diagram.relationships[0]!.label).toBe('places\\norders')\n    })\n\n    it('normalizes <br> in attribute comments', () => {\n      const lines = ['erDiagram', 'CUSTOMER {', 'int id PK \"primary<br>key\"', '}']\n      const diagram = parseErDiagram(lines)\n      expect(diagram.entities[0]!.attributes[0]!.comment).toBe('primary\\nkey')\n    })\n  })\n})\n\n// ============================================================================\n// Text metrics: multi-line measurement\n// ============================================================================\n\ndescribe('measureMultilineText', () => {\n  const fontSize = 13\n  const fontWeight = 500\n\n  it('returns single line metrics for text without newlines', () => {\n    const metrics = measureMultilineText('Hello', fontSize, fontWeight)\n\n    expect(metrics.lines).toEqual(['Hello'])\n    expect(metrics.lineHeight).toBe(fontSize * LINE_HEIGHT_RATIO)\n    expect(metrics.height).toBe(fontSize * LINE_HEIGHT_RATIO)\n    expect(metrics.width).toBe(measureTextWidth('Hello', fontSize, fontWeight))\n  })\n\n  it('splits text on newlines', () => {\n    const metrics = measureMultilineText('Line1\\nLine2\\nLine3', fontSize, fontWeight)\n    expect(metrics.lines).toEqual(['Line1', 'Line2', 'Line3'])\n  })\n\n  it('calculates height based on number of lines', () => {\n    const lineHeight = fontSize * LINE_HEIGHT_RATIO\n\n    const one = measureMultilineText('One', fontSize, fontWeight)\n    const two = measureMultilineText('One\\nTwo', fontSize, fontWeight)\n    const three = measureMultilineText('One\\nTwo\\nThree', fontSize, fontWeight)\n\n    expect(one.height).toBeCloseTo(lineHeight, 1)\n    expect(two.height).toBeCloseTo(lineHeight * 2, 1)\n    expect(three.height).toBeCloseTo(lineHeight * 3, 1)\n  })\n\n  it('uses maximum line width for overall width', () => {\n    const metrics = measureMultilineText('Short\\nMuch Longer Line\\nMedium', fontSize, fontWeight)\n\n    const shortWidth = measureTextWidth('Short', fontSize, fontWeight)\n    const longWidth = measureTextWidth('Much Longer Line', fontSize, fontWeight)\n    const mediumWidth = measureTextWidth('Medium', fontSize, fontWeight)\n\n    expect(metrics.width).toBe(longWidth)\n    expect(metrics.width).toBeGreaterThan(shortWidth)\n    expect(metrics.width).toBeGreaterThan(mediumWidth)\n  })\n\n  it('handles empty lines', () => {\n    const metrics = measureMultilineText('Line1\\n\\nLine3', fontSize, fontWeight)\n    expect(metrics.lines).toEqual(['Line1', '', 'Line3'])\n    expect(metrics.height).toBeCloseTo(fontSize * LINE_HEIGHT_RATIO * 3, 1)\n  })\n\n  it('exports LINE_HEIGHT_RATIO constant', () => {\n    expect(LINE_HEIGHT_RATIO).toBe(1.3)\n  })\n})\n\n// ============================================================================\n// Renderer: <tspan> element generation\n// ============================================================================\n\ndescribe('renderMermaid – multi-line labels', () => {\n  it('renders single-line node label without tspan', async () => {\n    const svg = await renderMermaid('graph TD\\n  A[Single Line]')\n\n    // Should have text element with direct content\n    expect(svg).toContain('Single Line</text>')\n    // Should NOT have tspan for single line\n    expect(svg).not.toMatch(/<tspan[^>]*>Single Line<\\/tspan>/)\n  })\n\n  it('renders multi-line node label with tspan elements', async () => {\n    const svg = await renderMermaid('graph TD\\n  A[Line1<br>Line2]')\n\n    // Should have tspan elements\n    expect(svg).toContain('<tspan')\n    expect(svg).toContain('>Line1</tspan>')\n    expect(svg).toContain('>Line2</tspan>')\n  })\n\n  it('renders 3-line node label with 3 tspan elements', async () => {\n    const svg = await renderMermaid('graph TD\\n  A[One<br>Two<br>Three]')\n\n    // Count tspan occurrences\n    const tspanMatches = svg.match(/<tspan/g)\n    expect(tspanMatches).toHaveLength(3)\n\n    expect(svg).toContain('>One</tspan>')\n    expect(svg).toContain('>Two</tspan>')\n    expect(svg).toContain('>Three</tspan>')\n  })\n\n  it('renders multi-line edge label with tspan elements', async () => {\n    const svg = await renderMermaid('graph TD\\n  A -->|First<br>Second| B')\n\n    expect(svg).toContain('<tspan')\n    expect(svg).toContain('>First</tspan>')\n    expect(svg).toContain('>Second</tspan>')\n  })\n\n  it('includes x attribute on each tspan for horizontal reset', async () => {\n    const svg = await renderMermaid('graph TD\\n  A[Line1<br>Line2]')\n\n    // Each tspan should have an x attribute\n    const tspanRegex = /<tspan x=\"[^\"]+\"/g\n    const matches = svg.match(tspanRegex)\n    expect(matches).toHaveLength(2)\n  })\n\n  it('includes dy attribute on each tspan for vertical positioning', async () => {\n    const svg = await renderMermaid('graph TD\\n  A[Line1<br>Line2]')\n\n    // Each tspan should have a dy attribute\n    const tspanRegex = /<tspan[^>]*dy=\"[^\"]+\"/g\n    const matches = svg.match(tspanRegex)\n    expect(matches).toHaveLength(2)\n  })\n\n  it('escapes XML characters in multi-line labels', async () => {\n    const svg = await renderMermaid('graph TD\\n  A[First &<br>Second >]')\n\n    expect(svg).toContain('&amp;')\n    expect(svg).toContain('&gt;')\n    expect(svg).not.toContain('First &</tspan>')\n    expect(svg).not.toContain('Second ></tspan>')\n  })\n})\n\n// ============================================================================\n// Integration: layout sizing\n// ============================================================================\n\ndescribe('renderMermaid – multi-line layout sizing', () => {\n  it('multi-line node is taller than single-line node', async () => {\n    const singleSvg = await renderMermaid('graph TD\\n  A[Single]')\n    const multiSvg = await renderMermaid('graph TD\\n  A[Line1<br>Line2]')\n\n    // Extract node rect heights\n    const singleHeight = extractFirstRectHeight(singleSvg)\n    const multiHeight = extractFirstRectHeight(multiSvg)\n\n    expect(multiHeight).toBeGreaterThan(singleHeight)\n  })\n\n  it('3-line node is taller than 2-line node', async () => {\n    const twoLineSvg = await renderMermaid('graph TD\\n  A[One<br>Two]')\n    const threeLineSvg = await renderMermaid('graph TD\\n  A[One<br>Two<br>Three]')\n\n    const twoLineHeight = extractFirstRectHeight(twoLineSvg)\n    const threeLineHeight = extractFirstRectHeight(threeLineSvg)\n\n    expect(threeLineHeight).toBeGreaterThan(twoLineHeight)\n  })\n\n  it('node width matches longest line', async () => {\n    // \"Much Longer\" is wider than \"Short\"\n    const svg = await renderMermaid('graph TD\\n  A[Short<br>Much Longer Line]')\n    const width = extractFirstRectWidth(svg)\n\n    // Compare to single-line with long text\n    const longSvg = await renderMermaid('graph TD\\n  A[Much Longer Line]')\n    const longWidth = extractFirstRectWidth(longSvg)\n\n    // Widths should be approximately equal (multi-line uses max line width)\n    expect(Math.abs(width - longWidth)).toBeLessThan(5)\n  })\n})\n\n// ============================================================================\n// Integration: sequence diagram multi-line rendering\n// ============================================================================\n\ndescribe('renderMermaid – sequence diagram multi-line', () => {\n  it('renders multi-line message labels with tspan elements', async () => {\n    const svg = await renderMermaid(`sequenceDiagram\n      A->>B: Hello<br>World\n    `)\n\n    expect(svg).toContain('<tspan')\n    expect(svg).toContain('>Hello</tspan>')\n    expect(svg).toContain('>World</tspan>')\n  })\n\n  it('renders multi-line actor labels with tspan elements', async () => {\n    const svg = await renderMermaid(`sequenceDiagram\n      participant A as First<br>Line\n      A->>A: test\n    `)\n\n    expect(svg).toContain('<tspan')\n    expect(svg).toContain('>First</tspan>')\n    expect(svg).toContain('>Line</tspan>')\n  })\n\n  it('renders multi-line note text with tspan elements', async () => {\n    const svg = await renderMermaid(`sequenceDiagram\n      A->>B: msg\n      Note over A,B: Note<br>Text\n    `)\n\n    expect(svg).toContain('<tspan')\n    expect(svg).toContain('>Note</tspan>')\n    expect(svg).toContain('>Text</tspan>')\n  })\n})\n\n// ============================================================================\n// Integration: class diagram multi-line rendering\n// ============================================================================\n\ndescribe('renderMermaid – class diagram multi-line', () => {\n  it('renders multi-line relationship labels with tspan elements', async () => {\n    const svg = await renderMermaid(`classDiagram\n      A --> B : uses<br>data\n    `)\n\n    expect(svg).toContain('<tspan')\n    expect(svg).toContain('>uses</tspan>')\n    expect(svg).toContain('>data</tspan>')\n  })\n\n  it('renders multi-line cardinality with tspan elements', async () => {\n    const svg = await renderMermaid(`classDiagram\n      A \"one<br>to\" --> B\n    `)\n\n    expect(svg).toContain('<tspan')\n    expect(svg).toContain('>one</tspan>')\n    expect(svg).toContain('>to</tspan>')\n  })\n})\n\n// ============================================================================\n// Integration: ER diagram multi-line rendering\n// ============================================================================\n\ndescribe('renderMermaid – ER diagram multi-line', () => {\n  it('renders multi-line relationship labels with tspan elements', async () => {\n    const svg = await renderMermaid(`erDiagram\n      CUSTOMER ||--o{ ORDER : places<br>orders\n    `)\n\n    expect(svg).toContain('<tspan')\n    expect(svg).toContain('>places</tspan>')\n    expect(svg).toContain('>orders</tspan>')\n  })\n})\n\n// ============================================================================\n// Edge cases\n// ============================================================================\n\ndescribe('renderMermaid – edge cases', () => {\n  it('handles consecutive <br><br> tags (empty lines)', async () => {\n    const svg = await renderMermaid('graph TD\\n  A[Line1<br><br>Line3]')\n    const tspanMatches = svg.match(/<tspan/g)\n    expect(tspanMatches).toHaveLength(3)\n  })\n\n  it('handles very long single line with <br>', async () => {\n    const longLine = 'VeryLongTextHere'\n    const svg = await renderMermaid(`graph TD\\n  A[${longLine}<br>Short]`)\n    expect(svg).toContain(`>${longLine}</tspan>`)\n    expect(svg).toContain('>Short</tspan>')\n  })\n\n  it('handles single character lines', async () => {\n    const svg = await renderMermaid('graph TD\\n  A[X<br>Y<br>Z]')\n    expect(svg).toContain('>X</tspan>')\n    expect(svg).toContain('>Y</tspan>')\n    expect(svg).toContain('>Z</tspan>')\n  })\n\n  it('handles unicode with <br>', async () => {\n    const svg = await renderMermaid('graph TD\\n  A[日本<br>語]')\n    expect(svg).toContain('>日本</tspan>')\n    expect(svg).toContain('>語</tspan>')\n  })\n\n  it('handles many lines (5+)', async () => {\n    const svg = await renderMermaid('graph TD\\n  A[1<br>2<br>3<br>4<br>5<br>6]')\n    const tspanMatches = svg.match(/<tspan/g)\n    expect(tspanMatches).toHaveLength(6)\n  })\n\n  it('handles mixed single-line and multi-line nodes', async () => {\n    const svg = await renderMermaid(`graph TD\n      A[Single] --> B[Multi<br>Line]\n      B --> C[Also Single]\n    `)\n    expect(svg).toContain('>Single</text>')\n    expect(svg).toContain('>Multi</tspan>')\n    expect(svg).toContain('>Also Single</text>')\n  })\n})\n\n// ============================================================================\n// Subgraph multi-line\n// ============================================================================\n\ndescribe('renderMermaid – subgraph multi-line', () => {\n  it('renders multi-line group headers with tspan', async () => {\n    const svg = await renderMermaid(`graph TD\n      subgraph sg [Group<br>Header]\n        A[Node]\n      end\n    `)\n    expect(svg).toContain('>Group</tspan>')\n    expect(svg).toContain('>Header</tspan>')\n  })\n})\n\n// ============================================================================\n// All flowchart shapes with multi-line\n// ============================================================================\n\ndescribe('renderMermaid – all flowchart shapes with multi-line', () => {\n  const shapes: [string, string][] = [\n    ['rectangle', 'A[Line1<br>Line2]'],\n    ['rounded', 'A(Line1<br>Line2)'],\n    ['diamond', 'A{Line1<br>Line2}'],\n    ['stadium', 'A([Line1<br>Line2])'],\n    ['circle', 'A((Line1<br>Line2))'],\n    ['subroutine', 'A[[Line1<br>Line2]]'],\n    ['double-circle', 'A(((Line1<br>Line2)))'],\n    ['hexagon', 'A{{Line1<br>Line2}}'],\n    ['cylinder', 'A[(Line1<br>Line2)]'],\n    ['flag', 'A>Line1<br>Line2]'],\n    ['trapezoid', 'A[/Line1<br>Line2\\\\]'],\n    ['inv-trapezoid', 'A[\\\\Line1<br>Line2/]'],\n  ]\n\n  shapes.forEach(([name, syntax]) => {\n    it(`renders multi-line in ${name} shape`, async () => {\n      const svg = await renderMermaid(`graph TD\\n  ${syntax}`)\n      expect(svg).toContain('<tspan')\n      expect(svg).toContain('>Line1</tspan>')\n      expect(svg).toContain('>Line2</tspan>')\n    })\n  })\n})\n\n// ============================================================================\n// Inline formatting: <b>, <i>, <u>, <s> → SVG tspan attributes\n// ============================================================================\n\ndescribe('renderMermaid – inline formatting', () => {\n  it('renders <b> as font-weight=\"bold\"', async () => {\n    const svg = await renderMermaid('graph TD\\n  A[Hello <b>bold</b> text]')\n    expect(svg).toContain('font-weight=\"bold\"')\n    expect(svg).toContain('>bold</tspan>')\n  })\n\n  it('renders <strong> as font-weight=\"bold\"', async () => {\n    const svg = await renderMermaid('graph TD\\n  A[Hello <strong>bold</strong>]')\n    expect(svg).toContain('font-weight=\"bold\"')\n  })\n\n  it('renders <i> as font-style=\"italic\"', async () => {\n    const svg = await renderMermaid('graph TD\\n  A[Hello <i>italic</i> text]')\n    expect(svg).toContain('font-style=\"italic\"')\n    expect(svg).toContain('>italic</tspan>')\n  })\n\n  it('renders <em> as font-style=\"italic\"', async () => {\n    const svg = await renderMermaid('graph TD\\n  A[Hello <em>italic</em>]')\n    expect(svg).toContain('font-style=\"italic\"')\n  })\n\n  it('renders <u> as text-decoration=\"underline\"', async () => {\n    const svg = await renderMermaid('graph TD\\n  A[Hello <u>underline</u> text]')\n    expect(svg).toContain('text-decoration=\"underline\"')\n    expect(svg).toContain('>underline</tspan>')\n  })\n\n  it('renders <s> as text-decoration=\"line-through\"', async () => {\n    const svg = await renderMermaid('graph TD\\n  A[Hello <s>strike</s> text]')\n    expect(svg).toContain('text-decoration=\"line-through\"')\n    expect(svg).toContain('>strike</tspan>')\n  })\n\n  it('renders <del> as text-decoration=\"line-through\"', async () => {\n    const svg = await renderMermaid('graph TD\\n  A[Hello <del>deleted</del>]')\n    expect(svg).toContain('text-decoration=\"line-through\"')\n  })\n\n  it('renders nested <b><i> with both attributes', async () => {\n    const svg = await renderMermaid('graph TD\\n  A[<b><i>bold italic</i></b>]')\n    expect(svg).toContain('font-weight=\"bold\"')\n    expect(svg).toContain('font-style=\"italic\"')\n    expect(svg).toContain('>bold italic</tspan>')\n  })\n\n  it('renders formatting combined with <br> multiline', async () => {\n    const svg = await renderMermaid('graph TD\\n  A[Line1<br><b>Bold Line2</b>]')\n    expect(svg).toContain('font-weight=\"bold\"')\n    expect(svg).toContain('>Bold Line2</tspan>')\n  })\n\n  it('does not include raw tag text in rendered text', async () => {\n    const svg = await renderMermaid('graph TD\\n  A[<b>bold</b>]')\n    // Tags should not appear as escaped text content inside <text> elements\n    expect(svg).toMatch(/<tspan font-weight=\"bold\">bold<\\/tspan>/)\n    expect(svg).not.toMatch(/<text[^>]*>&lt;b&gt;/)\n  })\n\n  it('renders plain text without formatting tspan wrappers', async () => {\n    const svg = await renderMermaid('graph TD\\n  A[Plain text]')\n    // Should not have bold/italic formatting tspans (font-weight=\"500\" on the text element is fine)\n    expect(svg).not.toContain('font-weight=\"bold\"')\n    expect(svg).not.toContain('font-style=\"italic\"')\n    expect(svg).not.toContain('text-decoration=')\n  })\n})\n\n// ============================================================================\n// Tag stripping: unsupported tags removed, formatting tags preserved\n// ============================================================================\n\ndescribe('normalizeBrTags – tag handling', () => {\n  it('strips <sub> tags', () => {\n    expect(normalizeBrTags('H<sub>2</sub>O')).toBe('H2O')\n  })\n\n  it('strips <sup> tags', () => {\n    expect(normalizeBrTags('x<sup>2</sup>')).toBe('x2')\n  })\n\n  it('strips <small> tags', () => {\n    expect(normalizeBrTags('big <small>small</small>')).toBe('big small')\n  })\n\n  it('strips <mark> tags', () => {\n    expect(normalizeBrTags('some <mark>highlighted</mark> text')).toBe('some highlighted text')\n  })\n\n  it('preserves <b> tags for rendering', () => {\n    expect(normalizeBrTags('Hello <b>bold</b>')).toContain('<b>')\n  })\n\n  it('preserves <i> tags for rendering', () => {\n    expect(normalizeBrTags('Hello <i>italic</i>')).toContain('<i>')\n  })\n\n  it('preserves <u> tags for rendering', () => {\n    expect(normalizeBrTags('Hello <u>under</u>')).toContain('<u>')\n  })\n\n  it('preserves <s> tags for rendering', () => {\n    expect(normalizeBrTags('Hello <s>strike</s>')).toContain('<s>')\n  })\n})\n\ndescribe('stripFormattingTags', () => {\n  it('strips all formatting tags', () => {\n    expect(stripFormattingTags('<b>bold</b> and <i>italic</i>')).toBe('bold and italic')\n  })\n\n  it('strips <strong> and <em>', () => {\n    expect(stripFormattingTags('<strong>bold</strong> <em>italic</em>')).toBe('bold italic')\n  })\n\n  it('strips <u>, <s>, <del>', () => {\n    expect(stripFormattingTags('<u>under</u> <s>strike</s> <del>del</del>')).toBe('under strike del')\n  })\n\n  it('handles nested tags', () => {\n    expect(stripFormattingTags('<b><i>nested</i></b>')).toBe('nested')\n  })\n\n  it('returns plain text unchanged', () => {\n    expect(stripFormattingTags('no tags here')).toBe('no tags here')\n  })\n})\n\n// ============================================================================\n// Text metrics: formatting tags excluded from width\n// ============================================================================\n\ndescribe('measureMultilineText – formatting tag exclusion', () => {\n  const fontSize = 13\n  const fontWeight = 500\n\n  it('measures width of plain text, not tag text', () => {\n    const withTags = measureMultilineText('<b>bold</b>', fontSize, fontWeight)\n    const plain = measureMultilineText('bold', fontSize, fontWeight)\n    expect(withTags.width).toBe(plain.width)\n  })\n\n  it('excludes nested tags from width', () => {\n    const withTags = measureMultilineText('<b><i>text</i></b>', fontSize, fontWeight)\n    const plain = measureMultilineText('text', fontSize, fontWeight)\n    expect(withTags.width).toBe(plain.width)\n  })\n})\n\n// ============================================================================\n// HTML entity decoding — prevents double-escaping in SVG output\n// ============================================================================\n\ndescribe('renderMermaid – HTML entity decoding', () => {\n  it('decodes &lt; and &gt; in node labels (prevents double-escaping)', async () => {\n    // Input has pre-encoded entities (as delivered by react-markdown + rehype-raw)\n    const svg = await renderMermaid('graph LR\\n  A[AsyncGenerator&lt;AgentEvent&gt;]')\n\n    // SVG should contain single-encoded &lt; (correct XML), NOT double-encoded &amp;lt;\n    expect(svg).toContain('AsyncGenerator&lt;AgentEvent&gt;')\n    expect(svg).not.toContain('&amp;lt;')\n    expect(svg).not.toContain('&amp;gt;')\n  })\n\n  it('decodes &amp; in node labels', async () => {\n    const svg = await renderMermaid('graph LR\\n  A[Tom &amp; Jerry]')\n\n    expect(svg).toContain('Tom &amp; Jerry')\n    expect(svg).not.toContain('&amp;amp;')\n  })\n\n  it('decodes numeric entity references (decimal)', async () => {\n    // &#60; = <, &#62; = >\n    const svg = await renderMermaid('graph LR\\n  A[List&#60;Item&#62;]')\n\n    expect(svg).toContain('List&lt;Item&gt;')\n    expect(svg).not.toContain('&#60;')\n    expect(svg).not.toContain('&#62;')\n  })\n\n  it('decodes numeric entity references (hex)', async () => {\n    // &#x3C; = <, &#x3E; = >\n    const svg = await renderMermaid('graph LR\\n  A[Map&#x3C;K, V&#x3E;]')\n\n    expect(svg).toContain('Map&lt;K, V&gt;')\n    expect(svg).not.toContain('&#x3C;')\n    expect(svg).not.toContain('&#x3E;')\n  })\n\n  it('decodes entities in edge labels', async () => {\n    const svg = await renderMermaid('graph LR\\n  A -->|returns &lt;T&gt;| B')\n\n    expect(svg).toContain('returns &lt;T&gt;')\n    expect(svg).not.toContain('&amp;lt;')\n  })\n\n  it('decodes entities in class diagram generics', async () => {\n    const svg = await renderMermaid(`classDiagram\n      class MyService~T~\n      MyService --> Handler : uses\n    `)\n\n    // Class parser converts ~T~ to <T> in the label, then escapeXml encodes it\n    expect(svg).toContain('MyService&lt;T&gt;')\n  })\n\n  it('handles raw angle brackets the same as decoded entities', async () => {\n    // Raw < and decoded &lt; should produce identical SVG output\n    const svgRaw = await renderMermaid('graph LR\\n  A[List<Item>]')\n    const svgEncoded = await renderMermaid('graph LR\\n  A[List&lt;Item&gt;]')\n\n    // Both should contain the same single-encoded entity in SVG\n    expect(svgRaw).toContain('List&lt;Item&gt;')\n    expect(svgEncoded).toContain('List&lt;Item&gt;')\n  })\n})\n\n// ============================================================================\n// Markdown formatting: **bold**, *italic*, ~~strike~~ → HTML tags\n// ============================================================================\n\ndescribe('normalizeBrTags – markdown formatting', () => {\n  it('converts **bold** to <b>bold</b>', () => {\n    expect(normalizeBrTags('Hello **World**')).toBe('Hello <b>World</b>')\n  })\n\n  it('converts *italic* to <i>italic</i>', () => {\n    expect(normalizeBrTags('Hello *World*')).toBe('Hello <i>World</i>')\n  })\n\n  it('converts ~~strikethrough~~ to <s>strikethrough</s>', () => {\n    expect(normalizeBrTags('Hello ~~World~~')).toBe('Hello <s>World</s>')\n  })\n\n  it('handles bold and italic together', () => {\n    expect(normalizeBrTags('**bold** and *italic*')).toBe('<b>bold</b> and <i>italic</i>')\n  })\n\n  it('does not match single * surrounded by spaces (multiplication)', () => {\n    expect(normalizeBrTags('a * b * c')).toBe('a * b * c')\n  })\n\n  it('handles ***bold italic*** (bold outer, italic inner)', () => {\n    const result = normalizeBrTags('***text***')\n    // ** matches first → <b>*text</b>, then * italic wraps across tag boundary\n    // Functionally correct: parseInlineFormatting() uses boolean state, not tag nesting\n    expect(result).toBe('<b><i>text</b></i>')\n  })\n\n  it('handles multiple bold segments', () => {\n    expect(normalizeBrTags('**one** and **two**')).toBe('<b>one</b> and <b>two</b>')\n  })\n\n  it('handles bold with <br> multiline', () => {\n    expect(normalizeBrTags('Line1<br>**Bold Line2**')).toBe('Line1\\n<b>Bold Line2</b>')\n  })\n\n  it('preserves existing HTML <b> tags alongside markdown', () => {\n    expect(normalizeBrTags('<b>html</b> and **md**')).toBe('<b>html</b> and <b>md</b>')\n  })\n\n  it('does not affect text without markdown formatting', () => {\n    expect(normalizeBrTags('plain text')).toBe('plain text')\n  })\n})\n\ndescribe('renderMermaid – markdown formatting in labels', () => {\n  it('renders **bold** as font-weight=\"bold\"', async () => {\n    const svg = await renderMermaid('graph TD\\n  A[Hello **bold** text]')\n    expect(svg).toContain('font-weight=\"bold\"')\n    expect(svg).toContain('>bold</tspan>')\n  })\n\n  it('renders *italic* as font-style=\"italic\"', async () => {\n    const svg = await renderMermaid('graph TD\\n  A[Hello *italic* text]')\n    expect(svg).toContain('font-style=\"italic\"')\n    expect(svg).toContain('>italic</tspan>')\n  })\n\n  it('renders ~~strike~~ as text-decoration=\"line-through\"', async () => {\n    const svg = await renderMermaid('graph TD\\n  A[Hello ~~strike~~ text]')\n    expect(svg).toContain('text-decoration=\"line-through\"')\n    expect(svg).toContain('>strike</tspan>')\n  })\n\n  it('renders **bold** in edge labels', async () => {\n    const svg = await renderMermaid('graph TD\\n  A -->|**important**| B')\n    expect(svg).toContain('font-weight=\"bold\"')\n    expect(svg).toContain('>important</tspan>')\n  })\n})\n\n// ============================================================================\n// Helper functions\n// ============================================================================\n\nfunction extractFirstRectHeight(svg: string): number {\n  const match = svg.match(/<rect[^>]*height=\"(\\d+(?:\\.\\d+)?)\"/)\n  return match ? parseFloat(match[1]!) : 0\n}\n\nfunction extractFirstRectWidth(svg: string): number {\n  const match = svg.match(/<rect[^>]*width=\"(\\d+(?:\\.\\d+)?)\"/)\n  return match ? parseFloat(match[1]!) : 0\n}\n"
  },
  {
    "path": "src/__tests__/parser.test.ts",
    "content": "/**\n * Tests for the Mermaid parser.\n *\n * Covers:\n * - Flowcharts: graph headers, node shapes (all 13), edge styles, chained edges,\n *   subgraphs (basic + nested), classDef/class, ::: shorthand, style statements,\n *   direction override, & parallel links, no-arrow edges, bidirectional arrows\n * - State diagrams: transitions, [*] pseudostates, composite states,\n *   state aliases, direction override\n * - Comments and error cases\n */\nimport { describe, it, expect } from 'bun:test'\nimport { parseMermaid } from '../parser.ts'\n\n// ============================================================================\n// Graph header parsing\n// ============================================================================\n\ndescribe('parseMermaid – graph header', () => {\n  it('parses \"graph TD\" header', () => {\n    const g = parseMermaid('graph TD\\n  A --> B')\n    expect(g.direction).toBe('TD')\n  })\n\n  it('parses \"flowchart LR\" header', () => {\n    const g = parseMermaid('flowchart LR\\n  A --> B')\n    expect(g.direction).toBe('LR')\n  })\n\n  it.each(['TD', 'TB', 'LR', 'BT', 'RL'] as const)('accepts direction %s', (dir) => {\n    const g = parseMermaid(`graph ${dir}\\n  A --> B`)\n    expect(g.direction).toBe(dir)\n  })\n\n  it('is case-insensitive for the keyword', () => {\n    const g = parseMermaid('graph td\\n  A --> B')\n    expect(g.direction).toBe('TD')\n  })\n\n  it('throws on empty input', () => {\n    expect(() => parseMermaid('')).toThrow('Empty mermaid diagram')\n  })\n\n  it('throws on invalid header', () => {\n    expect(() => parseMermaid('sequenceDiagram\\n  A ->> B')).toThrow('Invalid mermaid header')\n  })\n\n  it('throws on header without direction', () => {\n    expect(() => parseMermaid('graph\\n  A --> B')).toThrow('Invalid mermaid header')\n  })\n})\n\n// ============================================================================\n// Original node shapes\n// ============================================================================\n\ndescribe('parseMermaid – node shapes (original)', () => {\n  it('parses rectangle nodes: A[Label]', () => {\n    const g = parseMermaid('graph TD\\n  A[Hello World]')\n    const node = g.nodes.get('A')\n    expect(node).toBeDefined()\n    expect(node!.shape).toBe('rectangle')\n    expect(node!.label).toBe('Hello World')\n  })\n\n  it('parses rounded nodes: A(Label)', () => {\n    const g = parseMermaid('graph TD\\n  A(Rounded)')\n    expect(g.nodes.get('A')!.shape).toBe('rounded')\n    expect(g.nodes.get('A')!.label).toBe('Rounded')\n  })\n\n  it('parses diamond nodes: A{Label}', () => {\n    const g = parseMermaid('graph TD\\n  A{Decision}')\n    expect(g.nodes.get('A')!.shape).toBe('diamond')\n    expect(g.nodes.get('A')!.label).toBe('Decision')\n  })\n\n  it('parses stadium nodes: A([Label])', () => {\n    const g = parseMermaid('graph TD\\n  A([Stadium])')\n    expect(g.nodes.get('A')!.shape).toBe('stadium')\n    expect(g.nodes.get('A')!.label).toBe('Stadium')\n  })\n\n  it('parses circle nodes: A((Label))', () => {\n    const g = parseMermaid('graph TD\\n  A((Circle))')\n    expect(g.nodes.get('A')!.shape).toBe('circle')\n    expect(g.nodes.get('A')!.label).toBe('Circle')\n  })\n\n  it('creates a default rectangle for bare node references', () => {\n    const g = parseMermaid('graph TD\\n  A --> B')\n    expect(g.nodes.get('A')!.shape).toBe('rectangle')\n    expect(g.nodes.get('A')!.label).toBe('A')\n    expect(g.nodes.get('B')!.shape).toBe('rectangle')\n    expect(g.nodes.get('B')!.label).toBe('B')\n  })\n\n  it('supports hyphenated node IDs', () => {\n    const g = parseMermaid('graph TD\\n  my-node[My Node]')\n    expect(g.nodes.get('my-node')).toBeDefined()\n    expect(g.nodes.get('my-node')!.label).toBe('My Node')\n  })\n\n  it('first definition wins for shape and label', () => {\n    const g = parseMermaid('graph TD\\n  A[Start] --> B\\n  A --> B')\n    expect(g.nodes.get('A')!.shape).toBe('rectangle')\n    expect(g.nodes.get('A')!.label).toBe('Start')\n  })\n})\n\n// ============================================================================\n// Batch 1 node shapes\n// ============================================================================\n\ndescribe('parseMermaid – node shapes (Batch 1)', () => {\n  it('parses subroutine nodes: A[[Label]]', () => {\n    const g = parseMermaid('graph TD\\n  A[[Subroutine]]')\n    expect(g.nodes.get('A')!.shape).toBe('subroutine')\n    expect(g.nodes.get('A')!.label).toBe('Subroutine')\n  })\n\n  it('parses double circle nodes: A(((Label)))', () => {\n    const g = parseMermaid('graph TD\\n  A(((Double)))')\n    expect(g.nodes.get('A')!.shape).toBe('doublecircle')\n    expect(g.nodes.get('A')!.label).toBe('Double')\n  })\n\n  it('parses hexagon nodes: A{{Label}}', () => {\n    const g = parseMermaid('graph TD\\n  A{{Hexagon}}')\n    expect(g.nodes.get('A')!.shape).toBe('hexagon')\n    expect(g.nodes.get('A')!.label).toBe('Hexagon')\n  })\n})\n\n// ============================================================================\n// Batch 2 node shapes\n// ============================================================================\n\ndescribe('parseMermaid – node shapes (Batch 2)', () => {\n  it('parses cylinder / database nodes: A[(Label)]', () => {\n    const g = parseMermaid('graph TD\\n  A[(Database)]')\n    expect(g.nodes.get('A')!.shape).toBe('cylinder')\n    expect(g.nodes.get('A')!.label).toBe('Database')\n  })\n\n  it('parses asymmetric / flag nodes: A>Label]', () => {\n    const g = parseMermaid('graph TD\\n  A>Flag Shape]')\n    expect(g.nodes.get('A')!.shape).toBe('asymmetric')\n    expect(g.nodes.get('A')!.label).toBe('Flag Shape')\n  })\n\n  it('parses trapezoid nodes: A[/Label\\\\]', () => {\n    const g = parseMermaid('graph TD\\n  A[/Trapezoid\\\\]')\n    expect(g.nodes.get('A')!.shape).toBe('trapezoid')\n    expect(g.nodes.get('A')!.label).toBe('Trapezoid')\n  })\n\n  it('parses trapezoid-alt nodes: A[\\\\Label/]', () => {\n    const g = parseMermaid('graph TD\\n  A[\\\\Alt Trapezoid/]')\n    expect(g.nodes.get('A')!.shape).toBe('trapezoid-alt')\n    expect(g.nodes.get('A')!.label).toBe('Alt Trapezoid')\n  })\n})\n\n// ============================================================================\n// All shapes in one diagram — ensures no regex conflicts\n// ============================================================================\n\ndescribe('parseMermaid – all shapes combined', () => {\n  it('parses all 13 shapes correctly in one diagram', () => {\n    const g = parseMermaid(`graph TD\n      A[Rectangle]\n      B(Rounded)\n      C{Diamond}\n      D([Stadium])\n      E((Circle))\n      F[[Subroutine]]\n      G(((DoubleCircle)))\n      H{{Hexagon}}\n      I[(Cylinder)]\n      J>Asymmetric]\n      K[/Trapezoid\\\\]\n      L[\\\\TrapAlt/]`)\n\n    expect(g.nodes.get('A')!.shape).toBe('rectangle')\n    expect(g.nodes.get('B')!.shape).toBe('rounded')\n    expect(g.nodes.get('C')!.shape).toBe('diamond')\n    expect(g.nodes.get('D')!.shape).toBe('stadium')\n    expect(g.nodes.get('E')!.shape).toBe('circle')\n    expect(g.nodes.get('F')!.shape).toBe('subroutine')\n    expect(g.nodes.get('G')!.shape).toBe('doublecircle')\n    expect(g.nodes.get('H')!.shape).toBe('hexagon')\n    expect(g.nodes.get('I')!.shape).toBe('cylinder')\n    expect(g.nodes.get('J')!.shape).toBe('asymmetric')\n    expect(g.nodes.get('K')!.shape).toBe('trapezoid')\n    expect(g.nodes.get('L')!.shape).toBe('trapezoid-alt')\n  })\n})\n\n// ============================================================================\n// Edge parsing — original arrows\n// ============================================================================\n\ndescribe('parseMermaid – edges (original)', () => {\n  it('parses a solid edge: -->', () => {\n    const g = parseMermaid('graph TD\\n  A --> B')\n    expect(g.edges).toHaveLength(1)\n    expect(g.edges[0]!.source).toBe('A')\n    expect(g.edges[0]!.target).toBe('B')\n    expect(g.edges[0]!.style).toBe('solid')\n    expect(g.edges[0]!.label).toBeUndefined()\n  })\n\n  it('parses a dotted edge: -.->', () => {\n    const g = parseMermaid('graph TD\\n  A -.-> B')\n    expect(g.edges[0]!.style).toBe('dotted')\n  })\n\n  it('parses a thick edge: ==>', () => {\n    const g = parseMermaid('graph TD\\n  A ==> B')\n    expect(g.edges[0]!.style).toBe('thick')\n  })\n\n  it('parses edge label: -->|label|', () => {\n    const g = parseMermaid('graph TD\\n  A -->|Yes| B')\n    expect(g.edges[0]!.label).toBe('Yes')\n  })\n\n  it('parses edge label on dotted edges', () => {\n    const g = parseMermaid('graph TD\\n  A -.->|Maybe| B')\n    expect(g.edges[0]!.label).toBe('Maybe')\n    expect(g.edges[0]!.style).toBe('dotted')\n  })\n\n  it('parses chained edges: A --> B --> C', () => {\n    const g = parseMermaid('graph TD\\n  A --> B --> C')\n    expect(g.edges).toHaveLength(2)\n    expect(g.edges[0]!.source).toBe('A')\n    expect(g.edges[0]!.target).toBe('B')\n    expect(g.edges[1]!.source).toBe('B')\n    expect(g.edges[1]!.target).toBe('C')\n  })\n\n  it('parses chained edges with shapes: A[Start] --> B{Check} --> C(End)', () => {\n    const g = parseMermaid('graph TD\\n  A[Start] --> B{Check} --> C(End)')\n    expect(g.edges).toHaveLength(2)\n    expect(g.nodes.get('A')!.shape).toBe('rectangle')\n    expect(g.nodes.get('B')!.shape).toBe('diamond')\n    expect(g.nodes.get('C')!.shape).toBe('rounded')\n  })\n\n  it('handles multiple edge lines', () => {\n    const g = parseMermaid('graph TD\\n  A --> B\\n  B --> C\\n  C --> D')\n    expect(g.edges).toHaveLength(3)\n  })\n\n  it('sets hasArrowEnd=true for arrow operators (-->)', () => {\n    const g = parseMermaid('graph TD\\n  A --> B')\n    expect(g.edges[0]!.hasArrowEnd).toBe(true)\n    expect(g.edges[0]!.hasArrowStart).toBe(false)\n  })\n})\n\n// ============================================================================\n// No-arrow edges (Batch 1.1)\n// ============================================================================\n\ndescribe('parseMermaid – no-arrow edges', () => {\n  it('parses solid line without arrow: ---', () => {\n    const g = parseMermaid('graph TD\\n  A --- B')\n    expect(g.edges).toHaveLength(1)\n    expect(g.edges[0]!.style).toBe('solid')\n    expect(g.edges[0]!.hasArrowEnd).toBe(false)\n    expect(g.edges[0]!.hasArrowStart).toBe(false)\n  })\n\n  it('parses dotted line without arrow: -.-', () => {\n    const g = parseMermaid('graph TD\\n  A -.- B')\n    expect(g.edges[0]!.style).toBe('dotted')\n    expect(g.edges[0]!.hasArrowEnd).toBe(false)\n  })\n\n  it('parses thick line without arrow: ===', () => {\n    const g = parseMermaid('graph TD\\n  A === B')\n    expect(g.edges[0]!.style).toBe('thick')\n    expect(g.edges[0]!.hasArrowEnd).toBe(false)\n  })\n\n  it('parses no-arrow with label: ---|text|', () => {\n    const g = parseMermaid('graph TD\\n  A ---|connects| B')\n    expect(g.edges[0]!.label).toBe('connects')\n    expect(g.edges[0]!.hasArrowEnd).toBe(false)\n  })\n})\n\n// ============================================================================\n// Bidirectional arrows (Batch 2.4)\n// ============================================================================\n\ndescribe('parseMermaid – bidirectional arrows', () => {\n  it('parses solid bidirectional: <-->', () => {\n    const g = parseMermaid('graph TD\\n  A <--> B')\n    expect(g.edges).toHaveLength(1)\n    expect(g.edges[0]!.style).toBe('solid')\n    expect(g.edges[0]!.hasArrowStart).toBe(true)\n    expect(g.edges[0]!.hasArrowEnd).toBe(true)\n  })\n\n  it('parses dotted bidirectional: <-.->',  () => {\n    const g = parseMermaid('graph TD\\n  A <-.-> B')\n    expect(g.edges[0]!.style).toBe('dotted')\n    expect(g.edges[0]!.hasArrowStart).toBe(true)\n    expect(g.edges[0]!.hasArrowEnd).toBe(true)\n  })\n\n  it('parses thick bidirectional: <==>', () => {\n    const g = parseMermaid('graph TD\\n  A <==> B')\n    expect(g.edges[0]!.style).toBe('thick')\n    expect(g.edges[0]!.hasArrowStart).toBe(true)\n    expect(g.edges[0]!.hasArrowEnd).toBe(true)\n  })\n\n  it('parses bidirectional with label: <-->|text|', () => {\n    const g = parseMermaid('graph TD\\n  A <-->|sync| B')\n    expect(g.edges[0]!.label).toBe('sync')\n    expect(g.edges[0]!.hasArrowStart).toBe(true)\n    expect(g.edges[0]!.hasArrowEnd).toBe(true)\n  })\n})\n\n// ============================================================================\n// Text-embedded edge labels (fixes #32)\n// Based on PR #36 by @liuxiaopai-ai\n// ============================================================================\n\ndescribe('parseMermaid – text-embedded edge labels', () => {\n  it('parses solid arrow with text label: -- Yes -->', () => {\n    const g = parseMermaid('graph TD\\n  A -- Yes --> B')\n    expect(g.edges).toHaveLength(1)\n    expect(g.edges[0]!.label).toBe('Yes')\n    expect(g.edges[0]!.style).toBe('solid')\n    expect(g.edges[0]!.hasArrowEnd).toBe(true)\n  })\n\n  it('parses solid line with text label: -- text ---', () => {\n    const g = parseMermaid('graph TD\\n  A -- related --- B')\n    expect(g.edges[0]!.label).toBe('related')\n    expect(g.edges[0]!.style).toBe('solid')\n    expect(g.edges[0]!.hasArrowEnd).toBe(false)\n  })\n\n  it('parses dotted arrow with text label: -. Maybe .->', () => {\n    const g = parseMermaid('graph TD\\n  A -. Maybe .-> B')\n    expect(g.edges[0]!.label).toBe('Maybe')\n    expect(g.edges[0]!.style).toBe('dotted')\n    expect(g.edges[0]!.hasArrowEnd).toBe(true)\n  })\n\n  it('parses thick arrow with text label: == Sure ==>', () => {\n    const g = parseMermaid('graph TD\\n  A == Sure ==> B')\n    expect(g.edges[0]!.label).toBe('Sure')\n    expect(g.edges[0]!.style).toBe('thick')\n    expect(g.edges[0]!.hasArrowEnd).toBe(true)\n  })\n\n  it('parses multi-word text labels', () => {\n    const g = parseMermaid('graph TD\\n  A -- This is a label --> B')\n    expect(g.edges[0]!.label).toBe('This is a label')\n  })\n\n  it('parses shaped nodes with text-embedded labels', () => {\n    const g = parseMermaid('graph TD\\n  A[Start] -- Yes --> B(End)')\n    expect(g.edges[0]!.label).toBe('Yes')\n    expect(g.nodes.get('A')!.shape).toBe('rectangle')\n    expect(g.nodes.get('B')!.shape).toBe('rounded')\n  })\n\n  it('produces same result as pipe syntax (issue #32)', () => {\n    const pipe = parseMermaid(`graph TD\n      A --> B\n      B -->|Yes| C`)\n    const text = parseMermaid(`graph TD\n      A --> B\n      B -- Yes --> C`)\n    expect(pipe.edges[1]!.label).toBe(text.edges[1]!.label)\n    expect(pipe.edges[1]!.style).toBe(text.edges[1]!.style)\n    expect(pipe.edges[1]!.hasArrowEnd).toBe(text.edges[1]!.hasArrowEnd)\n  })\n\n  it('handles the exact issue #32 scenario', () => {\n    const g = parseMermaid(`flowchart TD\n      A(Start) --> B{Is it sunny?}\n      B -- Yes --> C[Go to the park]\n      B -- No --> D[Stay indoors]\n      C --> E[Finish]\n      D --> E`)\n    expect(g.edges).toHaveLength(5)\n    expect(g.edges[1]!.label).toBe('Yes')\n    expect(g.edges[2]!.label).toBe('No')\n  })\n})\n\n// ============================================================================\n// Parallel links with & (Batch 2.6)\n// ============================================================================\n\ndescribe('parseMermaid – parallel links (&)', () => {\n  it('expands A & B --> C to two edges', () => {\n    const g = parseMermaid('graph TD\\n  A & B --> C')\n    expect(g.edges).toHaveLength(2)\n    expect(g.edges[0]!.source).toBe('A')\n    expect(g.edges[0]!.target).toBe('C')\n    expect(g.edges[1]!.source).toBe('B')\n    expect(g.edges[1]!.target).toBe('C')\n  })\n\n  it('expands A --> C & D to two edges', () => {\n    const g = parseMermaid('graph TD\\n  A --> C & D')\n    expect(g.edges).toHaveLength(2)\n    expect(g.edges[0]!.source).toBe('A')\n    expect(g.edges[0]!.target).toBe('C')\n    expect(g.edges[1]!.source).toBe('A')\n    expect(g.edges[1]!.target).toBe('D')\n  })\n\n  it('expands A & B --> C & D to four edges (Cartesian product)', () => {\n    const g = parseMermaid('graph TD\\n  A & B --> C & D')\n    expect(g.edges).toHaveLength(4)\n    const edgePairs = g.edges.map(e => `${e.source}->${e.target}`)\n    expect(edgePairs).toContain('A->C')\n    expect(edgePairs).toContain('A->D')\n    expect(edgePairs).toContain('B->C')\n    expect(edgePairs).toContain('B->D')\n  })\n})\n\n// ============================================================================\n// ::: class shorthand (Batch 1.2)\n// ============================================================================\n\ndescribe('parseMermaid – ::: class shorthand', () => {\n  it('assigns class via ::: on shaped nodes', () => {\n    const g = parseMermaid('graph TD\\n  A[Start]:::highlight --> B')\n    expect(g.classAssignments.get('A')).toBe('highlight')\n  })\n\n  it('assigns class via ::: on bare nodes', () => {\n    const g = parseMermaid('graph TD\\n  A:::important --> B')\n    expect(g.classAssignments.get('A')).toBe('important')\n  })\n\n  it('works in chained edges', () => {\n    const g = parseMermaid('graph TD\\n  A:::start --> B:::mid --> C:::end')\n    expect(g.classAssignments.get('A')).toBe('start')\n    expect(g.classAssignments.get('B')).toBe('mid')\n    expect(g.classAssignments.get('C')).toBe('end')\n  })\n})\n\n// ============================================================================\n// Inline style statements (Batch 2.5)\n// ============================================================================\n\ndescribe('parseMermaid – style statements', () => {\n  it('parses style for a single node', () => {\n    const g = parseMermaid('graph TD\\n  A --> B\\n  style A fill:#ff0000,stroke:#333')\n    expect(g.nodeStyles.get('A')).toEqual({ fill: '#ff0000', stroke: '#333' })\n  })\n\n  it('parses style for multiple nodes', () => {\n    const g = parseMermaid('graph TD\\n  A --> B\\n  style A,B fill:#0f0')\n    expect(g.nodeStyles.get('A')).toEqual({ fill: '#0f0' })\n    expect(g.nodeStyles.get('B')).toEqual({ fill: '#0f0' })\n  })\n\n  it('merges multiple style statements for same node', () => {\n    const g = parseMermaid('graph TD\\n  A --> B\\n  style A fill:#f00\\n  style A stroke:#333')\n    expect(g.nodeStyles.get('A')).toEqual({ fill: '#f00', stroke: '#333' })\n  })\n})\n\n// ============================================================================\n// Subgraph direction override (Batch 2.7)\n// ============================================================================\n\ndescribe('parseMermaid – subgraph direction override', () => {\n  it('parses direction override inside subgraph', () => {\n    const g = parseMermaid(`graph TD\n      subgraph sub1 [Left-Right Group]\n        direction LR\n        A --> B\n      end`)\n    expect(g.subgraphs[0]!.direction).toBe('LR')\n  })\n\n  it('does not apply direction outside subgraph', () => {\n    // \"direction LR\" at root level without subgraph context should not change graph direction\n    const g = parseMermaid('graph TD\\n  A --> B')\n    expect(g.direction).toBe('TD')\n  })\n})\n\n// ============================================================================\n// Subgraphs\n// ============================================================================\n\ndescribe('parseMermaid – subgraphs', () => {\n  it('parses a basic subgraph', () => {\n    const g = parseMermaid(`graph TD\n      subgraph Backend\n        A --> B\n      end`)\n    expect(g.subgraphs).toHaveLength(1)\n    expect(g.subgraphs[0]!.label).toBe('Backend')\n    expect(g.subgraphs[0]!.nodeIds).toContain('A')\n    expect(g.subgraphs[0]!.nodeIds).toContain('B')\n  })\n\n  it('parses subgraph with bracket ID syntax: subgraph id [Label]', () => {\n    const g = parseMermaid(`graph TD\n      subgraph be [Backend Services]\n        A --> B\n      end`)\n    expect(g.subgraphs[0]!.id).toBe('be')\n    expect(g.subgraphs[0]!.label).toBe('Backend Services')\n  })\n\n  it('parses subgraph bracket syntax with hyphenated ID: subgraph us-east [US East]', () => {\n    const g = parseMermaid(`graph TD\n      subgraph us-east [US East Region]\n        A --> B\n      end`)\n    expect(g.subgraphs[0]!.id).toBe('us-east')\n    expect(g.subgraphs[0]!.label).toBe('US East Region')\n  })\n\n  it('slugifies label as id when bracket syntax is not used', () => {\n    const g = parseMermaid(`graph TD\n      subgraph My Group\n        A --> B\n      end`)\n    expect(g.subgraphs[0]!.id).toBe('My_Group')\n    expect(g.subgraphs[0]!.label).toBe('My Group')\n  })\n\n  it('parses nested subgraphs', () => {\n    const g = parseMermaid(`graph TD\n      subgraph Outer\n        subgraph Inner\n          A --> B\n        end\n        C --> D\n      end`)\n    expect(g.subgraphs).toHaveLength(1) // only top-level\n    const outer = g.subgraphs[0]!\n    expect(outer.label).toBe('Outer')\n    expect(outer.children).toHaveLength(1)\n    expect(outer.children[0]!.label).toBe('Inner')\n    expect(outer.children[0]!.nodeIds).toContain('A')\n    expect(outer.children[0]!.nodeIds).toContain('B')\n    expect(outer.nodeIds).toContain('C')\n    expect(outer.nodeIds).toContain('D')\n  })\n\n  it('does NOT track nodes in subgraphs where they are merely referenced (regression)', () => {\n    // This diagram has cross-subgraph edges:\n    // - B is defined in \"clients\" but referenced in \"services\"\n    // - D, E, F are defined in \"services\" but referenced in \"data\"\n    // Nodes should only belong to the subgraph where they are FIRST DEFINED.\n    const g = parseMermaid(`graph LR\n      subgraph clients [Client Layer]\n        A([Web App]) --> B[API Gateway]\n        C([Mobile App]) --> B\n      end\n      subgraph services [Service Layer]\n        B --> D[Auth Service]\n        B --> E[User Service]\n        B --> F[Order Service]\n      end\n      subgraph data [Data Layer]\n        D --> G[(Auth DB)]\n        E --> H[(User DB)]\n        F --> I[(Order DB)]\n        F --> J([Message Queue])\n      end`)\n\n    const clients = g.subgraphs.find(sg => sg.id === 'clients')!\n    const services = g.subgraphs.find(sg => sg.id === 'services')!\n    const data = g.subgraphs.find(sg => sg.id === 'data')!\n\n    // B should ONLY be in clients (where it's defined), NOT in services\n    expect(clients.nodeIds).toContain('B')\n    expect(services.nodeIds).not.toContain('B')\n\n    // D, E, F should ONLY be in services, NOT in data\n    expect(services.nodeIds).toContain('D')\n    expect(services.nodeIds).toContain('E')\n    expect(services.nodeIds).toContain('F')\n    expect(data.nodeIds).not.toContain('D')\n    expect(data.nodeIds).not.toContain('E')\n    expect(data.nodeIds).not.toContain('F')\n\n    // Data layer should only have its own nodes\n    expect(data.nodeIds).toContain('G')\n    expect(data.nodeIds).toContain('H')\n    expect(data.nodeIds).toContain('I')\n    expect(data.nodeIds).toContain('J')\n  })\n})\n\n// ============================================================================\n// classDef and class assignments\n// ============================================================================\n\ndescribe('parseMermaid – classDef and class', () => {\n  it('parses classDef with properties', () => {\n    const g = parseMermaid(`graph TD\n      classDef highlight fill:#f96,stroke:#333\n      A --> B`)\n    expect(g.classDefs.has('highlight')).toBe(true)\n    const props = g.classDefs.get('highlight')!\n    expect(props['fill']).toBe('#f96')\n    expect(props['stroke']).toBe('#333')\n  })\n\n  it('parses class assignments to single node', () => {\n    const g = parseMermaid(`graph TD\n      A --> B\n      class A highlight`)\n    expect(g.classAssignments.get('A')).toBe('highlight')\n  })\n\n  it('parses class assignments to multiple nodes', () => {\n    const g = parseMermaid(`graph TD\n      A --> B --> C\n      class A,B highlight`)\n    expect(g.classAssignments.get('A')).toBe('highlight')\n    expect(g.classAssignments.get('B')).toBe('highlight')\n  })\n})\n\n// ============================================================================\n// Comments\n// ============================================================================\n\ndescribe('parseMermaid – comments', () => {\n  it('ignores lines starting with %%', () => {\n    const g = parseMermaid(`graph TD\n      %% This is a comment\n      A --> B\n      %% Another comment`)\n    expect(g.nodes.size).toBe(2)\n    expect(g.edges).toHaveLength(1)\n  })\n})\n\n// ============================================================================\n// Edge cases\n// ============================================================================\n\ndescribe('parseMermaid – edge cases', () => {\n  it('handles extra whitespace', () => {\n    const g = parseMermaid('  graph TD  \\n    A  -->  B  ')\n    expect(g.edges).toHaveLength(1)\n    expect(g.nodes.size).toBe(2)\n  })\n\n  it('handles empty lines between definitions', () => {\n    const g = parseMermaid('graph TD\\n\\n  A --> B\\n\\n  B --> C')\n    expect(g.edges).toHaveLength(2)\n  })\n\n  it('handles diagram with only nodes (no edges)', () => {\n    const g = parseMermaid('graph TD\\n  A[Only Node]')\n    expect(g.nodes.size).toBe(1)\n    expect(g.edges).toHaveLength(0)\n  })\n\n  it('preserves node order in the map', () => {\n    const g = parseMermaid('graph TD\\n  Z[Last] --> A[First]')\n    const ids = [...g.nodes.keys()]\n    expect(ids[0]).toBe('Z')\n    expect(ids[1]).toBe('A')\n  })\n})\n\n// ============================================================================\n// State diagram parsing (Batch 3)\n// ============================================================================\n\ndescribe('parseMermaid – state diagrams', () => {\n  it('detects stateDiagram-v2 header', () => {\n    const g = parseMermaid('stateDiagram-v2\\n  s1 --> s2')\n    expect(g.direction).toBe('TD')\n  })\n\n  it('detects stateDiagram header (without -v2)', () => {\n    const g = parseMermaid('stateDiagram\\n  s1 --> s2')\n    expect(g.direction).toBe('TD')\n  })\n\n  it('parses basic state transitions', () => {\n    const g = parseMermaid(`stateDiagram-v2\n      Idle --> Active\n      Active --> Done`)\n    expect(g.edges).toHaveLength(2)\n    expect(g.edges[0]!.source).toBe('Idle')\n    expect(g.edges[0]!.target).toBe('Active')\n    expect(g.edges[1]!.source).toBe('Active')\n    expect(g.edges[1]!.target).toBe('Done')\n    // State nodes default to rounded shape\n    expect(g.nodes.get('Idle')!.shape).toBe('rounded')\n  })\n\n  it('parses transition labels', () => {\n    const g = parseMermaid(`stateDiagram-v2\n      Idle --> Active : start`)\n    expect(g.edges[0]!.label).toBe('start')\n  })\n\n  it('parses [*] start pseudostate', () => {\n    const g = parseMermaid(`stateDiagram-v2\n      [*] --> Idle`)\n    // [*] as source becomes _start with state-start shape\n    const startNode = g.nodes.get('_start')\n    expect(startNode).toBeDefined()\n    expect(startNode!.shape).toBe('state-start')\n    expect(g.edges[0]!.source).toBe('_start')\n  })\n\n  it('parses [*] end pseudostate', () => {\n    const g = parseMermaid(`stateDiagram-v2\n      Done --> [*]`)\n    const endNode = g.nodes.get('_end')\n    expect(endNode).toBeDefined()\n    expect(endNode!.shape).toBe('state-end')\n    expect(g.edges[0]!.target).toBe('_end')\n  })\n\n  it('assigns unique IDs to multiple [*] pseudostates', () => {\n    const g = parseMermaid(`stateDiagram-v2\n      [*] --> A\n      [*] --> B`)\n    // First [*] source → _start, second → _start2\n    expect(g.nodes.has('_start')).toBe(true)\n    expect(g.nodes.has('_start2')).toBe(true)\n  })\n\n  it('parses state description: s1 : Description', () => {\n    const g = parseMermaid(`stateDiagram-v2\n      s1 : Idle State\n      s1 --> s2`)\n    expect(g.nodes.get('s1')!.label).toBe('Idle State')\n    expect(g.nodes.get('s1')!.shape).toBe('rounded')\n  })\n\n  it('parses state alias: state \"Description\" as s1', () => {\n    const g = parseMermaid(`stateDiagram-v2\n      state \"Waiting for input\" as waiting\n      waiting --> active`)\n    expect(g.nodes.get('waiting')!.label).toBe('Waiting for input')\n  })\n\n  it('parses composite states', () => {\n    const g = parseMermaid(`stateDiagram-v2\n      state Processing {\n        parse --> validate\n        validate --> execute\n      }`)\n    expect(g.subgraphs).toHaveLength(1)\n    expect(g.subgraphs[0]!.id).toBe('Processing')\n    expect(g.subgraphs[0]!.label).toBe('Processing')\n    expect(g.subgraphs[0]!.nodeIds).toContain('parse')\n    expect(g.subgraphs[0]!.nodeIds).toContain('validate')\n    expect(g.subgraphs[0]!.nodeIds).toContain('execute')\n  })\n\n  it('parses composite states with alias', () => {\n    const g = parseMermaid(`stateDiagram-v2\n      state \"Active Processing\" as AP {\n        inner1 --> inner2\n      }`)\n    expect(g.subgraphs[0]!.id).toBe('AP')\n    expect(g.subgraphs[0]!.label).toBe('Active Processing')\n  })\n\n  it('parses direction override in state diagrams', () => {\n    const g = parseMermaid(`stateDiagram-v2\n      direction LR\n      s1 --> s2`)\n    expect(g.direction).toBe('LR')\n  })\n\n  it('parses direction override inside composite state', () => {\n    const g = parseMermaid(`stateDiagram-v2\n      state Processing {\n        direction LR\n        parse --> validate\n      }`)\n    expect(g.subgraphs[0]!.direction).toBe('LR')\n  })\n\n  it('parses CJK (Chinese) state names in transitions', () => {\n    const g = parseMermaid(`stateDiagram-v2\n      [*] --> 空闲\n      空闲 --> 完成`)\n    expect(g.edges).toHaveLength(2)\n    expect(g.edges[0]!.target).toBe('空闲')\n    expect(g.edges[1]!.source).toBe('空闲')\n    expect(g.edges[1]!.target).toBe('完成')\n    expect(g.nodes.get('空闲')!.shape).toBe('rounded')\n  })\n\n  it('parses CJK state names with transition labels', () => {\n    const g = parseMermaid(`stateDiagram-v2\n      空闲 --> 处理中 : 提交`)\n    expect(g.edges[0]!.source).toBe('空闲')\n    expect(g.edges[0]!.target).toBe('处理中')\n    expect(g.edges[0]!.label).toBe('提交')\n  })\n\n  it('parses CJK state descriptions', () => {\n    const g = parseMermaid(`stateDiagram-v2\n      空闲 : 等待输入\n      空闲 --> 完成`)\n    expect(g.nodes.get('空闲')!.label).toBe('等待输入')\n  })\n\n  it('parses Japanese state names', () => {\n    const g = parseMermaid(`stateDiagram-v2\n      [*] --> 待機\n      待機 --> 処理中 : 開始\n      処理中 --> 完了`)\n    expect(g.edges).toHaveLength(3)\n    expect(g.nodes.has('待機')).toBe(true)\n    expect(g.nodes.has('処理中')).toBe(true)\n    expect(g.nodes.has('完了')).toBe(true)\n  })\n\n  it('handles full state diagram with start/end and composites', () => {\n    const g = parseMermaid(`stateDiagram-v2\n      [*] --> Idle\n      Idle --> Processing : submit\n      state Processing {\n        parse --> validate\n        validate --> execute\n      }\n      Processing --> Complete : done\n      Complete --> [*]`)\n\n    expect(g.nodes.has('_start')).toBe(true)\n    expect(g.nodes.has('_end')).toBe(true)\n    expect(g.nodes.has('Idle')).toBe(true)\n    expect(g.nodes.has('Complete')).toBe(true)\n    expect(g.subgraphs).toHaveLength(1)\n    expect(g.subgraphs[0]!.id).toBe('Processing')\n    // Should have transitions for: [*]→Idle, Idle→Processing, parse→validate,\n    // validate→execute, Processing→Complete, Complete→[*]\n    expect(g.edges).toHaveLength(6)\n  })\n})\n"
  },
  {
    "path": "src/__tests__/renderer.test.ts",
    "content": "/**\n * Tests for the SVG renderer.\n *\n * Uses hand-crafted PositionedGraph data to test SVG output without\n * depending on the layout engine.\n */\nimport { describe, it, expect } from 'bun:test'\nimport { renderSvg } from '../renderer.ts'\nimport type { DiagramColors } from '../theme.ts'\nimport type { PositionedGraph, PositionedNode, PositionedEdge, PositionedGroup } from '../types.ts'\n\n/** Minimal positioned graph for testing */\nfunction makeGraph(overrides: Partial<PositionedGraph> = {}): PositionedGraph {\n  return {\n    width: 400,\n    height: 300,\n    nodes: [],\n    edges: [],\n    groups: [],\n    ...overrides,\n  }\n}\n\n/** Helper to build a positioned node */\nfunction makeNode(overrides: Partial<PositionedNode> = {}): PositionedNode {\n  return {\n    id: 'A',\n    label: 'Test',\n    shape: 'rectangle',\n    x: 100,\n    y: 100,\n    width: 80,\n    height: 40,\n    ...overrides,\n  }\n}\n\n/** Helper to build a positioned edge with arrow defaults */\nfunction makeEdge(overrides: Partial<PositionedEdge> = {}): PositionedEdge {\n  return {\n    source: 'A',\n    target: 'B',\n    style: 'solid',\n    hasArrowStart: false,\n    hasArrowEnd: true,\n    points: [{ x: 100, y: 120 }, { x: 100, y: 200 }],\n    ...overrides,\n  }\n}\n\n/** Default light colors — CSS custom properties handle actual styling */\nconst lightColors: DiagramColors = { bg: '#FFFFFF', fg: '#27272A' }\nconst darkColors: DiagramColors = { bg: '#18181B', fg: '#FAFAFA' }\n\n// ============================================================================\n// SVG structure\n// ============================================================================\n\ndescribe('renderSvg – SVG structure', () => {\n  it('produces a valid SVG root element', () => {\n    const svg = renderSvg(makeGraph(), lightColors)\n    expect(svg).toContain('<svg xmlns=\"http://www.w3.org/2000/svg\"')\n    expect(svg).toContain('viewBox=\"0 0 400 300\"')\n    expect(svg).toContain('width=\"400\"')\n    expect(svg).toContain('height=\"300\"')\n    expect(svg).toContain('</svg>')\n  })\n\n  it('includes <defs> with arrow markers', () => {\n    const svg = renderSvg(makeGraph(), lightColors)\n    expect(svg).toContain('<defs>')\n    expect(svg).toContain('<marker id=\"arrowhead\"')\n    expect(svg).toContain('<marker id=\"arrowhead-start\"')\n    expect(svg).toContain('</defs>')\n  })\n\n  it('includes embedded Google Fonts import', () => {\n    const svg = renderSvg(makeGraph(), lightColors, 'Inter')\n    expect(svg).toContain('fonts.googleapis.com')\n    expect(svg).toContain('Inter')\n  })\n\n  it('uses custom font name when specified', () => {\n    const svg = renderSvg(makeGraph(), lightColors, 'Roboto Mono')\n    // encodeURIComponent turns spaces into %20\n    expect(svg).toContain('Roboto%20Mono')\n    expect(svg).toContain(\"'Roboto Mono'\")\n  })\n\n  it('sets CSS color variables in inline style', () => {\n    const light = renderSvg(makeGraph(), lightColors)\n    expect(light).toContain('--bg:#FFFFFF')\n    expect(light).toContain('--fg:#27272A')\n\n    const dark = renderSvg(makeGraph(), darkColors)\n    expect(dark).toContain('--bg:#18181B')\n    expect(dark).toContain('--fg:#FAFAFA')\n  })\n})\n\n// ============================================================================\n// Original node shapes\n// ============================================================================\n\ndescribe('renderSvg – node shapes', () => {\n  it('renders rectangle with rx=0', () => {\n    const graph = makeGraph({ nodes: [makeNode({ shape: 'rectangle' })] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).toContain('rx=\"0\" ry=\"0\"')\n  })\n\n  it('renders rounded rectangle with rx=6', () => {\n    const graph = makeGraph({ nodes: [makeNode({ shape: 'rounded' })] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).toContain('rx=\"6\" ry=\"6\"')\n  })\n\n  it('renders stadium with rx=height/2', () => {\n    const node = makeNode({ shape: 'stadium', height: 40 })\n    const graph = makeGraph({ nodes: [node] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).toContain('rx=\"20\" ry=\"20\"')\n  })\n\n  it('renders circle with <circle> element', () => {\n    const node = makeNode({ shape: 'circle', width: 60, height: 60 })\n    const graph = makeGraph({ nodes: [node] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).toContain('<circle')\n    expect(svg).toContain('r=\"30\"')\n  })\n\n  it('renders diamond with <polygon>', () => {\n    const node = makeNode({ shape: 'diamond', width: 80, height: 80 })\n    const graph = makeGraph({ nodes: [node] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).toContain('<polygon')\n    expect(svg).toContain('points=\"140,100 180,140 140,180 100,140\"')\n  })\n\n  it('renders node labels as <text> elements', () => {\n    const graph = makeGraph({ nodes: [makeNode({ label: 'My Node' })] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).toContain('>My Node</text>')\n  })\n})\n\n// ============================================================================\n// New Batch 1 shapes\n// ============================================================================\n\ndescribe('renderSvg – new shapes (Batch 1)', () => {\n  it('renders subroutine with outer rect and inset vertical lines', () => {\n    const node = makeNode({ shape: 'subroutine', width: 100, height: 40 })\n    const graph = makeGraph({ nodes: [node] })\n    const svg = renderSvg(graph, lightColors)\n    // Outer rect\n    expect(svg).toContain('<rect x=\"100\" y=\"100\" width=\"100\" height=\"40\"')\n    // Left inset line at x=108 (100+8)\n    expect(svg).toContain('x1=\"108\"')\n    // Right inset line at x=192 (100+100-8)\n    expect(svg).toContain('x1=\"192\"')\n    expect(svg).toContain('<line')\n  })\n\n  it('renders double circle with two <circle> elements', () => {\n    const node = makeNode({ shape: 'doublecircle', width: 80, height: 80 })\n    const graph = makeGraph({ nodes: [node] })\n    const svg = renderSvg(graph, lightColors)\n    const circleMatches = svg.match(/<circle/g) ?? []\n    expect(circleMatches.length).toBe(2)\n    // Outer radius: min(80,80)/2 = 40, inner = 35\n    expect(svg).toContain('r=\"40\"')\n    expect(svg).toContain('r=\"35\"')\n  })\n\n  it('renders hexagon with 6-point <polygon>', () => {\n    const node = makeNode({ shape: 'hexagon', width: 100, height: 40 })\n    const graph = makeGraph({ nodes: [node] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).toContain('<polygon')\n    // 6 points means 6 coordinate pairs separated by spaces\n    const polygonMatch = svg.match(/points=\"([^\"]+)\"/)\n    const points = polygonMatch?.[1]?.split(' ') ?? []\n    expect(points.length).toBe(6)\n  })\n})\n\n// ============================================================================\n// New Batch 2 shapes\n// ============================================================================\n\ndescribe('renderSvg – new shapes (Batch 2)', () => {\n  it('renders cylinder with ellipses and body rect', () => {\n    const node = makeNode({ shape: 'cylinder', width: 80, height: 50 })\n    const graph = makeGraph({ nodes: [node] })\n    const svg = renderSvg(graph, lightColors)\n    // Should contain ellipses for top and bottom caps\n    const ellipseMatches = svg.match(/<ellipse/g) ?? []\n    expect(ellipseMatches.length).toBe(2)\n    // Body rect\n    expect(svg).toContain('<rect')\n  })\n\n  it('renders asymmetric / flag with 5-point <polygon>', () => {\n    const node = makeNode({ shape: 'asymmetric', width: 100, height: 40 })\n    const graph = makeGraph({ nodes: [node] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).toContain('<polygon')\n    // Match the shape polygon (comma-separated \"x,y\" pairs), not the arrowhead polygons\n    // Arrowhead polygons use space-separated \"x y,\" format, while shape polygons use \"x,y x,y\" format\n    const allPolygons = [...svg.matchAll(/points=\"([^\"]+)\"/g)]\n    // The shape polygon is the last one (rendered after defs/arrowheads)\n    const shapePolygon = allPolygons[allPolygons.length - 1]\n    const points = shapePolygon?.[1]?.split(' ') ?? []\n    expect(points.length).toBe(5)\n  })\n\n  it('renders trapezoid with 4-point <polygon>', () => {\n    const node = makeNode({ shape: 'trapezoid', width: 100, height: 40 })\n    const graph = makeGraph({ nodes: [node] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).toContain('<polygon')\n    const allPolygons = [...svg.matchAll(/points=\"([^\"]+)\"/g)]\n    const shapePolygon = allPolygons[allPolygons.length - 1]\n    const points = shapePolygon?.[1]?.split(' ') ?? []\n    expect(points.length).toBe(4)\n  })\n\n  it('renders trapezoid-alt with 4-point <polygon>', () => {\n    const node = makeNode({ shape: 'trapezoid-alt', width: 100, height: 40 })\n    const graph = makeGraph({ nodes: [node] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).toContain('<polygon')\n    const allPolygons = [...svg.matchAll(/points=\"([^\"]+)\"/g)]\n    const shapePolygon = allPolygons[allPolygons.length - 1]\n    const points = shapePolygon?.[1]?.split(' ') ?? []\n    expect(points.length).toBe(4)\n  })\n})\n\n// ============================================================================\n// Batch 3: State diagram pseudostates\n// ============================================================================\n\ndescribe('renderSvg – state pseudostates', () => {\n  it('renders state-start as a filled circle', () => {\n    const node = makeNode({ shape: 'state-start', label: '', width: 28, height: 28 })\n    const graph = makeGraph({ nodes: [node] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).toContain('<circle')\n    expect(svg).toContain('fill=\"var(--_text)\"')\n    expect(svg).toContain('stroke=\"none\"')\n  })\n\n  it('renders state-end as bullseye (two circles)', () => {\n    const node = makeNode({ shape: 'state-end', label: '', width: 28, height: 28 })\n    const graph = makeGraph({ nodes: [node] })\n    const svg = renderSvg(graph, lightColors)\n    const circleMatches = svg.match(/<circle/g) ?? []\n    expect(circleMatches.length).toBe(2)\n    // Outer is stroked, inner is filled\n    expect(svg).toContain('fill=\"none\"')\n    expect(svg).toContain('fill=\"var(--_text)\"')\n  })\n})\n\n// ============================================================================\n// Edge rendering\n// ============================================================================\n\ndescribe('renderSvg – edges', () => {\n  it('renders a solid edge as <polyline> with end arrow', () => {\n    const edge = makeEdge({ style: 'solid', hasArrowEnd: true })\n    const graph = makeGraph({ edges: [edge] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).toContain('<polyline')\n    expect(svg).toContain('points=\"100,120 100,200\"')\n    expect(svg).toContain('marker-end=\"url(#arrowhead)\"')\n  })\n\n  it('renders dotted edges with stroke-dasharray', () => {\n    const edge = makeEdge({ style: 'dotted' })\n    const graph = makeGraph({ edges: [edge] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).toContain('stroke-dasharray=\"4 4\"')\n  })\n\n  it('renders thick edges with doubled stroke width', () => {\n    const edge = makeEdge({ style: 'thick' })\n    const graph = makeGraph({ edges: [edge] })\n    const svg = renderSvg(graph, lightColors)\n    // Base connector stroke is 1px, thick is doubled to 2px\n    expect(svg).toContain('stroke-width=\"2\"')\n  })\n\n  it('does not add dasharray to solid edges', () => {\n    const edge = makeEdge({ style: 'solid' })\n    const graph = makeGraph({ edges: [edge] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).not.toContain('dasharray')\n  })\n\n  it('skips edges with fewer than 2 points', () => {\n    const edge = makeEdge({ points: [{ x: 0, y: 0 }] })\n    const graph = makeGraph({ edges: [edge] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).not.toContain('<polyline')\n  })\n\n  it('renders no-arrow edge without marker-end', () => {\n    const edge = makeEdge({ hasArrowEnd: false, hasArrowStart: false })\n    const graph = makeGraph({ edges: [edge] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).toContain('<polyline')\n    expect(svg).not.toContain('marker-end')\n    expect(svg).not.toContain('marker-start')\n  })\n\n  it('renders bidirectional edge with both markers', () => {\n    const edge = makeEdge({ hasArrowStart: true, hasArrowEnd: true })\n    const graph = makeGraph({ edges: [edge] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).toContain('marker-end=\"url(#arrowhead)\"')\n    expect(svg).toContain('marker-start=\"url(#arrowhead-start)\"')\n  })\n})\n\n// ============================================================================\n// Edge labels\n// ============================================================================\n\ndescribe('renderSvg – edge labels', () => {\n  it('renders edge label with background pill', () => {\n    const edge = makeEdge({ label: 'Yes' })\n    const graph = makeGraph({ edges: [edge] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).toContain('>Yes</text>')\n    expect(svg).toContain('rx=\"2\" ry=\"2\"')\n  })\n\n  it('does not render label elements for edges without labels', () => {\n    const edge = makeEdge({ label: undefined })\n    const graph = makeGraph({ edges: [edge] })\n    const svg = renderSvg(graph, lightColors)\n    const textMatches = svg.match(/<text[^>]*>.*?<\\/text>/g) ?? []\n    expect(textMatches).toHaveLength(0)\n  })\n\n  it('uses labelPosition when provided instead of edge midpoint', () => {\n    // Edge midpoint would be at (100, 160) given these points.\n    // labelPosition overrides to (50, 80) — verify the SVG uses that coordinate.\n    const edge = makeEdge({\n      label: 'Go',\n      points: [{ x: 100, y: 120 }, { x: 100, y: 200 }],\n      labelPosition: { x: 50, y: 80 },\n    })\n    const graph = makeGraph({ edges: [edge] })\n    const svg = renderSvg(graph, lightColors)\n\n    // The label text should be centered at the labelPosition (x=50, y=80)\n    expect(svg).toContain('x=\"50\" y=\"80\"')\n    // The midpoint y=160 should NOT appear in any label-related element\n    expect(svg).not.toContain('y=\"160\"')\n  })\n})\n\n// ============================================================================\n// Group rendering (subgraphs)\n// ============================================================================\n\ndescribe('renderSvg – groups', () => {\n  it('renders group with outer rectangle and header band', () => {\n    const group: PositionedGroup = {\n      id: 'sg1', label: 'Backend',\n      x: 20, y: 20, width: 200, height: 150, children: [],\n    }\n    const graph = makeGraph({ groups: [group] })\n    const svg = renderSvg(graph, lightColors)\n    const rectCount = (svg.match(/x=\"20\" y=\"20\"/g) ?? []).length\n    expect(rectCount).toBeGreaterThanOrEqual(2)\n    expect(svg).toContain('>Backend</text>')\n  })\n\n  it('renders nested groups recursively', () => {\n    const inner: PositionedGroup = {\n      id: 'inner', label: 'Inner',\n      x: 40, y: 60, width: 120, height: 80, children: [],\n    }\n    const outer: PositionedGroup = {\n      id: 'outer', label: 'Outer',\n      x: 20, y: 20, width: 200, height: 150, children: [inner],\n    }\n    const graph = makeGraph({ groups: [outer] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).toContain('>Outer</text>')\n    expect(svg).toContain('>Inner</text>')\n  })\n})\n\n// ============================================================================\n// Inline style support\n// ============================================================================\n\ndescribe('renderSvg – inline styles', () => {\n  it('applies inline fill override', () => {\n    const node = makeNode({ inlineStyle: { fill: '#ff0000' } })\n    const graph = makeGraph({ nodes: [node] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).toContain('fill=\"#ff0000\"')\n  })\n\n  it('applies inline stroke override', () => {\n    const node = makeNode({ inlineStyle: { stroke: '#00ff00' } })\n    const graph = makeGraph({ nodes: [node] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).toContain('stroke=\"#00ff00\"')\n  })\n\n  it('applies inline text color override', () => {\n    const node = makeNode({ inlineStyle: { color: '#0000ff' } })\n    const graph = makeGraph({ nodes: [node] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).toContain('fill=\"#0000ff\"')\n  })\n\n  it('falls back to theme when no inline style', () => {\n    const node = makeNode()\n    const graph = makeGraph({ nodes: [node] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).toContain('fill=\"var(--_node-fill)\"')\n  })\n})\n\n// ============================================================================\n// XML escaping\n// ============================================================================\n\ndescribe('renderSvg – XML escaping', () => {\n  it('escapes special characters in node labels', () => {\n    const node = makeNode({ label: '<script> & \"quotes\" \\'apos\\'' })\n    const graph = makeGraph({ nodes: [node] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).toContain('&lt;script&gt;')\n    expect(svg).toContain('&amp;')\n    expect(svg).toContain('&quot;quotes&quot;')\n    expect(svg).toContain('&#39;apos&#39;')\n  })\n\n  it('escapes special characters in edge labels', () => {\n    const edge = makeEdge({ label: 'A & B > C' })\n    const graph = makeGraph({ edges: [edge] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).toContain('A &amp; B &gt; C')\n  })\n\n  it('escapes special characters in group labels', () => {\n    const group: PositionedGroup = {\n      id: 'g1', label: 'A < B',\n      x: 0, y: 0, width: 100, height: 100, children: [],\n    }\n    const graph = makeGraph({ groups: [group] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).toContain('A &lt; B')\n  })\n})\n\n// ============================================================================\n// Security: inline style injection prevention\n// ============================================================================\n\ndescribe('renderSvg – inline style XSS prevention', () => {\n  it('escapes attribute injection in inline style fill', () => {\n    const node = makeNode({ inlineStyle: { fill: 'red\" onmouseover=\"alert(1)' } })\n    const graph = makeGraph({ nodes: [node] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).not.toContain('onmouseover=\"alert')\n    expect(svg).toContain('red&quot; onmouseover=&quot;alert(1)')\n  })\n\n  it('escapes element injection in inline style fill', () => {\n    const node = makeNode({ inlineStyle: { fill: 'red\"/><svg onload=\"alert(1)\"><rect fill=\"x' } })\n    const graph = makeGraph({ nodes: [node] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).not.toContain('<svg onload')\n    expect(svg).toContain('&lt;svg onload=')\n  })\n\n  it('escapes injection in inline style stroke', () => {\n    const node = makeNode({ inlineStyle: { stroke: 'blue\" onclick=\"alert(1)' } })\n    const graph = makeGraph({ nodes: [node] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).not.toContain('onclick=\"alert')\n    expect(svg).toContain('blue&quot; onclick=&quot;alert(1)')\n  })\n\n  it('escapes injection in inline style stroke-width', () => {\n    const node = makeNode({ inlineStyle: { 'stroke-width': '2\" onmouseover=\"alert(1)' } })\n    const graph = makeGraph({ nodes: [node] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).not.toContain('onmouseover=\"alert')\n    expect(svg).toContain('2&quot; onmouseover=&quot;alert(1)')\n  })\n\n  it('escapes injection in inline style color', () => {\n    const node = makeNode({ inlineStyle: { color: 'green\" onfocus=\"alert(1)' } })\n    const graph = makeGraph({ nodes: [node] })\n    const svg = renderSvg(graph, lightColors)\n    expect(svg).not.toContain('onfocus=\"alert')\n    expect(svg).toContain('green&quot; onfocus=&quot;alert(1)')\n  })\n})\n\n// ============================================================================\n// Theme application\n// ============================================================================\n\ndescribe('renderSvg – CSS variable theming', () => {\n  it('uses CSS variables for styling (light colors)', () => {\n    const node = makeNode()\n    const graph = makeGraph({ nodes: [node] })\n    const svg = renderSvg(graph, lightColors)\n    // SVG uses CSS custom property references, not hardcoded colors\n    expect(svg).toContain('var(--_node-fill)')\n    expect(svg).toContain('var(--_node-stroke)')\n    expect(svg).toContain('var(--_text)')\n  })\n\n  it('uses same CSS variables with dark colors', () => {\n    const node = makeNode()\n    const graph = makeGraph({ nodes: [node] })\n    const svg = renderSvg(graph, darkColors)\n    // Same CSS variable structure — colors differ via --bg/--fg on the SVG tag\n    expect(svg).toContain('var(--_node-fill)')\n    expect(svg).toContain('var(--_node-stroke)')\n    expect(svg).toContain('var(--_text)')\n    expect(svg).toContain('--bg:#18181B')\n  })\n\n  it('arrow marker uses CSS variable for fill', () => {\n    const svg = renderSvg(makeGraph(), lightColors)\n    expect(svg).toContain('fill=\"var(--_arrow)\"')\n  })\n})\n"
  },
  {
    "path": "src/__tests__/sequence-integration.test.ts",
    "content": "/**\n * Integration tests for sequence diagrams — end-to-end parse → layout → render.\n */\nimport { describe, it, expect } from 'bun:test'\nimport { renderMermaidSVG } from '../index.ts'\n\ndescribe('renderMermaidSVG – sequence diagrams', () => {\n  it('renders a basic sequence diagram to valid SVG', () => {\n    const svg = renderMermaidSVG(`sequenceDiagram\n      Alice->>Bob: Hello\n      Bob-->>Alice: Hi there`)\n    expect(svg).toContain('<svg')\n    expect(svg).toContain('</svg>')\n    expect(svg).toContain('Alice')\n    expect(svg).toContain('Bob')\n    expect(svg).toContain('Hello')\n  })\n\n  it('renders participant declarations', () => {\n    const svg = renderMermaidSVG(`sequenceDiagram\n      participant A as Alice\n      participant B as Bob\n      A->>B: Message`)\n    expect(svg).toContain('Alice')\n    expect(svg).toContain('Bob')\n    expect(svg).toContain('Message')\n  })\n\n  it('renders actor circle-person icons', () => {\n    const svg = renderMermaidSVG(`sequenceDiagram\n      actor U as User\n      participant S as System\n      U->>S: Click`)\n    // Actors use the circle-person icon (three paths inside a scaled <g>)\n    expect(svg).toContain('<g transform=\"translate(')\n    expect(svg).toContain('scale(')\n    expect(svg).toContain('User')\n    expect(svg).toContain('System')\n  })\n\n  it('renders dashed return arrows', () => {\n    const svg = renderMermaidSVG(`sequenceDiagram\n      A->>B: Request\n      B-->>A: Response`)\n    // Dashed lines have stroke-dasharray\n    expect(svg).toContain('stroke-dasharray')\n    expect(svg).toContain('Request')\n    expect(svg).toContain('Response')\n  })\n\n  it('renders loop blocks', () => {\n    const svg = renderMermaidSVG(`sequenceDiagram\n      A->>B: Start\n      loop Every 5s\n        A->>B: Ping\n      end`)\n    expect(svg).toContain('loop')\n    expect(svg).toContain('Every 5s')\n  })\n\n  it('renders alt/else blocks', () => {\n    const svg = renderMermaidSVG(`sequenceDiagram\n      A->>B: Request\n      alt Success\n        B->>A: 200\n      else Error\n        B->>A: 500\n      end`)\n    expect(svg).toContain('alt')\n    expect(svg).toContain('Success')\n    // Else divider (dashed line)\n    expect(svg).toContain('Error')\n  })\n\n  it('renders notes', () => {\n    const svg = renderMermaidSVG(`sequenceDiagram\n      A->>B: Hello\n      Note right of B: Think about response\n      B-->>A: Hi`)\n    expect(svg).toContain('Think about response')\n  })\n\n  it('renders with dark colors', () => {\n    const svg = renderMermaidSVG(`sequenceDiagram\n      A->>B: Hello`, { bg: '#18181B', fg: '#FAFAFA' })\n    expect(svg).toContain('--bg:#18181B')\n  })\n\n  it('renders lifeline dashed lines', () => {\n    const svg = renderMermaidSVG(`sequenceDiagram\n      A->>B: Hello`)\n    // Lifelines are dashed vertical lines\n    const dashedLines = svg.match(/stroke-dasharray=\"6 4\"/g)\n    expect(dashedLines).toBeTruthy()\n    expect(dashedLines!.length).toBeGreaterThanOrEqual(2) // at least 2 lifelines\n  })\n\n  it('renders a complex authentication flow', () => {\n    const svg = renderMermaidSVG(`sequenceDiagram\n      participant C as Client\n      participant S as Server\n      participant DB as Database\n      C->>S: POST /login\n      S->>DB: SELECT user\n      alt User found\n        DB-->>S: User record\n        S-->>C: 200 OK + token\n      else Not found\n        DB-->>S: null\n        S-->>C: 401 Unauthorized\n      end`)\n    expect(svg).toContain('<svg')\n    expect(svg).toContain('Client')\n    expect(svg).toContain('Server')\n    expect(svg).toContain('Database')\n    expect(svg).toContain('POST /login')\n  })\n})\n"
  },
  {
    "path": "src/__tests__/sequence-layout.test.ts",
    "content": "/**\n * Layout tests for sequence diagrams — verify that block headers and dividers\n * get extra vertical space so they don't overlap with messages.\n *\n * These tests call parseSequenceDiagram + layoutSequenceDiagram directly\n * to inspect Y coordinates, rather than checking SVG output.\n */\nimport { describe, it, expect } from 'bun:test'\nimport { parseSequenceDiagram } from '../sequence/parser.ts'\nimport { layoutSequenceDiagram } from '../sequence/layout.ts'\n\n/** Helper: parse and layout a sequence diagram from source lines */\nfunction layout(source: string) {\n  const lines = source\n    .split('\\n')\n    .map(l => l.trim())\n    .filter(l => l.length > 0 && !l.startsWith('%%'))\n  return layoutSequenceDiagram(parseSequenceDiagram(lines))\n}\n\ndescribe('sequence layout – block spacing', () => {\n  it('messages outside blocks are spaced at the base row height', () => {\n    const result = layout(`sequenceDiagram\n      A->>B: First\n      B->>A: Second\n      A->>B: Third`)\n\n    // Three messages with no blocks — uniform spacing\n    expect(result.messages).toHaveLength(3)\n    const gap1 = result.messages[1]!.y - result.messages[0]!.y\n    const gap2 = result.messages[2]!.y - result.messages[1]!.y\n    // Both gaps should be identical (base messageRowHeight, 40px)\n    expect(gap1).toBe(gap2)\n    expect(gap1).toBe(40) // SEQ.messageRowHeight\n  })\n\n  it('first message in a loop block gets extra header space', () => {\n    const result = layout(`sequenceDiagram\n      A->>B: Before loop\n      loop Every 5s\n        A->>B: Inside loop\n      end`)\n\n    expect(result.messages).toHaveLength(2)\n    const gap = result.messages[1]!.y - result.messages[0]!.y\n    // Should be baseRowHeight + blockHeaderExtra = 40 + 28 = 68\n    expect(gap).toBe(40 + 28)\n  })\n\n  it('first message in an alt block gets extra header space', () => {\n    const result = layout(`sequenceDiagram\n      A->>B: Login\n      alt Success\n        B->>A: 200\n      end`)\n\n    expect(result.messages).toHaveLength(2)\n    const gap = result.messages[1]!.y - result.messages[0]!.y\n    expect(gap).toBe(40 + 28)\n  })\n\n  it('messages after else dividers get extra divider space', () => {\n    const result = layout(`sequenceDiagram\n      A->>B: Login\n      alt Valid\n        B->>A: 200 OK\n      else Invalid\n        B->>A: 401\n      end`)\n\n    expect(result.messages).toHaveLength(3)\n    // msg[0] → msg[1]: base + blockHeaderExtra (first message in alt block)\n    const gap01 = result.messages[1]!.y - result.messages[0]!.y\n    expect(gap01).toBe(40 + 28)\n\n    // msg[1] → msg[2]: base + dividerExtra (message after \"else Invalid\")\n    const gap12 = result.messages[2]!.y - result.messages[1]!.y\n    expect(gap12).toBe(40 + 24)\n  })\n\n  it('multiple else dividers each get extra space', () => {\n    const result = layout(`sequenceDiagram\n      C->>S: Login\n      alt Valid credentials\n        S-->>C: 200 OK\n      else Invalid\n        S-->>C: 401 Unauthorized\n      else Account locked\n        S-->>C: 403 Forbidden\n      end`)\n\n    expect(result.messages).toHaveLength(4)\n\n    // msg[0] → msg[1]: base + blockHeaderExtra\n    const gap01 = result.messages[1]!.y - result.messages[0]!.y\n    expect(gap01).toBe(40 + 28)\n\n    // msg[1] → msg[2]: base + dividerExtra (else Invalid)\n    const gap12 = result.messages[2]!.y - result.messages[1]!.y\n    expect(gap12).toBe(40 + 24)\n\n    // msg[2] → msg[3]: base + dividerExtra (else Account locked)\n    const gap23 = result.messages[3]!.y - result.messages[2]!.y\n    expect(gap23).toBe(40 + 24)\n  })\n\n  it('par block with and dividers gets correct spacing', () => {\n    // Messages: Validate (idx 0), Get user (idx 1), Get orders (idx 2)\n    // Block: startIndex=1, dividers=[{index:2}]\n    const result = layout(`sequenceDiagram\n      G->>A: Validate\n      par Fetch user\n        G->>U: Get user\n      and Fetch orders\n        G->>O: Get orders\n      end`)\n\n    expect(result.messages).toHaveLength(3)\n\n    // msg[0] → msg[1]: base + blockHeaderExtra (first in par block)\n    const gap01 = result.messages[1]!.y - result.messages[0]!.y\n    expect(gap01).toBe(40 + 28)\n\n    // msg[1] → msg[2]: base + dividerExtra (and Fetch orders)\n    const gap12 = result.messages[2]!.y - result.messages[1]!.y\n    expect(gap12).toBe(40 + 24)\n  })\n\n  it('opt block header gets extra space', () => {\n    const result = layout(`sequenceDiagram\n      A->>B: Request\n      opt Cache available\n        B-->>A: Cached response\n      end`)\n\n    expect(result.messages).toHaveLength(2)\n    const gap = result.messages[1]!.y - result.messages[0]!.y\n    expect(gap).toBe(40 + 28)\n  })\n\n  it('critical block header gets extra space', () => {\n    const result = layout(`sequenceDiagram\n      A->>DB: BEGIN\n      critical Transaction\n        A->>DB: UPDATE\n      end`)\n\n    expect(result.messages).toHaveLength(2)\n    const gap = result.messages[1]!.y - result.messages[0]!.y\n    expect(gap).toBe(40 + 28)\n  })\n\n  it('messages after a block return to normal spacing', () => {\n    const result = layout(`sequenceDiagram\n      A->>B: Before\n      loop Retry\n        A->>B: Attempt\n      end\n      A->>B: After`)\n\n    expect(result.messages).toHaveLength(3)\n    // msg[1] → msg[2]: no block boundary, just base row height\n    const gap12 = result.messages[2]!.y - result.messages[1]!.y\n    expect(gap12).toBe(40)\n  })\n})\n\ndescribe('sequence layout – block positioning', () => {\n  it('block top is above the first message with room for the header', () => {\n    const result = layout(`sequenceDiagram\n      A->>B: Before\n      loop Retry\n        A->>B: Inside\n      end`)\n\n    const block = result.blocks[0]!\n    const firstMsg = result.messages[1]! // msg at index 1 is startIndex\n    // Block top should be above message Y by blockPadTop (40px)\n    expect(block.y).toBeLessThan(firstMsg.y)\n    expect(firstMsg.y - block.y).toBe(40) // SEQ.blockPadTop\n  })\n\n  it('divider Y is between the messages it separates', () => {\n    const result = layout(`sequenceDiagram\n      A->>B: Login\n      alt Success\n        B->>A: 200\n      else Failure\n        B->>A: 500\n      end`)\n\n    const block = result.blocks[0]!\n    expect(block.dividers).toHaveLength(1)\n    const divY = block.dividers[0]!.y\n    const msg1Y = result.messages[1]!.y // 200\n    const msg2Y = result.messages[2]!.y // 500\n\n    // Divider should be between the two messages\n    expect(divY).toBeGreaterThan(msg1Y)\n    expect(divY).toBeLessThan(msg2Y)\n  })\n\n  it('multiple dividers are each between their respective messages', () => {\n    const result = layout(`sequenceDiagram\n      C->>S: Login\n      alt Valid\n        S-->>C: 200\n      else Invalid\n        S-->>C: 401\n      else Locked\n        S-->>C: 403\n      end`)\n\n    const block = result.blocks[0]!\n    expect(block.dividers).toHaveLength(2)\n\n    // First divider between msg[1] (200) and msg[2] (401)\n    expect(block.dividers[0]!.y).toBeGreaterThan(result.messages[1]!.y)\n    expect(block.dividers[0]!.y).toBeLessThan(result.messages[2]!.y)\n\n    // Second divider between msg[2] (401) and msg[3] (403)\n    expect(block.dividers[1]!.y).toBeGreaterThan(result.messages[2]!.y)\n    expect(block.dividers[1]!.y).toBeLessThan(result.messages[3]!.y)\n  })\n\n  it('block height encompasses all its messages', () => {\n    const result = layout(`sequenceDiagram\n      A->>B: Before\n      alt Yes\n        B->>A: Response 1\n      else No\n        B->>A: Response 2\n      end`)\n\n    const block = result.blocks[0]!\n    const firstMsgY = result.messages[1]!.y\n    const lastMsgY = result.messages[2]!.y\n\n    // Block top is above first message\n    expect(block.y).toBeLessThan(firstMsgY)\n    // Block bottom is below last message\n    expect(block.y + block.height).toBeGreaterThan(lastMsgY)\n  })\n})\n\ndescribe('sequence layout – diagram dimensions', () => {\n  it('diagram height increases with block extra space', () => {\n    // Diagram without blocks\n    const plain = layout(`sequenceDiagram\n      A->>B: One\n      B->>A: Two\n      A->>B: Three`)\n\n    // Diagram with same messages but wrapped in a loop block\n    const withBlock = layout(`sequenceDiagram\n      A->>B: One\n      loop Repeat\n        B->>A: Two\n      end\n      A->>B: Three`)\n\n    // The block version should be taller due to header extra space\n    expect(withBlock.height).toBeGreaterThan(plain.height)\n    expect(withBlock.height - plain.height).toBe(28) // blockHeaderExtra\n  })\n\n  it('diagram with multiple dividers is taller than one with none', () => {\n    const noDividers = layout(`sequenceDiagram\n      A->>B: M1\n      B->>A: M2\n      A->>B: M3\n      B->>A: M4`)\n\n    const withDividers = layout(`sequenceDiagram\n      A->>B: M1\n      alt Case1\n        B->>A: M2\n      else Case2\n        A->>B: M3\n      else Case3\n        B->>A: M4\n      end`)\n\n    // blockHeaderExtra (28) + 2 × dividerExtra (24 each) = 76 extra\n    expect(withDividers.height - noDividers.height).toBe(28 + 24 + 24)\n  })\n})\n\n// ============================================================================\n// Clearance tests — verify that block headers, divider labels, and message\n// labels don't overlap at the rendered pixel level.\n//\n// The renderer draws:\n//   - Block header tab:  top at block.y, bottom at block.y + 18\n//   - Header label text: at block.y + 9 (dominant-baseline central)\n//   - Divider line:      at divider.y\n//   - Divider label:     baseline at divider.y + 14\n//   - Message arrow:     at msg.y\n//   - Message label:     at msg.y - 10 (above the arrow)\n// ============================================================================\n\ndescribe('sequence layout – render clearance', () => {\n  it('block header tab bottom is above the first message label', () => {\n    // The header tab has height 18, drawn starting at block.y.\n    // The message label is at msg.y - 6.\n    // We need: block.y + 18 < firstMsg.y - 6\n    const result = layout(`sequenceDiagram\n      A->>B: Before\n      loop Repeat\n        A->>B: Inside\n      end`)\n\n    const block = result.blocks[0]!\n    const firstMsg = result.messages[1]!\n    const tabBottom = block.y + 18\n    const msgLabel = firstMsg.y - 6\n\n    expect(tabBottom).toBeLessThan(msgLabel)\n    // Verify at least 10px of clearance\n    expect(msgLabel - tabBottom).toBeGreaterThanOrEqual(10)\n  })\n\n  it('block header tab does not overlap the previous message arrow', () => {\n    // The previous message arrow is at prevMsg.y.\n    // The block header tab starts at block.y.\n    // We need: prevMsg.y < block.y (header starts below previous arrow)\n    const result = layout(`sequenceDiagram\n      A->>B: Previous\n      alt Valid\n        B->>A: Response\n      end`)\n\n    const prevMsg = result.messages[0]!\n    const block = result.blocks[0]!\n\n    expect(prevMsg.y).toBeLessThan(block.y)\n    // Verify at least 20px clearance between previous arrow and block top\n    expect(block.y - prevMsg.y).toBeGreaterThanOrEqual(20)\n  })\n\n  it('divider label does not overlap the message label below it', () => {\n    // Divider label baseline at divider.y + 14 (approx bottom of text).\n    // Message label at msg.y - 6.\n    // We need: divider.y + 14 < msg.y - 6\n    const result = layout(`sequenceDiagram\n      A->>B: Login\n      alt Success\n        B->>A: 200\n      else Failure\n        B->>A: 500\n      end`)\n\n    const block = result.blocks[0]!\n    const divider = block.dividers[0]!\n    const msg2 = result.messages[2]! // 500 (after divider)\n\n    const divLabelBottom = divider.y + 14\n    const msgLabel = msg2.y - 6\n\n    expect(divLabelBottom).toBeLessThan(msgLabel)\n  })\n\n  it('divider line does not overlap the previous message arrow', () => {\n    // The previous message arrow is at prevMsg.y.\n    // The divider line is at divider.y.\n    // We need: prevMsg.y < divider.y\n    const result = layout(`sequenceDiagram\n      A->>B: Login\n      alt Success\n        B->>A: 200 OK\n      else Failure\n        B->>A: 500 Error\n      end`)\n\n    const block = result.blocks[0]!\n    const divider = block.dividers[0]!\n    const prevMsg = result.messages[1]! // 200 OK (before divider)\n\n    expect(prevMsg.y).toBeLessThan(divider.y)\n    // Verify at least 10px clearance\n    expect(divider.y - prevMsg.y).toBeGreaterThanOrEqual(10)\n  })\n\n  it('alt block with 3 else: no overlap at any boundary', () => {\n    const result = layout(`sequenceDiagram\n      C->>S: Login\n      alt Valid\n        S-->>C: 200\n      else Invalid\n        S-->>C: 401\n      else Locked\n        S-->>C: 403\n      end`)\n\n    const block = result.blocks[0]!\n\n    // Header tab bottom vs first message label\n    const tabBottom = block.y + 18\n    const firstMsgLabel = result.messages[1]!.y - 6\n    expect(tabBottom).toBeLessThan(firstMsgLabel)\n\n    // Each divider label vs its message label\n    for (let d = 0; d < block.dividers.length; d++) {\n      const divider = block.dividers[d]!\n      // The message after this divider is at index (d + 2):\n      // msg[0]=Login, msg[1]=200 (start), msg[2]=401 (div0), msg[3]=403 (div1)\n      const msgAfter = result.messages[d + 2]!\n      const divLabelBottom = divider.y + 14\n      const msgLabelTop = msgAfter.y - 6\n      expect(divLabelBottom).toBeLessThan(msgLabelTop)\n    }\n\n    // Each divider line is below the previous message arrow\n    expect(block.dividers[0]!.y).toBeGreaterThan(result.messages[1]!.y)\n    expect(block.dividers[1]!.y).toBeGreaterThan(result.messages[2]!.y)\n  })\n\n  it('long divider labels get extra offset to avoid overlapping message labels', () => {\n    // \"Account locked\" is long enough that \"[Account locked]\" (left-aligned at\n    // the block edge) overlaps horizontally with \"403 Forbidden\" (centered\n    // between actors). The layout should detect this and use a larger vertical\n    // offset so the two text elements don't collide.\n    const result = layout(`sequenceDiagram\n      participant C as Client\n      participant S as Server\n      C->>S: Login\n      alt Valid credentials\n        S-->>C: 200 OK\n      else Account locked\n        S-->>C: 403 Forbidden\n      end`)\n\n    const block = result.blocks[0]!\n    expect(block.dividers).toHaveLength(1)\n\n    const divider = block.dividers[0]!\n    const msgAfter = result.messages[2]! // \"403 Forbidden\"\n\n    // With the overlap-aware offset (36 instead of default 28), the divider\n    // label baseline (divider.y + 14) should be further from the message\n    // label baseline (msg.y - 6), giving at least 14px baseline clearance.\n    const divLabelBaseline = divider.y + 14\n    const msgLabelBaseline = msgAfter.y - 6\n    const baselineClearance = msgLabelBaseline - divLabelBaseline\n\n    expect(baselineClearance).toBeGreaterThanOrEqual(14)\n  })\n\n  it('short divider labels keep the default offset (no unnecessary extra space)', () => {\n    // \"No\" is short enough that \"[No]\" doesn't reach the centered message\n    // label — no overlap, so the default offset (28) is used.\n    const result = layout(`sequenceDiagram\n      A->>B: Login\n      alt Yes\n        B->>A: 200\n      else No\n        B->>A: 500\n      end`)\n\n    const block = result.blocks[0]!\n    const divider = block.dividers[0]!\n    const msgAfter = result.messages[2]!\n\n    // Default offset 28: baseline clearance = (msg.y - 6) - (msg.y - 28 + 14) = 8\n    const baselineClearance = (msgAfter.y - 6) - (divider.y + 14)\n    expect(baselineClearance).toBe(8)\n  })\n})\n\n// ============================================================================\n// Bounding-box tests — verify that notes positioned outside the actor columns\n// are fully contained within the diagram viewport after the post-processing\n// shift-and-expand step (layout.ts step 6).\n//\n// The layout engine positions actors first, then notes may extend left of the\n// first actor (\"left of\") or right of the last actor (\"right of\"). The\n// bounding-box post-processing shifts all elements right if needed and expands\n// the diagram width so every element has at least SEQ.padding (30px) margin.\n// ============================================================================\n\ndescribe('sequence layout – note bounding box', () => {\n  it('note \"right of\" last actor is within diagram width', () => {\n    const result = layout(`sequenceDiagram\n      A->>B: Hello\n      Note right of B: Right-side note\n      B-->>A: Hi`)\n\n    // Every note must fit within the diagram width with padding margin\n    for (const note of result.notes) {\n      expect(note.x).toBeGreaterThanOrEqual(0)\n      expect(note.x + note.width).toBeLessThanOrEqual(result.width)\n    }\n  })\n\n  it('note \"left of\" first actor is within diagram width', () => {\n    const result = layout(`sequenceDiagram\n      A->>B: Hello\n      Note left of A: Left-side note\n      B-->>A: Hi`)\n\n    // Left note should have been shifted right so x >= padding (30)\n    for (const note of result.notes) {\n      expect(note.x).toBeGreaterThanOrEqual(0)\n      expect(note.x + note.width).toBeLessThanOrEqual(result.width)\n    }\n  })\n\n  it('both left and right notes are within diagram width', () => {\n    const result = layout(`sequenceDiagram\n      A->>B: Hello\n      Note left of A: Left note\n      Note right of B: Right note\n      B-->>A: Hi`)\n\n    expect(result.notes).toHaveLength(2)\n    for (const note of result.notes) {\n      expect(note.x).toBeGreaterThanOrEqual(0)\n      expect(note.x + note.width).toBeLessThanOrEqual(result.width)\n    }\n  })\n\n  it('note \"over\" actor stays centered and within bounds', () => {\n    const result = layout(`sequenceDiagram\n      A->>B: Hello\n      Note over A: Centered note\n      B-->>A: Hi`)\n\n    for (const note of result.notes) {\n      expect(note.x).toBeGreaterThanOrEqual(0)\n      expect(note.x + note.width).toBeLessThanOrEqual(result.width)\n    }\n  })\n\n  it('shift preserves relative positions of all elements', () => {\n    // A \"left of\" note on the first actor triggers a right-shift.\n    // Verify that after the shift, messages still connect the correct actors\n    // (i.e. message x1/x2 match actor x positions).\n    const result = layout(`sequenceDiagram\n      A->>B: Hello\n      Note left of A: This shifts everything\n      B-->>A: Reply`)\n\n    // Build actor lookup\n    const actorX = new Map<string, number>()\n    for (const a of result.actors) actorX.set(a.id, a.x)\n\n    // Each message's x1/x2 should match its from/to actor center X\n    for (const msg of result.messages) {\n      expect(msg.x1).toBe(actorX.get(msg.from)!)\n      expect(msg.x2).toBe(actorX.get(msg.to)!)\n    }\n\n    // Lifelines should align with their actors\n    for (const ll of result.lifelines) {\n      expect(ll.x).toBe(actorX.get(ll.actorId)!)\n    }\n  })\n\n  it('diagram without notes has no unnecessary shift', () => {\n    // No notes → no shift needed. Actors should start near SEQ.padding (30).\n    const result = layout(`sequenceDiagram\n      A->>B: Hello\n      B-->>A: Hi`)\n\n    // First actor center should be near the padding\n    // (actorWidth/2 + padding = ~70, but the key check is no extra shift)\n    const firstActorX = result.actors[0]!.x\n    const firstActorLeft = firstActorX - result.actors[0]!.width / 2\n\n    // Left edge should be at exactly SEQ.padding (30) — no shift applied\n    expect(firstActorLeft).toBe(30)\n  })\n\n  it('diagram width expands for right-side notes beyond actors', () => {\n    // Compare diagram with and without a right-side note\n    const withoutNote = layout(`sequenceDiagram\n      A->>B: Hello\n      B-->>A: Hi`)\n\n    const withNote = layout(`sequenceDiagram\n      A->>B: Hello\n      Note right of B: Extra wide note text here\n      B-->>A: Hi`)\n\n    // The diagram with the right-side note should be wider\n    expect(withNote.width).toBeGreaterThan(withoutNote.width)\n  })\n\n  it('left-side note shifts actors right, expanding diagram width', () => {\n    const withoutNote = layout(`sequenceDiagram\n      A->>B: Hello\n      B-->>A: Hi`)\n\n    const withNote = layout(`sequenceDiagram\n      A->>B: Hello\n      Note left of A: Left note\n      B-->>A: Hi`)\n\n    // Actors should have shifted right in the note version\n    expect(withNote.actors[0]!.x).toBeGreaterThan(withoutNote.actors[0]!.x)\n\n    // The left note's left edge should be at or near padding\n    const leftNote = withNote.notes[0]!\n    expect(leftNote.x).toBeGreaterThanOrEqual(0)\n  })\n})\n\n// ============================================================================\n// Note positioning tests — verify that notes after self-messages clear the\n// loop, consecutive notes stack vertically, and notes push subsequent\n// messages down so nothing overlaps.\n// ============================================================================\n\ndescribe('sequence layout – note positioning', () => {\n  it('note after a self-message is positioned below the loop', () => {\n    const result = layout(`sequenceDiagram\n      A->>A: Process\n      Note over A: Done`)\n\n    const msg = result.messages[0]!\n    const note = result.notes[0]!\n\n    // Self-message loop bottom is at msg.y + selfMessageHeight (30).\n    // Note must start below that.\n    expect(note.y).toBeGreaterThan(msg.y + 30)\n  })\n\n  it('note after a normal message has clearance from the arrow', () => {\n    const result = layout(`sequenceDiagram\n      A->>B: Hello\n      Note over A: Noted`)\n\n    const msg = result.messages[0]!\n    const note = result.notes[0]!\n\n    // Note should be below the message arrow\n    expect(note.y).toBeGreaterThan(msg.y)\n  })\n\n  it('consecutive notes after the same message are stacked vertically', () => {\n    const result = layout(`sequenceDiagram\n      A->>B: Hello\n      Note over A: First\n      Note over A: Second\n      Note over A: Third`)\n\n    expect(result.notes).toHaveLength(3)\n\n    // Each note starts at or below the previous note's bottom edge\n    for (let i = 1; i < result.notes.length; i++) {\n      const prev = result.notes[i - 1]!\n      const curr = result.notes[i]!\n      expect(curr.y).toBeGreaterThanOrEqual(prev.y + prev.height)\n    }\n  })\n\n  it('consecutive notes after a self-message clear the loop and each other', () => {\n    const result = layout(`sequenceDiagram\n      A->>A: Process\n      Note over A: Step 1\n      Note over A: Step 2`)\n\n    const msg = result.messages[0]!\n    const note1 = result.notes[0]!\n    const note2 = result.notes[1]!\n\n    // First note below loop\n    expect(note1.y).toBeGreaterThan(msg.y + 30)\n    // Second note below first\n    expect(note2.y).toBeGreaterThanOrEqual(note1.y + note1.height)\n  })\n\n  it('messages after notes are pushed down to avoid overlap', () => {\n    // Multiple notes overflow the default self-message vertical space (70px),\n    // forcing the next message to shift down.\n    const withNotes = layout(`sequenceDiagram\n      A->>A: Self\n      Note over A: Note 1\n      Note over A: Note 2\n      Note over A: Note 3\n      A->>B: Next`)\n\n    const withoutNotes = layout(`sequenceDiagram\n      A->>A: Self\n      A->>B: Next`)\n\n    // Gap between messages should be larger when multiple notes are present\n    const gapWith = withNotes.messages[1]!.y - withNotes.messages[0]!.y\n    const gapWithout = withoutNotes.messages[1]!.y - withoutNotes.messages[0]!.y\n\n    expect(gapWith).toBeGreaterThan(gapWithout)\n\n    // Every note's bottom edge must be above the next message's label\n    const lastNote = withNotes.notes[withNotes.notes.length - 1]!\n    const nextMsg = withNotes.messages[1]!\n    expect(lastNote.y + lastNote.height).toBeLessThan(nextMsg.y - 10) // -10 is label offset\n  })\n\n  it('notes inside alt blocks with self-messages have no overlap', () => {\n    const result = layout(`sequenceDiagram\n      participant A\n      participant B\n      alt Case 1\n        A->>A: handle()\n        Note over A: Handled\n      else Case 2\n        B->>B: process()\n        Note over B: Processed\n      end`)\n\n    // Each note must be below its self-message's loop\n    for (let i = 0; i < result.notes.length; i++) {\n      const note = result.notes[i]!\n      const refMsg = result.messages[i]!\n      expect(note.y).toBeGreaterThan(refMsg.y + 30)\n    }\n  })\n\n  it('diagram height increases to accommodate notes', () => {\n    const withNotes = layout(`sequenceDiagram\n      A->>A: Process\n      Note over A: Note 1\n      Note over A: Note 2\n      A->>B: Done`)\n\n    const withoutNotes = layout(`sequenceDiagram\n      A->>A: Process\n      A->>B: Done`)\n\n    expect(withNotes.height).toBeGreaterThan(withoutNotes.height)\n  })\n\n  it('all notes remain within diagram bounds', () => {\n    const result = layout(`sequenceDiagram\n      A->>A: Self message\n      Note over A: Note 1\n      Note right of A: Right note\n      Note left of A: Left note`)\n\n    for (const note of result.notes) {\n      expect(note.x).toBeGreaterThanOrEqual(0)\n      expect(note.x + note.width).toBeLessThanOrEqual(result.width)\n      expect(note.y).toBeGreaterThanOrEqual(0)\n      expect(note.y + note.height).toBeLessThanOrEqual(result.height)\n    }\n  })\n})\n"
  },
  {
    "path": "src/__tests__/sequence-parser.test.ts",
    "content": "/**\n * Tests for the sequence diagram parser.\n *\n * Covers: participants, actors, messages (solid/dashed, filled/open arrows),\n * activation/deactivation, blocks (loop/alt/opt/par), notes, auto-created actors.\n */\nimport { describe, it, expect } from 'bun:test'\nimport { parseSequenceDiagram } from '../sequence/parser.ts'\n\n/** Helper to parse — preprocesses text the same way index.ts does */\nfunction parse(text: string) {\n  const lines = text.split('\\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%'))\n  return parseSequenceDiagram(lines)\n}\n\n// ============================================================================\n// Actor / Participant declarations\n// ============================================================================\n\ndescribe('parseSequenceDiagram – actors', () => {\n  it('parses participant declarations', () => {\n    const d = parse(`sequenceDiagram\n      participant A as Alice\n      participant B as Bob\n      A->>B: Hello`)\n    expect(d.actors).toHaveLength(2)\n    expect(d.actors[0]!.id).toBe('A')\n    expect(d.actors[0]!.label).toBe('Alice')\n    expect(d.actors[0]!.type).toBe('participant')\n  })\n\n  it('parses actor declarations (stick figures)', () => {\n    const d = parse(`sequenceDiagram\n      actor U as User\n      participant S as System\n      U->>S: Click`)\n    expect(d.actors[0]!.type).toBe('actor')\n    expect(d.actors[1]!.type).toBe('participant')\n  })\n\n  it('auto-creates participants from messages', () => {\n    const d = parse(`sequenceDiagram\n      Alice->>Bob: Hello`)\n    expect(d.actors).toHaveLength(2)\n    expect(d.actors[0]!.id).toBe('Alice')\n    expect(d.actors[0]!.label).toBe('Alice')\n    expect(d.actors[0]!.type).toBe('participant')\n  })\n\n  it('does not duplicate declared actors when also used in messages', () => {\n    const d = parse(`sequenceDiagram\n      participant A as Alice\n      A->>B: Hello\n      B->>A: Hi`)\n    expect(d.actors).toHaveLength(2)\n    expect(d.actors[0]!.label).toBe('Alice')\n    expect(d.actors[1]!.id).toBe('B')\n  })\n\n  it('participant without alias uses id as label', () => {\n    const d = parse(`sequenceDiagram\n      participant Server\n      Server->>Server: Ping`)\n    expect(d.actors[0]!.label).toBe('Server')\n  })\n})\n\n// ============================================================================\n// Messages\n// ============================================================================\n\ndescribe('parseSequenceDiagram – messages', () => {\n  it('parses solid arrow message: A->>B', () => {\n    const d = parse(`sequenceDiagram\n      A->>B: Hello`)\n    expect(d.messages).toHaveLength(1)\n    expect(d.messages[0]!.from).toBe('A')\n    expect(d.messages[0]!.to).toBe('B')\n    expect(d.messages[0]!.label).toBe('Hello')\n    expect(d.messages[0]!.lineStyle).toBe('solid')\n    expect(d.messages[0]!.arrowHead).toBe('filled')\n  })\n\n  it('parses dashed arrow message: A-->>B', () => {\n    const d = parse(`sequenceDiagram\n      A-->>B: Response`)\n    expect(d.messages[0]!.lineStyle).toBe('dashed')\n    expect(d.messages[0]!.arrowHead).toBe('filled')\n  })\n\n  it('parses open arrow message: A-)B', () => {\n    const d = parse(`sequenceDiagram\n      A-)B: Async`)\n    expect(d.messages[0]!.arrowHead).toBe('open')\n    expect(d.messages[0]!.lineStyle).toBe('solid')\n  })\n\n  it('parses multiple messages in order', () => {\n    const d = parse(`sequenceDiagram\n      A->>B: First\n      B->>C: Second\n      C->>A: Third`)\n    expect(d.messages).toHaveLength(3)\n    expect(d.messages[0]!.label).toBe('First')\n    expect(d.messages[1]!.label).toBe('Second')\n    expect(d.messages[2]!.label).toBe('Third')\n  })\n\n  it('parses activation marker (+)', () => {\n    const d = parse(`sequenceDiagram\n      A->>+B: Activate`)\n    expect(d.messages[0]!.activate).toBe(true)\n  })\n\n  it('parses deactivation marker (-)', () => {\n    const d = parse(`sequenceDiagram\n      B-->>-A: Deactivate`)\n    expect(d.messages[0]!.deactivate).toBe(true)\n  })\n})\n\n// ============================================================================\n// Blocks (loop, alt, opt, par)\n// ============================================================================\n\ndescribe('parseSequenceDiagram – blocks', () => {\n  it('parses loop block', () => {\n    const d = parse(`sequenceDiagram\n      A->>B: Start\n      loop Every 5s\n        A->>B: Ping\n      end\n      A->>B: Done`)\n    expect(d.blocks).toHaveLength(1)\n    expect(d.blocks[0]!.type).toBe('loop')\n    expect(d.blocks[0]!.label).toBe('Every 5s')\n    expect(d.blocks[0]!.startIndex).toBe(1) // second message\n  })\n\n  it('parses alt/else block', () => {\n    const d = parse(`sequenceDiagram\n      A->>B: Request\n      alt Success\n        B->>A: 200 OK\n      else Failure\n        B->>A: 500 Error\n      end`)\n    expect(d.blocks).toHaveLength(1)\n    expect(d.blocks[0]!.type).toBe('alt')\n    expect(d.blocks[0]!.label).toBe('Success')\n    expect(d.blocks[0]!.dividers).toHaveLength(1)\n    expect(d.blocks[0]!.dividers[0]!.label).toBe('Failure')\n  })\n\n  it('parses opt block', () => {\n    const d = parse(`sequenceDiagram\n      opt Extra logging\n        A->>Logger: Log\n      end`)\n    expect(d.blocks[0]!.type).toBe('opt')\n  })\n\n  it('parses par block with and dividers', () => {\n    const d = parse(`sequenceDiagram\n      par Task A\n        A->>B: Do A\n      and Task B\n        A->>C: Do B\n      end`)\n    expect(d.blocks[0]!.type).toBe('par')\n    expect(d.blocks[0]!.dividers).toHaveLength(1)\n    expect(d.blocks[0]!.dividers[0]!.label).toBe('Task B')\n  })\n})\n\n// ============================================================================\n// Notes\n// ============================================================================\n\ndescribe('parseSequenceDiagram – notes', () => {\n  it('parses \"Note left of\" note', () => {\n    const d = parse(`sequenceDiagram\n      A->>B: Hello\n      Note left of A: Important note`)\n    expect(d.notes).toHaveLength(1)\n    expect(d.notes[0]!.position).toBe('left')\n    expect(d.notes[0]!.actorIds).toEqual(['A'])\n    expect(d.notes[0]!.text).toBe('Important note')\n  })\n\n  it('parses \"Note right of\" note', () => {\n    const d = parse(`sequenceDiagram\n      Note right of B: Side note\n      A->>B: Hello`)\n    expect(d.notes[0]!.position).toBe('right')\n  })\n\n  it('parses \"Note over\" spanning multiple actors', () => {\n    const d = parse(`sequenceDiagram\n      Note over A,B: Shared note\n      A->>B: Hello`)\n    expect(d.notes[0]!.position).toBe('over')\n    expect(d.notes[0]!.actorIds).toEqual(['A', 'B'])\n  })\n})\n\n// ============================================================================\n// Full diagram\n// ============================================================================\n\ndescribe('parseSequenceDiagram – full diagram', () => {\n  it('parses a complete authentication flow', () => {\n    const d = parse(`sequenceDiagram\n      participant C as Client\n      participant S as Server\n      participant DB as Database\n      C->>S: POST /login\n      S->>DB: SELECT user\n      alt User found\n        DB-->>S: User record\n        S-->>C: 200 OK + token\n      else Not found\n        DB-->>S: null\n        S-->>C: 401 Unauthorized\n      end`)\n\n    expect(d.actors).toHaveLength(3)\n    expect(d.messages).toHaveLength(6)\n    expect(d.blocks).toHaveLength(1)\n    expect(d.blocks[0]!.type).toBe('alt')\n    expect(d.blocks[0]!.dividers).toHaveLength(1)\n  })\n})\n"
  },
  {
    "path": "src/__tests__/styles.test.ts",
    "content": "/**\n * Tests for styles module — text measurement and constants.\n * Theme resolution tests are in theme.test.ts (CSS custom property system).\n */\nimport { describe, it, expect } from 'bun:test'\nimport { estimateTextWidth, FONT_SIZES, FONT_WEIGHTS, NODE_PADDING, STROKE_WIDTHS, ARROW_HEAD } from '../styles.ts'\nimport { THEMES, DEFAULTS, fromShikiTheme, buildStyleBlock, svgOpenTag } from '../theme.ts'\nimport type { DiagramColors } from '../theme.ts'\n\n// ============================================================================\n// Theme system (CSS custom properties)\n// ============================================================================\n\ndescribe('THEMES', () => {\n  it('contains well-known theme palettes', () => {\n    expect(THEMES['zinc-light']).toBeDefined()\n    expect(THEMES['zinc-dark']).toBeDefined()\n    expect(THEMES['tokyo-night']).toBeDefined()\n    expect(THEMES['catppuccin-mocha']).toBeDefined()\n    expect(THEMES['nord']).toBeDefined()\n  })\n\n  it('each theme has valid bg and fg colors', () => {\n    for (const [name, colors] of Object.entries(THEMES)) {\n      expect(colors.bg).toMatch(/^#[0-9a-fA-F]{6}$/)\n      expect(colors.fg).toMatch(/^#[0-9a-fA-F]{6}$/)\n    }\n  })\n})\n\ndescribe('DEFAULTS', () => {\n  it('provides zinc-light bg/fg', () => {\n    expect(DEFAULTS.bg).toBe('#FFFFFF')\n    expect(DEFAULTS.fg).toBe('#27272A')\n  })\n})\n\ndescribe('svgOpenTag', () => {\n  it('sets --bg and --fg CSS variables in inline style', () => {\n    const tag = svgOpenTag(400, 300, { bg: '#1a1b26', fg: '#a9b1d6' })\n    expect(tag).toContain('--bg:#1a1b26')\n    expect(tag).toContain('--fg:#a9b1d6')\n    expect(tag).toContain('background:var(--bg)')\n  })\n\n  it('includes optional enrichment variables when provided', () => {\n    const colors: DiagramColors = {\n      bg: '#1a1b26', fg: '#a9b1d6',\n      line: '#3d59a1', accent: '#7aa2f7',\n    }\n    const tag = svgOpenTag(400, 300, colors)\n    expect(tag).toContain('--line:#3d59a1')\n    expect(tag).toContain('--accent:#7aa2f7')\n  })\n\n  it('omits unset enrichment variables', () => {\n    const tag = svgOpenTag(400, 300, { bg: '#fff', fg: '#000' })\n    expect(tag).not.toContain('--line')\n    expect(tag).not.toContain('--accent')\n    expect(tag).not.toContain('--muted')\n  })\n})\n\ndescribe('buildStyleBlock', () => {\n  it('includes derived CSS variable declarations', () => {\n    const style = buildStyleBlock('Inter', false)\n    expect(style).toContain('--_text')\n    expect(style).toContain('--_line')\n    expect(style).toContain('--_arrow')\n    expect(style).toContain('--_node-fill')\n    expect(style).toContain('--_node-stroke')\n  })\n\n  it('includes mono font class when requested', () => {\n    const withMono = buildStyleBlock('Inter', true)\n    expect(withMono).toContain('.mono')\n    expect(withMono).toContain('JetBrains Mono')\n\n    const withoutMono = buildStyleBlock('Inter', false)\n    expect(withoutMono).not.toContain('.mono')\n  })\n})\n\ndescribe('fromShikiTheme', () => {\n  it('extracts bg/fg from editor colors', () => {\n    const colors = fromShikiTheme({\n      type: 'dark',\n      colors: {\n        'editor.background': '#1a1b26',\n        'editor.foreground': '#a9b1d6',\n      },\n    })\n    expect(colors.bg).toBe('#1a1b26')\n    expect(colors.fg).toBe('#a9b1d6')\n  })\n\n  it('falls back for missing editor colors', () => {\n    const dark = fromShikiTheme({ type: 'dark' })\n    expect(dark.bg).toBe('#1e1e1e')\n    expect(dark.fg).toBe('#d4d4d4')\n\n    const light = fromShikiTheme({ type: 'light' })\n    expect(light.bg).toBe('#ffffff')\n    expect(light.fg).toBe('#333333')\n  })\n})\n\n// ============================================================================\n// Text width estimation\n// ============================================================================\n\ndescribe('estimateTextWidth', () => {\n  it('returns a positive number for non-empty text', () => {\n    const width = estimateTextWidth('Hello', 13, 500)\n    expect(width).toBeGreaterThan(0)\n  })\n\n  it('returns minimum padding for empty text', () => {\n    // Empty text still returns minimum padding (fontSize * 0.15) for layout safety\n    expect(estimateTextWidth('', 13, 500)).toBeCloseTo(1.95, 1)\n  })\n\n  it('scales with text length', () => {\n    const short = estimateTextWidth('Hi', 13, 500)\n    const long = estimateTextWidth('Hello World', 13, 500)\n    expect(long).toBeGreaterThan(short)\n  })\n\n  it('scales with font size', () => {\n    const small = estimateTextWidth('Text', 11, 500)\n    const large = estimateTextWidth('Text', 16, 500)\n    expect(large).toBeGreaterThan(small)\n  })\n\n  it('heavier weights produce wider estimates', () => {\n    const regular = estimateTextWidth('Text', 13, 400)\n    const bold = estimateTextWidth('Text', 13, 600)\n    expect(bold).toBeGreaterThan(regular)\n  })\n\n  it('produces reasonable widths for typical node labels', () => {\n    // A 5-character label at 13px/500w should be roughly 35px (5 * 13 * 0.55)\n    const width = estimateTextWidth('Hello', FONT_SIZES.nodeLabel, FONT_WEIGHTS.nodeLabel)\n    expect(width).toBeGreaterThan(25)\n    expect(width).toBeLessThan(60)\n  })\n})\n\n// ============================================================================\n// Exported constants\n// ============================================================================\n\ndescribe('constants', () => {\n  it('FONT_SIZES has expected values', () => {\n    expect(FONT_SIZES.nodeLabel).toBe(13)\n    expect(FONT_SIZES.edgeLabel).toBe(11)\n    expect(FONT_SIZES.groupHeader).toBe(12)\n  })\n\n  it('FONT_WEIGHTS has expected values', () => {\n    expect(FONT_WEIGHTS.nodeLabel).toBe(500)\n    expect(FONT_WEIGHTS.edgeLabel).toBe(400)\n    expect(FONT_WEIGHTS.groupHeader).toBe(600)\n  })\n\n  it('NODE_PADDING has expected values', () => {\n    expect(NODE_PADDING.horizontal).toBe(20)\n    expect(NODE_PADDING.vertical).toBe(10)\n    expect(NODE_PADDING.diamondExtra).toBe(24)\n  })\n\n  it('STROKE_WIDTHS has expected values', () => {\n    expect(STROKE_WIDTHS.outerBox).toBe(1)\n    expect(STROKE_WIDTHS.innerBox).toBe(0.75)\n    expect(STROKE_WIDTHS.connector).toBe(1)\n  })\n\n  it('ARROW_HEAD has expected values', () => {\n    expect(ARROW_HEAD.width).toBe(8)\n    expect(ARROW_HEAD.height).toBe(5)\n  })\n})\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/ampersand_lhs.txt",
    "content": "graph LR\nA --> B & C\n---\n+---+     +---+\n|   |     |   |\n| A |---->| B |\n|   |     |   |\n+---+     +---+\n  |            \n  |            \n  |            \n  |            \n  |            \n  |       +---+\n  |       |   |\n  +------>| C |\n          |   |\n          +---+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/ampersand_lhs_and_rhs.txt",
    "content": "graph LR\nA & B --> C & D\n---\n+---+     +---+\n|   |     |   |\n| A |--+->| C |\n|   |  |  |   |\n+---+  |  +---+\n  |    |       \n  |    |       \n  +----+       \n  |    |       \n  |    |       \n+---+  |  +---+\n|   |  |  |   |\n| B |--+->| D |\n|   |     |   |\n+---+     +---+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/ampersand_rhs.txt",
    "content": "graph LR\nA & B --> C\n---\n+---+     +---+\n|   |     |   |\n| A |---->| C |\n|   |     |   |\n+---+     +---+\n            ^  \n            |  \n            |  \n            |  \n            |  \n+---+       |  \n|   |       |  \n| B |-------+  \n|   |          \n+---+          \n"
  },
  {
    "path": "src/__tests__/testdata/ascii/ampersand_td_fanin.txt",
    "content": "graph TD\nA & B --> C\n---\n+---+     +---+\n|   |     |   |\n| A |     | B |\n|   |     |   |\n+---+     +---+\n  |         |\n  |         |\n  +---------+\n  |\n  v\n+---+\n|   |\n| C |\n|   |\n+---+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/ampersand_td_fanout.txt",
    "content": "graph TD\nA --> B & C\n---\n+---+\n|   |\n| A |\n|   |\n+---+\n  |\n  |\n  +---------+\n  |         |\n  v         v\n+---+     +---+\n|   |     |   |\n| B |     | C |\n|   |     |   |\n+---+     +---+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/ampersand_without_edge.txt",
    "content": "graph LR\nA & B\n---\n+---+\n|   |\n| A |\n|   |\n+---+\n     \n     \n     \n     \n     \n+---+\n|   |\n| B |\n|   |\n+---+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/back_reference_from_child.txt",
    "content": "graph LR\nA --> B --> C --> A\n---\n+---+     +---+     +---+\n|   |     |   |     |   |\n| A |---->| B |---->| C |\n|   |     |   |     |   |\n+---+     +---+     +---+\n  ^                   |  \n  +-------------------+  \n"
  },
  {
    "path": "src/__tests__/testdata/ascii/backlink_from_bottom.txt",
    "content": "graph LR\nA --> B\nB --> C\nA --> C\nB --> D\nC --> D\n---\n+---+     +---+     +---+\n|   |     |   |     |   |\n| A |---->| B |---->| D |\n|   |     |   |     |   |\n+---+     +---+     +---+\n  |         |         ^  \n  |         |         |  \n  |         |         |  \n  |         |         |  \n  |         v         |  \n  |       +---+       |  \n  |       |   |       |  \n  +------>| C |-------+  \n          |   |          \n          +---+          \n"
  },
  {
    "path": "src/__tests__/testdata/ascii/backlink_from_top.txt",
    "content": "graph LR\nA --> B\nB --> C\nA --> C\nB --> D\nD --> C\n---\n+---+     +---+     +---+\n|   |     |   |     |   |\n| A |---->| B |--+->| D |\n|   |     |   |  |  |   |\n+---+     +---+  |  +---+\n  |         |    |       \n  |         |    |       \n  |         +----+       \n  |         |            \n  |         v            \n  |       +---+          \n  |       |   |          \n  +------>| C |          \n          |   |          \n          +---+          \n"
  },
  {
    "path": "src/__tests__/testdata/ascii/backlink_with_short_y_padding.txt",
    "content": "paddingX=5\npaddingY=3\ngraph LR\nA --> B & C\nB --> C & D\nD --> C\n---\n+---+     +---+     +---+\n|   |     |   |     |   |\n| A |---->| B |--+->| D |\n|   |     |   |  |  |   |\n+---+     +---+  |  +---+\n  |         |    |       \n  |         +----+       \n  |         v            \n  |       +---+          \n  |       |   |          \n  +------>| C |          \n          |   |          \n          +---+          \n"
  },
  {
    "path": "src/__tests__/testdata/ascii/cls_all_relationships.txt",
    "content": "classDiagram\n  A <|-- B : inherits\n  C *-- D : owns\n  E o-- F : has\n  G --> H : uses\n  I ..> J : depends\n  K ..|> L : implements\n---\n+---+    +---+    +---+    +---+    +---+    +---+\n| A |    | C |    | E |    | G |    | I |    | L |\n+---+    +---+    +---+    +---+    +---+    +---+\n  ^        *        o        |        :        ^\n inherit owns      has     uses    depend implements\n  |        |        |        v        v        :\n+---+    +---+    +---+    +---+    +---+    +---+\n| B |    | D |    | F |    | H |    | J |    | K |\n+---+    +---+    +---+    +---+    +---+    +---+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/cls_annotation.txt",
    "content": "classDiagram\n  class Shape {\n    <<abstract>>\n    +draw() void\n  }\n  class Circle {\n    +radius int\n    +draw() void\n  }\n  Shape <|-- Circle\n---\n+--------------+    \n| <<abstract>> |    \n| Shape        |    \n+--------------+    \n|              |    \n+--------------+    \n| +draw: void  |    \n+--------------+    \n        ^           \n        |           \n        |           \n+--------------+    \n| Circle       |    \n+--------------+    \n| +int: radius |    \n+--------------+    \n| +draw: void  |    \n+--------------+    \n                    \n                    \n"
  },
  {
    "path": "src/__tests__/testdata/ascii/cls_association.txt",
    "content": "classDiagram\n  Student --> Course : enrolls\n---\n+---------+    \n| Student |    \n+---------+    \n     |         \n  enrolls      \n     v         \n+--------+     \n| Course |     \n+--------+     \n               \n               \n"
  },
  {
    "path": "src/__tests__/testdata/ascii/cls_basic.txt",
    "content": "classDiagram\n  class Animal {\n    +String name\n    +eat() void\n  }\n---\n+---------------+    \n| Animal        |    \n+---------------+    \n| +name: String |    \n+---------------+    \n| +eat: void    |    \n+---------------+    \n                     \n                     \n"
  },
  {
    "path": "src/__tests__/testdata/ascii/cls_dependency.txt",
    "content": "classDiagram\n  Client ..> Server : uses\n---\n+--------+    \n| Client |    \n+--------+    \n     :        \n   uses       \n     v        \n+--------+    \n| Server |    \n+--------+    \n              \n              \n"
  },
  {
    "path": "src/__tests__/testdata/ascii/cls_inheritance.txt",
    "content": "classDiagram\n  Animal <|-- Dog\n  Animal : +String name\n  Dog : +bark() void\n---\n+---------------+    \n| Animal        |    \n+---------------+    \n| +name: String |    \n+---------------+    \n        ^            \n       --            \n       |             \n+-------------+      \n| Dog         |      \n+-------------+      \n|             |      \n+-------------+      \n| +bark: void |      \n+-------------+      \n                     \n                     \n"
  },
  {
    "path": "src/__tests__/testdata/ascii/cls_methods.txt",
    "content": "classDiagram\n  class BankAccount {\n    +String owner\n    -int balance\n    +deposit(int amount) void\n    +withdraw(int amount) bool\n    -validate() bool\n  }\n---\n+-----------------+    \n| BankAccount     |    \n+-----------------+    \n| +owner: String  |    \n| -balance: int   |    \n+-----------------+    \n| +deposit: void  |    \n| +withdraw: bool |    \n| -validate: bool |    \n+-----------------+    \n                       \n                       \n"
  },
  {
    "path": "src/__tests__/testdata/ascii/comments.txt",
    "content": "graph LR\n%% This is a comment\nA --> B\n%% Another comment\nB --> C\nA --> C\n%% Final comment\n---\n+---+     +---+\n|   |     |   |\n| A |---->| B |\n|   |     |   |\n+---+     +---+\n  |         |  \n  |         |  \n  |         |  \n  |         |  \n  |         v  \n  |       +---+\n  |       |   |\n  +------>| C |\n          |   |\n          +---+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/custom_padding.txt",
    "content": "paddingX=2\npaddingY=1\ngraph LR\nA --> B\n---\n+---+  +---+\n|   |  |   |\n| A |->| B |\n|   |  |   |\n+---+  +---+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/duplicate_labels.txt",
    "content": "graph TD\nA[Server] --> B[Client]\nC[Server] --> D[Client]\n---\n+--------+     +--------+\n|        |     |        |\n| Server |     | Server |\n|        |     |        |\n+--------+     +--------+\n     |              |    \n     |              |    \n     |              |    \n     |              |    \n     v              v    \n+--------+     +--------+\n|        |     |        |\n| Client |     | Client |\n|        |     |        |\n+--------+     +--------+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/er_attributes.txt",
    "content": "erDiagram\n  CUSTOMER {\n    string name PK\n    string email UK\n    int age\n  }\n  ORDER {\n    int id PK\n    string status\n  }\n  CUSTOMER ||--o{ ORDER : places\n---\n+-----------------+      +------------------+\n| CUSTOMER        |      | ORDER            |\n+-----------------+      +------------------+\n| PK string name  ||---o<| PK int id        |\n| UK string email |places|    string status |\n|    int age      |      +------------------+\n+-----------------+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/er_basic.txt",
    "content": "erDiagram\n  CUSTOMER ||--o{ ORDER : places\n---\n+----------+      +-------+\n| CUSTOMER ||---o<| ORDER |\n+----------+places+-------+\n\n\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/er_identifying.txt",
    "content": "erDiagram\n  PERSON ||--o{ ADDRESS : lives_at\n  PERSON {\n    string name PK\n  }\n  ADDRESS {\n    string street\n    string city\n  }\n---\n+----------------+      +------------------+\n| PERSON         |      | ADDRESS          |\n+----------------+|---o<+------------------+\n| PK string name |lives_|    string street |\n+----------------+      |    string city   |\n                        +------------------+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/flowchart_tb_simple.txt",
    "content": "flowchart TB\n    A --> B\n    B --> C\n---\n+---+\n|   |\n| A |\n|   |\n+---+\n  |  \n  |  \n  |  \n  |  \n  v  \n+---+\n|   |\n| B |\n|   |\n+---+\n  |  \n  |  \n  |  \n  |  \n  v  \n+---+\n|   |\n| C |\n|   |\n+---+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/graph_bt_direction.txt",
    "content": "graph BT\n  A --> B --> C\n---\n+---+\n|   |\n| C |\n|   |\n+---+\n  ^  \n  |  \n  |  \n  |  \n  |  \n+---+\n|   |\n| B |\n|   |\n+---+\n  ^  \n  |  \n  |  \n  |  \n  |  \n+---+\n|   |\n| A |\n|   |\n+---+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/graph_tb_direction.txt",
    "content": "graph TB\nsubgraph one\n    A --> B\nend\n---\n+-------+\n|  one  |\n|       |\n|       |\n| +---+ |\n| |   | |\n| | A | |\n| |   | |\n| +---+ |\n|   |   |\n|   |   |\n|   |   |\n|   |   |\n|   v   |\n| +---+ |\n| |   | |\n| | B | |\n| |   | |\n| +---+ |\n|       |\n+-------+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/nested_subgraphs_with_labels.txt",
    "content": "graph TD\nA[Web Server] --> B[API Gateway]\nB --> C[Web Server]\nsubgraph Frontend\nA\nend\nsubgraph Backend\nB\nC\nend\n---\n+-------------+\n|             |\n|  Web Server |\n|             |\n+-------------+\n       |       \n       |       \n       |       \n       |       \n       v       \n+-------------+\n|             |\n| API Gateway |\n|             |\n+-------------+\n       |       \n       |       \n       |       \n       |       \n       v       \n+-------------+\n|             |\n|  Web Server |\n|             |\n+-------------+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/preserve_order_of_definition.txt",
    "content": "graph LR\nA\nB\nB --> A\nA --> A\nB --> C\nC --> A\n---\n+---+     +---+\n|   |     |   |\n| A |<-+--| C |\n|   |  |  |   |\n+---+  |  +---+\n  ^    |    ^  \n  |    |    |  \n  +----+    |  \n  |         |  \n  |         |  \n+---+       |  \n|   |       |  \n| B |-------+  \n|   |          \n+---+          \n"
  },
  {
    "path": "src/__tests__/testdata/ascii/self_reference.txt",
    "content": "graph LR\nA --> A\n---\n+---+  \n|   |  \n| A |-+\n|   | |\n+---+ |\n  ^   |\n  +---+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/self_reference_with_edge.txt",
    "content": "graph LR\nA --> A & B\n---\n+---+     +---+\n|   |     |   |\n| A |--+->| B |\n|   |  |  |   |\n+---+  |  +---+\n  ^    |       \n  +----+       \n"
  },
  {
    "path": "src/__tests__/testdata/ascii/seq_basic.txt",
    "content": "sequenceDiagram\n  Alice->>Bob: Hello Bob\n  Bob-->>Alice: Hi Alice\n---\n +-------+       +-----+   \n | Alice |       | Bob |   \n +-------+       +-----+   \n     |              |      \n     |  Hello Bob   |      \n     |-------------->      \n     |              |      \n     |  Hi Alice    |      \n     <..............|      \n     |              |      \n +-------+       +-----+   \n | Alice |       | Bob |   \n +-------+       +-----+   \n"
  },
  {
    "path": "src/__tests__/testdata/ascii/seq_multiple_messages.txt",
    "content": "sequenceDiagram\n  Alice->>Bob: Hello\n  Bob->>Charlie: Forward\n  Charlie-->>Bob: Reply\n  Bob-->>Alice: Done\n---\n +-------+   +-----+    +---------+   \n | Alice |   | Bob |    | Charlie |   \n +-------+   +-----+    +---------+   \n     |          |            |        \n     |  Hello   |            |        \n     |---------->            |        \n     |          |            |        \n     |          |  Forward   |        \n     |          |------------>        \n     |          |            |        \n     |          |   Reply    |        \n     |          <............|        \n     |          |            |        \n     |  Done    |            |        \n     <..........|            |        \n     |          |            |        \n +-------+   +-----+    +---------+   \n | Alice |   | Bob |    | Charlie |   \n +-------+   +-----+    +---------+   \n"
  },
  {
    "path": "src/__tests__/testdata/ascii/seq_self_message.txt",
    "content": "sequenceDiagram\n  Alice->>Alice: Think\n  Alice->>Bob: Result\n---\n +-------+    +-----+   \n | Alice |    | Bob |   \n +-------+    +-----+   \n     |           |      \n     +---+       |      \n     |   | Think |      \n     <---+       |      \n     |           |      \n     |  Result   |      \n     |----------->      \n     |           |      \n +-------+    +-----+   \n | Alice |    | Bob |   \n +-------+    +-----+   \n"
  },
  {
    "path": "src/__tests__/testdata/ascii/single_node.txt",
    "content": "graph LR\nA\n---\n+---+\n|   |\n| A |\n|   |\n+---+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/single_node_longer_name.txt",
    "content": "graph LR\nLongerName\n---\n+------------+\n|            |\n| LongerName |\n|            |\n+------------+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/subgraph_complex_mixed.txt",
    "content": "graph LR\nStart\nsubgraph Processing\n    A --> B\n    B --> C\nend\nsubgraph Storage\n    D\n    E\nend\nStart --> A\nC --> D\nC --> E\nD --> End\nE --> End\nEnd\n---\n            +---------------------------+ +-------+          \n            |        Processing         | |Storage|          \n            |                           | |       |          \n            |                           | |       |          \n+-------+   | +---+     +---+     +---+ | | +---+ |   +-----+\n|       |   | |   |     |   |     |   | | | |   | |   |     |\n| Start |---->| A |---->| B |---->| C |---->| D |---->| End |\n|       |   | |   |     |   |     |   | | | |   | |   |     |\n+-------+   | +---+     +---+     +---+ | | +---+ |   +-----+\n            |                       |   | |       |      ^   \n            +-----------------------|---+ |       |      |   \n                                    |     |       |      |   \n                                    |     |       |      |   \n                                    |     |       |      |   \n                                    |     | +---+ |      |   \n                                    |     | |   | |      |   \n                                    +------>| E |--------+   \n                                          | |   | |          \n                                          | +---+ |          \n                                          |       |          \n                                          +-------+          \n"
  },
  {
    "path": "src/__tests__/testdata/ascii/subgraph_complex_nested.txt",
    "content": "graph LR\nsubgraph outer\n    A\n    subgraph inner\n        B\n    end\n    C\nend\nD\n---\n+-----------+\n|   outer   |\n|           |\n|           |\n|   +---+   |\n|   |   |   |\n|   | A |   |\n|   |   |   |\n|   +---+   |\n|           |\n| +-------+ |\n| | inner | |\n| |       | |\n| |       | |\n| | +---+ | |\n| | |   | | |\n| | | B | | |\n| | |   | | |\n| | +---+ | |\n| |       | |\n| +-------+ |\n|           |\n|           |\n|           |\n|   +---+   |\n|   |   |   |\n|   | C |   |\n|   |   |   |\n|   +---+   |\n|           |\n+-----------+\n             \n             \n             \n    +---+    \n    |   |    \n    | D |    \n    |   |    \n    +---+    \n"
  },
  {
    "path": "src/__tests__/testdata/ascii/subgraph_direction_override.txt",
    "content": "graph TD\nsubgraph one [LR Group]\n    direction LR\n    A --> B\nend\nX --> A\nB --> Y\n---\n  +---+\n  |   |\n  | X |\n  |   |\n  +---+\n    |\n    |\n    |\n    |\n    |\n+---|-------------+\n|   |LR Group     |\n|   |             |\n|   v             |\n| +---+     +---+ |\n| |   |     |   | |\n| | A |---->| B | |\n| |   |     |   | |\n| +---+     +---+ |\n|             |   |\n+-------------|---+\n              |\n              |\n              |\n  +---+       |\n  |   |       |\n  | Y |<------+\n  |   |\n  +---+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/subgraph_empty.txt",
    "content": "graph LR\nsubgraph one\nend\nA --> B\n---\n+---+     +---+\n|   |     |   |\n| A |---->| B |\n|   |     |   |\n+---+     +---+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/subgraph_mixed_nodes.txt",
    "content": "graph LR\nX\nsubgraph one\n    A --> B\nend\nY\nX --> A\nB --> Y\n---\n        +-----------------+        \n        |       one       |        \n        |                 |        \n        |                 |        \n+---+   | +---+     +---+ |   +---+\n|   |   | |   |     |   | |   |   |\n| X |---->| A |---->| B |---->| Y |\n|   |   | |   |     |   | |   |   |\n+---+   | +---+     +---+ |   +---+\n        |                 |        \n        +-----------------+        \n"
  },
  {
    "path": "src/__tests__/testdata/ascii/subgraph_mixed_nodes_td.txt",
    "content": "graph TD\nX\nsubgraph one\n    A --> B\nend\nY\nX --> A\nB --> Y\n---\n  +---+  \n  |   |  \n  | X |  \n  |   |  \n  +---+  \n    |    \n    |    \n    |    \n    |    \n    |    \n+---|---+\n|  one  |\n|   |   |\n|   v   |\n| +---+ |\n| |   | |\n| | A | |\n| |   | |\n| +---+ |\n|   |   |\n|   |   |\n|   |   |\n|   |   |\n|   v   |\n| +---+ |\n| |   | |\n| | B | |\n| |   | |\n| +---+ |\n|   |   |\n+---|---+\n    |    \n    |    \n    v    \n  +---+  \n  |   |  \n  | Y |  \n  |   |  \n  +---+  \n"
  },
  {
    "path": "src/__tests__/testdata/ascii/subgraph_multiple_edges.txt",
    "content": "graph LR\nsubgraph one\n    A --> B\n    A --> C\nend\nsubgraph two\n    D --> E\nend\nB --> D\nC --> E\n---\n+-----------------+ +-------+\n|       one       | |  two  |\n|                 | |       |\n|                 | |       |\n| +---+     +---+ | | +---+ |\n| |   |     |   | | | |   | |\n| | A |---->| B |---->| D | |\n| |   |     |   | | | |   | |\n| +---+     +---+ | | +---+ |\n|   |             | |   |   |\n|   |             | |   |   |\n|   |             | |   |   |\n|   |             | |   |   |\n|   |             | |   v   |\n|   |       +---+ | | +---+ |\n|   |       |   | | | |   | |\n|   +------>| C |---->| E | |\n|           |   | | | |   | |\n|           +---+ | | +---+ |\n|                 | |       |\n+-----------------+ +-------+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/subgraph_multiple_nodes.txt",
    "content": "graph LR\nsubgraph one\n    A --> B\nend\n---\n+-----------------+\n|       one       |\n|                 |\n|                 |\n| +---+     +---+ |\n| |   |     |   | |\n| | A |---->| B | |\n| |   |     |   | |\n| +---+     +---+ |\n|                 |\n+-----------------+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/subgraph_nested.txt",
    "content": "graph LR\nsubgraph outer\n    subgraph inner\n        A\n    end\nend\n---\n+-----------+\n|   outer   |\n|           |\n|           |\n| +-------+ |\n| | inner | |\n| |       | |\n| |       | |\n| | +---+ | |\n| | |   | | |\n| | | A | | |\n| | |   | | |\n| | +---+ | |\n| |       | |\n| +-------+ |\n|           |\n+-----------+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/subgraph_nested_with_external.txt",
    "content": "graph LR\nX\nsubgraph outer\n    A\n    subgraph inner\n        B --> C\n    end\n    A --> B\nend\nX --> A\nC --> Y\nY\n---\n        +-----------------------------+      \n        |            outer            |      \n        |                             |      \n        |                             |      \n        |         +-----------------+ |      \n        |         |      inner      | |      \n        |         |                 | |      \n        |         |                 | |      \n+---+   | +---+   | +---+     +---+ | | +---+\n|   |   | |   |   | |   |     |   | | | |   |\n| X |---->| A |---->| B |---->| C |---->| Y |\n|   |   | |   |   | |   |     |   | | | |   |\n+---+   | +---+   | +---+     +---+ | | +---+\n        |         |                 | |      \n        |         +-----------------+ |      \n        |                             |      \n        +-----------------------------+      \n"
  },
  {
    "path": "src/__tests__/testdata/ascii/subgraph_node_outside_lr.txt",
    "content": "graph LR\nX\nsubgraph one\n    A --> B\nend\n---\n        +-----------------+\n        |       one       |\n        |                 |\n        |                 |\n+---+   | +---+     +---+ |\n|   |   | |   |     |   | |\n| X |   | | A |---->| B | |\n|   |   | |   |     |   | |\n+---+   | +---+     +---+ |\n        |                 |\n        +-----------------+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/subgraph_single_node.txt",
    "content": "graph LR\nsubgraph one\n    A\nend\n---\n+-------+\n|  one  |\n|       |\n|       |\n| +---+ |\n| |   | |\n| | A | |\n| |   | |\n| +---+ |\n|       |\n+-------+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/subgraph_td_direction.txt",
    "content": "graph TD\nsubgraph one\n    A --> B\nend\n---\n+-------+\n|  one  |\n|       |\n|       |\n| +---+ |\n| |   | |\n| | A | |\n| |   | |\n| +---+ |\n|   |   |\n|   |   |\n|   |   |\n|   |   |\n|   v   |\n| +---+ |\n| |   | |\n| | B | |\n| |   | |\n| +---+ |\n|       |\n+-------+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/subgraph_td_multiple.txt",
    "content": "graph TD\nsubgraph Frontend\n    UI --> API\nend\nsubgraph Backend\n    API --> Database\n    API --> Cache\nend\n---\n+--------------+              \n|  Frontend    |              \n|              |              \n|              |              \n| +----------+ |              \n| |          | |              \n| |    UI    | |              \n| |          | |              \n| +----------+ |              \n|       |      |              \n|       |      |              \n|       |      |              \n|       |      |              \n|       v      |              \n| +----------+ |              \n| |          | |              \n| |   API    |---------+      \n| |          | |       |      \n| +----------+ |       |      \n|       |      |       |      \n+-------|------+       |      \n        |              |      \n        |              |      \n        |              |      \n+-------|--------------|-----+\n|       |  Backend     |     |\n|       |              |     |\n|       v              v     |\n| +----------+     +-------+ |\n| |          |     |       | |\n| | Database |     | Cache | |\n| |          |     |       | |\n| +----------+     +-------+ |\n|                            |\n+----------------------------+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/subgraph_td_multiple_paddingy.txt",
    "content": "paddingX=3\npaddingY=3\ngraph TD\nsubgraph Frontend\n    UI --> API\nend\nsubgraph Backend\n    API --> Database\n    API --> Cache\nend\n---\n+--------------+            \n|  Frontend    |            \n|              |            \n|              |            \n| +----------+ |            \n| |          | |            \n| |    UI    | |            \n| |          | |            \n| +----------+ |            \n|       |      |            \n|       |      |            \n|       v      |            \n| +----------+ |            \n| |          | |            \n| |   API    |-------+      \n| |          | |     |      \n| +----------+ |     |      \n|       |      |     |      \n+-------|------+     |      \n        |            |      \n+-------|------------|-----+\n|       | Backend    |     |\n|       |            |     |\n|       v            v     |\n| +----------+   +-------+ |\n| |          |   |       | |\n| | Database |   | Cache | |\n| |          |   |       | |\n| +----------+   +-------+ |\n|                          |\n+--------------------------+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/subgraph_three_levels_nested.txt",
    "content": "graph LR\nsubgraph level1\n    subgraph level2\n        subgraph level3\n            A\n        end\n    end\nend\n---\n+---------------+\n|    level1     |\n|               |\n|               |\n| +-----------+ |\n| |  level2   | |\n| |           | |\n| |           | |\n| | +-------+ | |\n| | |level3 | | |\n| | |       | | |\n| | |       | | |\n| | | +---+ | | |\n| | | |   | | | |\n| | | | A | | | |\n| | | |   | | | |\n| | | +---+ | | |\n| | |       | | |\n| | +-------+ | |\n| |           | |\n| +-----------+ |\n|               |\n+---------------+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/subgraph_three_separate.txt",
    "content": "graph LR\nsubgraph Frontend\n    UI\nend\nsubgraph Backend\n    API\nend\nsubgraph Database\n    DB\nend\nUI --> API\nAPI --> DB\n---\n+--------+ +---------+ +--------+\n|Frontend| | Backend | |Database|\n|        | |         | |        |\n|        | |         | |        |\n| +----+ | | +-----+ | | +----+ |\n| |    | | | |     | | | |    | |\n| | UI |---->| API |---->| DB | |\n| |    | | | |     | | | |    | |\n| +----+ | | +-----+ | | +----+ |\n|        | |         | |        |\n+--------+ +---------+ +--------+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/subgraph_two_separate.txt",
    "content": "graph LR\nsubgraph one\n    A\nend\nsubgraph two\n    B\nend\nA --> B\n---\n+-------+ +-------+\n|  one  | |  two  |\n|       | |       |\n|       | |       |\n| +---+ | | +---+ |\n| |   | | | |   | |\n| | A |---->| B | |\n| |   | | | |   | |\n| +---+ | | +---+ |\n|       | |       |\n+-------+ +-------+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/subgraph_with_labels.txt",
    "content": "graph LR\nsubgraph one\n    A -->|sends| B\nend\nsubgraph two\n    C -->|receives| D\nend\nB -->|data| C\n---\n+-------------------+  +----------------------+\n|        one        |  |         two          |\n|                   |  |                      |\n|                   |  |                      |\n| +---+       +---+ |  | +---+          +---+ |\n| |   |       |   | |  | |   |          |   | |\n| | A |-sends>| B |data->| C |receives->| D | |\n| |   |       |   | |  | |   |          |   | |\n| +---+       +---+ |  | +---+          +---+ |\n|                   |  |                      |\n+-------------------+  +----------------------+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/three_nodes.txt",
    "content": "graph LR\nA --> B\nB --> C\n---\n+---+     +---+     +---+\n|   |     |   |     |   |\n| A |---->| B |---->| C |\n|   |     |   |     |   |\n+---+     +---+     +---+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/three_nodes_single_line.txt",
    "content": "graph LR\nA --> B --> C\n---\n+---+     +---+     +---+\n|   |     |   |     |   |\n| A |---->| B |---->| C |\n|   |     |   |     |   |\n+---+     +---+     +---+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/two_layer_single_graph.txt",
    "content": "graph LR\nA --> B\nA --> C\n---\n+---+     +---+\n|   |     |   |\n| A |---->| B |\n|   |     |   |\n+---+     +---+\n  |            \n  |            \n  |            \n  |            \n  |            \n  |       +---+\n  |       |   |\n  +------>| C |\n          |   |\n          +---+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/two_layer_single_graph_longer_names.txt",
    "content": "graph LR\nABC --> BCDEFG\nABC --> CDEFGHI\n---\n+-----+     +---------+\n|     |     |         |\n| ABC |---->|  BCDEFG |\n|     |     |         |\n+-----+     +---------+\n   |                   \n   |                   \n   |                   \n   |                   \n   |                   \n   |        +---------+\n   |        |         |\n   +------->| CDEFGHI |\n            |         |\n            +---------+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/two_nodes_linked.txt",
    "content": "graph LR\nA --> B\n---\n+---+     +---+\n|   |     |   |\n| A |---->| B |\n|   |     |   |\n+---+     +---+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/two_nodes_longer_names.txt",
    "content": "graph LR\nLongerName1 --> LongerName2\n---\n+-------------+     +-------------+\n|             |     |             |\n| LongerName1 |---->| LongerName2 |\n|             |     |             |\n+-------------+     +-------------+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/two_root_nodes.txt",
    "content": "graph LR\nA --> B\nC --> D\n---\n+---+     +---+\n|   |     |   |\n| A |---->| B |\n|   |     |   |\n+---+     +---+\n               \n               \n               \n               \n               \n+---+     +---+\n|   |     |   |\n| C |---->| D |\n|   |     |   |\n+---+     +---+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/two_root_nodes_longer_names.txt",
    "content": "graph LR\nABC --> BCDEFG\nCDEFGH --> DEF\n---\n+--------+     +--------+\n|        |     |        |\n|  ABC   |---->| BCDEFG |\n|        |     |        |\n+--------+     +--------+\n                         \n                         \n                         \n                         \n                         \n+--------+     +--------+\n|        |     |        |\n| CDEFGH |---->|  DEF   |\n|        |     |        |\n+--------+     +--------+\n"
  },
  {
    "path": "src/__tests__/testdata/ascii/two_single_root_nodes.txt",
    "content": "graph LR\nA\nB\n---\n+---+\n|   |\n| A |\n|   |\n+---+\n     \n     \n     \n     \n     \n+---+\n|   |\n| B |\n|   |\n+---+\n"
  },
  {
    "path": "src/__tests__/testdata/unicode/ampersand_lhs.txt",
    "content": "graph LR\nA --> B & C\n---\n┌───┐     ┌───┐\n│   │     │   │\n│ A ├────►│ B │\n│   │     │   │\n└─┬─┘     └───┘\n  │            \n  │            \n  │            \n  │            \n  │            \n  │       ┌───┐\n  │       │   │\n  └──────►│ C │\n          │   │\n          └───┘\n"
  },
  {
    "path": "src/__tests__/testdata/unicode/ampersand_lhs_and_rhs.txt",
    "content": "graph LR\nA & B --> C & D\n---\n┌───┐     ┌───┐\n│   │     │   │\n│ A ├──┬─►│ C │\n│   │  │  │   │\n└─┬─┘  │  └───┘\n  │    │       \n  │    │       \n  ├────┤       \n  │    │       \n  │    │       \n┌─┴─┐  │  ┌───┐\n│   │  │  │   │\n│ B ├──┴─►│ D │\n│   │     │   │\n└───┘     └───┘\n"
  },
  {
    "path": "src/__tests__/testdata/unicode/ampersand_rhs.txt",
    "content": "graph LR\nA & B --> C\n---\n┌───┐     ┌───┐\n│   │     │   │\n│ A ├────►│ C │\n│   │     │   │\n└───┘     └───┘\n            ▲  \n            │  \n            │  \n            │  \n            │  \n┌───┐       │  \n│   │       │  \n│ B ├───────┘  \n│   │          \n└───┘          \n"
  },
  {
    "path": "src/__tests__/testdata/unicode/ampersand_without_edge.txt",
    "content": "graph LR\nA & B\n---\n┌───┐\n│   │\n│ A │\n│   │\n└───┘\n     \n     \n     \n     \n     \n┌───┐\n│   │\n│ B │\n│   │\n└───┘\n"
  },
  {
    "path": "src/__tests__/testdata/unicode/back_reference_from_child.txt",
    "content": "graph LR\nA --> B --> C --> A\n---\n┌───┐     ┌───┐     ┌───┐\n│   │     │   │     │   │\n│ A ├────►│ B ├────►│ C │\n│   │     │   │     │   │\n└───┘     └───┘     └─┬─┘\n  ▲                   │  \n  └───────────────────┘  \n"
  },
  {
    "path": "src/__tests__/testdata/unicode/backlink_from_bottom.txt",
    "content": "graph LR\nA --> B\nB --> C\nA --> C\nB --> D\nC --> D\n---\n┌───┐     ┌───┐     ┌───┐\n│   │     │   │     │   │\n│ A ├────►│ B ├────►│ D │\n│   │     │   │     │   │\n└─┬─┘     └─┬─┘     └───┘\n  │         │         ▲  \n  │         │         │  \n  │         │         │  \n  │         │         │  \n  │         ▼         │  \n  │       ┌───┐       │  \n  │       │   │       │  \n  └──────►│ C ├───────┘  \n          │   │          \n          └───┘          \n"
  },
  {
    "path": "src/__tests__/testdata/unicode/backlink_from_top.txt",
    "content": "graph LR\nA --> B\nB --> C\nA --> C\nB --> D\nD --> C\n---\n┌───┐     ┌───┐     ┌───┐\n│   │     │   │     │   │\n│ A ├────►│ B ├──┬─►┤ D │\n│   │     │   │  │  │   │\n└─┬─┘     └─┬─┘  │  └───┘\n  │         │    │       \n  │         │    │       \n  │         ├────┘       \n  │         │            \n  │         ▼            \n  │       ┌───┐          \n  │       │   │          \n  └──────►│ C │          \n          │   │          \n          └───┘          \n"
  },
  {
    "path": "src/__tests__/testdata/unicode/cls_all_relationships.txt",
    "content": "classDiagram\n  A <|-- B : inherits\n  C *-- D : owns\n  E o-- F : has\n  G --> H : uses\n  I ..> J : depends\n  K ..|> L : implements\n---\n┌───┐    ┌───┐    ┌───┐    ┌───┐    ┌───┐    ┌───┐\n│ A │    │ C │    │ E │    │ G │    │ I │    │ L │\n└───┘    └───┘    └───┘    └───┘    └───┘    └───┘\n  △        ◆        ◇        │        ┊        △\n inherit owns      has     uses    depend implements\n  │        │        │        ▼        ▼        ┊\n┌───┐    ┌───┐    ┌───┐    ┌───┐    ┌───┐    ┌───┐\n│ B │    │ D │    │ F │    │ H │    │ J │    │ K │\n└───┘    └───┘    └───┘    └───┘    └───┘    └───┘\n"
  },
  {
    "path": "src/__tests__/testdata/unicode/cls_annotation.txt",
    "content": "classDiagram\n  class Shape {\n    <<abstract>>\n    +draw() void\n  }\n  class Circle {\n    +radius int\n    +draw() void\n  }\n  Shape <|-- Circle\n---\n┌──────────────┐    \n│ <<abstract>> │    \n│ Shape        │    \n├──────────────┤    \n│              │    \n├──────────────┤    \n│ +draw: void  │    \n└──────────────┘    \n        △           \n        │           \n        │           \n┌──────────────┐    \n│ Circle       │    \n├──────────────┤    \n│ +int: radius │    \n├──────────────┤    \n│ +draw: void  │    \n└──────────────┘    \n                    \n                    \n"
  },
  {
    "path": "src/__tests__/testdata/unicode/cls_association.txt",
    "content": "classDiagram\n  Student --> Course : enrolls\n---\n┌─────────┐    \n│ Student │    \n└─────────┘    \n     │         \n  enrolls      \n     ▼         \n┌────────┐     \n│ Course │     \n└────────┘     \n               \n               \n"
  },
  {
    "path": "src/__tests__/testdata/unicode/cls_basic.txt",
    "content": "classDiagram\n  class Animal {\n    +String name\n    +eat() void\n  }\n---\n┌───────────────┐    \n│ Animal        │    \n├───────────────┤    \n│ +name: String │    \n├───────────────┤    \n│ +eat: void    │    \n└───────────────┘    \n                     \n                     \n"
  },
  {
    "path": "src/__tests__/testdata/unicode/cls_dependency.txt",
    "content": "classDiagram\n  Client ..> Server : uses\n---\n┌────────┐    \n│ Client │    \n└────────┘    \n     ┊        \n   uses       \n     ▼        \n┌────────┐    \n│ Server │    \n└────────┘    \n              \n              \n"
  },
  {
    "path": "src/__tests__/testdata/unicode/cls_inheritance.txt",
    "content": "classDiagram\n  Animal <|-- Dog\n  Animal : +String name\n  Dog : +bark() void\n---\n┌───────────────┐    \n│ Animal        │    \n├───────────────┤    \n│ +name: String │    \n└───────────────┘    \n        △            \n       ┌┘            \n       │             \n┌─────────────┐      \n│ Dog         │      \n├─────────────┤      \n│             │      \n├─────────────┤      \n│ +bark: void │      \n└─────────────┘      \n                     \n                     \n"
  },
  {
    "path": "src/__tests__/testdata/unicode/cls_methods.txt",
    "content": "classDiagram\n  class BankAccount {\n    +String owner\n    -int balance\n    +deposit(int amount) void\n    +withdraw(int amount) bool\n    -validate() bool\n  }\n---\n┌─────────────────┐    \n│ BankAccount     │    \n├─────────────────┤    \n│ +owner: String  │    \n│ -balance: int   │    \n├─────────────────┤    \n│ +deposit: void  │    \n│ +withdraw: bool │    \n│ -validate: bool │    \n└─────────────────┘    \n                       \n                       \n"
  },
  {
    "path": "src/__tests__/testdata/unicode/comments.txt",
    "content": "graph LR\n%% This is a comment\nA --> B\n%% Another comment\nB --> C\nA --> C\n%% Final comment\n---\n┌───┐     ┌───┐\n│   │     │   │\n│ A ├────►│ B │\n│   │     │   │\n└─┬─┘     └─┬─┘\n  │         │  \n  │         │  \n  │         │  \n  │         │  \n  │         ▼  \n  │       ┌───┐\n  │       │   │\n  └──────►│ C │\n          │   │\n          └───┘\n"
  },
  {
    "path": "src/__tests__/testdata/unicode/duplicate_labels.txt",
    "content": "graph TD\nA[Server] --> B[Client]\nC[Server] --> D[Client]\n---\n┌────────┐     ┌────────┐\n│        │     │        │\n│ Server │     │ Server │\n│        │     │        │\n└────┬───┘     └────┬───┘\n     │              │    \n     │              │    \n     │              │    \n     │              │    \n     ▼              ▼    \n┌────────┐     ┌────────┐\n│        │     │        │\n│ Client │     │ Client │\n│        │     │        │\n└────────┘     └────────┘\n"
  },
  {
    "path": "src/__tests__/testdata/unicode/er_attributes.txt",
    "content": "erDiagram\n  CUSTOMER {\n    string name PK\n    string email UK\n    int age\n  }\n  ORDER {\n    int id PK\n    string status\n  }\n  CUSTOMER ||--o{ ORDER : places\n---\n┌─────────────────┐      ┌──────────────────┐\n│ CUSTOMER        │      │ ORDER            │\n├─────────────────┤      ├──────────────────┤\n│ PK string name  ││───○╟│ PK int id        │\n│ UK string email │places│    string status │\n│    int age      │      └──────────────────┘\n└─────────────────┘\n"
  },
  {
    "path": "src/__tests__/testdata/unicode/er_basic.txt",
    "content": "erDiagram\n  CUSTOMER ||--o{ ORDER : places\n---\n┌──────────┐      ┌───────┐\n│ CUSTOMER ││───○╟│ ORDER │\n└──────────┘places└───────┘\n\n\n"
  },
  {
    "path": "src/__tests__/testdata/unicode/er_identifying.txt",
    "content": "erDiagram\n  PERSON ||--o{ ADDRESS : lives_at\n  PERSON {\n    string name PK\n  }\n  ADDRESS {\n    string street\n    string city\n  }\n---\n┌────────────────┐      ┌──────────────────┐\n│ PERSON         │      │ ADDRESS          │\n├────────────────┤│───○╟├──────────────────┤\n│ PK string name │lives_│    string street │\n└────────────────┘      │    string city   │\n                        └──────────────────┘\n"
  },
  {
    "path": "src/__tests__/testdata/unicode/graph_bt_direction.txt",
    "content": "graph BT\n  A --> B --> C\n---\n┌───┐\n│   │\n│ C │\n│   │\n└───┘\n  ▲  \n  │  \n  │  \n  │  \n  │  \n┌─┴─┐\n│   │\n│ B │\n│   │\n└───┘\n  ▲  \n  │  \n  │  \n  │  \n  │  \n┌─┴─┐\n│   │\n│ A │\n│   │\n└───┘\n"
  },
  {
    "path": "src/__tests__/testdata/unicode/preserve_order_of_definition.txt",
    "content": "graph LR\nA\nB\nB --> A\nA --> A\nB --> C\nC --> A\n---\n┌───┐     ┌───┐\n│   │     │   │\n│ A ├◄─┬──┤ C │\n│   │  │  │   │\n└───┘  │  └───┘\n  ▲    │    ▲  \n  │    │    │  \n  ├────┘    │  \n  │         │  \n  │         │  \n┌─┴─┐       │  \n│   │       │  \n│ B ├───────┘  \n│   │          \n└───┘          \n"
  },
  {
    "path": "src/__tests__/testdata/unicode/self_reference.txt",
    "content": "graph LR\nA --> A\n---\n┌───┐  \n│   │  \n│ A ├─┐\n│   │ │\n└───┘ │\n  ▲   │\n  └───┘\n"
  },
  {
    "path": "src/__tests__/testdata/unicode/self_reference_with_edge.txt",
    "content": "graph LR\nA --> A & B\n---\n┌───┐     ┌───┐\n│   │     │   │\n│ A ├──┬─►│ B │\n│   │  │  │   │\n└───┘  │  └───┘\n  ▲    │       \n  └────┘       \n"
  },
  {
    "path": "src/__tests__/testdata/unicode/seq_basic.txt",
    "content": "sequenceDiagram\n  Alice->>Bob: Hello Bob\n  Bob-->>Alice: Hi Alice\n---\n ┌───────┐       ┌─────┐   \n │ Alice │       │ Bob │   \n └───┬───┘       └──┬──┘   \n     │              │      \n     │  Hello Bob   │      \n     │──────────────▶      \n     │              │      \n     │  Hi Alice    │      \n     ◀╌╌╌╌╌╌╌╌╌╌╌╌╌╌│      \n     │              │      \n ┌───┴───┐       ┌──┴──┐   \n │ Alice │       │ Bob │   \n └───────┘       └─────┘   \n"
  },
  {
    "path": "src/__tests__/testdata/unicode/seq_multiple_messages.txt",
    "content": "sequenceDiagram\n  Alice->>Bob: Hello\n  Bob->>Charlie: Forward\n  Charlie-->>Bob: Reply\n  Bob-->>Alice: Done\n---\n ┌───────┐   ┌─────┐    ┌─────────┐   \n │ Alice │   │ Bob │    │ Charlie │   \n └───┬───┘   └──┬──┘    └────┬────┘   \n     │          │            │        \n     │  Hello   │            │        \n     │──────────▶            │        \n     │          │            │        \n     │          │  Forward   │        \n     │          │────────────▶        \n     │          │            │        \n     │          │   Reply    │        \n     │          ◀╌╌╌╌╌╌╌╌╌╌╌╌│        \n     │          │            │        \n     │  Done    │            │        \n     ◀╌╌╌╌╌╌╌╌╌╌│            │        \n     │          │            │        \n ┌───┴───┐   ┌──┴──┐    ┌────┴────┐   \n │ Alice │   │ Bob │    │ Charlie │   \n └───────┘   └─────┘    └─────────┘   \n"
  },
  {
    "path": "src/__tests__/testdata/unicode/seq_self_message.txt",
    "content": "sequenceDiagram\n  Alice->>Alice: Think\n  Alice->>Bob: Result\n---\n ┌───────┐    ┌─────┐   \n │ Alice │    │ Bob │   \n └───┬───┘    └──┬──┘   \n     │           │      \n     ├───┐       │      \n     │   │ Think │      \n     ◀───┘       │      \n     │           │      \n     │  Result   │      \n     │───────────▶      \n     │           │      \n ┌───┴───┐    ┌──┴──┐   \n │ Alice │    │ Bob │   \n └───────┘    └─────┘   \n"
  },
  {
    "path": "src/__tests__/testdata/unicode/single_node.txt",
    "content": "graph LR\nA\n---\n┌───┐\n│   │\n│ A │\n│   │\n└───┘\n"
  },
  {
    "path": "src/__tests__/testdata/unicode/single_node_longer_name.txt",
    "content": "graph LR\nLongerName\n---\n┌────────────┐\n│            │\n│ LongerName │\n│            │\n└────────────┘\n"
  },
  {
    "path": "src/__tests__/testdata/unicode/three_nodes.txt",
    "content": "graph LR\nA --> B\nB --> C\n---\n┌───┐     ┌───┐     ┌───┐\n│   │     │   │     │   │\n│ A ├────►│ B ├────►│ C │\n│   │     │   │     │   │\n└───┘     └───┘     └───┘\n"
  },
  {
    "path": "src/__tests__/testdata/unicode/three_nodes_single_line.txt",
    "content": "graph LR\nA --> B --> C\n---\n┌───┐     ┌───┐     ┌───┐\n│   │     │   │     │   │\n│ A ├────►│ B ├────►│ C │\n│   │     │   │     │   │\n└───┘     └───┘     └───┘\n"
  },
  {
    "path": "src/__tests__/testdata/unicode/two_layer_single_graph.txt",
    "content": "graph LR\nA --> B\nA --> C\n---\n┌───┐     ┌───┐\n│   │     │   │\n│ A ├────►│ B │\n│   │     │   │\n└─┬─┘     └───┘\n  │            \n  │            \n  │            \n  │            \n  │            \n  │       ┌───┐\n  │       │   │\n  └──────►│ C │\n          │   │\n          └───┘\n"
  },
  {
    "path": "src/__tests__/testdata/unicode/two_layer_single_graph_longer_names.txt",
    "content": "graph LR\nABC --> BCDEFG\nABC --> CDEFGHI\n---\n┌─────┐     ┌─────────┐\n│     │     │         │\n│ ABC ├────►│  BCDEFG │\n│     │     │         │\n└──┬──┘     └─────────┘\n   │                   \n   │                   \n   │                   \n   │                   \n   │                   \n   │        ┌─────────┐\n   │        │         │\n   └───────►│ CDEFGHI │\n            │         │\n            └─────────┘\n"
  },
  {
    "path": "src/__tests__/testdata/unicode/two_nodes_linked.txt",
    "content": "graph LR\nA --> B\n---\n┌───┐     ┌───┐\n│   │     │   │\n│ A ├────►│ B │\n│   │     │   │\n└───┘     └───┘\n"
  },
  {
    "path": "src/__tests__/testdata/unicode/two_nodes_longer_names.txt",
    "content": "graph LR\nLongerName1 --> LongerName2\n---\n┌─────────────┐     ┌─────────────┐\n│             │     │             │\n│ LongerName1 ├────►│ LongerName2 │\n│             │     │             │\n└─────────────┘     └─────────────┘\n"
  },
  {
    "path": "src/__tests__/testdata/unicode/two_root_nodes.txt",
    "content": "graph LR\nA --> B\nC --> D\n---\n┌───┐     ┌───┐\n│   │     │   │\n│ A ├────►│ B │\n│   │     │   │\n└───┘     └───┘\n               \n               \n               \n               \n               \n┌───┐     ┌───┐\n│   │     │   │\n│ C ├────►│ D │\n│   │     │   │\n└───┘     └───┘\n"
  },
  {
    "path": "src/__tests__/testdata/unicode/two_root_nodes_longer_names.txt",
    "content": "graph LR\nABC --> BCDEFG\nCDEFGH --> DEF\n---\n┌────────┐     ┌────────┐\n│        │     │        │\n│  ABC   ├────►│ BCDEFG │\n│        │     │        │\n└────────┘     └────────┘\n                         \n                         \n                         \n                         \n                         \n┌────────┐     ┌────────┐\n│        │     │        │\n│ CDEFGH ├────►│  DEF   │\n│        │     │        │\n└────────┘     └────────┘\n"
  },
  {
    "path": "src/__tests__/testdata/unicode/two_single_root_nodes.txt",
    "content": "graph LR\nA\nB\n---\n┌───┐\n│   │\n│ A │\n│   │\n└───┘\n     \n     \n     \n     \n     \n┌───┐\n│   │\n│ B │\n│   │\n└───┘\n"
  },
  {
    "path": "src/__tests__/text-metrics.test.ts",
    "content": "/**\n * Tests for text-metrics module — variable-width character measurement.\n */\nimport { describe, it, expect } from 'bun:test'\nimport { getCharWidth, measureTextWidth } from '../text-metrics'\n\n// ============================================================================\n// Character width classification\n// ============================================================================\n\ndescribe('getCharWidth', () => {\n  describe('narrow characters', () => {\n    it('returns 0.4 for thin letters (i, l, t, f, j, I)', () => {\n      expect(getCharWidth('i')).toBe(0.4)\n      expect(getCharWidth('l')).toBe(0.4)\n      expect(getCharWidth('t')).toBe(0.4)\n      expect(getCharWidth('f')).toBe(0.4)\n      expect(getCharWidth('j')).toBe(0.4)\n      expect(getCharWidth('I')).toBe(0.4)\n    })\n\n    it('returns 0.4 for thin punctuation', () => {\n      expect(getCharWidth('!')).toBe(0.4)\n      expect(getCharWidth('|')).toBe(0.4)\n      expect(getCharWidth('.')).toBe(0.4)\n      expect(getCharWidth(',')).toBe(0.4)\n      expect(getCharWidth(':')).toBe(0.4)\n      expect(getCharWidth(';')).toBe(0.4)\n      expect(getCharWidth(\"'\")).toBe(0.4)\n      expect(getCharWidth('1')).toBe(0.4)\n    })\n\n    it('returns 0.8 for semi-narrow r', () => {\n      expect(getCharWidth('r')).toBe(0.8)\n    })\n  })\n\n  describe('normal characters', () => {\n    it('returns 1.0 for average lowercase letters', () => {\n      expect(getCharWidth('a')).toBe(1.0)\n      expect(getCharWidth('e')).toBe(1.0)\n      expect(getCharWidth('o')).toBe(1.0)\n      expect(getCharWidth('n')).toBe(1.0)\n      expect(getCharWidth('s')).toBe(1.0)\n    })\n\n    it('returns 1.0 for digits', () => {\n      expect(getCharWidth('0')).toBe(1.0)\n      expect(getCharWidth('2')).toBe(1.0)\n      expect(getCharWidth('9')).toBe(1.0)\n    })\n  })\n\n  describe('wide characters', () => {\n    it('returns 1.2 for uppercase letters (except I)', () => {\n      expect(getCharWidth('A')).toBe(1.2)\n      expect(getCharWidth('B')).toBe(1.2)\n      expect(getCharWidth('N')).toBe(1.2)\n      expect(getCharWidth('Z')).toBe(1.2)\n    })\n\n    it('returns 1.2 for wide lowercase (w, m)', () => {\n      expect(getCharWidth('w')).toBe(1.2)\n      expect(getCharWidth('m')).toBe(1.2)\n    })\n\n    it('returns 1.5 for very wide characters (W, M)', () => {\n      expect(getCharWidth('W')).toBe(1.5)\n      expect(getCharWidth('M')).toBe(1.5)\n    })\n  })\n\n  describe('space', () => {\n    it('returns 0.3 for space character', () => {\n      expect(getCharWidth(' ')).toBe(0.3)\n    })\n  })\n\n  describe('combining marks (zero-width)', () => {\n    it('returns 0 for combining diacritical marks', () => {\n      // U+0301 = combining acute accent\n      expect(getCharWidth('\\u0301')).toBe(0)\n      // U+0308 = combining diaeresis\n      expect(getCharWidth('\\u0308')).toBe(0)\n      // U+0327 = combining cedilla\n      expect(getCharWidth('\\u0327')).toBe(0)\n    })\n  })\n\n  describe('accented characters (precomposed)', () => {\n    it('returns normal width for precomposed accented letters', () => {\n      // These are single code points, treated as normal letters\n      expect(getCharWidth('é')).toBe(1.0) // U+00E9\n      expect(getCharWidth('ñ')).toBe(1.0) // U+00F1\n      expect(getCharWidth('ü')).toBe(1.0) // U+00FC\n      expect(getCharWidth('ç')).toBe(1.0) // U+00E7\n      expect(getCharWidth('ö')).toBe(1.0) // U+00F6\n    })\n  })\n\n  describe('CJK characters (fullwidth)', () => {\n    it('returns 2.0 for CJK ideographs', () => {\n      expect(getCharWidth('中')).toBe(2.0) // U+4E2D\n      expect(getCharWidth('国')).toBe(2.0) // U+56FD\n      expect(getCharWidth('字')).toBe(2.0) // U+5B57\n    })\n\n    it('returns 2.0 for Japanese hiragana/katakana', () => {\n      expect(getCharWidth('あ')).toBe(2.0) // Hiragana\n      expect(getCharWidth('ア')).toBe(2.0) // Katakana\n    })\n\n    it('returns 2.0 for Korean hangul', () => {\n      expect(getCharWidth('한')).toBe(2.0) // U+D55C\n      expect(getCharWidth('글')).toBe(2.0) // U+AE00\n    })\n  })\n\n  describe('emoji (fullwidth)', () => {\n    it('returns 2.0 for common emoji', () => {\n      expect(getCharWidth('😀')).toBe(2.0)\n      expect(getCharWidth('🚀')).toBe(2.0)\n      expect(getCharWidth('❤')).toBe(2.0)\n    })\n  })\n\n  describe('edge cases', () => {\n    it('returns 0 for empty string', () => {\n      expect(getCharWidth('')).toBe(0)\n    })\n  })\n})\n\n// ============================================================================\n// Text width measurement\n// ============================================================================\n\ndescribe('measureTextWidth', () => {\n  const fontSize = 13\n  const fontWeight = 500\n  const baseRatio = 0.57 // weight 500 (was 0.55, increased for edge truncation safety)\n  const minPadding = fontSize * 0.15 // minimum padding added to prevent truncation (increased for label separation)\n\n  it('returns minPadding for empty text', () => {\n    // Empty text still gets minimum padding to prevent edge truncation\n    expect(measureTextWidth('', fontSize, fontWeight)).toBeCloseTo(minPadding, 1)\n  })\n\n  it('handles lowercase text with narrow letters', () => {\n    // \"hello\" = h(1.0) + e(1.0) + l(0.4) + l(0.4) + o(1.0) = 3.8\n    const width = measureTextWidth('hello', fontSize, fontWeight)\n    expect(width).toBeCloseTo(3.8 * fontSize * baseRatio + minPadding, 1)\n  })\n\n  it('narrow text is narrower than uniform estimate', () => {\n    // \"illiterate\" has many narrow chars (i, l, t)\n    const narrow = measureTextWidth('illicit', fontSize, fontWeight)\n    const uniform = 'illicit'.length * fontSize * baseRatio\n    expect(narrow).toBeLessThan(uniform)\n  })\n\n  it('wide text is wider than uniform estimate', () => {\n    // \"MAMMOTH\" has wide chars (M, A, O)\n    const wide = measureTextWidth('MAMMOTH', fontSize, fontWeight)\n    const uniform = 'MAMMOTH'.length * fontSize * baseRatio\n    expect(wide).toBeGreaterThan(uniform)\n  })\n\n  it('handles mixed Latin text', () => {\n    // \"Will\" = W(1.5) + i(0.4) + l(0.4) + l(0.4) = 2.7\n    const width = measureTextWidth('Will', fontSize, fontWeight)\n    expect(width).toBeCloseTo(2.7 * fontSize * baseRatio + minPadding, 1)\n  })\n\n  it('handles spaces correctly', () => {\n    // \"a b\" = a(1.0) + space(0.3) + b(1.0) = 2.3\n    const width = measureTextWidth('a b', fontSize, fontWeight)\n    expect(width).toBeCloseTo(2.3 * fontSize * baseRatio + minPadding, 1)\n  })\n\n  it('handles decomposed accents (base + combining mark)', () => {\n    // \"café\" with decomposed é = c + a + f + e + combining accent\n    // Should be same width as \"cafe\" since combining mark is zero-width\n    const decomposed = 'cafe\\u0301' // e + combining acute\n    const precomposed = 'café'\n    const widthDecomposed = measureTextWidth(decomposed, fontSize, fontWeight)\n    const widthPrecomposed = measureTextWidth(precomposed, fontSize, fontWeight)\n    expect(widthDecomposed).toBeCloseTo(widthPrecomposed, 1)\n  })\n\n  it('handles CJK text', () => {\n    // \"中国\" = 2 chars × 2.0 width = 4.0\n    const width = measureTextWidth('中国', fontSize, fontWeight)\n    expect(width).toBeCloseTo(4.0 * fontSize * baseRatio + minPadding, 1)\n  })\n\n  it('handles mixed Latin and CJK', () => {\n    // \"Hello中国\" = H(1.2) + e(1.0) + l(0.4) + l(0.4) + o(1.0) + 中(2.0) + 国(2.0) = 8.0\n    const width = measureTextWidth('Hello中国', fontSize, fontWeight)\n    expect(width).toBeCloseTo(8.0 * fontSize * baseRatio + minPadding, 1)\n  })\n\n  it('heavier weights produce wider estimates', () => {\n    const regular = measureTextWidth('Test', fontSize, 400)\n    const medium = measureTextWidth('Test', fontSize, 500)\n    const bold = measureTextWidth('Test', fontSize, 600)\n\n    expect(medium).toBeGreaterThan(regular)\n    expect(bold).toBeGreaterThan(medium)\n  })\n\n  it('scales with font size', () => {\n    const small = measureTextWidth('Test', 11, fontWeight)\n    const large = measureTextWidth('Test', 16, fontWeight)\n\n    expect(large).toBeGreaterThan(small)\n    expect(large / small).toBeCloseTo(16 / 11, 1)\n  })\n})\n\n// ============================================================================\n// Real-world examples\n// ============================================================================\n\ndescribe('real-world text examples', () => {\n  const fontSize = 13\n  const fontWeight = 500\n\n  it('handles typical node labels', () => {\n    const labels = ['User', 'Database', 'API Gateway', 'Load Balancer']\n    for (const label of labels) {\n      const width = measureTextWidth(label, fontSize, fontWeight)\n      expect(width).toBeGreaterThan(0)\n      // Width should be reasonable (not too small or too large)\n      expect(width).toBeGreaterThan(label.length * 3)\n      expect(width).toBeLessThan(label.length * 15)\n    }\n  })\n\n  it('handles Japanese labels', () => {\n    const width = measureTextWidth('データベース', fontSize, fontWeight)\n    // 6 CJK chars × 2.0 × 13 × 0.57 + minPadding\n    const baseRatio = 0.57\n    const minPadding = fontSize * 0.15\n    expect(width).toBeCloseTo(6 * 2.0 * fontSize * baseRatio + minPadding, 1)\n  })\n\n  it('handles Hungarian text with accents', () => {\n    const width = measureTextWidth('Üdvözöljük', fontSize, fontWeight)\n    expect(width).toBeGreaterThan(0)\n    // Should be similar to unaccented version (within 5% difference)\n    const unaccented = measureTextWidth('Udvozoljuk', fontSize, fontWeight)\n    const percentDiff = Math.abs(width - unaccented) / unaccented\n    expect(percentDiff).toBeLessThan(0.05)\n  })\n})\n"
  },
  {
    "path": "src/__tests__/xychart-ascii.test.ts",
    "content": "/**\n * Tests for xychart-beta ASCII rendering.\n *\n * Tests bar charts, line charts, mixed charts, horizontal orientation,\n * multi-series support, staircase line routing, and edge cases.\n */\nimport { describe, it, expect } from 'bun:test'\nimport { renderMermaidASCII } from '../ascii/index.ts'\n\n// ============================================================================\n// Helper — render with no colors for easy string matching\n// ============================================================================\n\nfunction render(text: string, useAscii = false): string {\n  return renderMermaidASCII(text, { colorMode: 'none', useAscii })\n}\n\n// ============================================================================\n// Bar charts\n// ============================================================================\n\ndescribe('xychart ASCII – bar charts', () => {\n  it('renders a basic bar chart with block characters', () => {\n    const result = render(`xychart-beta\n      x-axis [A, B, C]\n      bar [10, 20, 30]`)\n    expect(result).toContain('█')\n    expect(result).toContain('A')\n    expect(result).toContain('B')\n    expect(result).toContain('C')\n  })\n\n  it('renders bars with # in ASCII mode', () => {\n    const result = render(`xychart-beta\n      x-axis [A, B, C]\n      bar [10, 20, 30]`, true)\n    expect(result).toContain('#')\n    expect(result).not.toContain('█')\n  })\n\n  it('renders a bar chart with title', () => {\n    const result = render(`xychart-beta\n      title \"Sales Report\"\n      x-axis [Q1, Q2, Q3, Q4]\n      bar [100, 200, 150, 250]`)\n    expect(result).toContain('Sales Report')\n    expect(result).toContain('Q1')\n    expect(result).toContain('Q4')\n  })\n\n  it('renders y-axis tick labels', () => {\n    const result = render(`xychart-beta\n      x-axis [A, B]\n      y-axis 0 --> 100\n      bar [25, 75]`)\n    // Should have at least some tick values visible\n    expect(result).toContain('┤')\n    expect(result).toContain('┼')\n  })\n\n  it('renders multi-series bars', () => {\n    const result = render(`xychart-beta\n      x-axis [A, B, C]\n      bar [10, 20, 30]\n      bar [15, 25, 35]`)\n    // Should contain legend for multi-series\n    expect(result).toContain('Bar 1')\n    expect(result).toContain('Bar 2')\n  })\n})\n\n// ============================================================================\n// Line charts\n// ============================================================================\n\ndescribe('xychart ASCII – line charts', () => {\n  it('renders a line chart with staircase routing', () => {\n    const result = render(`xychart-beta\n      x-axis [A, B, C, D]\n      line [10, 30, 20, 40]`)\n    // Should use box-drawing corners for staircase\n    expect(result).toContain('╭')\n    expect(result).toContain('─')\n  })\n\n  it('does NOT use dot markers on line charts', () => {\n    const result = render(`xychart-beta\n      x-axis [A, B, C, D]\n      line [10, 30, 20, 40]`)\n    expect(result).not.toContain('●')\n    expect(result).not.toContain('*')\n  })\n\n  it('uses vertical segments for row transitions', () => {\n    const result = render(`xychart-beta\n      x-axis [A, B, C]\n      line [10, 50, 20]`)\n    // Vertical segments connect rows\n    expect(result).toContain('│')\n  })\n\n  it('renders ascending line with ╯ and ╭ corners', () => {\n    const result = render(`xychart-beta\n      x-axis [A, B]\n      line [10, 50]`)\n    // Ascending: ╯ at bottom (left+up), ╭ at top (bottom+right)\n    expect(result).toContain('╯')\n    expect(result).toContain('╭')\n  })\n\n  it('renders descending line with ╮ and ╰ corners', () => {\n    const result = render(`xychart-beta\n      x-axis [A, B]\n      line [50, 10]`)\n    // Descending: ╮ at top (left+down), ╰ at bottom (top+right)\n    expect(result).toContain('╮')\n    expect(result).toContain('╰')\n  })\n\n  it('renders flat line with only ─', () => {\n    const result = render(`xychart-beta\n      x-axis [A, B, C]\n      line [50, 50, 50]`)\n    expect(result).toContain('─')\n    // No corners needed for flat lines\n    expect(result).not.toContain('╭')\n    expect(result).not.toContain('╮')\n  })\n\n  it('uses + for corners in ASCII mode', () => {\n    const result = render(`xychart-beta\n      x-axis [A, B]\n      line [10, 50]`, true)\n    expect(result).toContain('+')\n    expect(result).not.toContain('╭')\n    expect(result).not.toContain('╰')\n  })\n})\n\n// ============================================================================\n// Mixed bar + line\n// ============================================================================\n\ndescribe('xychart ASCII – mixed charts', () => {\n  it('renders bars and lines together', () => {\n    const result = render(`xychart-beta\n      x-axis [A, B, C, D]\n      bar [20, 40, 30, 50]\n      line [25, 35, 40, 45]`)\n    // Should have both bar blocks and line corners\n    expect(result).toContain('█')\n    expect(result).toContain('─')\n    // Legend for multi-series\n    expect(result).toContain('Bar 1')\n    expect(result).toContain('Line 1')\n  })\n})\n\n// ============================================================================\n// Horizontal orientation\n// ============================================================================\n\ndescribe('xychart ASCII – horizontal', () => {\n  it('renders horizontal bar chart', () => {\n    const result = render(`xychart-beta horizontal\n      x-axis [Python, JavaScript, Go]\n      bar [30, 25, 12]`)\n    // Category labels on left side\n    expect(result).toContain('Python')\n    expect(result).toContain('JavaScript')\n    expect(result).toContain('Go')\n    expect(result).toContain('█')\n  })\n\n  it('renders horizontal line chart with staircase', () => {\n    const result = render(`xychart-beta horizontal\n      x-axis [A, B, C]\n      line [10, 30, 20]`)\n    // Should have horizontal staircase routing\n    expect(result).toContain('─')\n    expect(result).toContain('│')\n  })\n})\n\n// ============================================================================\n// Titles and axis labels\n// ============================================================================\n\ndescribe('xychart ASCII – titles and axes', () => {\n  it('renders chart title centered', () => {\n    const result = render(`xychart-beta\n      title \"My Chart\"\n      x-axis [A, B]\n      bar [10, 20]`)\n    const titleLine = result.split('\\n').find(l => l.includes('My Chart'))\n    expect(titleLine).toBeDefined()\n  })\n\n  it('renders x-axis title', () => {\n    const result = render(`xychart-beta\n      x-axis \"Category\" [A, B, C]\n      bar [10, 20, 30]`)\n    expect(result).toContain('Category')\n  })\n\n  it('renders y-axis with explicit range', () => {\n    const result = render(`xychart-beta\n      x-axis [A, B]\n      y-axis \"Score\" 0 --> 100\n      bar [25, 75]`)\n    // Y-axis ticks should appear\n    expect(result).toContain('0')\n    expect(result).toContain('100')\n  })\n\n  it('renders without title when not specified', () => {\n    const result = render(`xychart-beta\n      x-axis [A, B]\n      bar [10, 20]`)\n    // First non-empty line should be chart content, not a title\n    const lines = result.split('\\n').filter(l => l.trim().length > 0)\n    expect(lines.length).toBeGreaterThan(0)\n  })\n})\n\n// ============================================================================\n// Edge cases\n// ============================================================================\n\ndescribe('xychart ASCII – edge cases', () => {\n  it('handles single data point', () => {\n    const result = render(`xychart-beta\n      x-axis [Only]\n      bar [42]`)\n    expect(result).toContain('Only')\n    expect(result).toContain('█')\n  })\n\n  it('handles two data points', () => {\n    const result = render(`xychart-beta\n      x-axis [A, B]\n      bar [10, 20]`)\n    expect(result).toContain('A')\n    expect(result).toContain('B')\n  })\n\n  it('handles all zeros', () => {\n    const result = render(`xychart-beta\n      x-axis [A, B, C]\n      bar [0, 0, 0]`)\n    expect(result).toContain('A')\n    // Should still render axes\n    expect(result).toContain('┼')\n  })\n\n  it('handles large values', () => {\n    const result = render(`xychart-beta\n      x-axis [A, B]\n      bar [1000000, 2000000]`)\n    expect(result).toContain('A')\n    expect(result).toContain('B')\n  })\n\n  it('renders line chart with single data point', () => {\n    const result = render(`xychart-beta\n      x-axis [A]\n      line [50]`)\n    expect(result).toContain('A')\n    expect(result).toContain('─')\n  })\n})\n\n// ============================================================================\n// Axis structure\n// ============================================================================\n\ndescribe('xychart ASCII – axis structure', () => {\n  it('has y-axis ticks (┤) at value positions', () => {\n    const result = render(`xychart-beta\n      x-axis [A, B, C]\n      y-axis 0 --> 100\n      bar [25, 50, 75]`)\n    expect(result).toContain('┤')\n  })\n\n  it('has x-axis ticks (┬) at category positions', () => {\n    const result = render(`xychart-beta\n      x-axis [A, B, C]\n      bar [25, 50, 75]`)\n    expect(result).toContain('┬')\n  })\n\n  it('has origin marker (┼)', () => {\n    const result = render(`xychart-beta\n      x-axis [A, B]\n      bar [10, 20]`)\n    expect(result).toContain('┼')\n  })\n\n  it('uses + for axis characters in ASCII mode', () => {\n    const result = render(`xychart-beta\n      x-axis [A, B, C]\n      y-axis 0 --> 100\n      bar [25, 50, 75]`, true)\n    expect(result).toContain('+')\n    expect(result).not.toContain('┤')\n    expect(result).not.toContain('┬')\n    expect(result).not.toContain('┼')\n  })\n})\n"
  },
  {
    "path": "src/__tests__/xychart-integration.test.ts",
    "content": "/**\n * Integration tests for xychart-beta rendering.\n *\n * Tests data-* attributes (always emitted) and interactive tooltip\n * groups (only when interactive: true).\n */\nimport { describe, it, expect } from 'bun:test'\nimport { renderMermaid } from '../index.ts'\n\nconst BAR_CHART = `xychart-beta\n  x-axis [Jan, Feb, Mar, Apr]\n  y-axis \"Revenue\" 0 --> 100\n  bar [30, 60, 45, 80]`\n\nconst LINE_CHART = `xychart-beta\n  x-axis [Jan, Feb, Mar]\n  y-axis \"Users\" 0 --> 500\n  line [100, 250, 400]`\n\nconst MIXED_CHART = `xychart-beta\n  x-axis [Q1, Q2, Q3, Q4]\n  y-axis \"Sales\" 0 --> 200\n  bar [50, 80, 120, 90]\n  line [40, 100, 110, 85]`\n\n// ============================================================================\n// Data attributes (always present)\n// ============================================================================\n\ndescribe('xychart – data attributes', () => {\n  it('emits data-value and data-label on bars', async () => {\n    const svg = await renderMermaid(BAR_CHART)\n    expect(svg).toContain('data-value=\"30\"')\n    expect(svg).toContain('data-value=\"80\"')\n    expect(svg).toContain('data-label=\"Jan\"')\n    expect(svg).toContain('data-label=\"Apr\"')\n  })\n\n  it('emits data-value and data-label on line dots (interactive)', async () => {\n    // Line dots are only rendered when interactive: true\n    const svg = await renderMermaid(LINE_CHART, { interactive: true })\n    expect(svg).toContain('data-value=\"100\"')\n    expect(svg).toContain('data-value=\"400\"')\n    expect(svg).toContain('data-label=\"Jan\"')\n    expect(svg).toContain('data-label=\"Mar\"')\n  })\n\n  it('shows dots on sparse line charts even without interactive', async () => {\n    // Sparse charts (≤12 points) render dots as visual markers\n    const svg = await renderMermaid(LINE_CHART)\n    expect(svg).toContain('<circle')\n    expect(svg).toContain('data-value=\"100\"')\n    // But no tooltips without interactive\n    expect(svg).not.toContain('xychart-tip-bg')\n    expect(svg).not.toContain('xychart-dot-group')\n  })\n\n  it('emits data attributes on mixed chart elements', async () => {\n    const svg = await renderMermaid(MIXED_CHART, { interactive: true })\n    // Bars\n    expect(svg).toContain('data-value=\"50\"')\n    expect(svg).toContain('data-value=\"120\"')\n    // Line dots (only when interactive)\n    expect(svg).toContain('data-value=\"40\"')\n    expect(svg).toContain('data-value=\"110\"')\n    // Labels on both\n    expect(svg).toContain('data-label=\"Q1\"')\n    expect(svg).toContain('data-label=\"Q4\"')\n  })\n})\n\n// ============================================================================\n// Interactive tooltips (opt-in)\n// ============================================================================\n\ndescribe('xychart – interactive tooltips', () => {\n  it('does not emit tooltip elements by default', async () => {\n    const svg = await renderMermaid(BAR_CHART)\n    expect(svg).not.toContain('xychart-tip')\n    expect(svg).not.toContain('xychart-bar-group')\n    expect(svg).not.toContain('<title>')\n  })\n\n  it('emits tooltip groups for bars when interactive', async () => {\n    const svg = await renderMermaid(BAR_CHART, { interactive: true })\n    expect(svg).toContain('class=\"xychart-bar-group\"')\n    expect(svg).toContain('class=\"xychart-tip xychart-tip-bg\"')\n    expect(svg).toContain('class=\"xychart-tip xychart-tip-text\"')\n    expect(svg).toContain('<title>Jan: 30</title>')\n    expect(svg).toContain('<title>Apr: 80</title>')\n  })\n\n  it('emits tooltip groups for line dots when interactive', async () => {\n    const svg = await renderMermaid(LINE_CHART, { interactive: true })\n    expect(svg).toContain('class=\"xychart-dot-group\"')\n    expect(svg).toContain('<title>Jan: 100</title>')\n    expect(svg).toContain('<title>Mar: 400</title>')\n  })\n\n  it('includes hover CSS rules when interactive', async () => {\n    const svg = await renderMermaid(BAR_CHART, { interactive: true })\n    expect(svg).toContain('.xychart-tip {')\n    expect(svg).toContain('opacity: 0')\n    expect(svg).toContain('.xychart-bar-group:hover .xychart-tip')\n    expect(svg).toContain('.xychart-dot-group:hover .xychart-tip')\n    // Tooltips appear instantly (no transition)\n  })\n\n  it('does not include hover CSS when not interactive', async () => {\n    const svg = await renderMermaid(BAR_CHART)\n    expect(svg).not.toContain('.xychart-tip {')\n    expect(svg).not.toContain('.xychart-bar-group:hover')\n  })\n\n  it('still emits data attributes when interactive', async () => {\n    const svg = await renderMermaid(BAR_CHART, { interactive: true })\n    expect(svg).toContain('data-value=\"30\"')\n    expect(svg).toContain('data-label=\"Jan\"')\n  })\n})\n\n// ============================================================================\n// CSS variable color inputs\n// ============================================================================\n\ndescribe('xychart – CSS variable color inputs', () => {\n  it('does not produce NaN colors when accent/bg are CSS variables', async () => {\n    const svg = await renderMermaid(MIXED_CHART, {\n      bg: 'var(--background)',\n      fg: 'var(--foreground)',\n      accent: 'var(--accent)',\n    })\n    expect(svg).not.toContain('NaN')\n    expect(svg).toContain('xychart-color-0')\n    expect(svg).toContain('xychart-color-1')\n  })\n})\n"
  },
  {
    "path": "src/ascii/ansi.ts",
    "content": "// ============================================================================\n// ASCII renderer — color utilities\n//\n// Provides color output for themed ASCII diagrams.\n// Supports ANSI terminal modes (16/256/truecolor) and HTML <span> tags\n// for browser rendering.\n// ============================================================================\n\nimport type { CharRole, AsciiTheme, ColorMode } from './types.ts'\nimport type { DiagramColors } from '../theme.ts'\nimport { MIX } from '../theme.ts'\n\ndeclare const document: unknown\n\n// ============================================================================\n// Default theme — matches SVG theme colors for consistency\n// ============================================================================\n\n/**\n * Default ASCII theme derived from the SVG renderer's color palette.\n * Uses the same mixing ratios to maintain visual consistency.\n */\nexport const DEFAULT_ASCII_THEME: AsciiTheme = {\n  fg: '#27272a',      // zinc-800 — primary text\n  border: '#a1a1aa',  // zinc-400 — node borders (12% mix)\n  line: '#71717a',    // zinc-500 — edge lines (35% mix)\n  arrow: '#52525b',   // zinc-600 — arrowheads (60% mix)\n  corner: '#71717a',  // same as line\n  junction: '#a1a1aa', // same as border\n}\n\n// ============================================================================\n// DiagramColors → AsciiTheme bridge\n//\n// Converts SVG DiagramColors into an AsciiTheme using the same MIX ratios\n// that the SVG renderer uses via CSS color-mix(). This ensures visual\n// consistency between SVG and ASCII output for any theme.\n// ============================================================================\n\n/** Mix fg into bg at a given percentage (replicates CSS color-mix(in srgb)). */\nfunction mixColors(fg: string, bg: string, pct: number): string {\n  const f = parseHex(fg), b = parseHex(bg)\n  const mix = (a: number, z: number) => Math.round(a * (pct / 100) + z * (1 - pct / 100))\n  const r = mix(f.r, b.r), g = mix(f.g, b.g), bl = mix(f.b, b.b)\n  return '#' + [r, g, bl].map(c => c.toString(16).padStart(2, '0')).join('')\n}\n\n/**\n * Derive an AsciiTheme from SVG DiagramColors using the same mixing ratios.\n * Honors optional enrichment colors (line, accent, border) when present,\n * otherwise falls back to color-mix derivation — matching SVG behavior.\n */\nexport function diagramColorsToAsciiTheme(colors: DiagramColors): AsciiTheme {\n  const line = colors.line ?? mixColors(colors.fg, colors.bg, MIX.line)\n  const border = colors.border ?? mixColors(colors.fg, colors.bg, MIX.nodeStroke)\n  return {\n    fg:       colors.fg,\n    border,\n    line,\n    arrow:    colors.accent ?? mixColors(colors.fg, colors.bg, MIX.arrow),\n    accent:   colors.accent,\n    bg:       colors.bg,\n    corner:   line,\n    junction: border,\n  }\n}\n\n// ============================================================================\n// Color mode detection\n// ============================================================================\n\n/**\n * Detect the best color mode for the current environment.\n *\n * Terminal detection order:\n * 1. COLORTERM=truecolor or COLORTERM=24bit → truecolor\n * 2. TERM contains \"256color\" → ansi256\n * 3. TERM is set and not \"dumb\" → ansi16\n *\n * Browser: returns 'html' (uses <span> tags with inline styles).\n * Unknown/piped: returns 'none'.\n */\nexport function detectColorMode(): ColorMode {\n  // Check if we're in a Node.js-like environment with process object\n  // Use globalThis to safely check for process without TypeScript errors\n  const proc = (globalThis as { process?: { stdout?: { isTTY?: boolean }, env?: Record<string, string | undefined> } }).process\n\n  if (proc) {\n    // Check if stdout is a TTY (not piped/redirected)\n    if (!proc.stdout?.isTTY) {\n      return 'none'\n    }\n\n    const colorTerm = proc.env?.COLORTERM?.toLowerCase() ?? ''\n    const term = proc.env?.TERM?.toLowerCase() ?? ''\n\n    // True color support\n    if (colorTerm === 'truecolor' || colorTerm === '24bit') {\n      return 'truecolor'\n    }\n\n    // 256 color support\n    if (term.includes('256color') || term.includes('256')) {\n      return 'ansi256'\n    }\n\n    // Basic color support\n    if (term && term !== 'dumb') {\n      return 'ansi16'\n    }\n\n    return 'none'\n  }\n\n  // No process object → browser environment → use HTML color output\n  if (typeof document !== 'undefined') {\n    return 'html'\n  }\n\n  return 'none'\n}\n\n// ============================================================================\n// Hex color parsing\n// ============================================================================\n\n/**\n * Parse a hex color string to RGB values.\n * Supports both 3-char (#RGB) and 6-char (#RRGGBB) formats.\n */\nfunction parseHex(hex: string): { r: number; g: number; b: number } {\n  const h = hex.replace('#', '')\n  if (h.length === 3) {\n    return {\n      r: parseInt(h[0]! + h[0]!, 16),\n      g: parseInt(h[1]! + h[1]!, 16),\n      b: parseInt(h[2]! + h[2]!, 16),\n    }\n  }\n  return {\n    r: parseInt(h.substring(0, 2), 16),\n    g: parseInt(h.substring(2, 4), 16),\n    b: parseInt(h.substring(4, 6), 16),\n  }\n}\n\n// ============================================================================\n// ANSI escape code generation\n// ============================================================================\n\n/** ANSI escape sequence prefix */\nconst ESC = '\\x1b['\n/** Reset all attributes */\nconst RESET = `${ESC}0m`\n\n/**\n * Generate ANSI foreground color escape sequence for 24-bit true color.\n * Format: ESC[38;2;R;G;Bm\n */\nfunction truecolorFg(hex: string): string {\n  const { r, g, b } = parseHex(hex)\n  return `${ESC}38;2;${r};${g};${b}m`\n}\n\n/**\n * Find the closest 256-color palette index for an RGB color.\n * The 256-color palette has:\n * - 0-15: Standard colors (duplicates of 16-color)\n * - 16-231: 6x6x6 color cube (216 colors)\n * - 232-255: Grayscale ramp (24 shades)\n */\nfunction rgbTo256(r: number, g: number, b: number): number {\n  // Check if it's close to grayscale\n  const avg = (r + g + b) / 3\n  const maxDiff = Math.max(Math.abs(r - avg), Math.abs(g - avg), Math.abs(b - avg))\n\n  if (maxDiff < 10) {\n    // Use grayscale ramp (232-255)\n    // Each step is ~10.625 (256/24)\n    const gray = Math.round((avg / 255) * 23)\n    return 232 + Math.min(23, Math.max(0, gray))\n  }\n\n  // Use 6x6x6 color cube (16-231)\n  // Each channel maps to 0-5: 0, 95, 135, 175, 215, 255\n  const toIndex = (v: number): number => {\n    if (v < 48) return 0\n    if (v < 115) return 1\n    return Math.min(5, Math.floor((v - 35) / 40))\n  }\n\n  const ri = toIndex(r)\n  const gi = toIndex(g)\n  const bi = toIndex(b)\n\n  return 16 + (36 * ri) + (6 * gi) + bi\n}\n\n/**\n * Generate ANSI foreground color escape sequence for 256-color mode.\n * Format: ESC[38;5;Nm\n */\nfunction ansi256Fg(hex: string): string {\n  const { r, g, b } = parseHex(hex)\n  const index = rgbTo256(r, g, b)\n  return `${ESC}38;5;${index}m`\n}\n\n/**\n * Map an RGB color to the closest 16-color ANSI code.\n * Returns the foreground color escape sequence.\n *\n * Standard 16 colors:\n * 0=black, 1=red, 2=green, 3=yellow, 4=blue, 5=magenta, 6=cyan, 7=white\n * 8-15 = bright versions\n */\nfunction ansi16Fg(hex: string): string {\n  const { r, g, b } = parseHex(hex)\n  const luma = 0.299 * r + 0.587 * g + 0.114 * b\n\n  // Determine brightness (use bright colors for better visibility)\n  const bright = luma > 100 ? 0 : 60 // 60 = bright variant offset\n\n  // Determine base color based on dominant channel\n  let code: number\n  if (r > 180 && g < 100 && b < 100) code = 31 // red\n  else if (g > 180 && r < 100 && b < 100) code = 32 // green\n  else if (r > 150 && g > 150 && b < 100) code = 33 // yellow\n  else if (b > 180 && r < 100 && g < 100) code = 34 // blue\n  else if (r > 150 && b > 150 && g < 100) code = 35 // magenta\n  else if (g > 150 && b > 150 && r < 100) code = 36 // cyan\n  else if (luma > 200) code = 37 // white\n  else if (luma < 50) code = 30 // black\n  else code = 37 // default to white for grays\n\n  return `${ESC}${code + bright}m`\n}\n\n// ============================================================================\n// HTML color output (for browser rendering)\n// ============================================================================\n\n/** Escape characters that would break HTML output. */\nfunction escapeHtml(text: string): string {\n  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')\n}\n\n/** Wrap text in a <span> with an inline color style. */\nfunction htmlSpan(hex: string, text: string): string {\n  return `<span style=\"color:${hex}\">${escapeHtml(text)}</span>`\n}\n\n// ============================================================================\n// Role → color mapping\n// ============================================================================\n\n/**\n * Get the color for a character role from the theme.\n */\nfunction getRoleColor(role: CharRole, theme: AsciiTheme): string {\n  switch (role) {\n    case 'text': return theme.fg\n    case 'border': return theme.border\n    case 'line': return theme.line\n    case 'arrow': return theme.arrow\n    case 'corner': return theme.corner ?? theme.line\n    case 'junction': return theme.junction ?? theme.border\n    default: return theme.fg\n  }\n}\n\n/**\n * Generate the ANSI escape sequence for a role color.\n */\nexport function getAnsiColor(role: CharRole, theme: AsciiTheme, mode: ColorMode): string {\n  if (mode === 'none') return ''\n\n  const hex = getRoleColor(role, theme)\n\n  switch (mode) {\n    case 'truecolor': return truecolorFg(hex)\n    case 'ansi256': return ansi256Fg(hex)\n    case 'ansi16': return ansi16Fg(hex)\n    default: return ''\n  }\n}\n\n/**\n * Get the ANSI reset sequence.\n */\nexport function getAnsiReset(mode: ColorMode): string {\n  return mode === 'none' ? '' : RESET\n}\n\n/**\n * Wrap a character with ANSI color codes based on its role.\n */\nexport function colorizeChar(\n  char: string,\n  role: CharRole | null,\n  theme: AsciiTheme,\n  mode: ColorMode,\n): string {\n  if (mode === 'none' || role === null || char === ' ') {\n    return char\n  }\n\n  const colorCode = getAnsiColor(role, theme, mode)\n  return `${colorCode}${char}${RESET}`\n}\n\n/**\n * Colorize an entire line efficiently by grouping consecutive same-role characters.\n * This reduces the number of escape sequences (ANSI) or span tags (HTML) in the output.\n */\nexport function colorizeLine(\n  chars: string[],\n  roles: (CharRole | null)[],\n  theme: AsciiTheme,\n  mode: ColorMode,\n): string {\n  if (mode === 'none') {\n    return chars.join('')\n  }\n\n  if (mode === 'html') {\n    return colorizeLineHtml(chars, roles, theme)\n  }\n\n  let result = ''\n  let currentRole: CharRole | null = null\n  let buffer = ''\n\n  for (let i = 0; i < chars.length; i++) {\n    const char = chars[i]!\n    const role = roles[i] ?? null\n\n    // Whitespace doesn't need coloring\n    if (char === ' ') {\n      // Flush any buffered characters (with or without color)\n      if (buffer.length > 0) {\n        if (currentRole !== null) {\n          result += getAnsiColor(currentRole, theme, mode) + buffer + RESET\n        } else {\n          result += buffer\n        }\n        buffer = ''\n        currentRole = null\n      }\n      result += char\n      continue\n    }\n\n    // Same role as previous — accumulate\n    if (role === currentRole) {\n      buffer += char\n      continue\n    }\n\n    // Role changed — flush buffer (with or without color) and start new\n    if (buffer.length > 0) {\n      if (currentRole !== null) {\n        result += getAnsiColor(currentRole, theme, mode) + buffer + RESET\n      } else {\n        result += buffer\n      }\n    }\n    buffer = char\n    currentRole = role\n  }\n\n  // Flush remaining buffer\n  if (buffer.length > 0 && currentRole !== null) {\n    result += getAnsiColor(currentRole, theme, mode) + buffer + RESET\n  } else if (buffer.length > 0) {\n    result += buffer\n  }\n\n  return result\n}\n\n/**\n * HTML-specific line colorization.\n * Groups consecutive same-role characters into <span> tags with inline color styles.\n * Whitespace is emitted bare (no wrapping) to keep output compact.\n */\nfunction colorizeLineHtml(\n  chars: string[],\n  roles: (CharRole | null)[],\n  theme: AsciiTheme,\n): string {\n  let result = ''\n  let currentRole: CharRole | null = null\n  let buffer = ''\n\n  const flush = () => {\n    if (buffer.length === 0) return\n    if (currentRole !== null) {\n      result += htmlSpan(getRoleColor(currentRole, theme), buffer)\n    } else {\n      result += escapeHtml(buffer)\n    }\n    buffer = ''\n    currentRole = null\n  }\n\n  for (let i = 0; i < chars.length; i++) {\n    const char = chars[i]!\n    const role = roles[i] ?? null\n\n    if (char === ' ') {\n      flush()\n      result += ' '\n      continue\n    }\n\n    if (role === currentRole) {\n      buffer += char\n      continue\n    }\n\n    flush()\n    buffer = char\n    currentRole = role\n  }\n\n  flush()\n  return result\n}\n\n/**\n * Colorize a text string with a direct hex color.\n * Used by renderers that need per-cell color control (e.g. multi-series xychart).\n * Handles all output modes: ANSI (16/256/truecolor) and HTML.\n */\nexport function colorizeText(text: string, hex: string, mode: ColorMode): string {\n  if (mode === 'none' || text.length === 0) return text\n  if (mode === 'html') return htmlSpan(hex, text)\n  let code: string\n  switch (mode) {\n    case 'truecolor': code = truecolorFg(hex); break\n    case 'ansi256': code = ansi256Fg(hex); break\n    case 'ansi16': code = ansi16Fg(hex); break\n    default: return text\n  }\n  return `${code}${text}${RESET}`\n}\n"
  },
  {
    "path": "src/ascii/canvas.ts",
    "content": "// ============================================================================\n// ASCII renderer — 2D text canvas\n//\n// Ported from AlexanderGrooff/mermaid-ascii cmd/draw.go.\n// The canvas is a column-major 2D array of single-character strings.\n// canvas[x][y] gives the character at column x, row y.\n// ============================================================================\n\nimport type { Canvas, DrawingCoord, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types.ts'\nimport { colorizeLine, DEFAULT_ASCII_THEME } from './ansi.ts'\n\n/**\n * Create a blank canvas filled with spaces.\n * Dimensions are inclusive: mkCanvas(3, 2) creates a 4x3 grid (indices 0..3, 0..2).\n */\nexport function mkCanvas(x: number, y: number): Canvas {\n  const canvas: Canvas = []\n  for (let i = 0; i <= x; i++) {\n    const col: string[] = []\n    for (let j = 0; j <= y; j++) {\n      col.push(' ')\n    }\n    canvas.push(col)\n  }\n  return canvas\n}\n\n/** Create a blank canvas with the same dimensions as the given canvas. */\nexport function copyCanvas(source: Canvas): Canvas {\n  const [maxX, maxY] = getCanvasSize(source)\n  return mkCanvas(maxX, maxY)\n}\n\n// ============================================================================\n// Role canvas creation and management\n// ============================================================================\n\n/**\n * Create a blank role canvas filled with nulls.\n * Same dimensions as mkCanvas — column-major, roleCanvas[x][y].\n */\nexport function mkRoleCanvas(x: number, y: number): RoleCanvas {\n  const roleCanvas: RoleCanvas = []\n  for (let i = 0; i <= x; i++) {\n    const col: (CharRole | null)[] = []\n    for (let j = 0; j <= y; j++) {\n      col.push(null)\n    }\n    roleCanvas.push(col)\n  }\n  return roleCanvas\n}\n\n/** Create a blank role canvas with the same dimensions as the given role canvas. */\nexport function copyRoleCanvas(source: RoleCanvas): RoleCanvas {\n  const maxX = source.length - 1\n  const maxY = (source[0]?.length ?? 1) - 1\n  return mkRoleCanvas(maxX, maxY)\n}\n\n/**\n * Grow the role canvas to fit at least (newX, newY), preserving existing roles.\n * Mutates the role canvas in place and returns it.\n */\nexport function increaseRoleCanvasSize(roleCanvas: RoleCanvas, newX: number, newY: number): RoleCanvas {\n  const currX = roleCanvas.length - 1\n  const currY = (roleCanvas[0]?.length ?? 1) - 1\n  const targetX = Math.max(newX, currX)\n  const targetY = Math.max(newY, currY)\n  const grown = mkRoleCanvas(targetX, targetY)\n  for (let x = 0; x < grown.length; x++) {\n    for (let y = 0; y < grown[0]!.length; y++) {\n      if (x < roleCanvas.length && y < roleCanvas[0]!.length) {\n        grown[x]![y] = roleCanvas[x]![y]!\n      }\n    }\n  }\n  roleCanvas.length = 0\n  roleCanvas.push(...grown)\n  return roleCanvas\n}\n\n/**\n * Set a role at a specific coordinate.\n * Expands the role canvas if necessary.\n */\nexport function setRole(roleCanvas: RoleCanvas, x: number, y: number, role: CharRole): void {\n  if (x >= roleCanvas.length || y >= (roleCanvas[0]?.length ?? 0)) {\n    increaseRoleCanvasSize(roleCanvas, x, y)\n  }\n  roleCanvas[x]![y] = role\n}\n\n/**\n * Merge role canvases — same logic as mergeCanvases but for roles.\n * Non-null roles in overlays overwrite null roles in base.\n */\nexport function mergeRoleCanvases(\n  base: RoleCanvas,\n  offset: DrawingCoord,\n  ...overlays: RoleCanvas[]\n): RoleCanvas {\n  let maxX = base.length - 1\n  let maxY = (base[0]?.length ?? 1) - 1\n\n  for (const overlay of overlays) {\n    const oX = overlay.length - 1\n    const oY = (overlay[0]?.length ?? 1) - 1\n    maxX = Math.max(maxX, oX + offset.x)\n    maxY = Math.max(maxY, oY + offset.y)\n  }\n\n  const merged = mkRoleCanvas(maxX, maxY)\n\n  // Copy base\n  for (let x = 0; x <= maxX; x++) {\n    for (let y = 0; y <= maxY; y++) {\n      if (x < base.length && y < base[0]!.length) {\n        merged[x]![y] = base[x]![y]!\n      }\n    }\n  }\n\n  // Apply overlays\n  for (const overlay of overlays) {\n    for (let x = 0; x < overlay.length; x++) {\n      for (let y = 0; y < overlay[0]!.length; y++) {\n        const role = overlay[x]?.[y]\n        if (role !== null && role !== undefined) {\n          const mx = x + offset.x\n          const my = y + offset.y\n          merged[mx]![my] = role\n        }\n      }\n    }\n  }\n\n  return merged\n}\n\n/** Returns [maxX, maxY] — the highest valid indices in each dimension. */\nexport function getCanvasSize(canvas: Canvas): [number, number] {\n  return [canvas.length - 1, (canvas[0]?.length ?? 1) - 1]\n}\n\n/**\n * Grow the canvas to fit at least (newX, newY), preserving existing content.\n * Mutates the canvas in place and returns it.\n */\nexport function increaseSize(canvas: Canvas, newX: number, newY: number): Canvas {\n  const [currX, currY] = getCanvasSize(canvas)\n  const targetX = Math.max(newX, currX)\n  const targetY = Math.max(newY, currY)\n  const grown = mkCanvas(targetX, targetY)\n  for (let x = 0; x < grown.length; x++) {\n    for (let y = 0; y < grown[0]!.length; y++) {\n      if (x < canvas.length && y < canvas[0]!.length) {\n        grown[x]![y] = canvas[x]![y]!\n      }\n    }\n  }\n  // Mutate in place: splice old contents and replace with grown\n  canvas.length = 0\n  canvas.push(...grown)\n  return canvas\n}\n\n// ============================================================================\n// Junction merging — Unicode box-drawing character compositing\n// ============================================================================\n\n/** All Unicode box-drawing characters that participate in junction merging. */\nconst JUNCTION_CHARS = new Set([\n  '─', '│', '┌', '┐', '└', '┘', '├', '┤', '┬', '┴', '┼', '╴', '╵', '╶', '╷',\n])\n\nexport function isJunctionChar(c: string): boolean {\n  return JUNCTION_CHARS.has(c)\n}\n\n/** Check if a character is alphanumeric (part of a label). */\nfunction isAlphanumeric(c: string): boolean {\n  return /^[a-zA-Z0-9]$/.test(c)\n}\n\n/**\n * When two junction characters overlap during canvas merging,\n * resolve them to the correct combined junction.\n * E.g., '─' overlapping '│' becomes '┼'.\n */\nconst JUNCTION_MAP: Record<string, Record<string, string>> = {\n  '─': { '│': '┼', '┌': '┬', '┐': '┬', '└': '┴', '┘': '┴', '├': '┼', '┤': '┼', '┬': '┬', '┴': '┴' },\n  '│': { '─': '┼', '┌': '├', '┐': '┤', '└': '├', '┘': '┤', '├': '├', '┤': '┤', '┬': '┼', '┴': '┼' },\n  '┌': { '─': '┬', '│': '├', '┐': '┬', '└': '├', '┘': '┼', '├': '├', '┤': '┼', '┬': '┬', '┴': '┼' },\n  '┐': { '─': '┬', '│': '┤', '┌': '┬', '└': '┼', '┘': '┤', '├': '┼', '┤': '┤', '┬': '┬', '┴': '┼' },\n  '└': { '─': '┴', '│': '├', '┌': '├', '┐': '┼', '┘': '┴', '├': '├', '┤': '┼', '┬': '┼', '┴': '┴' },\n  '┘': { '─': '┴', '│': '┤', '┌': '┼', '┐': '┤', '└': '┴', '├': '┼', '┤': '┤', '┬': '┼', '┴': '┴' },\n  '├': { '─': '┼', '│': '├', '┌': '├', '┐': '┼', '└': '├', '┘': '┼', '┤': '┼', '┬': '┼', '┴': '┼' },\n  '┤': { '─': '┼', '│': '┤', '┌': '┼', '┐': '┤', '└': '┼', '┘': '┤', '├': '┼', '┬': '┼', '┴': '┼' },\n  '┬': { '─': '┬', '│': '┼', '┌': '┬', '┐': '┬', '└': '┼', '┘': '┼', '├': '┼', '┤': '┼', '┴': '┼' },\n  '┴': { '─': '┴', '│': '┼', '┌': '┼', '┐': '┼', '└': '┴', '┘': '┴', '├': '┼', '┤': '┼', '┬': '┼' },\n}\n\nexport function mergeJunctions(c1: string, c2: string): string {\n  return JUNCTION_MAP[c1]?.[c2] ?? c1\n}\n\n// ============================================================================\n// Canvas merging — composite multiple canvases with offset\n// ============================================================================\n\n/**\n * Merge overlay canvases onto a base canvas at the given offset.\n * Non-space characters in overlays overwrite the base.\n * When both characters are Unicode junction chars, they're merged intelligently.\n */\nexport function mergeCanvases(\n  base: Canvas,\n  offset: DrawingCoord,\n  useAscii: boolean,\n  ...overlays: Canvas[]\n): Canvas {\n  let [maxX, maxY] = getCanvasSize(base)\n  for (const overlay of overlays) {\n    const [oX, oY] = getCanvasSize(overlay)\n    maxX = Math.max(maxX, oX + offset.x)\n    maxY = Math.max(maxY, oY + offset.y)\n  }\n\n  const merged = mkCanvas(maxX, maxY)\n\n  // Copy base\n  for (let x = 0; x <= maxX; x++) {\n    for (let y = 0; y <= maxY; y++) {\n      if (x < base.length && y < base[0]!.length) {\n        merged[x]![y] = base[x]![y]!\n      }\n    }\n  }\n\n  // Apply overlays\n  for (const overlay of overlays) {\n    for (let x = 0; x < overlay.length; x++) {\n      for (let y = 0; y < overlay[0]!.length; y++) {\n        const c = overlay[x]![y]!\n        if (c !== ' ') {\n          const mx = x + offset.x\n          const my = y + offset.y\n          const current = merged[mx]![my]!\n          if (!useAscii && isJunctionChar(c) && isJunctionChar(current)) {\n            merged[mx]![my] = mergeJunctions(current, c)\n          } else if (isAlphanumeric(current) && isAlphanumeric(c)) {\n            // Don't overwrite existing label text with new label text\n            // This prevents label collisions (first label wins)\n          } else {\n            merged[mx]![my] = c\n          }\n        }\n      }\n    }\n  }\n\n  return merged\n}\n\n// ============================================================================\n// Canvas → string conversion\n// ============================================================================\n\n/** Options for converting canvas to string with optional coloring. */\nexport interface CanvasToStringOptions {\n  /** Role canvas for applying colors. If not provided, output is plain text. */\n  roleCanvas?: RoleCanvas\n  /** Color mode for terminal output. Default: 'none' */\n  colorMode?: ColorMode\n  /** Theme colors for ASCII output. Uses default theme if not provided. */\n  theme?: AsciiTheme\n}\n\n/**\n * Convert the canvas to a multi-line string (row by row, left to right).\n * Optionally applies ANSI color codes based on character roles.\n */\nexport function canvasToString(canvas: Canvas, options?: CanvasToStringOptions): string {\n  const [maxX, maxY] = getCanvasSize(canvas)\n  const lines: string[] = []\n\n  const roleCanvas = options?.roleCanvas\n  const colorMode = options?.colorMode ?? 'none'\n  const theme = options?.theme ?? DEFAULT_ASCII_THEME\n\n  for (let y = 0; y <= maxY; y++) {\n    if (colorMode === 'none' || !roleCanvas) {\n      // Plain text output — no colors\n      let line = ''\n      for (let x = 0; x <= maxX; x++) {\n        line += canvas[x]![y]!\n      }\n      lines.push(line)\n    } else {\n      // Colored output — collect chars and roles for this row\n      const chars: string[] = []\n      const roles: (CharRole | null)[] = []\n      for (let x = 0; x <= maxX; x++) {\n        chars.push(canvas[x]![y]!)\n        roles.push(roleCanvas[x]?.[y] ?? null)\n      }\n      lines.push(colorizeLine(chars, roles, theme, colorMode))\n    }\n  }\n\n  return lines.join('\\n')\n}\n\n// ============================================================================\n// Canvas vertical flip — used for BT (bottom-to-top) direction support.\n//\n// The ASCII renderer lays out graphs top-down (TD). For BT direction, we\n// flip the finished canvas vertically and remap directional characters so\n// arrows point upward and corners are mirrored correctly.\n// ============================================================================\n\n/**\n * Characters that change meaning when the Y-axis is flipped.\n * Symmetric characters (─, │, ├, ┤, ┼) are unchanged.\n */\nconst VERTICAL_FLIP_MAP: Record<string, string> = {\n  // Unicode arrows\n  '▲': '▼', '▼': '▲',\n  '◤': '◣', '◣': '◤',\n  '◥': '◢', '◢': '◥',\n  // ASCII arrows\n  '^': 'v', 'v': '^',\n  // Unicode corners\n  '┌': '└', '└': '┌',\n  '┐': '┘', '┘': '┐',\n  // Unicode junctions (T-pieces flip vertically)\n  '┬': '┴', '┴': '┬',\n  // Box-start junctions (exit points from node boxes)\n  '╵': '╷', '╷': '╵',\n}\n\n/**\n * Flip the canvas vertically (mirror across the horizontal center).\n * Reverses row order within each column and remaps directional characters\n * (arrows, corners, junctions) so they point the correct way after flip.\n *\n * Used to transform a TD-rendered canvas into BT output.\n * Mutates the canvas in place and returns it.\n */\nexport function flipCanvasVertically(canvas: Canvas): Canvas {\n  // Reverse each column array (Y-axis flip in column-major layout)\n  for (const col of canvas) {\n    col.reverse()\n  }\n\n  // Remap directional characters that change meaning after vertical flip\n  for (const col of canvas) {\n    for (let y = 0; y < col.length; y++) {\n      const flipped = VERTICAL_FLIP_MAP[col[y]!]\n      if (flipped) col[y] = flipped\n    }\n  }\n\n  return canvas\n}\n\n/**\n * Flip the role canvas vertically to match flipCanvasVertically.\n * Mutates the role canvas in place and returns it.\n */\nexport function flipRoleCanvasVertically(roleCanvas: RoleCanvas): RoleCanvas {\n  for (const col of roleCanvas) {\n    col.reverse()\n  }\n  return roleCanvas\n}\n\n/**\n * Draw text string onto the canvas starting at the given coordinate.\n * By default, preserves existing non-space characters (labels don't overwrite each other).\n * Set forceOverwrite=true to always overwrite (for box content).\n */\nexport function drawText(\n  canvas: Canvas,\n  start: DrawingCoord,\n  text: string,\n  forceOverwrite = false\n): void {\n  increaseSize(canvas, start.x + text.length, start.y)\n  for (let i = 0; i < text.length; i++) {\n    const x = start.x + i\n    const current = canvas[x]![start.y]!\n    // Only write if target is empty or we're forcing overwrite\n    if (forceOverwrite || current === ' ') {\n      canvas[x]![start.y] = text[i]!\n    }\n  }\n}\n\n/**\n * Set the canvas size to fit all grid columns and rows.\n * Called after layout to ensure the canvas covers the full drawing area.\n */\nexport function setCanvasSizeToGrid(\n  canvas: Canvas,\n  columnWidth: Map<number, number>,\n  rowHeight: Map<number, number>,\n): void {\n  let maxX = 0\n  let maxY = 0\n  for (const w of columnWidth.values()) maxX += w\n  for (const h of rowHeight.values()) maxY += h\n  increaseSize(canvas, maxX - 1, maxY - 1)\n}\n\n/**\n * Set the role canvas size to match the grid dimensions.\n * Should be called alongside setCanvasSizeToGrid.\n */\nexport function setRoleCanvasSizeToGrid(\n  roleCanvas: RoleCanvas,\n  columnWidth: Map<number, number>,\n  rowHeight: Map<number, number>,\n): void {\n  let maxX = 0\n  let maxY = 0\n  for (const w of columnWidth.values()) maxX += w\n  for (const h of rowHeight.values()) maxY += h\n  increaseRoleCanvasSize(roleCanvas, maxX - 1, maxY - 1)\n}\n"
  },
  {
    "path": "src/ascii/class-diagram.ts",
    "content": "// ============================================================================\n// ASCII renderer — class diagrams\n//\n// Renders classDiagram text to ASCII/Unicode art.\n// Each class is a multi-compartment box (header | attributes | methods).\n// Relationships are drawn as lines between classes with UML markers.\n//\n// Layout: level-based top-down. \"From\" classes are placed above \"to\" classes\n// for all relationship types, matching ELK/mermaid.com behavior.\n// Relationship lines use simple Manhattan routing (vertical + horizontal).\n// ============================================================================\n\nimport { parseClassDiagram } from '../class/parser.ts'\nimport type { ClassDiagram, ClassNode, ClassMember, ClassRelationship, RelationshipType } from '../class/types.ts'\nimport type { Canvas, AsciiConfig, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types.ts'\nimport { mkCanvas, mkRoleCanvas, canvasToString, increaseSize, increaseRoleCanvasSize, setRole } from './canvas.ts'\nimport { drawMultiBox } from './draw.ts'\nimport { splitLines } from './multiline-utils.ts'\n\n/** Classify a character from a box drawing as 'border' or 'text'. */\nfunction classifyBoxChar(ch: string): CharRole {\n  if (/^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\\-|]$/.test(ch)) return 'border'\n  return 'text'\n}\n\n// ============================================================================\n// Class member formatting\n// ============================================================================\n\n/** Format a class member as a display string: visibility + name + optional type */\nfunction formatMember(m: ClassMember): string {\n  const vis = m.visibility || ''\n  const type = m.type ? `: ${m.type}` : ''\n  return `${vis}${m.name}${type}`\n}\n\n/** Build the text sections for a class box: [header], [attributes], [methods] */\nfunction buildClassSections(cls: ClassNode): string[][] {\n  // Header section: optional annotation + class name (may be multi-line)\n  const header: string[] = []\n  if (cls.annotation) header.push(`<<${cls.annotation}>>`)\n  // Support multi-line class names\n  const nameLines = splitLines(cls.label)\n  header.push(...nameLines)\n\n  // Attributes section\n  const attrs = cls.attributes.map(formatMember)\n\n  // Methods section\n  const methods = cls.methods.map(formatMember)\n\n  // If no attrs and no methods, just return header (1-section box)\n  if (attrs.length === 0 && methods.length === 0) return [header]\n  // If no methods, return header + attrs (2-section box)\n  if (methods.length === 0) return [header, attrs]\n  // Full 3-section box\n  return [header, attrs, methods]\n}\n\n// ============================================================================\n// Relationship marker characters\n// ============================================================================\n\ninterface RelMarker {\n  /** Relationship type (determines marker shape) */\n  type: RelationshipType\n  /** Which end the marker is placed at */\n  markerAt: 'from' | 'to'\n  /** Whether the line is dashed */\n  dashed: boolean\n}\n\n/**\n * Build the marker metadata for a relationship.\n * The actual marker character will be determined at placement time based on line direction.\n */\nfunction getRelMarker(type: RelationshipType, markerAt: 'from' | 'to'): RelMarker {\n  const dashed = type === 'dependency' || type === 'realization'\n  return { type, markerAt, dashed }\n}\n\n/**\n * Get the UML marker shape character for a relationship type.\n * For directional arrows (association/dependency), the direction parameter\n * specifies which way the arrow should point.\n */\nfunction getMarkerShape(\n  type: RelationshipType,\n  useAscii: boolean,\n  direction?: 'up' | 'down' | 'left' | 'right'\n): string {\n  switch (type) {\n    case 'inheritance':\n    case 'realization':\n      // Hollow triangle - rotate based on line direction\n      // Triangle points TOWARD the parent class\n      if (direction === 'down') {\n        // Line goes down (parent above, child below) - triangle points UP\n        return useAscii ? '^' : '△'\n      } else if (direction === 'up') {\n        // Line goes up (parent below, child above) - triangle points DOWN\n        return useAscii ? 'v' : '▽'\n      } else if (direction === 'left') {\n        // Line goes left - triangle points LEFT\n        return useAscii ? '>' : '◁'\n      } else {\n        // Default: line goes right - triangle points RIGHT\n        return useAscii ? '<' : '▷'\n      }\n    case 'composition':\n      // Filled diamond - omnidirectional shape\n      return useAscii ? '*' : '◆'\n    case 'aggregation':\n      // Hollow diamond - omnidirectional shape\n      return useAscii ? 'o' : '◇'\n    case 'association':\n    case 'dependency':\n      // Directional arrow - rotate based on line direction\n      if (direction === 'down') {\n        return useAscii ? 'v' : '▼'\n      } else if (direction === 'up') {\n        return useAscii ? '^' : '▲'\n      } else if (direction === 'left') {\n        return useAscii ? '<' : '◀'\n      } else {\n        // Default to right (or when direction not specified)\n        return useAscii ? '>' : '▶'\n      }\n  }\n}\n\n// ============================================================================\n// Layout and rendering\n// ============================================================================\n\n/** Positioned class node on the canvas */\ninterface PlacedClass {\n  cls: ClassNode\n  sections: string[][]\n  x: number\n  y: number\n  width: number\n  height: number\n}\n\n/**\n * Render a Mermaid class diagram to ASCII/Unicode text.\n *\n * Pipeline: parse → build boxes → level-based layout → draw boxes → draw relationships → string.\n */\nexport function renderClassAscii(text: string, config: AsciiConfig, colorMode?: ColorMode, theme?: AsciiTheme): string {\n  const lines = text.split('\\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%'))\n  const diagram = parseClassDiagram(lines)\n\n  if (diagram.classes.length === 0) return ''\n\n  const useAscii = config.useAscii\n  const hGap = 4  // horizontal gap between class boxes\n  const vGap = 3  // vertical gap between levels (enough for relationship lines)\n\n  // --- Build box dimensions for each class ---\n  const classSections = new Map<string, string[][]>()\n  const classBoxW = new Map<string, number>()\n  const classBoxH = new Map<string, number>()\n\n  for (const cls of diagram.classes) {\n    const sections = buildClassSections(cls)\n    classSections.set(cls.id, sections)\n\n    // Compute box dimensions from drawMultiBox logic\n    let maxTextW = 0\n    for (const section of sections) {\n      for (const line of section) maxTextW = Math.max(maxTextW, line.length)\n    }\n    const boxW = maxTextW + 4 // 2 border + 2 padding\n\n    let totalLines = 0\n    for (const section of sections) totalLines += Math.max(section.length, 1)\n    const boxH = totalLines + (sections.length - 1) + 2 // section lines + dividers + top/bottom border\n\n    classBoxW.set(cls.id, boxW)\n    classBoxH.set(cls.id, boxH)\n  }\n\n  // --- Assign levels: topological sort based on directed relationships ---\n  // All relationship types place \"from\" above \"to\" in the layout, matching\n  // ELK's layered algorithm and the official mermaid.com renderer behavior.\n  // For \"Animal <|-- Dog\": from=\"Animal\", to=\"Dog\" → Animal above Dog.\n  //\n  // Every relationship type (including association and dependency) forces nodes\n  // to different levels. Same-row routing for mixed diagrams causes collisions:\n  // detour lines overlap with cross-level routing, and labels overwrite box borders.\n\n  const classById = new Map<string, ClassNode>()\n  for (const cls of diagram.classes) classById.set(cls.id, cls)\n\n  const parents = new Map<string, Set<string>>()  // child → set of parent IDs\n  const children = new Map<string, Set<string>>() // parent → set of child IDs\n\n  for (const rel of diagram.relationships) {\n    // For inheritance/realization, the marker (hollow triangle) points to the parent.\n    // - `Animal <|-- Dog` (markerAt='from'): Animal is parent, Dog is child\n    // - `Bird ..|> Flyable` (markerAt='to'): Flyable is parent, Bird is child\n    // For other relationships, use the default from→to direction.\n    const isHierarchical = rel.type === 'inheritance' || rel.type === 'realization'\n    const parentId = isHierarchical && rel.markerAt === 'to' ? rel.to : rel.from\n    const childId = isHierarchical && rel.markerAt === 'to' ? rel.from : rel.to\n\n    if (!parents.has(childId)) parents.set(childId, new Set())\n    parents.get(childId)!.add(parentId)\n    if (!children.has(parentId)) children.set(parentId, new Set())\n    children.get(parentId)!.add(childId)\n  }\n\n  // BFS from roots (classes that have no parents) to assign levels.\n  // Cap at classes.length - 1 to prevent infinite loops on cyclic graphs\n  // (e.g. View --> Model and Model ..> View would otherwise push levels\n  // upward forever). In a DAG the longest path has at most N-1 edges.\n  const level = new Map<string, number>()\n  const roots = diagram.classes.filter(c => !parents.has(c.id) || parents.get(c.id)!.size === 0)\n  const queue: string[] = roots.map(c => c.id)\n  for (const id of queue) level.set(id, 0)\n\n  const levelCap = diagram.classes.length - 1\n  let qi = 0\n  while (qi < queue.length) {\n    const id = queue[qi++]!\n    const childSet = children.get(id)\n    if (!childSet) continue\n    for (const childId of childSet) {\n      const newLevel = (level.get(id) ?? 0) + 1\n      if (newLevel > levelCap) continue // cycle detected — skip to prevent infinite loop\n      if (!level.has(childId) || level.get(childId)! < newLevel) {\n        level.set(childId, newLevel)\n        queue.push(childId)\n      }\n    }\n  }\n\n  // Assign remaining (unconnected) classes to level 0\n  for (const cls of diagram.classes) {\n    if (!level.has(cls.id)) level.set(cls.id, 0)\n  }\n\n  // --- Position classes by level ---\n  // Group classes by level\n  const maxLevel = Math.max(...[...level.values()], 0)\n  const levelGroups: string[][] = Array.from({ length: maxLevel + 1 }, () => [])\n  for (const cls of diagram.classes) {\n    levelGroups[level.get(cls.id)!]!.push(cls.id)\n  }\n\n  // Compute positions: each level is a row, classes in a row are spaced horizontally\n  const placed = new Map<string, PlacedClass>()\n  let currentY = 0\n\n  for (let lv = 0; lv <= maxLevel; lv++) {\n    const group = levelGroups[lv]!\n    if (group.length === 0) continue\n\n    let currentX = 0\n    let maxH = 0\n\n    for (const id of group) {\n      const cls = classById.get(id)!\n      const w = classBoxW.get(id)!\n      const h = classBoxH.get(id)!\n      placed.set(id, {\n        cls,\n        sections: classSections.get(id)!,\n        x: currentX,\n        y: currentY,\n        width: w,\n        height: h,\n      })\n      currentX += w + hGap\n      maxH = Math.max(maxH, h)\n    }\n\n    currentY += maxH + vGap\n  }\n\n  // --- Create canvas ---\n  let totalW = 0\n  let totalH = 0\n  for (const p of placed.values()) {\n    totalW = Math.max(totalW, p.x + p.width)\n    totalH = Math.max(totalH, p.y + p.height)\n  }\n\n  // Extra space for relationship lines that may go below/beside\n  totalW += 4\n  totalH += 2\n\n  const canvas = mkCanvas(totalW - 1, totalH - 1)\n  const rc = mkRoleCanvas(totalW - 1, totalH - 1)\n\n  /** Set a character on the canvas and track its role. */\n  function setC(x: number, y: number, ch: string, role: CharRole): void {\n    if (x >= 0 && x < canvas.length && y >= 0 && y < (canvas[0]?.length ?? 0)) {\n      canvas[x]![y] = ch\n      setRole(rc, x, y, role)\n    }\n  }\n\n  // --- Draw class boxes ---\n  for (const p of placed.values()) {\n    const boxCanvas = drawMultiBox(p.sections, useAscii)\n    // Copy box onto main canvas at (p.x, p.y) with role tracking\n    for (let bx = 0; bx < boxCanvas.length; bx++) {\n      for (let by = 0; by < boxCanvas[0]!.length; by++) {\n        const ch = boxCanvas[bx]![by]!\n        if (ch !== ' ') {\n          const cx = p.x + bx\n          const cy = p.y + by\n          if (cx < totalW && cy < totalH) {\n            setC(cx, cy, ch, classifyBoxChar(ch))\n          }\n        }\n      }\n    }\n  }\n\n  // --- Build occupancy map for collision avoidance ---\n  // Track which x positions are occupied at each y level (to avoid routing through boxes)\n  const boxOccupancy: { x1: number; x2: number; y1: number; y2: number }[] = []\n  for (const p of placed.values()) {\n    boxOccupancy.push({\n      x1: p.x,\n      x2: p.x + p.width - 1,\n      y1: p.y,\n      y2: p.y + p.height - 1,\n    })\n  }\n\n  /** Check if a point (x, y) is inside any class box */\n  function isInsideBox(x: number, y: number, excludeIds?: Set<string>): boolean {\n    for (const [id, p] of placed.entries()) {\n      if (excludeIds?.has(id)) continue\n      if (x >= p.x && x <= p.x + p.width - 1 && y >= p.y && y <= p.y + p.height - 1) {\n        return true\n      }\n    }\n    return false\n  }\n\n  /** Find a clear vertical column for routing that doesn't pass through any boxes */\n  function findClearColumn(startX: number, y1: number, y2: number, excludeIds: Set<string>): number {\n    // Try the original column first\n    let clear = true\n    for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) {\n      if (isInsideBox(startX, y, excludeIds)) {\n        clear = false\n        break\n      }\n    }\n    if (clear) return startX\n\n    // Try columns to the left and right, alternating\n    for (let offset = 1; offset < totalW + 10; offset++) {\n      // Try right\n      const rightX = startX + offset\n      clear = true\n      for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) {\n        if (isInsideBox(rightX, y, excludeIds)) {\n          clear = false\n          break\n        }\n      }\n      if (clear) return rightX\n\n      // Try left\n      const leftX = startX - offset\n      if (leftX >= 0) {\n        clear = true\n        for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) {\n          if (isInsideBox(leftX, y, excludeIds)) {\n            clear = false\n            break\n          }\n        }\n        if (clear) return leftX\n      }\n    }\n\n    // Fallback to right edge of canvas + some extra space\n    return totalW + 2\n  }\n\n  // --- Draw relationship lines ---\n  const H = useAscii ? '-' : '─'\n  const V = useAscii ? '|' : '│'\n  const dashH = useAscii ? '.' : '╌'\n  const dashV = useAscii ? ':' : '┊'\n\n  for (const rel of diagram.relationships) {\n    const fromP = placed.get(rel.from)\n    const toP = placed.get(rel.to)\n    if (!fromP || !toP) continue\n\n    const marker = getRelMarker(rel.type, rel.markerAt)\n    const lineH = marker.dashed ? dashH : H\n    const lineV = marker.dashed ? dashV : V\n\n    // Exclude source and target boxes from collision detection\n    const excludeIds = new Set([rel.from, rel.to])\n\n    // Connection points: center-bottom of source → center-top of target\n    const fromCX = fromP.x + Math.floor(fromP.width / 2)\n    const fromBY = fromP.y + fromP.height - 1\n    const toCX = toP.x + Math.floor(toP.width / 2)\n    const toTY = toP.y\n\n    // Route: Manhattan routing with collision avoidance\n    // If target is below source: vertical down from source, horizontal if needed, vertical down to target\n    // If same row: horizontal line with a small vertical detour above or below\n    if (fromBY < toTY) {\n      // Target is below source — routing with collision avoidance\n      // Find a clear vertical column for the ENTIRE path from source to target\n      const routeX = findClearColumn(fromCX, fromBY + 1, toTY - 1, excludeIds)\n      const needsDetour = routeX !== fromCX\n\n      // Expand canvas if needed to accommodate routing column\n      if (routeX >= totalW) {\n        increaseSize(canvas, routeX + 2, totalH)\n      }\n\n      if (needsDetour) {\n        // COLLISION CASE: Route around intermediate boxes\n        // Path: source center → horizontal to routeX → vertical to entry → horizontal to target center\n\n        const exitY = fromBY + 1\n        const entryY = toTY - 1\n\n        // 1. Horizontal from source center to route column\n        const lx1 = Math.min(fromCX, routeX)\n        const rx1 = Math.max(fromCX, routeX)\n        for (let x = lx1; x <= rx1; x++) {\n          setC(x, exitY, lineH, 'line')\n        }\n        if (!useAscii && exitY < (canvas[0]?.length ?? 0)) {\n          if (fromCX < routeX) {\n            setC(fromCX, exitY, '└', 'corner')\n            setC(routeX, exitY, '┐', 'corner')\n          } else {\n            setC(fromCX, exitY, '┘', 'corner')\n            setC(routeX, exitY, '┌', 'corner')\n          }\n        }\n\n        // 2. Vertical at routeX from exit to entry\n        for (let y = exitY + 1; y <= entryY; y++) {\n          setC(routeX, y, lineV, 'line')\n        }\n\n        // 3. Horizontal from routeX to target center at entry\n        if (routeX !== toCX) {\n          const lx2 = Math.min(routeX, toCX)\n          const rx2 = Math.max(routeX, toCX)\n          for (let x = lx2; x <= rx2; x++) {\n            setC(x, entryY, lineH, 'line')\n          }\n          if (!useAscii && entryY < (canvas[0]?.length ?? 0)) {\n            if (routeX < toCX) {\n              setC(routeX, entryY, '└', 'corner')\n              setC(toCX, entryY, '┐', 'corner')\n            } else {\n              setC(routeX, entryY, '┘', 'corner')\n              setC(toCX, entryY, '┌', 'corner')\n            }\n          }\n        }\n\n        // Markers for detour case\n        if (marker.markerAt === 'to') {\n          const markerChar = getMarkerShape(marker.type, useAscii, 'down')\n          setC(toCX, entryY, markerChar, 'arrow')\n        }\n        if (marker.markerAt === 'from') {\n          const markerChar = getMarkerShape(marker.type, useAscii, 'down')\n          setC(fromCX, fromBY + 1, markerChar, 'arrow')\n        }\n      } else {\n        // NO COLLISION CASE: Use original midpoint-based routing\n        // Path: source center → vertical to midY → horizontal at midY → vertical to target\n\n        const midY = fromBY + Math.floor((toTY - fromBY) / 2)\n\n        // 1. Vertical from source bottom to midY\n        for (let y = fromBY + 1; y <= midY; y++) {\n          setC(fromCX, y, lineV, 'line')\n        }\n\n        // 2. Horizontal from fromCX to toCX at midY (if needed)\n        if (fromCX !== toCX && midY < (canvas[0]?.length ?? 0)) {\n          const lx = Math.min(fromCX, toCX)\n          const rx = Math.max(fromCX, toCX)\n          for (let x = lx; x <= rx; x++) {\n            setC(x, midY, lineH, 'line')\n          }\n          if (!useAscii) {\n            setC(fromCX, midY, fromCX < toCX ? '└' : '┘', 'corner')\n            setC(toCX, midY, fromCX < toCX ? '┐' : '┌', 'corner')\n          }\n        }\n\n        // 3. Vertical from midY to target top\n        for (let y = midY + 1; y < toTY; y++) {\n          setC(toCX, y, lineV, 'line')\n        }\n\n        // Markers for no-collision case\n        if (marker.markerAt === 'to') {\n          setC(toCX, toTY - 1, getMarkerShape(marker.type, useAscii, 'down'), 'arrow')\n        }\n        if (marker.markerAt === 'from') {\n          setC(fromCX, fromBY + 1, getMarkerShape(marker.type, useAscii, 'down'), 'arrow')\n        }\n      }\n    } else if (toP.y + toP.height - 1 < fromP.y) {\n      // Target is ABOVE source — draw upward from source top to target bottom\n      const fromTY = fromP.y\n      const toBY = toP.y + toP.height - 1\n      const midY = toBY + Math.floor((fromTY - toBY) / 2)\n\n      for (let y = fromTY - 1; y >= midY; y--) {\n        setC(fromCX, y, lineV, 'line')\n      }\n\n      if (fromCX !== toCX) {\n        const lx = Math.min(fromCX, toCX)\n        const rx = Math.max(fromCX, toCX)\n        for (let x = lx; x <= rx; x++) {\n          setC(x, midY, lineH, 'line')\n        }\n        if (!useAscii && midY >= 0 && midY < totalH) {\n          setC(fromCX, midY, fromCX < toCX ? '┌' : '┐', 'corner')\n          setC(toCX, midY, fromCX < toCX ? '┘' : '└', 'corner')\n        }\n      }\n\n      for (let y = midY - 1; y > toBY; y--) {\n        setC(toCX, y, lineV, 'line')\n      }\n\n      // Draw markers - arrows point in the direction of the vertical segment (upward)\n      if (marker.markerAt === 'from') {\n        const markerChar = getMarkerShape(marker.type, useAscii, 'up')\n        const my = fromTY - 1\n        for (let i = 0; i < markerChar.length; i++) {\n          setC(fromCX - Math.floor(markerChar.length / 2) + i, my, markerChar[i]!, 'arrow')\n        }\n      }\n      if (marker.markerAt === 'to') {\n        const isHierarchical = marker.type === 'inheritance' || marker.type === 'realization'\n        const markerDir = isHierarchical ? 'down' : 'up'\n        const markerChar = getMarkerShape(marker.type, useAscii, markerDir)\n        const my = toBY + 1\n        for (let i = 0; i < markerChar.length; i++) {\n          setC(toCX - Math.floor(markerChar.length / 2) + i, my, markerChar[i]!, 'arrow')\n        }\n      }\n    } else {\n      // Same level — draw horizontal line with a detour below both boxes\n      const detourY = Math.max(fromBY, toP.y + toP.height - 1) + 2\n      increaseSize(canvas, totalW, detourY + 1)\n      increaseRoleCanvasSize(rc, totalW, detourY + 1)\n\n      // Vertical down from source\n      for (let y = fromBY + 1; y <= detourY; y++) {\n        setC(fromCX, y, lineV, 'line')\n      }\n      // Horizontal\n      const lx = Math.min(fromCX, toCX)\n      const rx = Math.max(fromCX, toCX)\n      for (let x = lx; x <= rx; x++) {\n        setC(x, detourY, lineH, 'line')\n      }\n      // Vertical up to target\n      for (let y = detourY - 1; y >= toP.y + toP.height; y--) {\n        setC(toCX, y, lineV, 'line')\n      }\n\n      // Draw markers - same-level routing uses vertical segments at both ends\n      if (marker.markerAt === 'from') {\n        const markerChar = getMarkerShape(marker.type, useAscii, 'down')\n        const my = fromBY + 1\n        for (let i = 0; i < markerChar.length; i++) {\n          setC(fromCX - Math.floor(markerChar.length / 2) + i, my, markerChar[i]!, 'arrow')\n        }\n      }\n      if (marker.markerAt === 'to') {\n        const markerChar = getMarkerShape(marker.type, useAscii, 'up')\n        const my = toP.y + toP.height\n        for (let i = 0; i < markerChar.length; i++) {\n          setC(toCX - Math.floor(markerChar.length / 2) + i, my, markerChar[i]!, 'arrow')\n        }\n      }\n    }\n\n    // Draw relationship label at midpoint if present (supports multi-line)\n    // Add padding around the label for readability\n    if (rel.label) {\n      const lines = splitLines(rel.label)\n      const maxLabelWidth = Math.max(...lines.map(l => l.length)) + 2 // +2 for padding\n\n      // Calculate ideal label position based on routing direction\n      let baseMidY: number\n      let idealMidX: number\n\n      if (fromBY < toTY) {\n        // Target below source: place in gap between source bottom and target top\n        baseMidY = Math.floor((fromBY + 1 + toTY - 1) / 2)\n        idealMidX = Math.floor((fromCX + toCX) / 2)\n      } else if (toP.y + toP.height - 1 < fromP.y) {\n        // Target above source: place in gap between target bottom and source top\n        const toBY = toP.y + toP.height - 1\n        baseMidY = Math.floor((toBY + 1 + fromP.y - 1) / 2)\n        idealMidX = Math.floor((fromCX + toCX) / 2)\n      } else {\n        // Same level: place label at midpoint of the detour line\n        baseMidY = Math.max(fromBY, toP.y + toP.height - 1) + 2\n        idealMidX = Math.floor((fromCX + toCX) / 2)\n      }\n\n      // Find a clear vertical position for the label (not inside any box)\n      let labelY = baseMidY\n      const halfHeight = Math.floor(lines.length / 2)\n\n      // Check if any label line would be inside a box\n      let labelInBox = false\n      for (let i = 0; i < lines.length; i++) {\n        const y = labelY - halfHeight + i\n        const idealLabelStart = idealMidX - Math.floor(maxLabelWidth / 2)\n        const labelStart = Math.max(0, idealLabelStart)\n        // Check if this line overlaps any box\n        for (let x = labelStart; x < labelStart + maxLabelWidth; x++) {\n          if (isInsideBox(x, y, excludeIds)) {\n            labelInBox = true\n            break\n          }\n        }\n        if (labelInBox) break\n      }\n\n      // If label is inside a box, find the gap between boxes\n      if (labelInBox) {\n        // Find the gap between source and target boxes\n        const gapTop = fromBY + 1\n        const gapBottom = toTY - 1\n\n        // Place label in the middle of the gap, outside any intermediate box\n        for (let y = gapTop; y <= gapBottom; y++) {\n          let clearRow = true\n          const idealLabelStart = idealMidX - Math.floor(maxLabelWidth / 2)\n          const labelStart = Math.max(0, idealLabelStart)\n          for (let x = labelStart; x < labelStart + maxLabelWidth; x++) {\n            if (isInsideBox(x, y, excludeIds)) {\n              clearRow = false\n              break\n            }\n          }\n          if (clearRow) {\n            labelY = y\n            break\n          }\n        }\n      }\n\n      // Center lines vertically around labelY\n      const startY = labelY - halfHeight\n\n      for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {\n        const paddedLine = ` ${lines[lineIdx]!} `  // Add space padding on both sides\n        // Calculate label start, but ensure it doesn't go negative\n        const idealLabelStart = idealMidX - Math.floor(paddedLine.length / 2)\n        const labelStart = Math.max(0, idealLabelStart)\n        const y = startY + lineIdx\n        // Ensure canvas is wide enough for the label\n        const labelEnd = labelStart + paddedLine.length\n        if (labelEnd > 0 && y >= 0) {\n          increaseSize(canvas, Math.max(labelEnd, 1), Math.max(y + 1, 1))\n          increaseRoleCanvasSize(rc, Math.max(labelEnd, 1), Math.max(y + 1, 1))\n        }\n        // Clear the area first (overwrite line characters) then draw the padded label\n        for (let i = 0; i < paddedLine.length; i++) {\n          const lx = labelStart + i\n          if (lx >= 0 && y >= 0) {\n            setC(lx, y, paddedLine[i]!, 'text')\n          }\n        }\n      }\n    }\n  }\n\n  return canvasToString(canvas, { roleCanvas: rc, colorMode, theme })\n}\n"
  },
  {
    "path": "src/ascii/converter.ts",
    "content": "// ============================================================================\n// ASCII renderer — MermaidGraph → AsciiGraph converter\n//\n// Bridges the existing TypeScript parser output to the ASCII renderer's\n// internal graph structure. This avoids maintaining a separate parser\n// for ASCII rendering — we reuse parseMermaid() and convert its output.\n// ============================================================================\n\nimport type { MermaidGraph, MermaidSubgraph } from '../types.ts'\nimport type {\n  AsciiGraph, AsciiNode, AsciiEdge, AsciiSubgraph, AsciiConfig,\n} from './types.ts'\nimport { EMPTY_STYLE } from './types.ts'\nimport { mkCanvas, mkRoleCanvas } from './canvas.ts'\n\n/**\n * Convert a parsed MermaidGraph into an AsciiGraph ready for grid layout.\n *\n * Key mappings:\n * - MermaidGraph.nodes (Map) → ordered AsciiNode[] preserving insertion order\n * - MermaidGraph.edges → AsciiEdge[] with resolved node references\n * - MermaidGraph.subgraphs → AsciiSubgraph[] with parent/child tree\n * - Node labels are used as display names (not raw IDs)\n */\nexport function convertToAsciiGraph(parsed: MermaidGraph, config: AsciiConfig): AsciiGraph {\n  // Build node list preserving Map insertion order\n  const nodeMap = new Map<string, AsciiNode>()\n  let index = 0\n\n  for (const [id, mNode] of parsed.nodes) {\n    const asciiNode: AsciiNode = {\n      // Use the parser ID as the unique identity key to avoid collisions\n      // when multiple nodes share the same label (e.g. A[Web Server], C[Web Server]).\n      name: id,\n      // The label is used for rendering inside the box.\n      displayLabel: mNode.label,\n      // Preserve shape from parser for shape-aware rendering\n      shape: mNode.shape,\n      index,\n      gridCoord: null,\n      drawingCoord: null,\n      drawing: null,\n      drawn: false,\n      styleClassName: '',\n      styleClass: EMPTY_STYLE,\n    }\n    nodeMap.set(id, asciiNode)\n    index++\n  }\n\n  const nodes = [...nodeMap.values()]\n\n  // Build edges with resolved node references\n  const edges: AsciiEdge[] = []\n  for (const mEdge of parsed.edges) {\n    const from = nodeMap.get(mEdge.source)\n    const to = nodeMap.get(mEdge.target)\n    if (!from || !to) continue\n\n    edges.push({\n      from,\n      to,\n      text: mEdge.label ?? '',\n      path: [],\n      labelLine: [],\n      startDir: { x: 0, y: 0 },\n      endDir: { x: 0, y: 0 },\n      style: mEdge.style,\n      hasArrowStart: mEdge.hasArrowStart,\n      hasArrowEnd: mEdge.hasArrowEnd,\n    })\n  }\n\n  // Convert subgraphs recursively\n  const subgraphs: AsciiSubgraph[] = []\n  for (const mSg of parsed.subgraphs) {\n    convertSubgraph(mSg, null, nodeMap, subgraphs)\n  }\n\n  // Deduplicate subgraph node membership to match Go parser behavior.\n  // In Go, a node belongs only to the subgraph where it was FIRST DEFINED.\n  // The TS parser adds referenced nodes to all subgraphs they appear in,\n  // which causes incorrect bounding boxes when nodes span subgraph boundaries.\n  deduplicateSubgraphNodes(parsed.subgraphs, subgraphs, nodeMap, parsed)\n\n  // Apply class definitions\n  for (const [nodeId, className] of parsed.classAssignments) {\n    const node = nodeMap.get(nodeId)\n    const classDef = parsed.classDefs.get(className)\n    if (node && classDef) {\n      node.styleClassName = className\n      node.styleClass = { name: className, styles: classDef }\n    }\n  }\n\n  return {\n    nodes,\n    edges,\n    canvas: mkCanvas(0, 0),\n    roleCanvas: mkRoleCanvas(0, 0),\n    grid: new Map(),\n    columnWidth: new Map(),\n    rowHeight: new Map(),\n    subgraphs,\n    config,\n    offsetX: 0,\n    offsetY: 0,\n    bundles: [], // Populated by analyzeEdgeBundles() during layout\n  }\n}\n\n/**\n * Recursively convert a MermaidSubgraph to AsciiSubgraph.\n * Flattens the tree into the subgraphs array while maintaining parent/child references.\n * This matches the Go implementation where all subgraphs are in a flat list\n * but linked via parent/children pointers.\n */\nfunction convertSubgraph(\n  mSg: MermaidSubgraph,\n  parent: AsciiSubgraph | null,\n  nodeMap: Map<string, AsciiNode>,\n  allSubgraphs: AsciiSubgraph[],\n): AsciiSubgraph {\n  // Normalize subgraph direction: BT→TD, RL→LR (same as root graph normalization)\n  let normalizedDirection: 'LR' | 'TD' | undefined\n  if (mSg.direction) {\n    normalizedDirection = (mSg.direction === 'LR' || mSg.direction === 'RL') ? 'LR' : 'TD'\n  }\n\n  const sg: AsciiSubgraph = {\n    name: mSg.label,\n    nodes: [],\n    parent,\n    children: [],\n    minX: 0, minY: 0, maxX: 0, maxY: 0,\n    direction: normalizedDirection,\n  }\n\n  // Resolve node references\n  for (const nodeId of mSg.nodeIds) {\n    const node = nodeMap.get(nodeId)\n    if (node) sg.nodes.push(node)\n  }\n\n  allSubgraphs.push(sg)\n\n  // Recurse into children\n  for (const childMSg of mSg.children) {\n    const child = convertSubgraph(childMSg, sg, nodeMap, allSubgraphs)\n    sg.children.push(child)\n\n    // Child nodes are also part of parent subgraphs (Go behavior).\n    // The Go parser adds nodes to ALL subgraphs in the stack, so a nested\n    // node belongs to both the inner and outer subgraph.\n    for (const childNode of child.nodes) {\n      if (!sg.nodes.includes(childNode)) {\n        sg.nodes.push(childNode)\n      }\n    }\n  }\n\n  return sg\n}\n\n/**\n * Deduplicate subgraph node membership to match Go parser behavior.\n *\n * The Go parser only adds a node to the subgraph that was active when the node\n * was FIRST CREATED. If a node is later referenced inside a different subgraph,\n * it is NOT added to that subgraph. The TS parser is more permissive — it adds\n * referenced nodes to whichever subgraph they appear in.\n *\n * This function fixes the discrepancy by:\n * 1. Walking the edges to determine which nodes were first created inside each subgraph\n * 2. Removing nodes from subgraphs where they weren't first created\n */\nfunction deduplicateSubgraphNodes(\n  mermaidSubgraphs: MermaidSubgraph[],\n  asciiSubgraphs: AsciiSubgraph[],\n  nodeMap: Map<string, AsciiNode>,\n  parsed: MermaidGraph,\n): void {\n  // Build a map from MermaidSubgraph to its corresponding AsciiSubgraph.\n  // The ordering matches since we convert them in the same order.\n  const sgMap = new Map<MermaidSubgraph, AsciiSubgraph>()\n  buildSgMap(mermaidSubgraphs, asciiSubgraphs, sgMap)\n\n  // Determine which subgraph each node was \"first defined\" in.\n  // A node is first defined in the subgraph where it first appears as a NEW node\n  // in the ordered edge/node list. We approximate this by checking the global\n  // node insertion order against subgraph membership.\n  const nodeOwner = new Map<string, AsciiSubgraph>() // nodeId → owning subgraph\n\n  // Walk all mermaid subgraphs in document order. For each subgraph,\n  // claim nodes that haven't been claimed yet by any previous subgraph.\n  function claimNodes(mSg: MermaidSubgraph): void {\n    const asciiSg = sgMap.get(mSg)\n    if (!asciiSg) return\n\n    // Recurse into children first (they appear before parent in the Go parser stack,\n    // but nodes defined in children are added to parent too — this is handled by\n    // the convertSubgraph function which propagates child nodes to parents).\n    // For dedup, we process children first so their claims propagate up correctly.\n    for (const child of mSg.children) {\n      claimNodes(child)\n    }\n\n    // Claim unclaimed nodes in this subgraph\n    for (const nodeId of mSg.nodeIds) {\n      if (!nodeOwner.has(nodeId)) {\n        nodeOwner.set(nodeId, asciiSg)\n      }\n    }\n  }\n\n  for (const mSg of mermaidSubgraphs) {\n    claimNodes(mSg)\n  }\n\n  // Now remove nodes from subgraphs that don't own them.\n  // A node should remain in: its owner subgraph + all ancestors of the owner.\n  for (const asciiSg of asciiSubgraphs) {\n    asciiSg.nodes = asciiSg.nodes.filter(node => {\n      // Find this node's ID in the nodeMap\n      let nodeId: string | undefined\n      for (const [id, n] of nodeMap) {\n        if (n === node) { nodeId = id; break }\n      }\n      if (!nodeId) return false\n\n      const owner = nodeOwner.get(nodeId)\n      if (!owner) return true // not in any subgraph claim — keep as-is\n\n      // Keep the node if this subgraph is the owner or an ancestor of the owner\n      return isAncestorOrSelf(asciiSg, owner)\n    })\n  }\n}\n\n/** Check if `candidate` is the same as or an ancestor of `target`. */\nfunction isAncestorOrSelf(candidate: AsciiSubgraph, target: AsciiSubgraph): boolean {\n  let current: AsciiSubgraph | null = target\n  while (current !== null) {\n    if (current === candidate) return true\n    current = current.parent\n  }\n  return false\n}\n\n/** Build a mapping from MermaidSubgraph → AsciiSubgraph (matching by position). */\nfunction buildSgMap(\n  mSgs: MermaidSubgraph[],\n  aSgs: AsciiSubgraph[],\n  result: Map<MermaidSubgraph, AsciiSubgraph>,\n): void {\n  // The asciiSubgraphs array is flat (all subgraphs including nested ones),\n  // while mermaidSubgraphs is hierarchical. We need to flatten the mermaid tree\n  // in the same order the converter processes them (pre-order DFS).\n  const flatMermaid: MermaidSubgraph[] = []\n  function flatten(sgs: MermaidSubgraph[]): void {\n    for (const sg of sgs) {\n      flatMermaid.push(sg)\n      flatten(sg.children)\n    }\n  }\n  flatten(mSgs)\n\n  for (let i = 0; i < flatMermaid.length && i < aSgs.length; i++) {\n    result.set(flatMermaid[i]!, aSgs[i]!)\n  }\n}\n"
  },
  {
    "path": "src/ascii/draw.ts",
    "content": "// ============================================================================\n// ASCII renderer — drawing operations\n//\n// Ported from AlexanderGrooff/mermaid-ascii cmd/draw.go + cmd/arrow.go.\n// Contains all visual rendering: boxes, lines, arrows, corners,\n// subgraphs, labels, and the top-level draw orchestrator.\n// ============================================================================\n\nimport type {\n  Canvas, DrawingCoord, GridCoord, Direction,\n  AsciiGraph, AsciiNode, AsciiEdge, AsciiSubgraph, AsciiEdgeStyle, EdgeBundle,\n} from './types.ts'\nimport {\n  Up, Down, Left, Right, UpperLeft, UpperRight, LowerLeft, LowerRight, Middle,\n  drawingCoordEquals,\n} from './types.ts'\nimport { mkCanvas, copyCanvas, getCanvasSize, mergeCanvases, drawText, mkRoleCanvas, setRole, mergeRoleCanvases } from './canvas.ts'\nimport type { RoleCanvas, CharRole } from './types.ts'\nimport { determineDirection, dirEquals } from './edge-routing.ts'\nimport { gridToDrawingCoord, lineToDrawing } from './grid.ts'\nimport { splitLines } from './multiline-utils.ts'\nimport { getCorners } from './shapes/corners.ts'\nimport { getShapeAttachmentPoint } from './shapes/index.ts'\n\n// ============================================================================\n// Node drawing — renders a node using shape-aware rendering\n// ============================================================================\n\n/**\n * Draw a node using its shape type.\n * Returns a standalone canvas containing the rendered shape.\n *\n * For basic shapes (rectangle, rounded), uses grid-determined dimensions\n * to ensure consistent sizing across nodes in the same column.\n * For special shapes (diamond, circle, state pseudo-states, etc.),\n * uses shape-specific dimension calculation but centers the content\n * within the grid cell dimensions to ensure proper vertical alignment.\n */\nexport function drawNode(node: AsciiNode, graph: AsciiGraph): Canvas {\n  // All shapes use grid-determined dimensions to fill their allocated space.\n  // This ensures consistent sizing across nodes and eliminates gaps between\n  // nodes and subgraph borders. All shapes are rectangles with distinctive\n  // corner characters (defined in corners.ts) to indicate shape type.\n  return drawBoxWithGridDimensions(node, graph)\n}\n\n/**\n * Draw a box shape using grid-determined dimensions.\n * This ensures consistent sizing when multiple nodes share a column,\n * and eliminates gaps between nodes and subgraph borders by filling\n * the entire allocated grid space.\n *\n * All shapes are rendered as rectangles with distinctive corner characters\n * (defined in corners.ts) to indicate shape type.\n */\nfunction drawBoxWithGridDimensions(node: AsciiNode, graph: AsciiGraph): Canvas {\n  const gc = node.gridCoord!\n  const useAscii = graph.config.useAscii\n\n  // Width spans 2 columns (border + content) - matching original behavior\n  let w = 0\n  for (let i = 0; i < 2; i++) {\n    w += graph.columnWidth.get(gc.x + i) ?? 0\n  }\n  // Height spans 2 rows (border + content)\n  let h = 0\n  for (let i = 0; i < 2; i++) {\n    h += graph.rowHeight.get(gc.y + i) ?? 0\n  }\n\n  const from: DrawingCoord = { x: 0, y: 0 }\n  const to: DrawingCoord = { x: w, y: h }\n  const box = mkCanvas(Math.max(from.x, to.x), Math.max(from.y, to.y))\n\n  // Get corner characters for this shape type\n  const corners = getCorners(node.shape, useAscii)\n\n  // State-end uses double border to differentiate from state-start\n  const isDoubleBox = node.shape === 'state-end'\n  const hChar = useAscii ? (isDoubleBox ? '=' : '-') : (isDoubleBox ? '═' : '─')\n  const vChar = useAscii ? (isDoubleBox ? '‖' : '|') : (isDoubleBox ? '║' : '│')\n\n  // Double-box corners (for state-end)\n  const doubleCorners = useAscii\n    ? { tl: '#', tr: '#', bl: '#', br: '#' }\n    : { tl: '╔', tr: '╗', bl: '╚', br: '╝' }\n  const effectiveCorners = isDoubleBox ? doubleCorners : corners\n\n  // Draw box border with shape-specific corners\n  for (let x = from.x + 1; x < to.x; x++) box[x]![from.y] = hChar\n  for (let x = from.x + 1; x < to.x; x++) box[x]![to.y] = hChar\n  for (let y = from.y + 1; y < to.y; y++) box[from.x]![y] = vChar\n  for (let y = from.y + 1; y < to.y; y++) box[to.x]![y] = vChar\n  box[from.x]![from.y] = effectiveCorners.tl\n  box[to.x]![from.y] = effectiveCorners.tr\n  box[from.x]![to.y] = effectiveCorners.bl\n  box[to.x]![to.y] = effectiveCorners.br\n\n  // Center the multi-line display label inside the box\n  const label = node.displayLabel\n  const lines = splitLines(label)\n  const textCenterY = from.y + Math.floor(h / 2)\n  const startY = textCenterY - Math.floor((lines.length - 1) / 2)\n\n  for (let i = 0; i < lines.length; i++) {\n    const line = lines[i]!\n    const textX = from.x + Math.floor(w / 2) - Math.ceil(line.length / 2) + 1\n    for (let j = 0; j < line.length; j++) {\n      if (textX + j >= 0 && textX + j < box.length && startY + i >= 0 && startY + i < box[0]!.length) {\n        box[textX + j]![startY + i] = line[j]!\n      }\n    }\n  }\n\n  return box\n}\n\n/**\n * Draw a node box with centered label text.\n * Returns a standalone canvas containing just the box.\n * Box size is determined by the grid column/row sizes for the node's position.\n */\nexport function drawBox(node: AsciiNode, graph: AsciiGraph): Canvas {\n  return drawNode(node, graph)\n}\n\n// ============================================================================\n// Multi-section box drawing — for class and ER diagram nodes\n// ============================================================================\n\n/**\n * Draw a multi-section box with horizontal dividers between sections.\n * Used by class diagrams (header | attributes | methods) and ER diagrams (header | attributes).\n * Each section is an array of text lines to render left-aligned with padding.\n *\n * @param sections - Array of sections, each section is an array of text lines\n * @param useAscii - true for ASCII chars, false for Unicode box-drawing\n * @param padding - horizontal padding inside the box (default 1)\n * @returns A standalone Canvas containing the multi-section box\n */\nexport function drawMultiBox(\n  sections: string[][],\n  useAscii: boolean,\n  padding: number = 1,\n): Canvas {\n  // Compute width: widest line across all sections + 2*padding + 2 border chars\n  let maxTextWidth = 0\n  for (const section of sections) {\n    for (const line of section) {\n      maxTextWidth = Math.max(maxTextWidth, line.length)\n    }\n  }\n  const innerWidth = maxTextWidth + 2 * padding\n  const boxWidth = innerWidth + 2 // +2 for left/right border\n\n  // Compute height: sum of all section line counts + dividers + 2 border rows\n  let totalLines = 0\n  for (const section of sections) {\n    totalLines += Math.max(section.length, 1) // at least 1 row per section\n  }\n  const numDividers = sections.length - 1\n  const boxHeight = totalLines + numDividers + 2 // +2 for top/bottom border\n\n  // Box-drawing characters\n  const hLine = useAscii ? '-' : '─'\n  const vLine = useAscii ? '|' : '│'\n  const tl = useAscii ? '+' : '┌'\n  const tr = useAscii ? '+' : '┐'\n  const bl = useAscii ? '+' : '└'\n  const br = useAscii ? '+' : '┘'\n  const divL = useAscii ? '+' : '├'\n  const divR = useAscii ? '+' : '┤'\n\n  const canvas = mkCanvas(boxWidth - 1, boxHeight - 1)\n\n  // Top border\n  canvas[0]![0] = tl\n  for (let x = 1; x < boxWidth - 1; x++) canvas[x]![0] = hLine\n  canvas[boxWidth - 1]![0] = tr\n\n  // Bottom border\n  canvas[0]![boxHeight - 1] = bl\n  for (let x = 1; x < boxWidth - 1; x++) canvas[x]![boxHeight - 1] = hLine\n  canvas[boxWidth - 1]![boxHeight - 1] = br\n\n  // Left and right borders (full height)\n  for (let y = 1; y < boxHeight - 1; y++) {\n    canvas[0]![y] = vLine\n    canvas[boxWidth - 1]![y] = vLine\n  }\n\n  // Render sections with dividers\n  let row = 1 // current y position (starts after top border)\n  for (let s = 0; s < sections.length; s++) {\n    const section = sections[s]!\n    const lines = section.length > 0 ? section : ['']\n\n    // Draw section text lines\n    for (const line of lines) {\n      const startX = 1 + padding\n      for (let i = 0; i < line.length; i++) {\n        canvas[startX + i]![row] = line[i]!\n      }\n      row++\n    }\n\n    // Draw divider after each section except the last\n    if (s < sections.length - 1) {\n      canvas[0]![row] = divL\n      for (let x = 1; x < boxWidth - 1; x++) canvas[x]![row] = hLine\n      canvas[boxWidth - 1]![row] = divR\n      row++\n    }\n  }\n\n  return canvas\n}\n\n// ============================================================================\n// Line drawing — 8-directional lines on the canvas\n// ============================================================================\n\n/**\n * Line character sets for different edge styles.\n * Each style has horizontal, vertical, and diagonal characters for both\n * Unicode (box-drawing) and ASCII (basic punctuation) modes.\n *\n * Unicode dotted: ┄ (horizontal), ┆ (vertical) — U+2504, U+2506\n * Unicode thick:  ━ (horizontal), ┃ (vertical) — U+2501, U+2503\n */\n/**\n * Line character sets for different edge styles.\n * Only horizontal and vertical characters - no diagonals.\n * All edges use orthogonal Manhattan routing (90° bends only).\n */\nconst LINE_CHARS = {\n  solid: {\n    h: { unicode: '─', ascii: '-' },\n    v: { unicode: '│', ascii: '|' },\n  },\n  dotted: {\n    h: { unicode: '┄', ascii: '.' },\n    v: { unicode: '┆', ascii: ':' },\n  },\n  thick: {\n    h: { unicode: '━', ascii: '=' },\n    v: { unicode: '┃', ascii: '‖' },\n  },\n} as const\n\n/**\n * Draw a line between two drawing coordinates using orthogonal Manhattan routing.\n * Returns the list of coordinates that were drawn on.\n * offsetFrom/offsetTo control how many cells to skip at the start/end.\n *\n * All lines use 90° bends only - no diagonal lines are produced.\n * For diagonal directions, uses horizontal-first routing (draws horizontal\n * segment, then vertical segment).\n */\nexport function drawLine(\n  canvas: Canvas,\n  from: DrawingCoord,\n  to: DrawingCoord,\n  offsetFrom: number,\n  offsetTo: number,\n  useAscii: boolean,\n  style: AsciiEdgeStyle = 'solid',\n): DrawingCoord[] {\n  const dir = determineDirection(from, to)\n  const drawnCoords: DrawingCoord[] = []\n\n  // Select character set based on style (horizontal and vertical only)\n  const chars = LINE_CHARS[style]\n  const hChar = useAscii ? chars.h.ascii : chars.h.unicode\n  const vChar = useAscii ? chars.v.ascii : chars.v.unicode\n\n  // Pure vertical directions\n  if (dirEquals(dir, Up)) {\n    for (let y = from.y - offsetFrom; y >= to.y - offsetTo; y--) {\n      drawnCoords.push({ x: from.x, y })\n      canvas[from.x]![y] = vChar\n    }\n  } else if (dirEquals(dir, Down)) {\n    for (let y = from.y + offsetFrom; y <= to.y + offsetTo; y++) {\n      drawnCoords.push({ x: from.x, y })\n      canvas[from.x]![y] = vChar\n    }\n  }\n  // Pure horizontal directions\n  else if (dirEquals(dir, Left)) {\n    for (let x = from.x - offsetFrom; x >= to.x - offsetTo; x--) {\n      drawnCoords.push({ x, y: from.y })\n      canvas[x]![from.y] = hChar\n    }\n  } else if (dirEquals(dir, Right)) {\n    for (let x = from.x + offsetFrom; x <= to.x + offsetTo; x++) {\n      drawnCoords.push({ x, y: from.y })\n      canvas[x]![from.y] = hChar\n    }\n  }\n  // Diagonal directions: use Manhattan routing (horizontal-first, then vertical)\n  // UpperLeft: go left first, then up\n  else if (dirEquals(dir, UpperLeft)) {\n    // Horizontal segment: from.x -> to.x (going left)\n    for (let x = from.x - offsetFrom; x >= to.x; x--) {\n      drawnCoords.push({ x, y: from.y })\n      canvas[x]![from.y] = hChar\n    }\n    // Vertical segment: from.y -> to.y (going up)\n    for (let y = from.y - 1; y >= to.y - offsetTo; y--) {\n      drawnCoords.push({ x: to.x, y })\n      canvas[to.x]![y] = vChar\n    }\n  }\n  // UpperRight: go right first, then up\n  else if (dirEquals(dir, UpperRight)) {\n    // Horizontal segment: from.x -> to.x (going right)\n    for (let x = from.x + offsetFrom; x <= to.x; x++) {\n      drawnCoords.push({ x, y: from.y })\n      canvas[x]![from.y] = hChar\n    }\n    // Vertical segment: from.y -> to.y (going up)\n    for (let y = from.y - 1; y >= to.y - offsetTo; y--) {\n      drawnCoords.push({ x: to.x, y })\n      canvas[to.x]![y] = vChar\n    }\n  }\n  // LowerLeft: go left first, then down\n  else if (dirEquals(dir, LowerLeft)) {\n    // Horizontal segment: from.x -> to.x (going left)\n    for (let x = from.x - offsetFrom; x >= to.x; x--) {\n      drawnCoords.push({ x, y: from.y })\n      canvas[x]![from.y] = hChar\n    }\n    // Vertical segment: from.y -> to.y (going down)\n    for (let y = from.y + 1; y <= to.y + offsetTo; y++) {\n      drawnCoords.push({ x: to.x, y })\n      canvas[to.x]![y] = vChar\n    }\n  }\n  // LowerRight: go right first, then down\n  // Special case: if x difference is small (1), draw straight vertical at from.x\n  // This keeps edges visually aligned with the source node\n  else if (dirEquals(dir, LowerRight)) {\n    const dx = to.x - from.x\n    if (dx <= 1) {\n      // Draw vertical line at from.x (source's x-coordinate)\n      for (let y = from.y + offsetFrom; y <= to.y + offsetTo; y++) {\n        drawnCoords.push({ x: from.x, y })\n        canvas[from.x]![y] = vChar\n      }\n    } else {\n      // Horizontal segment: from.x -> to.x (going right)\n      for (let x = from.x + offsetFrom; x <= to.x; x++) {\n        drawnCoords.push({ x, y: from.y })\n        canvas[x]![from.y] = hChar\n      }\n      // Vertical segment: from.y -> to.y (going down)\n      for (let y = from.y + 1; y <= to.y + offsetTo; y++) {\n        drawnCoords.push({ x: to.x, y })\n        canvas[to.x]![y] = vChar\n      }\n    }\n  }\n\n  return drawnCoords\n}\n\n// ============================================================================\n// Arrow drawing — path, corners, arrowheads, box-start junctions, labels\n// ============================================================================\n\n/**\n * Draw a complete arrow (edge) between two nodes.\n * Returns 6 separate canvases for layered compositing:\n * [path, boxStart, arrowHeadEnd, arrowHeadStart, corners, label]\n *\n * Supports bidirectional arrows via edge.hasArrowStart and edge.hasArrowEnd.\n */\nexport function drawArrow(\n  graph: AsciiGraph,\n  edge: AsciiEdge,\n): [Canvas, Canvas, Canvas, Canvas, Canvas, Canvas] {\n  if (edge.path.length === 0) {\n    const empty = copyCanvas(graph.canvas)\n    return [empty, empty, empty, empty, empty, empty]\n  }\n\n  const labelCanvas = drawArrowLabel(graph, edge)\n  const [pathCanvas, linesDrawn, lineDirs] = drawPath(graph, edge.path, edge.style)\n  const boxStartCanvas = drawBoxStart(graph, edge.path, linesDrawn[0]!, edge.from.shape)\n\n  // Draw end arrowhead only if hasArrowEnd is true (default behavior)\n  let arrowHeadEndCanvas: Canvas\n  if (edge.hasArrowEnd) {\n    arrowHeadEndCanvas = drawArrowHead(\n      graph,\n      linesDrawn[linesDrawn.length - 1]!,\n      lineDirs[lineDirs.length - 1]!,\n    )\n  } else {\n    arrowHeadEndCanvas = copyCanvas(graph.canvas)\n  }\n\n  // Draw start arrowhead for bidirectional edges\n  // The start arrowhead needs to be at the box connector position (one step back\n  // from the first line point), pointing into the source node.\n  let arrowHeadStartCanvas: Canvas\n  if (edge.hasArrowStart && linesDrawn.length > 0) {\n    const firstLine = linesDrawn[0]!\n    const firstPoint = firstLine[0]!\n    const startDir = reverseDirection(lineDirs[0]!)\n\n    // Calculate the box connector position (one step back from first point)\n    const arrowPos: DrawingCoord = { x: firstPoint.x, y: firstPoint.y }\n    if (dirEquals(lineDirs[0]!, Right)) arrowPos.x = firstPoint.x - 1\n    else if (dirEquals(lineDirs[0]!, Left)) arrowPos.x = firstPoint.x + 1\n    else if (dirEquals(lineDirs[0]!, Down)) arrowPos.y = firstPoint.y - 1\n    else if (dirEquals(lineDirs[0]!, Up)) arrowPos.y = firstPoint.y + 1\n\n    // Create a synthetic line ending at the arrow position for drawArrowHead\n    const syntheticLine: DrawingCoord[] = [firstPoint, arrowPos]\n    arrowHeadStartCanvas = drawArrowHead(graph, syntheticLine, startDir)\n  } else {\n    arrowHeadStartCanvas = copyCanvas(graph.canvas)\n  }\n\n  const cornersCanvas = drawCorners(graph, edge.path)\n\n  return [pathCanvas, boxStartCanvas, arrowHeadEndCanvas, arrowHeadStartCanvas, cornersCanvas, labelCanvas]\n}\n\n/**\n * Reverse a direction (for bidirectional arrow start heads).\n */\nfunction reverseDirection(dir: Direction): Direction {\n  if (dirEquals(dir, Up)) return Down\n  if (dirEquals(dir, Down)) return Up\n  if (dirEquals(dir, Left)) return Right\n  if (dirEquals(dir, Right)) return Left\n  if (dirEquals(dir, UpperLeft)) return LowerRight\n  if (dirEquals(dir, UpperRight)) return LowerLeft\n  if (dirEquals(dir, LowerLeft)) return UpperRight\n  if (dirEquals(dir, LowerRight)) return UpperLeft\n  return Middle\n}\n\n/**\n * Draw the path lines for an edge.\n * Returns the canvas, the coordinates drawn for each segment, and the direction of each segment.\n */\nfunction drawPath(\n  graph: AsciiGraph,\n  path: GridCoord[],\n  style: AsciiEdgeStyle = 'solid',\n): [Canvas, DrawingCoord[][], Direction[]] {\n  const canvas = copyCanvas(graph.canvas)\n  let previousCoord = path[0]!\n  const linesDrawn: DrawingCoord[][] = []\n  const lineDirs: Direction[] = []\n\n  for (let i = 1; i < path.length; i++) {\n    const nextCoord = path[i]!\n    const prevDC = gridToDrawingCoord(graph, previousCoord)\n    const nextDC = gridToDrawingCoord(graph, nextCoord)\n\n    if (drawingCoordEquals(prevDC, nextDC)) {\n      previousCoord = nextCoord\n      continue\n    }\n\n    const dir = determineDirection(previousCoord, nextCoord)\n    const segment = drawLine(canvas, prevDC, nextDC, 1, -1, graph.config.useAscii, style)\n    if (segment.length === 0) segment.push(prevDC)\n    linesDrawn.push(segment)\n    lineDirs.push(dir)\n    previousCoord = nextCoord\n  }\n\n  return [canvas, linesDrawn, lineDirs]\n}\n\n/**\n * Draw the junction character where an edge exits the source node's box.\n * Only applies to Unicode mode (ASCII mode just uses the line characters).\n * Skips drawing for state pseudo-states which have their own visual borders.\n */\nfunction drawBoxStart(\n  graph: AsciiGraph,\n  path: GridCoord[],\n  firstLine: DrawingCoord[],\n  sourceShape: string,\n): Canvas {\n  const canvas = copyCanvas(graph.canvas)\n  if (graph.config.useAscii) return canvas\n\n  // Skip box start connectors for state pseudo-states (they have their own bordered design)\n  if (sourceShape === 'state-start' || sourceShape === 'state-end') {\n    return canvas\n  }\n\n  const from = firstLine[0]!\n  const dir = determineDirection(path[0]!, path[1]!)\n\n  if (dirEquals(dir, Up)) canvas[from.x]![from.y + 1] = '┴'\n  else if (dirEquals(dir, Down)) canvas[from.x]![from.y - 1] = '┬'\n  else if (dirEquals(dir, Left)) canvas[from.x + 1]![from.y] = '┤'\n  else if (dirEquals(dir, Right)) canvas[from.x - 1]![from.y] = '├'\n\n  return canvas\n}\n\n/**\n * Draw the arrowhead at the end of an edge path.\n * Uses triangular Unicode symbols (▲▼◄►) or ASCII symbols (^v<>).\n */\nfunction drawArrowHead(\n  graph: AsciiGraph,\n  lastLine: DrawingCoord[],\n  fallbackDir: Direction,\n): Canvas {\n  const canvas = copyCanvas(graph.canvas)\n  if (lastLine.length === 0) return canvas\n\n  const from = lastLine[0]!\n  const lastPos = lastLine[lastLine.length - 1]!\n  let dir = determineDirection(from, lastPos)\n  if (lastLine.length === 1 || dirEquals(dir, Middle)) dir = fallbackDir\n\n  let char: string\n\n  if (!graph.config.useAscii) {\n    if (dirEquals(dir, Up)) char = '▲'\n    else if (dirEquals(dir, Down)) char = '▼'\n    else if (dirEquals(dir, Left)) char = '◄'\n    else if (dirEquals(dir, Right)) char = '►'\n    else if (dirEquals(dir, UpperRight)) char = '◥'\n    else if (dirEquals(dir, UpperLeft)) char = '◤'\n    else if (dirEquals(dir, LowerRight)) char = '◢'\n    else if (dirEquals(dir, LowerLeft)) char = '◣'\n    else {\n      // Fallback\n      if (dirEquals(fallbackDir, Up)) char = '▲'\n      else if (dirEquals(fallbackDir, Down)) char = '▼'\n      else if (dirEquals(fallbackDir, Left)) char = '◄'\n      else if (dirEquals(fallbackDir, Right)) char = '►'\n      else if (dirEquals(fallbackDir, UpperRight)) char = '◥'\n      else if (dirEquals(fallbackDir, UpperLeft)) char = '◤'\n      else if (dirEquals(fallbackDir, LowerRight)) char = '◢'\n      else if (dirEquals(fallbackDir, LowerLeft)) char = '◣'\n      else char = '●'\n    }\n  } else {\n    if (dirEquals(dir, Up)) char = '^'\n    else if (dirEquals(dir, Down)) char = 'v'\n    else if (dirEquals(dir, Left)) char = '<'\n    else if (dirEquals(dir, Right)) char = '>'\n    else {\n      if (dirEquals(fallbackDir, Up)) char = '^'\n      else if (dirEquals(fallbackDir, Down)) char = 'v'\n      else if (dirEquals(fallbackDir, Left)) char = '<'\n      else if (dirEquals(fallbackDir, Right)) char = '>'\n      else char = '*'\n    }\n  }\n\n  canvas[lastPos.x]![lastPos.y] = char\n  return canvas\n}\n\n/**\n * Draw corner characters at path bends (where the direction changes).\n * Uses ┌┐└┘ in Unicode mode, + in ASCII mode.\n */\nfunction drawCorners(graph: AsciiGraph, path: GridCoord[]): Canvas {\n  const canvas = copyCanvas(graph.canvas)\n\n  for (let idx = 1; idx < path.length - 1; idx++) {\n    const coord = path[idx]!\n    const dc = gridToDrawingCoord(graph, coord)\n    const prevDir = determineDirection(path[idx - 1]!, coord)\n    const nextDir = determineDirection(coord, path[idx + 1]!)\n\n    let corner: string\n    if (!graph.config.useAscii) {\n      if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Down)) ||\n          (dirEquals(prevDir, Up) && dirEquals(nextDir, Left))) {\n        corner = '┐'\n      } else if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Up)) ||\n                 (dirEquals(prevDir, Down) && dirEquals(nextDir, Left))) {\n        corner = '┘'\n      } else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Down)) ||\n                 (dirEquals(prevDir, Up) && dirEquals(nextDir, Right))) {\n        corner = '┌'\n      } else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Up)) ||\n                 (dirEquals(prevDir, Down) && dirEquals(nextDir, Right))) {\n        corner = '└'\n      } else {\n        corner = '+'\n      }\n    } else {\n      corner = '+'\n    }\n\n    canvas[dc.x]![dc.y] = corner\n  }\n\n  return canvas\n}\n\n/** Draw edge label text centered on the widest path segment. */\nfunction drawArrowLabel(graph: AsciiGraph, edge: AsciiEdge): Canvas {\n  const canvas = copyCanvas(graph.canvas)\n  if (edge.text.length === 0) return canvas\n\n  const drawingLine = lineToDrawing(graph, edge.labelLine)\n\n  // Determine if this is an upward edge (target is above source in the path)\n  // This is used to offset labels on bidirectional edges to prevent overlap\n  let isUpwardEdge: boolean | undefined\n  if (edge.path.length >= 2) {\n    const startY = edge.path[0]!.y\n    const endY = edge.path[edge.path.length - 1]!.y\n    // Edge goes up if end Y is less than start Y (smaller Y = higher on screen)\n    if (endY < startY) {\n      isUpwardEdge = true\n    } else if (endY > startY) {\n      isUpwardEdge = false\n    }\n    // If endY === startY, it's horizontal, leave isUpwardEdge undefined\n  }\n\n  drawTextOnLine(canvas, drawingLine, edge.text, isUpwardEdge)\n  return canvas\n}\n\n/**\n * Draw text centered on a line segment defined by two drawing coordinates.\n * Supports multi-line labels.\n *\n * When isUpwardEdge is provided, offsets the label vertically to prevent\n * overlapping with labels from edges going the opposite direction:\n * - Upward edges: label placed in lower portion of segment\n * - Downward edges (isUpwardEdge=false): label placed in upper portion\n * - No direction (isUpwardEdge=undefined): label centered (default)\n */\nfunction drawTextOnLine(canvas: Canvas, line: DrawingCoord[], label: string, isUpwardEdge?: boolean): void {\n  if (line.length < 2) return\n  const minX = Math.min(line[0]!.x, line[1]!.x)\n  const maxX = Math.max(line[0]!.x, line[1]!.x)\n  const minY = Math.min(line[0]!.y, line[1]!.y)\n  const maxY = Math.max(line[0]!.y, line[1]!.y)\n  const middleX = minX + Math.floor((maxX - minX) / 2)\n  let middleY = minY + Math.floor((maxY - minY) / 2)\n\n  // Offset label vertically to prevent overlap on bidirectional edges\n  // For vertical segments (same X), shift based on edge direction\n  if (isUpwardEdge !== undefined && minX === maxX) {\n    const segmentHeight = maxY - minY\n    const offset = Math.max(1, Math.floor(segmentHeight / 4))\n    if (isUpwardEdge) {\n      // Upward edge: place label in lower portion\n      middleY = middleY + offset\n    } else {\n      // Downward edge: place label in upper portion\n      middleY = middleY - offset\n    }\n  }\n\n  // Support multi-line labels\n  const lines = splitLines(label)\n  const startY = middleY - Math.floor((lines.length - 1) / 2)\n\n  for (let i = 0; i < lines.length; i++) {\n    const lineText = lines[i]!\n    const startX = middleX - Math.floor(lineText.length / 2)\n    drawText(canvas, { x: startX, y: startY + i }, lineText)\n  }\n}\n\n// ============================================================================\n// Node attachment point helper\n// ============================================================================\n\n/**\n * Get the drawing coordinate where an edge attaches to a node's border.\n * Uses grid-allocated dimensions so attachment points align with the actual\n * drawn box (which may be wider/taller than the intrinsic shape dimensions\n * when sharing a column/row with a larger node).\n */\nfunction getNodeAttachmentPoint(\n  graph: AsciiGraph,\n  node: AsciiNode,\n  dir: Direction,\n): DrawingCoord {\n  const gc = node.gridCoord!\n\n  // Calculate actual drawn dimensions from grid (matching drawBoxWithGridDimensions)\n  let w = 0\n  for (let i = 0; i < 2; i++) {\n    w += graph.columnWidth.get(gc.x + i) ?? 0\n  }\n  let h = 0\n  for (let i = 0; i < 2; i++) {\n    h += graph.rowHeight.get(gc.y + i) ?? 0\n  }\n\n  // Build dimensions matching the actual drawn box size\n  const gridDimensions = {\n    width: w + 1,\n    height: h + 1,\n    labelArea: { x: 0, y: 0, width: 0, height: 0 },\n    gridColumns: [0, 0, 0] as [number, number, number],\n    gridRows: [0, 0, 0] as [number, number, number],\n  }\n\n  const baseCoord = node.drawingCoord!\n  return getShapeAttachmentPoint(node.shape, dir, gridDimensions, baseCoord)\n}\n\n// ============================================================================\n// Bundled edge drawing — for parallel links (A & B --> C)\n// ============================================================================\n\n/**\n * Draw a single edge's segment in a bundle (source → junction for fan-in,\n * junction → target for fan-out).\n *\n * Returns the same tuple format as drawArrow for consistency.\n */\nfunction drawBundledEdgeSegment(\n  graph: AsciiGraph,\n  edge: AsciiEdge,\n  bundle: EdgeBundle,\n): [Canvas, Canvas, Canvas, Canvas, Canvas, Canvas] {\n  const empty = copyCanvas(graph.canvas)\n\n  if (!edge.pathToJunction || edge.pathToJunction.length === 0) {\n    return [empty, empty, empty, empty, empty, empty]\n  }\n\n  // Draw the path segment (pathToJunction)\n  const pathCanvas = copyCanvas(graph.canvas)\n  const useAscii = graph.config.useAscii\n\n  // Convert grid coords to drawing coords\n  // For fan-in: first point is at source node border (use attachment point)\n  // For fan-out: last point is at target node border (use attachment point)\n  const drawingPath = edge.pathToJunction.map((gc, idx) => {\n    if (bundle.type === 'fan-in' && idx === 0) {\n      // First point: use source node's actual border position\n      return getNodeAttachmentPoint(graph, edge.from, edge.startDir)\n    }\n    if (bundle.type === 'fan-out' && idx === edge.pathToJunction!.length - 1) {\n      // Last point: use target node's actual border position\n      return getNodeAttachmentPoint(graph, edge.to, edge.endDir)\n    }\n    return gridToDrawingCoord(graph, gc)\n  })\n\n  // Draw line segments\n  for (let i = 1; i < drawingPath.length; i++) {\n    const from = drawingPath[i - 1]!\n    const to = drawingPath[i]!\n    if (!drawingCoordEquals(from, to)) {\n      // Always skip both endpoints of every segment (offset 1, -1),\n      // matching non-bundled drawPath behavior. This leaves endpoint\n      // characters to corner/junction/boxStart canvases, preventing\n      // line characters from corrupting them via mergeJunctions.\n      drawLine(pathCanvas, from, to, 1, -1, useAscii, edge.style)\n    }\n  }\n\n  // Draw corners at path bends\n  const cornersCanvas = copyCanvas(graph.canvas)\n  for (let idx = 1; idx < edge.pathToJunction.length - 1; idx++) {\n    const coord = edge.pathToJunction[idx]!\n    const dc = gridToDrawingCoord(graph, coord)\n    const prevDir = determineDirection(edge.pathToJunction[idx - 1]!, coord)\n    const nextDir = determineDirection(coord, edge.pathToJunction[idx + 1]!)\n\n    let corner: string\n    if (!useAscii) {\n      if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Down)) ||\n          (dirEquals(prevDir, Up) && dirEquals(nextDir, Left))) {\n        corner = '┐'\n      } else if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Up)) ||\n                 (dirEquals(prevDir, Down) && dirEquals(nextDir, Left))) {\n        corner = '┘'\n      } else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Down)) ||\n                 (dirEquals(prevDir, Up) && dirEquals(nextDir, Right))) {\n        corner = '┌'\n      } else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Up)) ||\n                 (dirEquals(prevDir, Down) && dirEquals(nextDir, Right))) {\n        corner = '└'\n      } else {\n        corner = '+'\n      }\n    } else {\n      corner = '+'\n    }\n\n    cornersCanvas[dc.x]![dc.y] = corner\n  }\n\n  // Draw box start connector (for fan-in, from source node)\n  // The connector is placed at the first point coordinate (box border position)\n  // since we use offsets 1,-1 for drawLine, the line starts one step past this point\n  const boxStartCanvas = copyCanvas(graph.canvas)\n  if (bundle.type === 'fan-in' && edge.pathToJunction.length >= 2) {\n    const firstPoint = drawingPath[0]!\n    const dir = determineDirection(edge.pathToJunction[0]!, edge.pathToJunction[1]!)\n\n    if (!useAscii) {\n      if (dirEquals(dir, Up)) boxStartCanvas[firstPoint.x]![firstPoint.y] = '┴'\n      else if (dirEquals(dir, Down)) boxStartCanvas[firstPoint.x]![firstPoint.y] = '┬'\n      else if (dirEquals(dir, Left)) boxStartCanvas[firstPoint.x]![firstPoint.y] = '┤'\n      else if (dirEquals(dir, Right)) boxStartCanvas[firstPoint.x]![firstPoint.y] = '├'\n    }\n  }\n\n  // Label canvas (bundled edges typically don't have labels, but handle it)\n  const labelCanvas = copyCanvas(graph.canvas)\n\n  return [pathCanvas, boxStartCanvas, empty, empty, cornersCanvas, labelCanvas]\n}\n\n/**\n * Draw the shared path segment of a bundle (junction → target for fan-in,\n * source → junction for fan-out).\n */\nfunction drawBundleSharedPath(graph: AsciiGraph, bundle: EdgeBundle): [Canvas, Canvas] {\n  const pathCanvas = copyCanvas(graph.canvas)\n  const cornersCanvas = copyCanvas(graph.canvas)\n\n  if (bundle.sharedPath.length < 2) {\n    return [pathCanvas, cornersCanvas]\n  }\n\n  const useAscii = graph.config.useAscii\n  const style = bundle.edges[0]?.style ?? 'solid'\n  const graphDir = graph.config.graphDirection\n\n  // Convert grid coords to drawing coords\n  // For fan-in: last point is at target node border\n  // For fan-out: first point is at source node border\n  const drawingPath = bundle.sharedPath.map((gc, idx) => {\n    if (bundle.type === 'fan-in' && idx === bundle.sharedPath.length - 1) {\n      // Last point: use target node's actual border position (entry from above/left)\n      const entryDir = graphDir === 'TD' ? Up : Left\n      return getNodeAttachmentPoint(graph, bundle.sharedNode, entryDir)\n    }\n    if (bundle.type === 'fan-out' && idx === 0) {\n      // First point: use source node's actual border position (exit going down/right)\n      const exitDir = graphDir === 'TD' ? Down : Right\n      return getNodeAttachmentPoint(graph, bundle.sharedNode, exitDir)\n    }\n    return gridToDrawingCoord(graph, gc)\n  })\n\n  // Draw line segments with appropriate offsets\n  for (let i = 1; i < drawingPath.length; i++) {\n    const from = drawingPath[i - 1]!\n    const to = drawingPath[i]!\n    if (!drawingCoordEquals(from, to)) {\n      // Always skip both endpoints (offset 1, -1), matching non-bundled drawPath.\n      drawLine(pathCanvas, from, to, 1, -1, useAscii, style)\n    }\n  }\n\n  // Draw corners at path bends\n  for (let idx = 1; idx < bundle.sharedPath.length - 1; idx++) {\n    const coord = bundle.sharedPath[idx]!\n    const dc = gridToDrawingCoord(graph, coord)\n    const prevDir = determineDirection(bundle.sharedPath[idx - 1]!, coord)\n    const nextDir = determineDirection(coord, bundle.sharedPath[idx + 1]!)\n\n    let corner: string\n    if (!useAscii) {\n      if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Down)) ||\n          (dirEquals(prevDir, Up) && dirEquals(nextDir, Left))) {\n        corner = '┐'\n      } else if ((dirEquals(prevDir, Right) && dirEquals(nextDir, Up)) ||\n                 (dirEquals(prevDir, Down) && dirEquals(nextDir, Left))) {\n        corner = '┘'\n      } else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Down)) ||\n                 (dirEquals(prevDir, Up) && dirEquals(nextDir, Right))) {\n        corner = '┌'\n      } else if ((dirEquals(prevDir, Left) && dirEquals(nextDir, Up)) ||\n                 (dirEquals(prevDir, Down) && dirEquals(nextDir, Right))) {\n        corner = '└'\n      } else {\n        corner = '+'\n      }\n    } else {\n      corner = '+'\n    }\n\n    cornersCanvas[dc.x]![dc.y] = corner\n  }\n\n  return [pathCanvas, cornersCanvas]\n}\n\n/**\n * Draw the arrowhead for a fan-in bundle (single arrowhead at the shared target).\n */\nfunction drawBundleArrowhead(graph: AsciiGraph, bundle: EdgeBundle): Canvas {\n  const canvas = copyCanvas(graph.canvas)\n\n  if (bundle.sharedPath.length < 2) return canvas\n\n  // Get the last segment direction\n  const lastIdx = bundle.sharedPath.length - 1\n  const secondLast = bundle.sharedPath[lastIdx - 1]!\n  const last = bundle.sharedPath[lastIdx]!\n  const dir = determineDirection(secondLast, last)\n\n  // Get drawing coord 1 char outside the target node's border (not on the border itself).\n  // This matches non-bundled edges where drawPath uses offsetTo=-1 and the arrowhead\n  // sits at the last drawn point (1 char before the border).\n  const graphDir = graph.config.graphDirection\n  const entryDir = graphDir === 'TD' ? Up : Left\n  const dc = getNodeAttachmentPoint(graph, bundle.sharedNode, entryDir)\n  // Offset 1 char away from the box border so arrowhead sits outside the box\n  if (graphDir === 'TD') dc.y -= 1\n  else dc.x -= 1\n\n  // Draw arrowhead\n  let char: string\n  if (!graph.config.useAscii) {\n    if (dirEquals(dir, Up)) char = '▲'\n    else if (dirEquals(dir, Down)) char = '▼'\n    else if (dirEquals(dir, Left)) char = '◄'\n    else if (dirEquals(dir, Right)) char = '►'\n    else char = '▼'  // default\n  } else {\n    if (dirEquals(dir, Up)) char = '^'\n    else if (dirEquals(dir, Down)) char = 'v'\n    else if (dirEquals(dir, Left)) char = '<'\n    else if (dirEquals(dir, Right)) char = '>'\n    else char = 'v'  // default\n  }\n\n  canvas[dc.x]![dc.y] = char\n  return canvas\n}\n\n/**\n * Draw the arrowhead for a single edge in a fan-out bundle.\n */\nfunction drawBundledEdgeArrowhead(graph: AsciiGraph, edge: AsciiEdge): Canvas {\n  const canvas = copyCanvas(graph.canvas)\n\n  if (!edge.pathToJunction || edge.pathToJunction.length < 2) return canvas\n\n  // Get the last segment direction\n  const lastIdx = edge.pathToJunction.length - 1\n  const secondLast = edge.pathToJunction[lastIdx - 1]!\n  const last = edge.pathToJunction[lastIdx]!\n  const dir = determineDirection(secondLast, last)\n\n  // Get drawing coord 1 char outside the target node's border\n  const graphDir = graph.config.graphDirection\n  const entryDir = graphDir === 'TD' ? Up : Left\n  const dc = getNodeAttachmentPoint(graph, edge.to, entryDir)\n  // Offset 1 char away from the box border so arrowhead sits outside the box\n  if (graphDir === 'TD') dc.y -= 1\n  else dc.x -= 1\n\n  // Draw arrowhead\n  let char: string\n  if (!graph.config.useAscii) {\n    if (dirEquals(dir, Up)) char = '▲'\n    else if (dirEquals(dir, Down)) char = '▼'\n    else if (dirEquals(dir, Left)) char = '◄'\n    else if (dirEquals(dir, Right)) char = '►'\n    else char = '▼'  // default\n  } else {\n    if (dirEquals(dir, Up)) char = '^'\n    else if (dirEquals(dir, Down)) char = 'v'\n    else if (dirEquals(dir, Left)) char = '<'\n    else if (dirEquals(dir, Right)) char = '>'\n    else char = 'v'  // default\n  }\n\n  canvas[dc.x]![dc.y] = char\n  return canvas\n}\n\n/**\n * Draw the junction character where bundled edges merge/split.\n *\n * Analyzes actual connecting directions to choose the correct character:\n * - ┼ (cross): lines from all 4 directions\n * - ┬ (T down): lines from left, right, and down\n * - ┴ (T up): lines from left, right, and up\n * - ├ (T right): lines from up, down, and right\n * - ┤ (T left): lines from up, down, and left\n */\nfunction drawJunctionCharacter(graph: AsciiGraph, bundle: EdgeBundle): Canvas {\n  const canvas = copyCanvas(graph.canvas)\n\n  if (!bundle.junctionPoint) return canvas\n\n  const dc = gridToDrawingCoord(graph, bundle.junctionPoint)\n  const useAscii = graph.config.useAscii\n\n  // Analyze what directions actually connect to the junction\n  let hasUp = false\n  let hasDown = false\n  let hasLeft = false\n  let hasRight = false\n\n  // Check shared path direction (where the line continues to/from the shared node)\n  if (bundle.sharedPath.length >= 2) {\n    // For fan-in: shared path goes FROM junction TO target (index 0 is junction)\n    // For fan-out: shared path goes FROM source TO junction (last index is junction)\n    const junctionIdx = bundle.type === 'fan-in' ? 0 : bundle.sharedPath.length - 1\n    const adjacentIdx = bundle.type === 'fan-in' ? 1 : bundle.sharedPath.length - 2\n    const sharedDir = determineDirection(\n      bundle.sharedPath[junctionIdx]!,\n      bundle.sharedPath[adjacentIdx]!\n    )\n    // This is the direction the shared path GOES from junction\n    if (dirEquals(sharedDir, Down)) hasDown = true\n    else if (dirEquals(sharedDir, Up)) hasUp = true\n    else if (dirEquals(sharedDir, Right)) hasRight = true\n    else if (dirEquals(sharedDir, Left)) hasLeft = true\n  }\n\n  // Check each edge's path direction at the junction\n  for (const edge of bundle.edges) {\n    if (edge.pathToJunction && edge.pathToJunction.length >= 2) {\n      // For fan-in: pathToJunction goes FROM source TO junction (last is junction)\n      // For fan-out: pathToJunction goes FROM junction TO target (first is junction)\n      const junctionIdx = bundle.type === 'fan-in'\n        ? edge.pathToJunction.length - 1\n        : 0\n      const adjacentIdx = bundle.type === 'fan-in'\n        ? edge.pathToJunction.length - 2\n        : 1\n\n      const arrivalDir = determineDirection(\n        edge.pathToJunction[adjacentIdx]!,\n        edge.pathToJunction[junctionIdx]!\n      )\n      // This is the direction the edge ARRIVES at junction from\n      // e.g., if arrivalDir is Right, the line comes FROM the left\n      if (dirEquals(arrivalDir, Down)) hasUp = true    // arrived going down = came from up\n      else if (dirEquals(arrivalDir, Up)) hasDown = true\n      else if (dirEquals(arrivalDir, Right)) hasLeft = true\n      else if (dirEquals(arrivalDir, Left)) hasRight = true\n    }\n  }\n\n  // Select character based on connected directions\n  let char: string\n  if (!useAscii) {\n    if (hasUp && hasDown && hasLeft && hasRight) {\n      char = '┼'  // cross - all 4 directions\n    } else if (hasDown && hasLeft && hasRight && !hasUp) {\n      char = '┬'  // T pointing down\n    } else if (hasUp && hasLeft && hasRight && !hasDown) {\n      char = '┴'  // T pointing up\n    } else if (hasUp && hasDown && hasRight && !hasLeft) {\n      char = '├'  // T pointing right\n    } else if (hasUp && hasDown && hasLeft && !hasRight) {\n      char = '┤'  // T pointing left\n    } else if (hasLeft && hasRight) {\n      char = '─'  // horizontal only\n    } else if (hasUp && hasDown) {\n      char = '│'  // vertical only\n    } else if (hasDown && hasRight) {\n      char = '┌'  // corner\n    } else if (hasDown && hasLeft) {\n      char = '┐'\n    } else if (hasUp && hasRight) {\n      char = '└'\n    } else if (hasUp && hasLeft) {\n      char = '┘'\n    } else {\n      char = '┼'  // fallback\n    }\n  } else {\n    char = '+'\n  }\n\n  canvas[dc.x]![dc.y] = char\n  return canvas\n}\n\n// ============================================================================\n// Subgraph drawing\n// ============================================================================\n\n/** Draw a subgraph border rectangle. */\nexport function drawSubgraphBox(sg: AsciiSubgraph, graph: AsciiGraph): Canvas {\n  const width = sg.maxX - sg.minX\n  const height = sg.maxY - sg.minY\n  if (width <= 0 || height <= 0) return mkCanvas(0, 0)\n\n  const from: DrawingCoord = { x: 0, y: 0 }\n  const to: DrawingCoord = { x: width, y: height }\n  const canvas = mkCanvas(width, height)\n\n  if (!graph.config.useAscii) {\n    for (let x = from.x + 1; x < to.x; x++) canvas[x]![from.y] = '─'\n    for (let x = from.x + 1; x < to.x; x++) canvas[x]![to.y] = '─'\n    for (let y = from.y + 1; y < to.y; y++) canvas[from.x]![y] = '│'\n    for (let y = from.y + 1; y < to.y; y++) canvas[to.x]![y] = '│'\n    canvas[from.x]![from.y] = '┌'\n    canvas[to.x]![from.y] = '┐'\n    canvas[from.x]![to.y] = '└'\n    canvas[to.x]![to.y] = '┘'\n  } else {\n    for (let x = from.x + 1; x < to.x; x++) canvas[x]![from.y] = '-'\n    for (let x = from.x + 1; x < to.x; x++) canvas[x]![to.y] = '-'\n    for (let y = from.y + 1; y < to.y; y++) canvas[from.x]![y] = '|'\n    for (let y = from.y + 1; y < to.y; y++) canvas[to.x]![y] = '|'\n    canvas[from.x]![from.y] = '+'\n    canvas[to.x]![from.y] = '+'\n    canvas[from.x]![to.y] = '+'\n    canvas[to.x]![to.y] = '+'\n  }\n\n  return canvas\n}\n\n/** Draw a subgraph label centered in its header area. Supports multi-line labels. */\nexport function drawSubgraphLabel(sg: AsciiSubgraph, graph: AsciiGraph): [Canvas, DrawingCoord] {\n  const width = sg.maxX - sg.minX\n  const height = sg.maxY - sg.minY\n  if (width <= 0 || height <= 0) return [mkCanvas(0, 0), { x: 0, y: 0 }]\n\n  const canvas = mkCanvas(width, height)\n\n  // Support multi-line subgraph labels\n  const lines = splitLines(sg.name)\n\n  // Start at row 1 inside subgraph, expand downward for multiple lines\n  for (let i = 0; i < lines.length; i++) {\n    const line = lines[i]!\n    const labelY = 1 + i\n    let labelX = Math.floor(width / 2) - Math.floor(line.length / 2)\n    if (labelX < 1) labelX = 1\n\n    for (let j = 0; j < line.length; j++) {\n      if (labelX + j < width && labelY < height) {\n        canvas[labelX + j]![labelY] = line[j]!\n      }\n    }\n  }\n\n  return [canvas, { x: sg.minX, y: sg.minY }]\n}\n\n// ============================================================================\n// Top-level draw orchestrator\n// ============================================================================\n\n/** Sort subgraphs by nesting depth (shallowest first) for correct layered rendering. */\nfunction sortSubgraphsByDepth(subgraphs: AsciiSubgraph[]): AsciiSubgraph[] {\n  function getDepth(sg: AsciiSubgraph): number {\n    return sg.parent === null ? 0 : 1 + getDepth(sg.parent)\n  }\n  const sorted = [...subgraphs]\n  sorted.sort((a, b) => getDepth(a) - getDepth(b))\n  return sorted\n}\n\n// ============================================================================\n// Role tracking helpers for colored output\n// ============================================================================\n\n/**\n * Fill roles for all non-space characters in a canvas region.\n * Used after drawing a layer to record what role those characters have.\n */\nfunction fillRolesFromCanvas(\n  roleCanvas: RoleCanvas,\n  canvas: Canvas,\n  offset: DrawingCoord,\n  role: CharRole,\n): void {\n  for (let x = 0; x < canvas.length; x++) {\n    for (let y = 0; y < (canvas[0]?.length ?? 0); y++) {\n      const char = canvas[x]?.[y]\n      if (char && char !== ' ') {\n        const rx = x + offset.x\n        const ry = y + offset.y\n        // Use setRole which auto-expands the role canvas if needed\n        if (rx >= 0 && ry >= 0) {\n          setRole(roleCanvas, rx, ry, role)\n        }\n      }\n    }\n  }\n}\n\n/**\n * Fill roles for multiple canvases with the same role.\n */\nfunction fillRolesFromCanvases(\n  roleCanvas: RoleCanvas,\n  canvases: Canvas[],\n  offset: DrawingCoord,\n  role: CharRole,\n): void {\n  for (const canvas of canvases) {\n    fillRolesFromCanvas(roleCanvas, canvas, offset, role)\n  }\n}\n\n/**\n * Special handling for node boxes: border chars get 'border' role, text gets 'text' role.\n * Detects text by checking if character is alphanumeric or common punctuation.\n */\nfunction fillRolesForNodeBox(\n  roleCanvas: RoleCanvas,\n  canvas: Canvas,\n  offset: DrawingCoord,\n): void {\n  const isBorderChar = (c: string) => /^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\\-|.':]$/.test(c)\n\n  for (let x = 0; x < canvas.length; x++) {\n    for (let y = 0; y < (canvas[0]?.length ?? 0); y++) {\n      const char = canvas[x]?.[y]\n      if (char && char !== ' ') {\n        const rx = x + offset.x\n        const ry = y + offset.y\n        // Use setRole which auto-expands the role canvas if needed\n        if (rx >= 0 && ry >= 0) {\n          setRole(roleCanvas, rx, ry, isBorderChar(char) ? 'border' : 'text')\n        }\n      }\n    }\n  }\n}\n\n/**\n * Main draw function — renders the entire graph onto the canvas.\n * Drawing order matters for correct layering:\n * 1. Subgraph borders (bottom layer)\n * 2. Node boxes\n * 3. Edge paths (lines)\n * 4. Edge corners\n * 5. Arrowheads\n * 6. Box-start junctions\n * 7. Edge labels\n * 8. Subgraph labels (top layer)\n *\n * Also fills the roleCanvas with character roles for colored output.\n */\nexport function drawGraph(graph: AsciiGraph): Canvas {\n  const useAscii = graph.config.useAscii\n  const zero: DrawingCoord = { x: 0, y: 0 }\n\n  // Draw subgraph borders\n  const sortedSgs = sortSubgraphsByDepth(graph.subgraphs)\n  for (const sg of sortedSgs) {\n    const sgCanvas = drawSubgraphBox(sg, graph)\n    const offset: DrawingCoord = { x: sg.minX, y: sg.minY }\n    graph.canvas = mergeCanvases(graph.canvas, offset, useAscii, sgCanvas)\n    // Subgraph borders get 'border' role\n    fillRolesFromCanvas(graph.roleCanvas, sgCanvas, offset, 'border')\n  }\n\n  // Draw node boxes\n  for (const node of graph.nodes) {\n    if (!node.drawn && node.drawingCoord && node.drawing) {\n      graph.canvas = mergeCanvases(graph.canvas, node.drawingCoord, useAscii, node.drawing)\n      // Node boxes: detect border vs text characters\n      fillRolesForNodeBox(graph.roleCanvas, node.drawing, node.drawingCoord)\n      node.drawn = true\n    }\n  }\n\n  // Collect all edge drawing layers\n  const lineCanvases: Canvas[] = []\n  const cornerCanvases: Canvas[] = []\n  const arrowHeadEndCanvases: Canvas[] = []\n  const arrowHeadStartCanvases: Canvas[] = []\n  const boxStartCanvases: Canvas[] = []\n  const labelCanvases: Canvas[] = []\n  const junctionCanvases: Canvas[] = []\n\n  // Track which bundles have been processed (to draw shared paths only once)\n  const processedBundles = new Set<EdgeBundle>()\n\n  for (const edge of graph.edges) {\n    // Handle bundled edges specially\n    if (edge.bundle && edge.pathToJunction) {\n      const bundle = edge.bundle\n\n      // Draw this edge's individual path (source → junction for fan-in, junction → target for fan-out)\n      const [pathC, boxStartC, , , cornersC, labelC] = drawBundledEdgeSegment(graph, edge, bundle)\n      lineCanvases.push(pathC)\n      cornerCanvases.push(cornersC)\n      boxStartCanvases.push(boxStartC)\n      labelCanvases.push(labelC)\n\n      // Draw the bundle's shared path and arrowhead only once\n      if (!processedBundles.has(bundle)) {\n        processedBundles.add(bundle)\n\n        // Draw shared path (junction → target for fan-in, source → junction for fan-out)\n        const [sharedPathC, sharedCornersC] = drawBundleSharedPath(graph, bundle)\n        lineCanvases.push(sharedPathC)\n        cornerCanvases.push(sharedCornersC)\n\n        // Draw arrowhead at target for fan-in (once for all edges in bundle)\n        if (bundle.type === 'fan-in') {\n          const arrowHeadC = drawBundleArrowhead(graph, bundle)\n          arrowHeadEndCanvases.push(arrowHeadC)\n        }\n\n        // Draw junction character\n        const junctionC = drawJunctionCharacter(graph, bundle)\n        junctionCanvases.push(junctionC)\n      }\n\n      // For fan-out bundles, draw arrowhead at each target\n      if (bundle.type === 'fan-out' && edge.hasArrowEnd) {\n        const arrowHeadC = drawBundledEdgeArrowhead(graph, edge)\n        arrowHeadEndCanvases.push(arrowHeadC)\n      }\n    } else {\n      // Non-bundled edge: use standard drawing\n      const [pathC, boxStartC, arrowHeadEndC, arrowHeadStartC, cornersC, labelC] = drawArrow(graph, edge)\n      lineCanvases.push(pathC)\n      cornerCanvases.push(cornersC)\n      arrowHeadEndCanvases.push(arrowHeadEndC)\n      arrowHeadStartCanvases.push(arrowHeadStartC)\n      boxStartCanvases.push(boxStartC)\n      labelCanvases.push(labelC)\n    }\n  }\n\n  // Merge edge layers in order and track roles\n  // Note: arrowHeadStart is merged AFTER boxStart so bidirectional arrows\n  // properly overwrite the box connector at the source end\n  graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...lineCanvases)\n  fillRolesFromCanvases(graph.roleCanvas, lineCanvases, zero, 'line')\n\n  graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...cornerCanvases)\n  fillRolesFromCanvases(graph.roleCanvas, cornerCanvases, zero, 'corner')\n\n  graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...junctionCanvases)\n  fillRolesFromCanvases(graph.roleCanvas, junctionCanvases, zero, 'junction')\n\n  graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...arrowHeadEndCanvases)\n  fillRolesFromCanvases(graph.roleCanvas, arrowHeadEndCanvases, zero, 'arrow')\n\n  graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...boxStartCanvases)\n  fillRolesFromCanvases(graph.roleCanvas, boxStartCanvases, zero, 'junction')\n\n  graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...arrowHeadStartCanvases)\n  fillRolesFromCanvases(graph.roleCanvas, arrowHeadStartCanvases, zero, 'arrow')\n\n  graph.canvas = mergeCanvases(graph.canvas, zero, useAscii, ...labelCanvases)\n  fillRolesFromCanvases(graph.roleCanvas, labelCanvases, zero, 'text')\n\n  // Draw subgraph labels last (on top)\n  for (const sg of graph.subgraphs) {\n    if (sg.nodes.length === 0) continue\n    const [labelCanvas, offset] = drawSubgraphLabel(sg, graph)\n    graph.canvas = mergeCanvases(graph.canvas, offset, useAscii, labelCanvas)\n    fillRolesFromCanvas(graph.roleCanvas, labelCanvas, offset, 'text')\n  }\n\n  return graph.canvas\n}\n"
  },
  {
    "path": "src/ascii/edge-bundling.ts",
    "content": "// ============================================================================\n// ASCII renderer — edge bundling for parallel links\n//\n// Analyzes edges to find parallel links (A & B --> C or A --> B & C) and\n// groups them into bundles. Bundled edges share a visual junction point\n// where they merge/split, creating cleaner diagrams.\n//\n// This module provides:\n//   - analyzeEdgeBundles(): Finds and creates bundles from graph edges\n//   - calculateJunctionPoint(): Computes optimal merge/split locations\n//   - routeBundledEdges(): Routes edges through junction points\n// ============================================================================\n\nimport type {\n  AsciiGraph, AsciiNode, AsciiEdge, EdgeBundle, GridCoord, Direction,\n} from './types.ts'\nimport { Up, Down, Left, Right, Middle, gridKey, gridCoordEquals } from './types.ts'\nimport { getPath, mergePath } from './pathfinder.ts'\nimport { getNodeSubgraph } from './grid.ts'\n\n// ============================================================================\n// Bundle analysis\n// ============================================================================\n\n/**\n * Analyze graph edges and create bundles for parallel links.\n *\n * Groups edges by:\n * - Fan-in: Multiple edges sharing the same target (A & B --> C)\n * - Fan-out: Multiple edges sharing the same source (A --> B & C)\n *\n * Only creates bundles when:\n * - Graph direction is TD (top-down) - LR routing handles merging naturally\n * - 2+ edges share the endpoint\n * - All edges have the same style (solid/dotted/thick)\n * - None of the edges have labels (labels would overlap at junction)\n * - Edges are not self-loops\n *\n * @returns Array of bundles. Each edge can belong to at most one bundle.\n */\nexport function analyzeEdgeBundles(graph: AsciiGraph): EdgeBundle[] {\n  // Only bundle in TD direction - LR routing handles merging naturally at corners\n  if (graph.config.graphDirection !== 'TD') {\n    return []\n  }\n  const bundles: EdgeBundle[] = []\n  const bundledEdges = new Set<AsciiEdge>()\n\n  // Group edges by target (fan-in candidates)\n  const edgesByTarget = new Map<AsciiNode, AsciiEdge[]>()\n  for (const edge of graph.edges) {\n    // Skip self-loops\n    if (edge.from === edge.to) continue\n\n    const existing = edgesByTarget.get(edge.to) ?? []\n    existing.push(edge)\n    edgesByTarget.set(edge.to, existing)\n  }\n\n  // Create fan-in bundles\n  for (const [target, edges] of edgesByTarget) {\n    if (edges.length < 2) continue\n    if (!canBundle(edges, graph)) continue\n\n    // Check if all edges are already bundled\n    if (edges.some(e => bundledEdges.has(e))) continue\n\n    const bundle: EdgeBundle = {\n      type: 'fan-in',\n      edges: [...edges],\n      sharedNode: target,\n      otherNodes: edges.map(e => e.from),\n      junctionPoint: null,\n      sharedPath: [],\n      junctionDir: Middle,\n      sharedNodeDir: Middle,\n    }\n\n    // Mark edges as bundled\n    for (const edge of edges) {\n      edge.bundle = bundle\n      bundledEdges.add(edge)\n    }\n\n    bundles.push(bundle)\n  }\n\n  // Group edges by source (fan-out candidates)\n  const edgesBySource = new Map<AsciiNode, AsciiEdge[]>()\n  for (const edge of graph.edges) {\n    // Skip self-loops and already bundled edges\n    if (edge.from === edge.to) continue\n    if (bundledEdges.has(edge)) continue\n\n    const existing = edgesBySource.get(edge.from) ?? []\n    existing.push(edge)\n    edgesBySource.set(edge.from, existing)\n  }\n\n  // Create fan-out bundles\n  for (const [source, edges] of edgesBySource) {\n    if (edges.length < 2) continue\n    if (!canBundle(edges, graph)) continue\n\n    const bundle: EdgeBundle = {\n      type: 'fan-out',\n      edges: [...edges],\n      sharedNode: source,\n      otherNodes: edges.map(e => e.to),\n      junctionPoint: null,\n      sharedPath: [],\n      junctionDir: Middle,\n      sharedNodeDir: Middle,\n    }\n\n    // Mark edges as bundled\n    for (const edge of edges) {\n      edge.bundle = bundle\n      bundledEdges.add(edge)\n    }\n\n    bundles.push(bundle)\n  }\n\n  return bundles\n}\n\n/**\n * Check if a group of edges can be bundled together.\n * Returns false if edges have different styles, any have labels,\n * or if the edges span subgraph boundaries (which creates complex routing).\n */\nfunction canBundle(edges: AsciiEdge[], graph: AsciiGraph): boolean {\n  if (edges.length < 2) return false\n\n  const firstStyle = edges[0]!.style\n  const firstFromSg = getNodeSubgraph(graph, edges[0]!.from)\n  const firstToSg = getNodeSubgraph(graph, edges[0]!.to)\n\n  for (const edge of edges) {\n    // Different styles can't be bundled (would look confusing)\n    if (edge.style !== firstStyle) return false\n\n    // Edges with labels can't be bundled (labels would overlap at junction)\n    if (edge.text.length > 0) return false\n\n    // Don't bundle if edges span different subgraph boundaries\n    // (creates complex routing that doesn't look good)\n    const fromSg = getNodeSubgraph(graph, edge.from)\n    const toSg = getNodeSubgraph(graph, edge.to)\n    if (fromSg !== firstFromSg || toSg !== firstToSg) return false\n\n    // Don't bundle if source and target are in different subgraphs\n    // (cross-boundary edges have special routing needs)\n    if (fromSg !== toSg) return false\n  }\n\n  return true\n}\n\n// ============================================================================\n// Junction point calculation\n// ============================================================================\n\n/**\n * Calculate the optimal junction point for a bundle.\n *\n * For fan-in (A & B --> C):\n *   - Junction is placed between the sources and the target\n *   - In TD: above the target, horizontally centered between sources\n *   - In LR: left of the target, vertically centered between sources\n *\n * For fan-out (A --> B & C):\n *   - Junction is placed between the source and the targets\n *   - In TD: below the source, horizontally centered between targets\n *   - In LR: right of the source, vertically centered between targets\n */\nexport function calculateJunctionPoint(\n  graph: AsciiGraph,\n  bundle: EdgeBundle,\n): GridCoord {\n  const dir = graph.config.graphDirection\n  const sharedCoord = bundle.sharedNode.gridCoord!\n  const otherCoords = bundle.otherNodes.map(n => n.gridCoord!)\n\n  if (bundle.type === 'fan-in') {\n    // Junction is BEFORE the shared target\n    // Calculate center of sources\n    const minX = Math.min(...otherCoords.map(c => c.x))\n    const maxX = Math.max(...otherCoords.map(c => c.x))\n    const minY = Math.min(...otherCoords.map(c => c.y))\n    const maxY = Math.max(...otherCoords.map(c => c.y))\n\n    if (dir === 'TD') {\n      // Junction above target, centered between sources\n      // Place it one row above the target's entry point\n      const junctionY = sharedCoord.y - 1\n      // X is centered between sources, but clamped to shared node's X for alignment\n      const centerX = Math.floor((minX + maxX) / 2) + 1 // +1 for center of 3x3 block\n      const junctionX = sharedCoord.x + 1 // Align with target's center\n\n      return { x: junctionX, y: junctionY }\n    } else {\n      // LR: Junction left of target, centered between sources\n      const junctionX = sharedCoord.x - 1\n      const junctionY = sharedCoord.y + 1 // Align with target's center\n\n      return { x: junctionX, y: junctionY }\n    }\n  } else {\n    // fan-out: Junction is AFTER the shared source\n    const minX = Math.min(...otherCoords.map(c => c.x))\n    const maxX = Math.max(...otherCoords.map(c => c.x))\n    const minY = Math.min(...otherCoords.map(c => c.y))\n    const maxY = Math.max(...otherCoords.map(c => c.y))\n\n    if (dir === 'TD') {\n      // Junction below source, will then split to targets\n      const junctionY = sharedCoord.y + 3 // Just below source's 3x3 block\n      const junctionX = sharedCoord.x + 1 // Align with source's center\n\n      return { x: junctionX, y: junctionY }\n    } else {\n      // LR: Junction right of source\n      const junctionX = sharedCoord.x + 3\n      const junctionY = sharedCoord.y + 1\n\n      return { x: junctionX, y: junctionY }\n    }\n  }\n}\n\n// ============================================================================\n// Bundled edge routing\n// ============================================================================\n\n/**\n * Route all edges in a bundle through the junction point.\n *\n * For fan-in bundles:\n *   1. Route each source → junction (stored in edge.pathToJunction)\n *   2. Route junction → target (stored in bundle.sharedPath)\n *\n * For fan-out bundles:\n *   1. Route source → junction (stored in bundle.sharedPath)\n *   2. Route junction → each target (stored in edge.pathToJunction)\n */\nexport function routeBundledEdges(graph: AsciiGraph, bundle: EdgeBundle): void {\n  const dir = graph.config.graphDirection\n\n  // Calculate and store junction point\n  bundle.junctionPoint = calculateJunctionPoint(graph, bundle)\n  const junction = bundle.junctionPoint\n\n  // Determine directions based on graph direction and bundle type\n  if (bundle.type === 'fan-in') {\n    // Sources converge to junction, then junction to target\n    bundle.junctionDir = dir === 'TD' ? Up : Left\n    bundle.sharedNodeDir = dir === 'TD' ? Down : Right\n\n    // Route junction → target (shared path)\n    const targetCoord = bundle.sharedNode.gridCoord!\n    const targetEntry = dir === 'TD'\n      ? { x: targetCoord.x + 1, y: targetCoord.y } // Top center of target\n      : { x: targetCoord.x, y: targetCoord.y + 1 } // Left center of target\n\n    const sharedPath = getPath(graph.grid, junction, targetEntry)\n    bundle.sharedPath = sharedPath ? mergePath(sharedPath) : [junction, targetEntry]\n\n    // Route each source → junction\n    for (const edge of bundle.edges) {\n      const sourceCoord = edge.from.gridCoord!\n      const sourceExit = dir === 'TD'\n        ? { x: sourceCoord.x + 1, y: sourceCoord.y + 2 } // Bottom center of source\n        : { x: sourceCoord.x + 2, y: sourceCoord.y + 1 } // Right center of source\n\n      const pathToJunction = getPath(graph.grid, sourceExit, junction)\n      edge.pathToJunction = pathToJunction ? mergePath(pathToJunction) : [sourceExit, junction]\n\n      // Set edge directions for proper drawing\n      edge.startDir = dir === 'TD' ? Down : Right\n      edge.endDir = dir === 'TD' ? Up : Left\n\n      // Build full path for grid size calculation: source → junction → target\n      edge.path = [...edge.pathToJunction, ...bundle.sharedPath.slice(1)]\n    }\n  } else {\n    // fan-out: Source to junction, then junction splits to targets\n    bundle.junctionDir = dir === 'TD' ? Down : Right\n    bundle.sharedNodeDir = dir === 'TD' ? Up : Left\n\n    // Route source → junction (shared path)\n    const sourceCoord = bundle.sharedNode.gridCoord!\n    const sourceExit = dir === 'TD'\n      ? { x: sourceCoord.x + 1, y: sourceCoord.y + 2 } // Bottom center of source\n      : { x: sourceCoord.x + 2, y: sourceCoord.y + 1 } // Right center of source\n\n    const sharedPath = getPath(graph.grid, sourceExit, junction)\n    bundle.sharedPath = sharedPath ? mergePath(sharedPath) : [sourceExit, junction]\n\n    // Route junction → each target\n    for (const edge of bundle.edges) {\n      const targetCoord = edge.to.gridCoord!\n      const targetEntry = dir === 'TD'\n        ? { x: targetCoord.x + 1, y: targetCoord.y } // Top center of target\n        : { x: targetCoord.x, y: targetCoord.y + 1 } // Left center of target\n\n      const pathToJunction = getPath(graph.grid, junction, targetEntry)\n      edge.pathToJunction = pathToJunction ? mergePath(pathToJunction) : [junction, targetEntry]\n\n      // Set edge directions\n      edge.startDir = dir === 'TD' ? Down : Right\n      edge.endDir = dir === 'TD' ? Up : Left\n\n      // Build full path for grid size calculation: source → junction → target\n      edge.path = [...bundle.sharedPath, ...edge.pathToJunction.slice(1)]\n    }\n  }\n}\n\n/**\n * Process all bundles in a graph: calculate junction points and route edges.\n */\nexport function processBundles(graph: AsciiGraph): void {\n  for (const bundle of graph.bundles) {\n    routeBundledEdges(graph, bundle)\n  }\n}\n"
  },
  {
    "path": "src/ascii/edge-routing.ts",
    "content": "// ============================================================================\n// ASCII renderer — direction system and edge path determination\n//\n// Ported from AlexanderGrooff/mermaid-ascii cmd/direction.go + cmd/mapping_edge.go.\n// Handles direction constants, edge attachment point selection,\n// and dual-path comparison for optimal edge routing.\n// ============================================================================\n\nimport type { GridCoord, Direction, AsciiEdge, AsciiGraph } from './types.ts'\nimport {\n  Up, Down, Left, Right, UpperRight, UpperLeft, LowerRight, LowerLeft, Middle,\n  gridCoordDirection,\n} from './types.ts'\nimport { getPath, mergePath } from './pathfinder.ts'\nimport { getEffectiveDirection, getNodeSubgraph } from './grid.ts'\n\n// ============================================================================\n// Direction utilities\n// ============================================================================\n\nexport function getOpposite(d: Direction): Direction {\n  if (d === Up) return Down\n  if (d === Down) return Up\n  if (d === Left) return Right\n  if (d === Right) return Left\n  if (d === UpperRight) return LowerLeft\n  if (d === UpperLeft) return LowerRight\n  if (d === LowerRight) return UpperLeft\n  if (d === LowerLeft) return UpperRight\n  return Middle\n}\n\n/** Compare directions by value (not reference). */\nexport function dirEquals(a: Direction, b: Direction): boolean {\n  return a.x === b.x && a.y === b.y\n}\n\n/**\n * Determine 8-way direction from one coordinate to another.\n * Uses the coordinate difference to pick one of 8 cardinal/ordinal directions.\n */\nexport function determineDirection(from: { x: number; y: number }, to: { x: number; y: number }): Direction {\n  if (from.x === to.x) {\n    return from.y < to.y ? Down : Up\n  } else if (from.y === to.y) {\n    return from.x < to.x ? Right : Left\n  } else if (from.x < to.x) {\n    return from.y < to.y ? LowerRight : UpperRight\n  } else {\n    return from.y < to.y ? LowerLeft : UpperLeft\n  }\n}\n\n// ============================================================================\n// Start/end direction selection for edges\n// ============================================================================\n\n/** Self-reference routing (node points to itself). */\nfunction selfReferenceDirection(graphDirection: string): [Direction, Direction, Direction, Direction] {\n  if (graphDirection === 'LR') return [Right, Down, Down, Right]\n  return [Down, Right, Right, Down]\n}\n\n/**\n * Determine preferred and alternative start/end directions for an edge.\n * Returns [preferredStart, preferredEnd, alternativeStart, alternativeEnd].\n *\n * The edge routing tries both pairs and picks the shorter path.\n * Direction selection depends on relative node positions and graph direction (LR vs TD).\n */\nexport function determineStartAndEndDir(\n  edge: AsciiEdge,\n  graphDirection: string,\n): [Direction, Direction, Direction, Direction] {\n  if (edge.from === edge.to) return selfReferenceDirection(graphDirection)\n\n  const d = determineDirection(edge.from.gridCoord!, edge.to.gridCoord!)\n\n  let preferredDir: Direction\n  let preferredOppositeDir: Direction\n  let alternativeDir: Direction\n  let alternativeOppositeDir: Direction\n\n  const isBackwards = graphDirection === 'LR'\n    ? (dirEquals(d, Left) || dirEquals(d, UpperLeft) || dirEquals(d, LowerLeft))\n    : (dirEquals(d, Up) || dirEquals(d, UpperLeft) || dirEquals(d, UpperRight))\n\n  if (dirEquals(d, LowerRight)) {\n    if (graphDirection === 'LR') {\n      preferredDir = Down; preferredOppositeDir = Left\n      alternativeDir = Right; alternativeOppositeDir = Up\n    } else {\n      preferredDir = Right; preferredOppositeDir = Up\n      alternativeDir = Down; alternativeOppositeDir = Left\n    }\n  } else if (dirEquals(d, UpperRight)) {\n    if (graphDirection === 'LR') {\n      preferredDir = Up; preferredOppositeDir = Left\n      alternativeDir = Right; alternativeOppositeDir = Down\n    } else {\n      preferredDir = Right; preferredOppositeDir = Down\n      alternativeDir = Up; alternativeOppositeDir = Left\n    }\n  } else if (dirEquals(d, LowerLeft)) {\n    if (graphDirection === 'LR') {\n      preferredDir = Down; preferredOppositeDir = Down\n      alternativeDir = Left; alternativeOppositeDir = Up\n    } else {\n      preferredDir = Left; preferredOppositeDir = Up\n      alternativeDir = Down; alternativeOppositeDir = Right\n    }\n  } else if (dirEquals(d, UpperLeft)) {\n    if (graphDirection === 'LR') {\n      preferredDir = Down; preferredOppositeDir = Down\n      alternativeDir = Left; alternativeOppositeDir = Down\n    } else {\n      preferredDir = Right; preferredOppositeDir = Right\n      alternativeDir = Up; alternativeOppositeDir = Right\n    }\n  } else if (isBackwards) {\n    if (graphDirection === 'LR' && dirEquals(d, Left)) {\n      preferredDir = Down; preferredOppositeDir = Down\n      alternativeDir = Left; alternativeOppositeDir = Right\n    } else if (graphDirection === 'TD' && dirEquals(d, Up)) {\n      preferredDir = Right; preferredOppositeDir = Right\n      alternativeDir = Up; alternativeOppositeDir = Down\n    } else {\n      preferredDir = d; preferredOppositeDir = getOpposite(d)\n      alternativeDir = d; alternativeOppositeDir = getOpposite(d)\n    }\n  } else {\n    // Default: go in the natural direction\n    preferredDir = d; preferredOppositeDir = getOpposite(d)\n    alternativeDir = d; alternativeOppositeDir = getOpposite(d)\n  }\n\n  return [preferredDir, preferredOppositeDir, alternativeDir, alternativeOppositeDir]\n}\n\n// ============================================================================\n// Edge path determination\n// ============================================================================\n\n/**\n * Determine the path for an edge by trying two candidate routes (preferred + alternative)\n * and picking the shorter one. Sets edge.path, edge.startDir, edge.endDir.\n *\n * When both A* paths fail (common for edges crossing subgraph boundaries), falls back\n * to a direct path using the start/end points. This ensures edges always have a path\n * for arrowhead rendering.\n *\n * Uses the effective direction for edge routing, respecting subgraph direction overrides\n * when both source and target are in the same subgraph.\n */\nexport function determinePath(graph: AsciiGraph, edge: AsciiEdge): void {\n  // Determine effective direction for this edge\n  // If both nodes are in the same subgraph with a direction override, use it\n  // Otherwise, use the graph's direction (not source's effective direction)\n  const sourceSg = getNodeSubgraph(graph, edge.from)\n  const targetSg = getNodeSubgraph(graph, edge.to)\n  const effectiveDir = (sourceSg && sourceSg === targetSg && sourceSg.direction)\n    ? sourceSg.direction\n    : graph.config.graphDirection\n\n  const [preferredDir, preferredOppositeDir, alternativeDir, alternativeOppositeDir] =\n    determineStartAndEndDir(edge, effectiveDir)\n\n  // Try preferred path\n  const prefFrom = gridCoordDirection(edge.from.gridCoord!, preferredDir)\n  const prefTo = gridCoordDirection(edge.to.gridCoord!, preferredOppositeDir)\n  let preferredPath = getPath(graph.grid, prefFrom, prefTo)\n\n  // Try alternative path\n  const altFrom = gridCoordDirection(edge.from.gridCoord!, alternativeDir)\n  const altTo = gridCoordDirection(edge.to.gridCoord!, alternativeOppositeDir)\n  let alternativePath = getPath(graph.grid, altFrom, altTo)\n\n  // Case 1: Both paths found — pick the shorter one\n  if (preferredPath !== null && alternativePath !== null) {\n    preferredPath = mergePath(preferredPath)\n    alternativePath = mergePath(alternativePath)\n\n    if (preferredPath.length <= alternativePath.length) {\n      edge.startDir = preferredDir\n      edge.endDir = preferredOppositeDir\n      edge.path = preferredPath\n    } else {\n      edge.startDir = alternativeDir\n      edge.endDir = alternativeOppositeDir\n      edge.path = alternativePath\n    }\n    return\n  }\n\n  // Case 2: Only preferred path found\n  if (preferredPath !== null) {\n    edge.startDir = preferredDir\n    edge.endDir = preferredOppositeDir\n    edge.path = mergePath(preferredPath)\n    return\n  }\n\n  // Case 3: Only alternative path found\n  if (alternativePath !== null) {\n    edge.startDir = alternativeDir\n    edge.endDir = alternativeOppositeDir\n    edge.path = mergePath(alternativePath)\n    return\n  }\n\n  // Case 4: Both paths failed — create a direct fallback path\n  // This happens for edges crossing subgraph boundaries where A* can't find\n  // a clear route. We create a direct path from source to target exit points\n  // so arrowheads can still be rendered correctly.\n  edge.startDir = preferredDir\n  edge.endDir = preferredOppositeDir\n  edge.path = [prefFrom, prefTo]\n}\n\n/**\n * Find the best line segment in an edge's path to place a label on.\n * Prefers vertical segments for TD/BT graphs and horizontal for LR/RL to avoid\n * label collisions when multiple edges share initial segments.\n * Falls back to the widest segment if none are suitable.\n * Also increases the column width at the label position to fit the text.\n */\nexport function determineLabelLine(graph: AsciiGraph, edge: AsciiEdge): void {\n  if (edge.text.length === 0) return\n\n  const lenLabel = edge.text.length\n  const pathLen = edge.path.length\n  const isVerticalFlow = graph.config.graphDirection === 'TD'\n\n  // Collect all segments with their widths and orientation\n  const segments: {\n    line: [GridCoord, GridCoord]\n    width: number\n    index: number\n    isVertical: boolean\n  }[] = []\n\n  for (let i = 1; i < pathLen; i++) {\n    const p1 = edge.path[i - 1]!\n    const p2 = edge.path[i]!\n    const line: [GridCoord, GridCoord] = [p1, p2]\n    const width = calculateLineWidth(graph, line)\n    // A segment is vertical if X coords are same, horizontal if Y coords are same\n    const isVertical = p1.x === p2.x\n    segments.push({ line, width, index: i, isVertical })\n  }\n\n  // Find segments wide enough for the label, excluding the first segment\n  // The first segment is often shared between edges from the same source node\n  const suitableSegments = segments.filter(s => s.width >= lenLabel && s.index > 1)\n\n  let largestLine: [GridCoord, GridCoord]\n\n  if (suitableSegments.length > 0) {\n    // Prefer segments near the end of the path (closer to target)\n    // This avoids the shared initial segments from source\n    suitableSegments.sort((a, b) => b.index - a.index)\n    largestLine = suitableSegments[0]!.line\n  } else {\n    // Fall back to any suitable segment including the first\n    const fallbackSegments = segments.filter(s => s.width >= lenLabel)\n    if (fallbackSegments.length > 0) {\n      fallbackSegments.sort((a, b) => b.index - a.index)\n      largestLine = fallbackSegments[0]!.line\n    } else {\n      // No segment wide enough — use the widest one\n      segments.sort((a, b) => b.width - a.width)\n      largestLine = segments[0]?.line ?? [edge.path[0]!, edge.path[1]!]\n    }\n  }\n\n  // Ensure column at midpoint is wide enough for the label\n  const minX = Math.min(largestLine[0].x, largestLine[1].x)\n  const maxX = Math.max(largestLine[0].x, largestLine[1].x)\n  const middleX = minX + Math.floor((maxX - minX) / 2)\n\n  const current = graph.columnWidth.get(middleX) ?? 0\n  graph.columnWidth.set(middleX, Math.max(current, lenLabel + 2))\n\n  edge.labelLine = [largestLine[0], largestLine[1]]\n}\n\n/** Calculate the total character width of a line segment by summing column widths. */\nfunction calculateLineWidth(graph: AsciiGraph, line: [GridCoord, GridCoord]): number {\n  let total = 0\n  const startX = Math.min(line[0].x, line[1].x)\n  const endX = Math.max(line[0].x, line[1].x)\n  for (let x = startX; x <= endX; x++) {\n    total += graph.columnWidth.get(x) ?? 0\n  }\n  return total\n}\n"
  },
  {
    "path": "src/ascii/er-diagram.ts",
    "content": "// ============================================================================\n// ASCII renderer — ER diagrams\n//\n// Renders erDiagram text to ASCII/Unicode art.\n// Each entity is a 2-section box (header | attributes).\n// Relationships are drawn as lines with crow's foot notation at endpoints.\n//\n// Layout: entities are placed in a grid pattern (multiple rows if needed).\n// Relationship lines use Manhattan routing between entity boxes.\n// ============================================================================\n\nimport { parseErDiagram } from '../er/parser.ts'\nimport type { ErDiagram, ErEntity, ErAttribute, Cardinality } from '../er/types.ts'\nimport type { Canvas, AsciiConfig, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types.ts'\nimport { mkCanvas, mkRoleCanvas, canvasToString, increaseSize, increaseRoleCanvasSize, setRole } from './canvas.ts'\nimport { drawMultiBox } from './draw.ts'\nimport { splitLines } from './multiline-utils.ts'\n\n/** Classify a character from a box drawing as 'border' or 'text'. */\nfunction classifyBoxChar(ch: string): CharRole {\n  if (/^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\\-|]$/.test(ch)) return 'border'\n  return 'text'\n}\n\n// ============================================================================\n// Entity box content\n// ============================================================================\n\n/** Format an attribute line: \"PK type name\" or \"FK type name\" etc. */\nfunction formatAttribute(attr: ErAttribute): string {\n  const keyStr = attr.keys.length > 0 ? attr.keys.join(',') + ' ' : '   '\n  return `${keyStr}${attr.type} ${attr.name}`\n}\n\n/** Build sections for an entity box: [header], [attributes] */\nfunction buildEntitySections(entity: ErEntity): string[][] {\n  // Support multi-line entity names\n  const header = splitLines(entity.label)\n  const attrs = entity.attributes.map(formatAttribute)\n  if (attrs.length === 0) return [header]\n  return [header, attrs]\n}\n\n// ============================================================================\n// Crow's foot notation\n// ============================================================================\n\n/**\n * Returns the ASCII/Unicode characters for a crow's foot cardinality marker.\n * Markers are drawn adjacent to entity boxes at relationship endpoints.\n *\n * Standard ER notation:\n *   one:       ─┤├─   perpendicular line (exactly one)\n *   zero-one:  ─○┤─   circle + perpendicular (zero or one)\n *   many:      ─<>─   crow's foot (one or more)\n *   zero-many: ─○<─   circle + crow's foot (zero or more)\n *\n * @param card - The cardinality type\n * @param useAscii - Use ASCII-only characters\n * @param isRight - True if this marker is on the right side of the relationship\n */\nfunction getCrowsFootChars(card: Cardinality, useAscii: boolean, isRight = false): string {\n  if (useAscii) {\n    switch (card) {\n      case 'one':       return '|'\n      case 'zero-one':  return 'o|'\n      case 'many':      return isRight ? '<' : '>'\n      case 'zero-many': return isRight ? 'o<' : '>o'\n    }\n  } else {\n    // Use cleaner Unicode characters\n    switch (card) {\n      case 'one':       return '│'\n      case 'zero-one':  return '○│'\n      case 'many':      return isRight ? '╟' : '╢'\n      case 'zero-many': return isRight ? '○╟' : '╢○'\n    }\n  }\n}\n\n// ============================================================================\n// Positioned entity\n// ============================================================================\n\ninterface PlacedEntity {\n  entity: ErEntity\n  sections: string[][]\n  x: number\n  y: number\n  width: number\n  height: number\n}\n\n// ============================================================================\n// Connected Component Detection\n// ============================================================================\n\n/**\n * Find connected components in the ER diagram using DFS.\n * Treats relationships as undirected edges for connectivity.\n *\n * Returns an array of entity ID sets, one per connected component.\n */\nfunction findConnectedComponents(diagram: ErDiagram): Set<string>[] {\n  const visited = new Set<string>()\n  const components: Set<string>[] = []\n\n  // Build undirected adjacency list from relationships\n  const neighbors = new Map<string, Set<string>>()\n  for (const ent of diagram.entities) {\n    neighbors.set(ent.id, new Set())\n  }\n  for (const rel of diagram.relationships) {\n    neighbors.get(rel.entity1)?.add(rel.entity2)\n    neighbors.get(rel.entity2)?.add(rel.entity1)\n  }\n\n  // DFS to find each component\n  function dfs(startId: string, component: Set<string>): void {\n    const stack = [startId]\n    while (stack.length > 0) {\n      const nodeId = stack.pop()!\n      if (visited.has(nodeId)) continue\n\n      visited.add(nodeId)\n      component.add(nodeId)\n\n      for (const neighbor of neighbors.get(nodeId) ?? []) {\n        if (!visited.has(neighbor)) {\n          stack.push(neighbor)\n        }\n      }\n    }\n  }\n\n  // Find all components\n  for (const ent of diagram.entities) {\n    if (!visited.has(ent.id)) {\n      const component = new Set<string>()\n      dfs(ent.id, component)\n      if (component.size > 0) {\n        components.push(component)\n      }\n    }\n  }\n\n  return components\n}\n\n// ============================================================================\n// Layout and rendering\n// ============================================================================\n\n/**\n * Render a Mermaid ER diagram to ASCII/Unicode text.\n *\n * Pipeline: parse → build boxes → component-aware layout → draw boxes → draw relationships → string.\n */\nexport function renderErAscii(text: string, config: AsciiConfig, colorMode?: ColorMode, theme?: AsciiTheme): string {\n  const lines = text.split('\\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%'))\n  const diagram = parseErDiagram(lines)\n\n  if (diagram.entities.length === 0) return ''\n\n  const useAscii = config.useAscii\n  const hGap = 6  // horizontal gap between entity boxes\n  const vGap = 4  // vertical gap between rows (for relationship lines)\n  const componentGap = 6  // vertical gap between disconnected components\n\n  // --- Build entity box dimensions ---\n  const entitySections = new Map<string, string[][]>()\n  const entityBoxW = new Map<string, number>()\n  const entityBoxH = new Map<string, number>()\n  const entityById = new Map<string, ErEntity>()\n\n  for (const ent of diagram.entities) {\n    entityById.set(ent.id, ent)\n    const sections = buildEntitySections(ent)\n    entitySections.set(ent.id, sections)\n\n    let maxTextW = 0\n    for (const section of sections) {\n      for (const line of section) maxTextW = Math.max(maxTextW, line.length)\n    }\n    const boxW = maxTextW + 4 // 2 border + 2 padding\n\n    let totalLines = 0\n    for (const section of sections) totalLines += Math.max(section.length, 1)\n    const boxH = totalLines + (sections.length - 1) + 2\n\n    entityBoxW.set(ent.id, boxW)\n    entityBoxH.set(ent.id, boxH)\n  }\n\n  // --- Find connected components ---\n  const components = findConnectedComponents(diagram)\n\n  // --- Layout: place each component, then stack components vertically ---\n  const placed = new Map<string, PlacedEntity>()\n  let currentY = 0\n\n  for (const component of components) {\n    // Get entities in this component (preserve original order for consistency)\n    const componentEntities = diagram.entities.filter(e => component.has(e.id))\n\n    // Layout entities within this component horizontally\n    // Use sqrt-based row limit for larger components\n    const maxPerRow = Math.max(2, Math.ceil(Math.sqrt(componentEntities.length)))\n\n    let currentX = 0\n    let maxRowH = 0\n    let colCount = 0\n    const componentStartY = currentY\n\n    for (const ent of componentEntities) {\n      const w = entityBoxW.get(ent.id)!\n      const h = entityBoxH.get(ent.id)!\n\n      if (colCount >= maxPerRow) {\n        // Wrap to next row within this component\n        currentY += maxRowH + vGap\n        currentX = 0\n        maxRowH = 0\n        colCount = 0\n      }\n\n      placed.set(ent.id, {\n        entity: ent,\n        sections: entitySections.get(ent.id)!,\n        x: currentX,\n        y: currentY,\n        width: w,\n        height: h,\n      })\n\n      currentX += w + hGap\n      maxRowH = Math.max(maxRowH, h)\n      colCount++\n    }\n\n    // Move to next component row (add gap between components)\n    currentY += maxRowH + componentGap\n  }\n\n  // --- Create canvas ---\n  let totalW = 0\n  let totalH = 0\n  for (const p of placed.values()) {\n    totalW = Math.max(totalW, p.x + p.width)\n    totalH = Math.max(totalH, p.y + p.height)\n  }\n  totalW += 4\n  totalH += 2\n\n  const canvas = mkCanvas(totalW - 1, totalH - 1)\n  const rc = mkRoleCanvas(totalW - 1, totalH - 1)\n\n  /** Set a character on the canvas and track its role. */\n  function setC(x: number, y: number, ch: string, role: CharRole): void {\n    if (x >= 0 && x < canvas.length && y >= 0 && y < (canvas[0]?.length ?? 0)) {\n      canvas[x]![y] = ch\n      setRole(rc, x, y, role)\n    }\n  }\n\n  // --- Draw entity boxes ---\n  for (const p of placed.values()) {\n    const boxCanvas = drawMultiBox(p.sections, useAscii)\n    for (let bx = 0; bx < boxCanvas.length; bx++) {\n      for (let by = 0; by < boxCanvas[0]!.length; by++) {\n        const ch = boxCanvas[bx]![by]!\n        if (ch !== ' ') {\n          const cx = p.x + bx\n          const cy = p.y + by\n          if (cx < totalW && cy < totalH) {\n            setC(cx, cy, ch, classifyBoxChar(ch))\n          }\n        }\n      }\n    }\n  }\n\n  // --- Draw relationships ---\n  const H = useAscii ? '-' : '─'\n  const V = useAscii ? '|' : '│'\n  const dashH = useAscii ? '.' : '╌'\n  const dashV = useAscii ? ':' : '┊'\n\n  for (const rel of diagram.relationships) {\n    const e1 = placed.get(rel.entity1)\n    const e2 = placed.get(rel.entity2)\n    if (!e1 || !e2) continue\n\n    const lineH = rel.identifying ? H : dashH\n    const lineV = rel.identifying ? V : dashV\n\n    // Determine connection direction based on relative position.\n    // Connect from right side of left entity to left side of right entity (horizontal),\n    // or from bottom of upper entity to top of lower entity (vertical).\n    const e1CX = e1.x + Math.floor(e1.width / 2)\n    const e1CY = e1.y + Math.floor(e1.height / 2)\n    const e2CX = e2.x + Math.floor(e2.width / 2)\n    const e2CY = e2.y + Math.floor(e2.height / 2)\n\n    // Check if entities are on the same row (horizontal connection)\n    const sameRow = Math.abs(e1CY - e2CY) < Math.max(e1.height, e2.height)\n\n    if (sameRow) {\n      // Horizontal connection: right side of left entity → left side of right entity\n      const [left, right] = e1CX < e2CX ? [e1, e2] : [e2, e1]\n      const [leftCard, rightCard] = e1CX < e2CX\n        ? [rel.cardinality1, rel.cardinality2]\n        : [rel.cardinality2, rel.cardinality1]\n\n      const startX = left.x + left.width\n      const endX = right.x - 1\n      const lineY = left.y + Math.floor(left.height / 2)\n\n      // Draw horizontal line\n      for (let x = startX; x <= endX; x++) {\n        setC(x, lineY, lineH, 'line')\n      }\n\n      // Draw crow's foot markers at endpoints\n      // Left marker (at left entity's right edge) - isRight=false\n      const leftChars = getCrowsFootChars(leftCard, useAscii, false)\n      for (let i = 0; i < leftChars.length; i++) {\n        setC(startX + i, lineY, leftChars[i]!, 'arrow')\n      }\n\n      // Right marker (at right entity's left edge) - isRight=true\n      const rightChars = getCrowsFootChars(rightCard, useAscii, true)\n      for (let i = 0; i < rightChars.length; i++) {\n        setC(endX - rightChars.length + 1 + i, lineY, rightChars[i]!, 'arrow')\n      }\n\n      // Relationship label centered in the gap between the two entities, below the line.\n      // Clamp label to the gap region [startX, endX] to avoid overwriting box borders.\n      // Supports multi-line labels.\n      if (rel.label) {\n        const lines = splitLines(rel.label)\n        const gapMid = Math.floor((startX + endX) / 2)\n\n        // Place lines below the relationship line (lineY + 1, lineY + 2, ...)\n        for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {\n          const line = lines[lineIdx]!\n          const labelStart = Math.max(startX, gapMid - Math.floor(line.length / 2))\n          const labelY = lineY + 1 + lineIdx\n          // Ensure canvas is tall enough\n          increaseSize(canvas, Math.max(labelStart + line.length, 1), Math.max(labelY + 1, 1))\n          increaseRoleCanvasSize(rc, Math.max(labelStart + line.length, 1), Math.max(labelY + 1, 1))\n          for (let i = 0; i < line.length; i++) {\n            const lx = labelStart + i\n            if (lx >= startX && lx <= endX) {\n              setC(lx, labelY, line[i]!, 'text')\n            }\n          }\n        }\n      }\n    } else {\n      // Vertical connection: bottom of upper entity → top of lower entity\n      const [upper, lower] = e1CY < e2CY ? [e1, e2] : [e2, e1]\n      const [upperCard, lowerCard] = e1CY < e2CY\n        ? [rel.cardinality1, rel.cardinality2]\n        : [rel.cardinality2, rel.cardinality1]\n\n      const startY = upper.y + upper.height\n      const endY = lower.y - 1\n      const lineX = upper.x + Math.floor(upper.width / 2)\n\n      // Vertical line\n      for (let y = startY; y <= endY; y++) {\n        setC(lineX, y, lineV, 'line')\n      }\n\n      // If horizontal offset needed, add a horizontal segment\n      const lowerCX = lower.x + Math.floor(lower.width / 2)\n      if (lineX !== lowerCX) {\n        const midY = Math.floor((startY + endY) / 2)\n        // Horizontal segment at midY\n        const lx = Math.min(lineX, lowerCX)\n        const rx = Math.max(lineX, lowerCX)\n        for (let x = lx; x <= rx; x++) {\n          setC(x, midY, lineH, 'line')\n        }\n        // Vertical from midY to lower entity\n        for (let y = midY + 1; y <= endY; y++) {\n          setC(lowerCX, y, lineV, 'line')\n        }\n      }\n\n      // Crow's foot markers (vertical direction)\n      // Upper marker (at upper entity's bottom edge) - treat as source side (isRight=false)\n      const upperChars = getCrowsFootChars(upperCard, useAscii, false)\n      for (let i = 0; i < upperChars.length; i++) {\n        setC(lineX - Math.floor(upperChars.length / 2) + i, startY, upperChars[i]!, 'arrow')\n      }\n\n      // Lower marker (at lower entity's top edge) - treat as target side (isRight=true)\n      const targetX = lineX !== lowerCX ? lowerCX : lineX\n      const lowerChars = getCrowsFootChars(lowerCard, useAscii, true)\n      for (let i = 0; i < lowerChars.length; i++) {\n        setC(targetX - Math.floor(lowerChars.length / 2) + i, endY, lowerChars[i]!, 'arrow')\n      }\n\n      // Relationship label — placed to the right of the vertical line at the midpoint.\n      // We expand the canvas as needed since labels can extend beyond the initial bounds.\n      // Supports multi-line labels.\n      if (rel.label) {\n        const lines = splitLines(rel.label)\n        const midY = Math.floor((startY + endY) / 2)\n        // Center lines vertically around midY\n        const startLabelY = midY - Math.floor((lines.length - 1) / 2)\n\n        for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {\n          const line = lines[lineIdx]!\n          const labelX = lineX + 2\n          const y = startLabelY + lineIdx\n          if (y >= 0) {\n            for (let i = 0; i < line.length; i++) {\n              const lx = labelX + i\n              if (lx >= 0) {\n                increaseSize(canvas, lx + 1, y + 1)\n                increaseRoleCanvasSize(rc, lx + 1, y + 1)\n                setC(lx, y, line[i]!, 'text')\n              }\n            }\n          }\n        }\n      }\n    }\n  }\n\n  return canvasToString(canvas, { roleCanvas: rc, colorMode, theme })\n}\n"
  },
  {
    "path": "src/ascii/grid.ts",
    "content": "// ============================================================================\n// ASCII renderer — grid-based layout\n//\n// Ported from AlexanderGrooff/mermaid-ascii cmd/graph.go + cmd/mapping_node.go.\n// Places nodes on a logical grid, computes column/row sizes,\n// converts grid coordinates to character-level drawing coordinates,\n// and handles subgraph bounding boxes.\n// ============================================================================\n\nimport type {\n  GridCoord, DrawingCoord, Direction, AsciiGraph, AsciiNode, AsciiSubgraph,\n} from './types.ts'\nimport { gridKey } from './types.ts'\nimport { mkCanvas, setCanvasSizeToGrid, setRoleCanvasSizeToGrid } from './canvas.ts'\nimport { determinePath, determineLabelLine } from './edge-routing.ts'\nimport { analyzeEdgeBundles, processBundles } from './edge-bundling.ts'\nimport { drawBox } from './draw.ts'\nimport { maxLineWidth, lineCount } from './multiline-utils.ts'\nimport { getShapeDimensions } from './shapes/index.ts'\n\n// ============================================================================\n// Grid coordinate → drawing coordinate conversion\n// ============================================================================\n\n/**\n * Convert a grid coordinate to a drawing (character) coordinate.\n * Sums column widths up to the target column, and row heights up to the target row,\n * then centers within the cell.\n */\nexport function gridToDrawingCoord(\n  graph: AsciiGraph,\n  c: GridCoord,\n  dir?: Direction,\n): DrawingCoord {\n  const target: GridCoord = dir\n    ? { x: c.x + dir.x, y: c.y + dir.y }\n    : c\n\n  let x = 0\n  for (let col = 0; col < target.x; col++) {\n    x += graph.columnWidth.get(col) ?? 0\n  }\n\n  let y = 0\n  for (let row = 0; row < target.y; row++) {\n    y += graph.rowHeight.get(row) ?? 0\n  }\n\n  const colW = graph.columnWidth.get(target.x) ?? 0\n  const rowH = graph.rowHeight.get(target.y) ?? 0\n  return {\n    x: x + Math.floor(colW / 2) + graph.offsetX,\n    y: y + Math.floor(rowH / 2) + graph.offsetY,\n  }\n}\n\n/** Convert a path of grid coords to drawing coords. */\nexport function lineToDrawing(graph: AsciiGraph, line: GridCoord[]): DrawingCoord[] {\n  return line.map(c => gridToDrawingCoord(graph, c))\n}\n\n// ============================================================================\n// Node placement on the grid\n// ============================================================================\n\n/**\n * Reserve a 3x3 block in the grid for a node.\n * If the requested position is occupied, recursively shift by 4 grid units\n * (in the perpendicular direction based on effective direction) until a free spot is found.\n *\n * @param effectiveDir - Optional direction override. If not provided, uses the node's\n *                       effective direction (subgraph direction if in a subgraph with override,\n *                       otherwise graph direction).\n */\nexport function reserveSpotInGrid(\n  graph: AsciiGraph,\n  node: AsciiNode,\n  requested: GridCoord,\n  effectiveDir?: 'LR' | 'TD',\n): GridCoord {\n  // Determine direction for collision handling\n  const dir = effectiveDir ?? getEffectiveDirection(graph, node)\n\n  if (graph.grid.has(gridKey(requested))) {\n    // Collision — shift perpendicular to main flow direction\n    if (dir === 'LR') {\n      return reserveSpotInGrid(graph, node, { x: requested.x, y: requested.y + 4 }, dir)\n    } else {\n      return reserveSpotInGrid(graph, node, { x: requested.x + 4, y: requested.y }, dir)\n    }\n  }\n\n  // Reserve the 3x3 block\n  for (let dx = 0; dx < 3; dx++) {\n    for (let dy = 0; dy < 3; dy++) {\n      const reserved: GridCoord = { x: requested.x + dx, y: requested.y + dy }\n      graph.grid.set(gridKey(reserved), node)\n    }\n  }\n\n  node.gridCoord = requested\n  return requested\n}\n\n// ============================================================================\n// Column width / row height computation\n// ============================================================================\n\n/**\n * Set column widths and row heights for a node's 3x3 grid block.\n * Each node occupies 3 columns (border, content, border) and 3 rows.\n * Uses shape-aware dimensions to properly size non-rectangular shapes.\n */\nexport function setColumnWidth(graph: AsciiGraph, node: AsciiNode): void {\n  const gc = node.gridCoord!\n  const padding = graph.config.boxBorderPadding\n\n  // Get shape-aware dimensions\n  const shapeDims = getShapeDimensions(node.shape, node.displayLabel, {\n    useAscii: graph.config.useAscii,\n    padding,\n  })\n\n  // Use shape-provided grid dimensions\n  const colWidths = shapeDims.gridColumns\n  const rowHeights = shapeDims.gridRows\n\n  for (let idx = 0; idx < colWidths.length; idx++) {\n    const xCoord = gc.x + idx\n    const current = graph.columnWidth.get(xCoord) ?? 0\n    graph.columnWidth.set(xCoord, Math.max(current, colWidths[idx]!))\n  }\n\n  for (let idx = 0; idx < rowHeights.length; idx++) {\n    const yCoord = gc.y + idx\n    const current = graph.rowHeight.get(yCoord) ?? 0\n    graph.rowHeight.set(yCoord, Math.max(current, rowHeights[idx]!))\n  }\n\n  // Padding column/row before the node (spacing between nodes)\n  if (gc.x > 0) {\n    const current = graph.columnWidth.get(gc.x - 1) ?? 0\n    graph.columnWidth.set(gc.x - 1, Math.max(current, graph.config.paddingX))\n  }\n\n  if (gc.y > 0) {\n    let basePadding = graph.config.paddingY\n    // Extra vertical padding for nodes with incoming edges from outside their subgraph\n    if (hasIncomingEdgeFromOutsideSubgraph(graph, node)) {\n      const subgraphOverhead = 4\n      basePadding += subgraphOverhead\n    }\n    const current = graph.rowHeight.get(gc.y - 1) ?? 0\n    graph.rowHeight.set(gc.y - 1, Math.max(current, basePadding))\n  }\n}\n\n/** Ensure grid has width/height entries for all cells along an edge path. */\nexport function increaseGridSizeForPath(graph: AsciiGraph, path: GridCoord[]): void {\n  for (const c of path) {\n    if (!graph.columnWidth.has(c.x)) {\n      graph.columnWidth.set(c.x, Math.floor(graph.config.paddingX / 2))\n    }\n    if (!graph.rowHeight.has(c.y)) {\n      graph.rowHeight.set(c.y, Math.floor(graph.config.paddingY / 2))\n    }\n  }\n}\n\n// ============================================================================\n// Subgraph helpers\n// ============================================================================\n\nfunction isNodeInAnySubgraph(graph: AsciiGraph, node: AsciiNode): boolean {\n  return graph.subgraphs.some(sg => sg.nodes.includes(node))\n}\n\n/**\n * Get the innermost subgraph that directly contains this node.\n * Returns null if node is not in any subgraph.\n */\nexport function getNodeSubgraph(graph: AsciiGraph, node: AsciiNode): AsciiSubgraph | null {\n  // Find the innermost (most deeply nested) subgraph containing the node\n  let innermost: AsciiSubgraph | null = null\n  for (const sg of graph.subgraphs) {\n    if (sg.nodes.includes(node)) {\n      // Check if this subgraph is deeper (more nested) than current innermost\n      if (!innermost || isAncestorOrSelf(innermost, sg)) {\n        innermost = sg\n      }\n    }\n  }\n  return innermost\n}\n\n/** Check if `candidate` is the same as or an ancestor of `target`. */\nfunction isAncestorOrSelf(candidate: AsciiSubgraph, target: AsciiSubgraph): boolean {\n  let current: AsciiSubgraph | null = target\n  while (current !== null) {\n    if (current === candidate) return true\n    current = current.parent\n  }\n  return false\n}\n\n/**\n * Get the effective direction for a node's layout.\n * Returns the subgraph's direction override if the node is in a subgraph with one,\n * otherwise returns the graph-level direction.\n */\nexport function getEffectiveDirection(graph: AsciiGraph, node: AsciiNode): 'LR' | 'TD' {\n  const sg = getNodeSubgraph(graph, node)\n  if (sg?.direction) {\n    return sg.direction\n  }\n  return graph.config.graphDirection\n}\n\n/**\n * Check if a node has an incoming edge from outside its subgraph\n * AND is the topmost such node in its subgraph.\n * Used to add extra vertical padding for subgraph borders.\n */\nfunction hasIncomingEdgeFromOutsideSubgraph(graph: AsciiGraph, node: AsciiNode): boolean {\n  const nodeSg = getNodeSubgraph(graph, node)\n  if (!nodeSg) return false\n\n  let hasExternalEdge = false\n  for (const edge of graph.edges) {\n    if (edge.to === node) {\n      const sourceSg = getNodeSubgraph(graph, edge.from)\n      if (sourceSg !== nodeSg) {\n        hasExternalEdge = true\n        break\n      }\n    }\n  }\n\n  if (!hasExternalEdge) return false\n\n  // Only return true for the topmost node with an external incoming edge\n  for (const otherNode of nodeSg.nodes) {\n    if (otherNode === node || !otherNode.gridCoord) continue\n    let otherHasExternal = false\n    for (const edge of graph.edges) {\n      if (edge.to === otherNode) {\n        const sourceSg = getNodeSubgraph(graph, edge.from)\n        if (sourceSg !== nodeSg) {\n          otherHasExternal = true\n          break\n        }\n      }\n    }\n    if (otherHasExternal && otherNode.gridCoord.y < node.gridCoord!.y) {\n      return false\n    }\n  }\n\n  return true\n}\n\n// ============================================================================\n// Subgraph bounding boxes\n// ============================================================================\n\nfunction calculateSubgraphBoundingBox(graph: AsciiGraph, sg: AsciiSubgraph): void {\n  if (sg.nodes.length === 0) return\n\n  let minX = 1_000_000\n  let minY = 1_000_000\n  let maxX = -1_000_000\n  let maxY = -1_000_000\n\n  // Include children's bounding boxes\n  for (const child of sg.children) {\n    calculateSubgraphBoundingBox(graph, child)\n    if (child.nodes.length > 0) {\n      minX = Math.min(minX, child.minX)\n      minY = Math.min(minY, child.minY)\n      maxX = Math.max(maxX, child.maxX)\n      maxY = Math.max(maxY, child.maxY)\n    }\n  }\n\n  // Include node positions\n  for (const node of sg.nodes) {\n    if (!node.drawingCoord || !node.drawing) continue\n    const nodeMinX = node.drawingCoord.x\n    const nodeMinY = node.drawingCoord.y\n    const nodeMaxX = nodeMinX + node.drawing.length - 1\n    const nodeMaxY = nodeMinY + node.drawing[0]!.length - 1\n    minX = Math.min(minX, nodeMinX)\n    minY = Math.min(minY, nodeMinY)\n    maxX = Math.max(maxX, nodeMaxX)\n    maxY = Math.max(maxY, nodeMaxY)\n  }\n\n  const subgraphPadding = 2\n  const subgraphLabelSpace = 2\n  sg.minX = minX - subgraphPadding\n  sg.minY = minY - subgraphPadding - subgraphLabelSpace\n  sg.maxX = maxX + subgraphPadding\n  sg.maxY = maxY + subgraphPadding\n}\n\n/** Ensure non-overlapping root subgraphs have minimum spacing. */\nfunction ensureSubgraphSpacing(graph: AsciiGraph): void {\n  const minSpacing = 1\n  const rootSubgraphs = graph.subgraphs.filter(sg => sg.parent === null && sg.nodes.length > 0)\n\n  for (let i = 0; i < rootSubgraphs.length; i++) {\n    for (let j = i + 1; j < rootSubgraphs.length; j++) {\n      const sg1 = rootSubgraphs[i]!\n      const sg2 = rootSubgraphs[j]!\n\n      // Horizontal overlap → adjust vertical\n      if (sg1.minX < sg2.maxX && sg1.maxX > sg2.minX) {\n        if (sg1.maxY >= sg2.minY - minSpacing && sg1.minY < sg2.minY) {\n          sg2.minY = sg1.maxY + minSpacing + 1\n        } else if (sg2.maxY >= sg1.minY - minSpacing && sg2.minY < sg1.minY) {\n          sg1.minY = sg2.maxY + minSpacing + 1\n        }\n      }\n      // Vertical overlap → adjust horizontal\n      if (sg1.minY < sg2.maxY && sg1.maxY > sg2.minY) {\n        if (sg1.maxX >= sg2.minX - minSpacing && sg1.minX < sg2.minX) {\n          sg2.minX = sg1.maxX + minSpacing + 1\n        } else if (sg2.maxX >= sg1.minX - minSpacing && sg2.minX < sg1.minX) {\n          sg1.minX = sg2.maxX + minSpacing + 1\n        }\n      }\n    }\n  }\n}\n\nexport function calculateSubgraphBoundingBoxes(graph: AsciiGraph): void {\n  for (const sg of graph.subgraphs) {\n    calculateSubgraphBoundingBox(graph, sg)\n  }\n  ensureSubgraphSpacing(graph)\n}\n\n/**\n * Offset all drawing coordinates so subgraph borders don't go negative.\n * If any subgraph has negative min coordinates, shift everything positive.\n */\nexport function offsetDrawingForSubgraphs(graph: AsciiGraph): void {\n  if (graph.subgraphs.length === 0) return\n\n  let minX = 0\n  let minY = 0\n  for (const sg of graph.subgraphs) {\n    minX = Math.min(minX, sg.minX)\n    minY = Math.min(minY, sg.minY)\n  }\n\n  const offsetX = -minX\n  const offsetY = -minY\n  if (offsetX === 0 && offsetY === 0) return\n\n  graph.offsetX = offsetX\n  graph.offsetY = offsetY\n\n  for (const sg of graph.subgraphs) {\n    sg.minX += offsetX\n    sg.minY += offsetY\n    sg.maxX += offsetX\n    sg.maxY += offsetY\n  }\n\n  for (const node of graph.nodes) {\n    if (node.drawingCoord) {\n      node.drawingCoord.x += offsetX\n      node.drawingCoord.y += offsetY\n    }\n  }\n}\n\n// ============================================================================\n// Main layout orchestrator\n// ============================================================================\n\n/**\n * createMapping performs the full grid layout:\n * 1. Place root nodes on the grid\n * 2. Place child nodes level by level\n * 3. Compute column widths and row heights\n * 4. Run A* pathfinding for all edges\n * 5. Determine label placement\n * 6. Convert grid coords → drawing coords\n * 7. Generate node box drawings\n * 8. Calculate subgraph bounding boxes\n */\nexport function createMapping(graph: AsciiGraph): void {\n  const dir = graph.config.graphDirection\n  const highestPositionPerLevel: number[] = new Array(100).fill(0)\n\n  // Identify root nodes — nodes that aren't the target of any edge\n  const nodesFound = new Set<string>()\n  const initialRoots: AsciiNode[] = []\n\n  for (const node of graph.nodes) {\n    if (!nodesFound.has(node.name)) {\n      initialRoots.push(node)\n    }\n    nodesFound.add(node.name)\n    for (const child of getChildren(graph, node)) {\n      nodesFound.add(child.name)\n    }\n  }\n\n  // Filter out subgraph nodes that have incoming edges from external sources.\n  // This handles the case where subgraph is declared before external nodes\n  // (e.g., `subgraph s; A-->B; end; X-->A` - A shouldn't be a root, X should).\n  const rootNodes = initialRoots.filter(node => {\n    const nodeSg = getNodeSubgraph(graph, node)\n    if (!nodeSg) return true  // external nodes: keep as roots\n\n    // Check if this subgraph node has incoming edges from outside its subgraph\n    for (const edge of graph.edges) {\n      if (edge.to === node) {\n        const sourceSg = getNodeSubgraph(graph, edge.from)\n        if (sourceSg !== nodeSg) {\n          return false  // has external incoming edge → not a root\n        }\n      }\n    }\n    return true\n  })\n\n  // In LR mode with both external and subgraph roots, separate them\n  // so subgraph roots are placed one level deeper\n  let hasExternalRoots = false\n  let hasSubgraphRootsWithEdges = false\n  for (const node of rootNodes) {\n    if (isNodeInAnySubgraph(graph, node)) {\n      if (getChildren(graph, node).length > 0) hasSubgraphRootsWithEdges = true\n    } else {\n      hasExternalRoots = true\n    }\n  }\n  const shouldSeparate = dir === 'LR' && hasExternalRoots && hasSubgraphRootsWithEdges\n\n  let externalRootNodes: AsciiNode[]\n  let subgraphRootNodes: AsciiNode[] = []\n\n  if (shouldSeparate) {\n    externalRootNodes = rootNodes.filter(n => !isNodeInAnySubgraph(graph, n))\n    subgraphRootNodes = rootNodes.filter(n => isNodeInAnySubgraph(graph, n))\n  } else {\n    externalRootNodes = rootNodes\n  }\n\n  // Place external root nodes\n  for (const node of externalRootNodes) {\n    const requested: GridCoord = dir === 'LR'\n      ? { x: 0, y: highestPositionPerLevel[0]! }\n      : { x: highestPositionPerLevel[0]!, y: 0 }\n    reserveSpotInGrid(graph, graph.nodes[node.index]!, requested)\n    highestPositionPerLevel[0] = highestPositionPerLevel[0]! + 4\n  }\n\n  // Place subgraph root nodes at level 4 (one level in from the edge)\n  if (shouldSeparate && subgraphRootNodes.length > 0) {\n    const subgraphLevel = 4\n    for (const node of subgraphRootNodes) {\n      const requested: GridCoord = dir === 'LR'\n        ? { x: subgraphLevel, y: highestPositionPerLevel[subgraphLevel]! }\n        : { x: highestPositionPerLevel[subgraphLevel]!, y: subgraphLevel }\n      reserveSpotInGrid(graph, graph.nodes[node.index]!, requested)\n      highestPositionPerLevel[subgraphLevel] = highestPositionPerLevel[subgraphLevel]! + 4\n    }\n  }\n\n  // Place child nodes level by level\n  // Use subgraph direction only when both parent and child are in the same subgraph\n  // Multi-pass: iterate until all nodes are placed (handles non-topological node order)\n  // Note: when shouldSeparate, externalRootNodes + subgraphRootNodes = rootNodes\n  //       otherwise, externalRootNodes = rootNodes and subgraphRootNodes is empty\n  let placedCount = externalRootNodes.length + subgraphRootNodes.length\n  while (placedCount < graph.nodes.length) {\n    const prevCount = placedCount\n    for (const node of graph.nodes) {\n      if (node.gridCoord === null) continue  // skip unplaced nodes\n      const gc = node.gridCoord\n\n      for (const child of getChildren(graph, node)) {\n        if (child.gridCoord !== null) continue // already placed\n\n        // Determine direction for this edge (parent -> child)\n        // Use subgraph direction only if both are in the same subgraph with override\n        const parentSg = getNodeSubgraph(graph, node)\n        const childSg = getNodeSubgraph(graph, child)\n        const edgeDir = (parentSg && parentSg === childSg && parentSg.direction)\n          ? parentSg.direction\n          : graph.config.graphDirection\n\n        const childLevel = edgeDir === 'LR' ? gc.x + 4 : gc.y + 4\n\n        // Determine position based on direction context\n        let highestPosition: number\n        if (edgeDir !== graph.config.graphDirection) {\n          // Cross-direction: use parent's perpendicular coordinate\n          // This keeps children aligned with parent when direction changes\n          highestPosition = edgeDir === 'LR' ? gc.y : gc.x\n        } else {\n          // Same direction: use level tracker\n          highestPosition = highestPositionPerLevel[childLevel]!\n        }\n\n        const requested: GridCoord = edgeDir === 'LR'\n          ? { x: childLevel, y: highestPosition }\n          : { x: highestPosition, y: childLevel }\n        reserveSpotInGrid(graph, graph.nodes[child.index]!, requested, edgeDir)\n\n        // Only update level tracker for same-direction placements\n        if (edgeDir === graph.config.graphDirection) {\n          highestPositionPerLevel[childLevel] = highestPosition + 4\n        }\n        placedCount++\n      }\n    }\n    // Safety: break if no progress made (handles disconnected nodes)\n    if (placedCount === prevCount) break\n  }\n\n  // Compute column widths and row heights\n  for (const node of graph.nodes) {\n    setColumnWidth(graph, node)\n  }\n\n  // Analyze edges for bundling (parallel links like A & B --> C)\n  // This groups edges that share sources or targets for cleaner visualization\n  graph.bundles = analyzeEdgeBundles(graph)\n\n  // Route bundled edges through junction points\n  processBundles(graph)\n\n  // Route non-bundled edges via A* and determine label positions\n  for (const edge of graph.edges) {\n    // Skip edges that were already routed as part of a bundle\n    if (edge.bundle && edge.path.length > 0) {\n      increaseGridSizeForPath(graph, edge.path)\n      determineLabelLine(graph, edge)\n      continue\n    }\n\n    determinePath(graph, edge)\n    increaseGridSizeForPath(graph, edge.path)\n    determineLabelLine(graph, edge)\n  }\n\n  // Convert grid coords → drawing coords and generate box drawings\n  for (const node of graph.nodes) {\n    node.drawingCoord = gridToDrawingCoord(graph, node.gridCoord!)\n    node.drawing = drawBox(node, graph)\n  }\n\n  // Set canvas size and compute subgraph bounding boxes\n  setCanvasSizeToGrid(graph.canvas, graph.columnWidth, graph.rowHeight)\n  setRoleCanvasSizeToGrid(graph.roleCanvas, graph.columnWidth, graph.rowHeight)\n  calculateSubgraphBoundingBoxes(graph)\n  offsetDrawingForSubgraphs(graph)\n}\n\n// ============================================================================\n// Graph traversal helpers\n// ============================================================================\n\n/** Get all edges originating from a node. */\nfunction getEdgesFromNode(graph: AsciiGraph, node: AsciiNode): AsciiGraph['edges'] {\n  return graph.edges.filter(e => e.from.name === node.name)\n}\n\n/** Get all direct children of a node (targets of outgoing edges). */\nfunction getChildren(graph: AsciiGraph, node: AsciiNode): AsciiNode[] {\n  return getEdgesFromNode(graph, node).map(e => e.to)\n}\n"
  },
  {
    "path": "src/ascii/index.ts",
    "content": "// ============================================================================\n// beautiful-mermaid — ASCII renderer public API\n//\n// Renders Mermaid diagrams to ASCII or Unicode box-drawing art.\n// No external dependencies — pure TypeScript.\n//\n// Supported diagram types:\n//   - Flowcharts (graph TD / flowchart LR) — grid-based layout with A* pathfinding\n//   - State diagrams (stateDiagram-v2) — same pipeline as flowcharts\n//   - Sequence diagrams (sequenceDiagram) — column-based timeline layout\n//   - Class diagrams (classDiagram) — level-based UML layout\n//   - ER diagrams (erDiagram) — grid layout with crow's foot notation\n//\n// Usage:\n//   import { renderMermaidASCII } from 'beautiful-mermaid'\n//   const ascii = renderMermaidASCII('graph LR\\n  A --> B')\n// ============================================================================\n\nimport { parseMermaid } from '../parser.ts'\nimport { convertToAsciiGraph } from './converter.ts'\nimport { createMapping } from './grid.ts'\nimport { drawGraph } from './draw.ts'\nimport { canvasToString, flipCanvasVertically, flipRoleCanvasVertically } from './canvas.ts'\nimport { renderSequenceAscii } from './sequence.ts'\nimport { renderClassAscii } from './class-diagram.ts'\nimport { renderErAscii } from './er-diagram.ts'\nimport { renderXYChartAscii } from './xychart.ts'\nimport { detectColorMode, DEFAULT_ASCII_THEME, diagramColorsToAsciiTheme } from './ansi.ts'\nimport type { AsciiConfig, AsciiTheme, ColorMode } from './types.ts'\n\n// Re-export types for external use\nexport type { AsciiTheme, ColorMode }\nexport { DEFAULT_ASCII_THEME, detectColorMode, diagramColorsToAsciiTheme }\n\nexport interface AsciiRenderOptions {\n  /** true = ASCII chars (+,-,|,>), false = Unicode box-drawing (┌,─,│,►). Default: false */\n  useAscii?: boolean\n  /** Horizontal spacing between nodes. Default: 5 */\n  paddingX?: number\n  /** Vertical spacing between nodes. Default: 5 */\n  paddingY?: number\n  /** Padding inside node boxes. Default: 1 */\n  boxBorderPadding?: number\n  /**\n   * Color mode for output.\n   * - 'none': No colors (plain text)\n   * - 'auto': Auto-detect (terminal ANSI capabilities, or HTML in browsers)\n   * - 'ansi16': 16-color ANSI\n   * - 'ansi256': 256-color xterm\n   * - 'truecolor': 24-bit RGB\n   * - 'html': HTML <span> tags with inline color styles (for browser rendering)\n   * Default: 'auto'\n   */\n  colorMode?: ColorMode | 'auto'\n  /** Theme colors for ASCII output. Uses default theme if not provided. */\n  theme?: Partial<AsciiTheme>\n}\n\n/**\n * Detect the diagram type from the mermaid source text.\n * Mirrors the detection logic in src/index.ts for the SVG renderer.\n */\nfunction detectDiagramType(text: string): 'flowchart' | 'sequence' | 'class' | 'er' | 'xychart' {\n  const firstLine = text.trim().split('\\n')[0]?.trim().toLowerCase() ?? ''\n\n  if (/^xychart(-beta)?\\b/.test(firstLine)) return 'xychart'\n  if (/^sequencediagram\\s*$/.test(firstLine)) return 'sequence'\n  if (/^classdiagram\\s*$/.test(firstLine)) return 'class'\n  if (/^erdiagram\\s*$/.test(firstLine)) return 'er'\n\n  // Default: flowchart/state (handled by parseMermaid internally)\n  return 'flowchart'\n}\n\n/**\n * Render Mermaid diagram text to an ASCII/Unicode string.\n *\n * Synchronous — no async layout engine needed (unlike the SVG renderer).\n * Auto-detects diagram type from the header line and dispatches to\n * the appropriate renderer.\n *\n * @param text - Mermaid source text (any supported diagram type)\n * @param options - Rendering options\n * @returns Multi-line ASCII/Unicode string\n *\n * @example\n * ```ts\n * const result = renderMermaidAscii(`\n *   graph LR\n *     A --> B --> C\n * `, { useAscii: true })\n *\n * // Output:\n * // +---+     +---+     +---+\n * // |   |     |   |     |   |\n * // | A |---->| B |---->| C |\n * // |   |     |   |     |   |\n * // +---+     +---+     +---+\n * ```\n */\nexport function renderMermaidASCII(\n  text: string,\n  options: AsciiRenderOptions = {},\n): string {\n  const config: AsciiConfig = {\n    useAscii: options.useAscii ?? false,\n    paddingX: options.paddingX ?? 5,\n    paddingY: options.paddingY ?? 5,\n    boxBorderPadding: options.boxBorderPadding ?? 1,\n    graphDirection: 'TD', // default, overridden for flowcharts below\n  }\n\n  // Resolve color mode ('auto' or unset → detect environment, otherwise use specified mode)\n  const colorMode: ColorMode = options.colorMode === 'auto' || options.colorMode === undefined\n    ? detectColorMode()\n    : options.colorMode\n\n  // Merge user theme with defaults\n  const theme: AsciiTheme = { ...DEFAULT_ASCII_THEME, ...options.theme }\n\n  const diagramType = detectDiagramType(text)\n\n  switch (diagramType) {\n    case 'xychart':\n      return renderXYChartAscii(text, config, colorMode, theme)\n\n    case 'sequence':\n      return renderSequenceAscii(text, config, colorMode, theme)\n\n    case 'class':\n      return renderClassAscii(text, config, colorMode, theme)\n\n    case 'er':\n      return renderErAscii(text, config, colorMode, theme)\n\n    case 'flowchart':\n    default: {\n      // Flowchart + state diagram pipeline (original)\n      const parsed = parseMermaid(text)\n\n      // Normalize direction for grid layout.\n      // BT is laid out as TD then flipped vertically after drawing.\n      // RL is treated as LR (full RL support not yet implemented).\n      if (parsed.direction === 'LR' || parsed.direction === 'RL') {\n        config.graphDirection = 'LR'\n      } else {\n        config.graphDirection = 'TD'\n      }\n\n      const graph = convertToAsciiGraph(parsed, config)\n      createMapping(graph)\n      drawGraph(graph)\n\n      // BT: flip the finished canvas vertically so the flow runs bottom→top.\n      // The grid layout ran as TD; flipping + character remapping produces BT.\n      if (parsed.direction === 'BT') {\n        flipCanvasVertically(graph.canvas)\n        flipRoleCanvasVertically(graph.roleCanvas)\n      }\n\n      return canvasToString(graph.canvas, {\n        roleCanvas: graph.roleCanvas,\n        colorMode,\n        theme,\n      })\n    }\n  }\n}\n\n/** @deprecated Use `renderMermaidASCII` */\nexport const renderMermaidAscii = renderMermaidASCII\n"
  },
  {
    "path": "src/ascii/multiline-utils.ts",
    "content": "// ============================================================================\n// ASCII renderer — multi-line text utilities\n//\n// Shared utilities for handling multi-line labels (containing \\n from <br> tags)\n// in ASCII/Unicode rendering. Provides consistent text splitting, sizing, and\n// centered rendering across all diagram types.\n// ============================================================================\n\nimport type { Canvas } from './types.ts'\nimport { drawText } from './canvas.ts'\n\n/**\n * Split a label into lines.\n * Labels are already normalized by parsers (br tags → \\n).\n */\nexport function splitLines(label: string): string[] {\n  return label.split('\\n')\n}\n\n/**\n * Get the maximum line width for sizing calculations.\n * Used to determine column widths for multi-line labels.\n */\nexport function maxLineWidth(label: string): number {\n  const lines = splitLines(label)\n  return Math.max(...lines.map(l => l.length), 0)\n}\n\n/**\n * Get the number of lines for height calculations.\n * Used to determine row heights for multi-line labels.\n */\nexport function lineCount(label: string): number {\n  return splitLines(label).length\n}\n\n/**\n * Draw multi-line text centered at (cx, cy).\n * Expands vertically from the center point.\n * Each line is horizontally centered independently.\n */\nexport function drawMultilineTextCentered(\n  canvas: Canvas,\n  label: string,\n  cx: number,\n  cy: number\n): void {\n  const lines = splitLines(label)\n  const totalHeight = lines.length\n  // Center vertically: start y positions lines evenly around cy\n  const startY = cy - Math.floor((totalHeight - 1) / 2)\n\n  for (let i = 0; i < lines.length; i++) {\n    const line = lines[i]!\n    // Center each line horizontally\n    const startX = cx - Math.floor(line.length / 2)\n    // Force overwrite for node labels (they take priority)\n    drawText(canvas, { x: startX, y: startY + i }, line, true)\n  }\n}\n\n/**\n * Draw multi-line text left-aligned starting at (x, y).\n * Each subsequent line is placed one row below.\n */\nexport function drawMultilineTextLeft(\n  canvas: Canvas,\n  label: string,\n  x: number,\n  y: number\n): void {\n  const lines = splitLines(label)\n  for (let i = 0; i < lines.length; i++) {\n    // Force overwrite for node labels (they take priority)\n    drawText(canvas, { x, y: y + i }, lines[i]!, true)\n  }\n}\n"
  },
  {
    "path": "src/ascii/pathfinder.ts",
    "content": "// ============================================================================\n// ASCII renderer — A* pathfinding for edge routing\n//\n// Ported from AlexanderGrooff/mermaid-ascii cmd/arrow.go.\n// Uses A* search with a corner-penalizing heuristic to find clean\n// paths between nodes on the grid. Prefers straight lines over zigzags.\n// ============================================================================\n\nimport type { GridCoord, AsciiNode } from './types.ts'\nimport { gridKey, gridCoordEquals } from './types.ts'\n\n// ============================================================================\n// Priority queue (min-heap) for A* open set\n// ============================================================================\n\ninterface PQItem {\n  coord: GridCoord\n  priority: number\n}\n\n/**\n * Simple min-heap priority queue.\n * For the grid sizes we handle (~100s of cells), this is more than fast enough.\n */\nclass MinHeap {\n  private items: PQItem[] = []\n\n  get length(): number {\n    return this.items.length\n  }\n\n  push(item: PQItem): void {\n    this.items.push(item)\n    this.bubbleUp(this.items.length - 1)\n  }\n\n  pop(): PQItem | undefined {\n    if (this.items.length === 0) return undefined\n    const top = this.items[0]!\n    const last = this.items.pop()!\n    if (this.items.length > 0) {\n      this.items[0] = last\n      this.sinkDown(0)\n    }\n    return top\n  }\n\n  private bubbleUp(i: number): void {\n    while (i > 0) {\n      const parent = (i - 1) >> 1\n      if (this.items[i]!.priority < this.items[parent]!.priority) {\n        ;[this.items[i], this.items[parent]] = [this.items[parent]!, this.items[i]!]\n        i = parent\n      } else {\n        break\n      }\n    }\n  }\n\n  private sinkDown(i: number): void {\n    const n = this.items.length\n    while (true) {\n      let smallest = i\n      const left = 2 * i + 1\n      const right = 2 * i + 2\n      if (left < n && this.items[left]!.priority < this.items[smallest]!.priority) {\n        smallest = left\n      }\n      if (right < n && this.items[right]!.priority < this.items[smallest]!.priority) {\n        smallest = right\n      }\n      if (smallest !== i) {\n        ;[this.items[i], this.items[smallest]] = [this.items[smallest]!, this.items[i]!]\n        i = smallest\n      } else {\n        break\n      }\n    }\n  }\n}\n\n// ============================================================================\n// A* heuristic\n// ============================================================================\n\n/**\n * Manhattan distance with a +1 penalty when both dx and dy are non-zero.\n * This encourages the pathfinder to prefer straight lines and minimize corners.\n */\nexport function heuristic(a: GridCoord, b: GridCoord): number {\n  const absX = Math.abs(a.x - b.x)\n  const absY = Math.abs(a.y - b.y)\n  if (absX === 0 || absY === 0) {\n    return absX + absY\n  }\n  return absX + absY + 1\n}\n\n// ============================================================================\n// A* pathfinding\n// ============================================================================\n\n/** 4-directional movement (no diagonals in grid pathfinding). */\nconst MOVE_DIRS: GridCoord[] = [\n  { x: 1, y: 0 },\n  { x: -1, y: 0 },\n  { x: 0, y: 1 },\n  { x: 0, y: -1 },\n]\n\n/** Check if a grid cell is unoccupied and has non-negative coordinates. */\nfunction isFreeInGrid(grid: Map<string, AsciiNode>, c: GridCoord): boolean {\n  if (c.x < 0 || c.y < 0) return false\n  return !grid.has(gridKey(c))\n}\n\n/**\n * Find a path from `from` to `to` on the grid using A*.\n * Returns the path as an array of GridCoords, or null if no path exists.\n */\nexport function getPath(\n  grid: Map<string, AsciiNode>,\n  from: GridCoord,\n  to: GridCoord,\n): GridCoord[] | null {\n  const pq = new MinHeap()\n  pq.push({ coord: from, priority: 0 })\n\n  const costSoFar = new Map<string, number>()\n  costSoFar.set(gridKey(from), 0)\n\n  const cameFrom = new Map<string, GridCoord | null>()\n  cameFrom.set(gridKey(from), null)\n\n  while (pq.length > 0) {\n    const current = pq.pop()!.coord\n\n    if (gridCoordEquals(current, to)) {\n      // Reconstruct path by walking backwards through cameFrom\n      const path: GridCoord[] = []\n      let c: GridCoord | null = current\n      while (c !== null) {\n        path.unshift(c)\n        c = cameFrom.get(gridKey(c)) ?? null\n      }\n      return path\n    }\n\n    const currentCost = costSoFar.get(gridKey(current))!\n\n    for (const dir of MOVE_DIRS) {\n      const next: GridCoord = { x: current.x + dir.x, y: current.y + dir.y }\n\n      // Allow moving to the destination even if it's occupied (it's a node boundary)\n      if (!isFreeInGrid(grid, next) && !gridCoordEquals(next, to)) {\n        continue\n      }\n\n      const newCost = currentCost + 1\n      const nextKey = gridKey(next)\n      const existingCost = costSoFar.get(nextKey)\n\n      if (existingCost === undefined || newCost < existingCost) {\n        costSoFar.set(nextKey, newCost)\n        const priority = newCost + heuristic(next, to)\n        pq.push({ coord: next, priority })\n        cameFrom.set(nextKey, current)\n      }\n    }\n  }\n\n  return null // No path found\n}\n\n/**\n * Simplify a path by removing intermediate waypoints on straight segments.\n * E.g., [(0,0), (1,0), (2,0), (2,1)] becomes [(0,0), (2,0), (2,1)].\n * This reduces the number of line-drawing operations.\n */\nexport function mergePath(path: GridCoord[]): GridCoord[] {\n  if (path.length <= 2) return path\n\n  const toRemove = new Set<number>()\n  let step0 = path[0]!\n  let step1 = path[1]!\n\n  for (let idx = 2; idx < path.length; idx++) {\n    const step2 = path[idx]!\n    const prevDx = step1.x - step0.x\n    const prevDy = step1.y - step0.y\n    const dx = step2.x - step1.x\n    const dy = step2.y - step1.y\n\n    // Same direction — the middle point is redundant\n    if (prevDx === dx && prevDy === dy) {\n      // In Go: indexToRemove = append(indexToRemove, idx+1) but idx is 0-based from path[2:]\n      // which corresponds to index idx in the full path. Go uses idx+1 because idx iterates\n      // from 0 in the [2:] slice, mapping to full-array index idx+1.\n      // Actually re-checking Go code: the loop is `for idx, step2 := range path[2:]`\n      // so idx=0 → path[2], and it removes idx+1 which is index 1 in the full array.\n      // Wait, that doesn't look right. Let me re-read:\n      //   step0 = path[0], step1 = path[1]\n      //   for idx, step2 := range path[2:] { ... indexToRemove = append(indexToRemove, idx+1) ... }\n      //   When idx=0, step2=path[2], and it removes index 1 (step1 = path[1]) if directions match\n      // So it removes the middle point (step1) which is at index idx+1 in the original array\n      // when counting from the 2-ahead loop. Let me just track which middle indices to remove.\n      toRemove.add(idx - 1) // Remove the middle point (step1's position)\n    }\n\n    step0 = step1\n    step1 = step2\n  }\n\n  return path.filter((_, i) => !toRemove.has(i))\n}\n"
  },
  {
    "path": "src/ascii/sequence.ts",
    "content": "// ============================================================================\n// ASCII renderer — sequence diagrams\n//\n// Renders sequenceDiagram text to ASCII/Unicode art using a column-based layout.\n// Each actor occupies a column with a vertical lifeline; messages are horizontal\n// arrows between lifelines. Blocks (loop/alt/opt/par) wrap around message groups.\n//\n// Layout is fundamentally different from flowcharts — no grid or A* pathfinding.\n// Instead: actors → columns, messages → rows, all positioned linearly.\n// ============================================================================\n\nimport { parseSequenceDiagram } from '../sequence/parser.ts'\nimport type { SequenceDiagram, Block } from '../sequence/types.ts'\nimport type { Canvas, AsciiConfig, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types.ts'\nimport { mkCanvas, mkRoleCanvas, canvasToString, increaseSize, increaseRoleCanvasSize, setRole } from './canvas.ts'\nimport { splitLines, maxLineWidth, lineCount } from './multiline-utils.ts'\n\n/** Classify a box-drawing character as 'border' or 'text'. */\nfunction classifyBoxChar(ch: string): CharRole {\n  if (/^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\\-|]$/.test(ch)) return 'border'\n  return 'text'\n}\n\n/**\n * Render a Mermaid sequence diagram to ASCII/Unicode text.\n *\n * Pipeline: parse → layout (columns + rows) → draw onto canvas → string.\n */\nexport function renderSequenceAscii(text: string, config: AsciiConfig, colorMode?: ColorMode, theme?: AsciiTheme): string {\n  const lines = text.split('\\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%'))\n  const diagram = parseSequenceDiagram(lines)\n\n  if (diagram.actors.length === 0) return ''\n\n  const useAscii = config.useAscii\n\n  // Box-drawing characters\n  const H = useAscii ? '-' : '─'\n  const V = useAscii ? '|' : '│'\n  const TL = useAscii ? '+' : '┌'\n  const TR = useAscii ? '+' : '┐'\n  const BL = useAscii ? '+' : '└'\n  const BR = useAscii ? '+' : '┘'\n  const JT = useAscii ? '+' : '┬' // top junction on lifeline\n  const JB = useAscii ? '+' : '┴' // bottom junction on lifeline\n  const JL = useAscii ? '+' : '├' // left junction\n  const JR = useAscii ? '+' : '┤' // right junction\n\n  // ---- LAYOUT: compute lifeline X positions ----\n\n  const actorIdx = new Map<string, number>()\n  diagram.actors.forEach((a, i) => actorIdx.set(a.id, i))\n\n  const boxPad = 1\n  // Use max line width for multi-line actor labels\n  const actorBoxWidths = diagram.actors.map(a => maxLineWidth(a.label) + 2 * boxPad + 2)\n  const halfBox = actorBoxWidths.map(w => Math.ceil(w / 2))\n  // Calculate actor box heights based on number of lines in label\n  const actorBoxHeights = diagram.actors.map(a => lineCount(a.label) + 2) // lines + top/bottom border\n  const actorBoxH = Math.max(...actorBoxHeights, 3) // Use max height for consistent lifeline positioning\n\n  // Compute minimum gap between adjacent lifelines based on message labels.\n  // For messages spanning multiple actors, distribute the required width across gaps.\n  const adjMaxWidth: number[] = new Array(Math.max(diagram.actors.length - 1, 0)).fill(0)\n\n  for (const msg of diagram.messages) {\n    const fi = actorIdx.get(msg.from)!\n    const ti = actorIdx.get(msg.to)!\n    if (fi === ti) continue // self-messages don't affect spacing\n    const lo = Math.min(fi, ti)\n    const hi = Math.max(fi, ti)\n    // Required gap per span = (max line width + arrow decorations) / number of gaps\n    const needed = maxLineWidth(msg.label) + 4\n    const numGaps = hi - lo\n    const perGap = Math.ceil(needed / numGaps)\n    for (let g = lo; g < hi; g++) {\n      adjMaxWidth[g] = Math.max(adjMaxWidth[g]!, perGap)\n    }\n  }\n\n  // Compute lifeline x-positions (greedy left-to-right)\n  const llX: number[] = [halfBox[0]!]\n  for (let i = 1; i < diagram.actors.length; i++) {\n    const gap = Math.max(\n      halfBox[i - 1]! + halfBox[i]! + 2,\n      adjMaxWidth[i - 1]! + 2,\n      10,\n    )\n    llX[i] = llX[i - 1]! + gap\n  }\n\n  // ---- LAYOUT: compute vertical positions for messages ----\n\n  // For each message index, track the y where its arrow is drawn.\n  // Also track block start/end y positions and divider y positions.\n  const msgArrowY: number[] = []\n  const msgLabelY: number[] = []\n  const blockStartY = new Map<number, number>()\n  const blockEndY = new Map<number, number>()\n  const divYMap = new Map<string, number>() // \"blockIdx:divIdx\" → y\n  const notePositions: Array<{ x: number; y: number; width: number; height: number; lines: string[] }> = []\n\n  let curY = actorBoxH // start right below header boxes\n\n  for (let m = 0; m < diagram.messages.length; m++) {\n    // Block openings at this message\n    for (let b = 0; b < diagram.blocks.length; b++) {\n      if (diagram.blocks[b]!.startIndex === m) {\n        curY += 2 // 1 blank + 1 header row\n        blockStartY.set(b, curY - 1)\n      }\n    }\n\n    // Dividers at this message index\n    for (let b = 0; b < diagram.blocks.length; b++) {\n      for (let d = 0; d < diagram.blocks[b]!.dividers.length; d++) {\n        if (diagram.blocks[b]!.dividers[d]!.index === m) {\n          curY += 1\n          divYMap.set(`${b}:${d}`, curY)\n          curY += 1\n        }\n      }\n    }\n\n    curY += 1 // blank row before message\n\n    const msg = diagram.messages[m]!\n    const isSelf = msg.from === msg.to\n\n    // Calculate height needed for multi-line message labels\n    const msgLineCount = lineCount(msg.label)\n\n    if (isSelf) {\n      // Self-message occupies 3+ rows: top-arm, label-col(s), bottom-arm\n      msgLabelY[m] = curY + 1\n      msgArrowY[m] = curY\n      curY += 2 + msgLineCount // top-arm + label lines + bottom-arm\n    } else {\n      // Normal message: label row(s) then arrow row\n      msgLabelY[m] = curY\n      msgArrowY[m] = curY + msgLineCount  // arrow goes after all label lines\n      curY += msgLineCount + 1  // label lines + arrow row\n    }\n\n    // Notes after this message\n    for (let n = 0; n < diagram.notes.length; n++) {\n      if (diagram.notes[n]!.afterIndex === m) {\n        curY += 1\n        const note = diagram.notes[n]!\n        const nLines = splitLines(note.text)\n        const nWidth = Math.max(...nLines.map(l => l.length)) + 4\n        const nHeight = nLines.length + 2\n\n        // Determine x position based on note.position\n        const aIdx = actorIdx.get(note.actorIds[0]!) ?? 0\n        let nx: number\n        if (note.position === 'left') {\n          nx = llX[aIdx]! - nWidth - 1\n        } else if (note.position === 'right') {\n          nx = llX[aIdx]! + 2\n        } else {\n          // 'over' — center over actor(s)\n          if (note.actorIds.length >= 2) {\n            const aIdx2 = actorIdx.get(note.actorIds[1]!) ?? aIdx\n            nx = Math.floor((llX[aIdx]! + llX[aIdx2]!) / 2) - Math.floor(nWidth / 2)\n          } else {\n            nx = llX[aIdx]! - Math.floor(nWidth / 2)\n          }\n        }\n        nx = Math.max(0, nx)\n\n        notePositions.push({ x: nx, y: curY, width: nWidth, height: nHeight, lines: nLines })\n        curY += nHeight\n      }\n    }\n\n    // Block closings after this message\n    for (let b = 0; b < diagram.blocks.length; b++) {\n      if (diagram.blocks[b]!.endIndex === m) {\n        curY += 1\n        blockEndY.set(b, curY)\n        curY += 1\n      }\n    }\n  }\n\n  curY += 1 // gap before footer\n  const footerY = curY\n  const totalH = footerY + actorBoxH\n\n  // Total canvas width\n  const lastLL = llX[llX.length - 1] ?? 0\n  const lastHalf = halfBox[halfBox.length - 1] ?? 0\n  let totalW = lastLL + lastHalf + 2\n\n  // Ensure canvas is wide enough for self-message labels and notes\n  for (let m = 0; m < diagram.messages.length; m++) {\n    const msg = diagram.messages[m]!\n    if (msg.from === msg.to) {\n      const fi = actorIdx.get(msg.from)!\n      const selfRight = llX[fi]! + 6 + 2 + msg.label.length\n      totalW = Math.max(totalW, selfRight + 1)\n    }\n  }\n  for (const np of notePositions) {\n    totalW = Math.max(totalW, np.x + np.width + 1)\n  }\n\n  const canvas = mkCanvas(totalW, totalH - 1)\n  const rc = mkRoleCanvas(totalW, totalH - 1)\n\n  /** Set a character on the canvas and track its role. */\n  function setC(x: number, y: number, ch: string, role: CharRole): void {\n    if (x >= 0 && x < canvas.length && y >= 0 && y < (canvas[0]?.length ?? 0)) {\n      canvas[x]![y] = ch\n      setRole(rc, x, y, role)\n    }\n  }\n\n  // ---- DRAW: helper to place a bordered actor box (supports multi-line labels) ----\n\n  function drawActorBox(cx: number, topY: number, label: string): void {\n    const lines = splitLines(label)\n    const maxW = maxLineWidth(label)\n    const w = maxW + 2 * boxPad + 2\n    const h = lines.length + 2  // lines + top/bottom border\n    const left = cx - Math.floor(w / 2)\n\n    // Top border\n    setC(left, topY, TL, 'border')\n    for (let x = 1; x < w - 1; x++) setC(left + x, topY, H, 'border')\n    setC(left + w - 1, topY, TR, 'border')\n\n    // Content lines (centered horizontally within the box)\n    for (let i = 0; i < lines.length; i++) {\n      const row = topY + 1 + i\n      setC(left, row, V, 'border')\n      setC(left + w - 1, row, V, 'border')\n      // Center this line within the box\n      const line = lines[i]!\n      const ls = left + 1 + boxPad + Math.floor((maxW - line.length) / 2)\n      for (let j = 0; j < line.length; j++) {\n        setC(ls + j, row, line[j]!, 'text')\n      }\n    }\n\n    // Bottom border\n    const bottomY = topY + h - 1\n    setC(left, bottomY, BL, 'border')\n    for (let x = 1; x < w - 1; x++) setC(left + x, bottomY, H, 'border')\n    setC(left + w - 1, bottomY, BR, 'border')\n  }\n\n  // ---- DRAW: lifelines ----\n\n  for (let i = 0; i < diagram.actors.length; i++) {\n    const x = llX[i]!\n    for (let y = actorBoxH; y <= footerY; y++) {\n      setC(x, y, V, 'line')\n    }\n  }\n\n  // ---- DRAW: actor header + footer boxes (drawn over lifelines) ----\n\n  for (let i = 0; i < diagram.actors.length; i++) {\n    const actor = diagram.actors[i]!\n    drawActorBox(llX[i]!, 0, actor.label)\n    drawActorBox(llX[i]!, footerY, actor.label)\n\n    // Lifeline junctions on box borders (Unicode only)\n    if (!useAscii) {\n      setC(llX[i]!, actorBoxH - 1, JT, 'junction')\n      setC(llX[i]!, footerY, JB, 'junction')\n    }\n  }\n\n  // ---- DRAW: messages ----\n\n  for (let m = 0; m < diagram.messages.length; m++) {\n    const msg = diagram.messages[m]!\n    const fi = actorIdx.get(msg.from)!\n    const ti = actorIdx.get(msg.to)!\n    const fromX = llX[fi]!\n    const toX = llX[ti]!\n    const isSelf = fi === ti\n    const isDashed = msg.lineStyle === 'dashed'\n    const isFilled = msg.arrowHead === 'filled'\n\n    // Arrow line character (solid vs dashed)\n    const lineChar = isDashed ? (useAscii ? '.' : '╌') : H\n\n    if (isSelf) {\n      // Self-message: 3-row loop to the right of the lifeline\n      //   ├──┐           (row 0 = msgArrowY)\n      //   │  │ Label     (row 1)\n      //   │◄─┘           (row 2)\n      const y0 = msgArrowY[m]!\n      const loopW = Math.max(4, 4)\n\n      // Row 0: start junction + horizontal + top-right corner\n      setC(fromX, y0, JL, 'junction')\n      for (let x = fromX + 1; x < fromX + loopW; x++) setC(x, y0, lineChar, 'line')\n      setC(fromX + loopW, y0, useAscii ? '+' : '┐', 'corner')\n\n      // Row 1: vertical on right side + label\n      setC(fromX + loopW, y0 + 1, V, 'line')\n      const labelX = fromX + loopW + 2\n      for (let i = 0; i < msg.label.length; i++) {\n        if (labelX + i < totalW) setC(labelX + i, y0 + 1, msg.label[i]!, 'text')\n      }\n\n      // Row 2: arrow-back + horizontal + bottom-right corner\n      const arrowChar = isFilled ? (useAscii ? '<' : '◀') : (useAscii ? '<' : '◁')\n      setC(fromX, y0 + 2, arrowChar, 'arrow')\n      for (let x = fromX + 1; x < fromX + loopW; x++) setC(x, y0 + 2, lineChar, 'line')\n      setC(fromX + loopW, y0 + 2, useAscii ? '+' : '┘', 'corner')\n    } else {\n      // Normal message: label on row above, arrow on row below\n      const labelY = msgLabelY[m]!\n      const arrowY = msgArrowY[m]!\n      const leftToRight = fromX < toX\n\n      // Draw label centered between the two lifelines (supports multi-line)\n      const midX = Math.floor((fromX + toX) / 2)\n      const msgLines = splitLines(msg.label)\n\n      for (let lineIdx = 0; lineIdx < msgLines.length; lineIdx++) {\n        const line = msgLines[lineIdx]!\n        const labelStart = midX - Math.floor(line.length / 2)\n        const y = labelY + lineIdx\n        for (let i = 0; i < line.length; i++) {\n          const lx = labelStart + i\n          if (lx >= 0 && lx < totalW) setC(lx, y, line[i]!, 'text')\n        }\n      }\n\n      // Draw arrow line\n      if (leftToRight) {\n        for (let x = fromX + 1; x < toX; x++) setC(x, arrowY, lineChar, 'line')\n        // Arrowhead at destination\n        const ah = isFilled ? (useAscii ? '>' : '▶') : (useAscii ? '>' : '▷')\n        setC(toX, arrowY, ah, 'arrow')\n      } else {\n        for (let x = toX + 1; x < fromX; x++) setC(x, arrowY, lineChar, 'line')\n        const ah = isFilled ? (useAscii ? '<' : '◀') : (useAscii ? '<' : '◁')\n        setC(toX, arrowY, ah, 'arrow')\n      }\n    }\n  }\n\n  // ---- DRAW: blocks (loop, alt, opt, par, etc.) ----\n\n  for (let b = 0; b < diagram.blocks.length; b++) {\n    const block = diagram.blocks[b]!\n    const topY = blockStartY.get(b)\n    const botY = blockEndY.get(b)\n    if (topY === undefined || botY === undefined) continue\n\n    // Find the leftmost/rightmost lifelines involved in this block's messages\n    let minLX = totalW\n    let maxLX = 0\n    for (let m = block.startIndex; m <= block.endIndex; m++) {\n      if (m >= diagram.messages.length) break\n      const msg = diagram.messages[m]!\n      const f = actorIdx.get(msg.from) ?? 0\n      const t = actorIdx.get(msg.to) ?? 0\n      minLX = Math.min(minLX, llX[Math.min(f, t)]!)\n      maxLX = Math.max(maxLX, llX[Math.max(f, t)]!)\n    }\n\n    const bLeft = Math.max(0, minLX - 4)\n    const bRight = Math.min(totalW - 1, maxLX + 4)\n\n    // Top border with block type label\n    setC(bLeft, topY, TL, 'border')\n    for (let x = bLeft + 1; x < bRight; x++) setC(x, topY, H, 'border')\n    setC(bRight, topY, TR, 'border')\n    // Write block header label over the top border (supports multi-line)\n    const hdrLabel = block.label ? `${block.type} [${block.label}]` : block.type\n    const hdrLines = splitLines(hdrLabel)\n\n    for (let lineIdx = 0; lineIdx < hdrLines.length && topY + lineIdx < botY; lineIdx++) {\n      const line = hdrLines[lineIdx]!\n      for (let i = 0; i < line.length && bLeft + 1 + i < bRight; i++) {\n        setC(bLeft + 1 + i, topY + lineIdx, line[i]!, 'text')\n      }\n    }\n\n    // Bottom border\n    setC(bLeft, botY, BL, 'border')\n    for (let x = bLeft + 1; x < bRight; x++) setC(x, botY, H, 'border')\n    setC(bRight, botY, BR, 'border')\n\n    // Side borders\n    for (let y = topY + 1; y < botY; y++) {\n      setC(bLeft, y, V, 'border')\n      setC(bRight, y, V, 'border')\n    }\n\n    // Dividers\n    for (let d = 0; d < block.dividers.length; d++) {\n      const dY = divYMap.get(`${b}:${d}`)\n      if (dY === undefined) continue\n      const dashChar = isDashedH()\n      setC(bLeft, dY, JL, 'junction')\n      for (let x = bLeft + 1; x < bRight; x++) setC(x, dY, dashChar, 'line')\n      setC(bRight, dY, JR, 'junction')\n      // Divider label\n      const dLabel = block.dividers[d]!.label\n      if (dLabel) {\n        const dStr = `[${dLabel}]`\n        for (let i = 0; i < dStr.length && bLeft + 1 + i < bRight; i++) {\n          setC(bLeft + 1 + i, dY, dStr[i]!, 'text')\n        }\n      }\n    }\n  }\n\n  // ---- DRAW: notes ----\n\n  for (const np of notePositions) {\n    // Ensure canvas is big enough\n    increaseSize(canvas, np.x + np.width, np.y + np.height)\n    increaseRoleCanvasSize(rc, np.x + np.width, np.y + np.height)\n    // Top border\n    setC(np.x, np.y, TL, 'border')\n    for (let x = 1; x < np.width - 1; x++) setC(np.x + x, np.y, H, 'border')\n    setC(np.x + np.width - 1, np.y, TR, 'border')\n    // Content rows\n    for (let l = 0; l < np.lines.length; l++) {\n      const ly = np.y + 1 + l\n      setC(np.x, ly, V, 'border')\n      setC(np.x + np.width - 1, ly, V, 'border')\n      for (let i = 0; i < np.lines[l]!.length; i++) {\n        setC(np.x + 2 + i, ly, np.lines[l]![i]!, 'text')\n      }\n    }\n    // Bottom border\n    const by = np.y + np.height - 1\n    setC(np.x, by, BL, 'border')\n    for (let x = 1; x < np.width - 1; x++) setC(np.x + x, by, H, 'border')\n    setC(np.x + np.width - 1, by, BR, 'border')\n  }\n\n  return canvasToString(canvas, { roleCanvas: rc, colorMode, theme })\n\n  // ---- Helper: dashed horizontal character ----\n  function isDashedH(): string {\n    return useAscii ? '-' : '╌'\n  }\n}\n"
  },
  {
    "path": "src/ascii/shapes/circle.ts",
    "content": "// ============================================================================\n// Circle shape renderer — uses corner decorators instead of curves\n// ============================================================================\n\nimport type { ShapeRenderer } from './types.ts'\nimport { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle.ts'\nimport { getCorners } from './corners.ts'\n\n/**\n * Circle shape renderer.\n * Uses circle markers (◯) at corners to indicate circular shape semantics.\n *\n * Renders as:\n *   ◯─────────◯\n *   │  Label  │\n *   ◯─────────◯\n */\nexport const circleRenderer: ShapeRenderer = {\n  getDimensions: getBoxDimensions,\n\n  render(label, dimensions, options) {\n    const corners = getCorners('circle', options.useAscii)\n    return renderBox(label, dimensions, corners, options.useAscii)\n  },\n\n  getAttachmentPoint: getBoxAttachmentPoint,\n}\n"
  },
  {
    "path": "src/ascii/shapes/corners.ts",
    "content": "// ============================================================================\n// Corner character lookup table for shape rendering\n// ============================================================================\n//\n// All shapes are rendered as rectangles with distinctive corner characters\n// to indicate shape type. This eliminates diagonal characters while keeping\n// shapes visually distinguishable.\n\nimport type { AsciiNodeShape } from '../types.ts'\n\n/**\n * Corner characters for a shape in both Unicode and ASCII modes.\n */\nexport interface CornerChars {\n  /** Top-left corner */\n  tl: string\n  /** Top-right corner */\n  tr: string\n  /** Bottom-left corner */\n  bl: string\n  /** Bottom-right corner */\n  br: string\n}\n\n/**\n * Shape corner configuration with both Unicode and ASCII variants.\n */\nexport interface ShapeCorners {\n  unicode: CornerChars\n  ascii: CornerChars\n}\n\n/**\n * Corner character lookup table for all shape types.\n *\n * Design principles:\n * - All shapes use orthogonal box structure (no diagonals)\n * - Corner characters indicate shape semantics\n * - ASCII fallbacks use available punctuation\n */\nexport const SHAPE_CORNERS: Record<AsciiNodeShape, ShapeCorners> = {\n  // Standard rectangular shapes\n  rectangle: {\n    unicode: { tl: '┌', tr: '┐', bl: '└', br: '┘' },\n    ascii: { tl: '+', tr: '+', bl: '+', br: '+' },\n  },\n  rounded: {\n    unicode: { tl: '╭', tr: '╮', bl: '╰', br: '╯' },\n    ascii: { tl: '.', tr: '.', bl: \"'\", br: \"'\" },\n  },\n\n  // Circular shapes - use circle markers at corners\n  circle: {\n    unicode: { tl: '◯', tr: '◯', bl: '◯', br: '◯' },\n    ascii: { tl: 'o', tr: 'o', bl: 'o', br: 'o' },\n  },\n  doublecircle: {\n    unicode: { tl: '◎', tr: '◎', bl: '◎', br: '◎' },\n    ascii: { tl: '@', tr: '@', bl: '@', br: '@' },\n  },\n\n  // Diamond - decision nodes\n  diamond: {\n    unicode: { tl: '◇', tr: '◇', bl: '◇', br: '◇' },\n    ascii: { tl: '<', tr: '>', bl: '<', br: '>' },\n  },\n\n  // Hexagon - process nodes (crop corners — monospace-safe, distinct from rectangle)\n  hexagon: {\n    unicode: { tl: '⌜', tr: '⌝', bl: '⌞', br: '⌟' },\n    ascii: { tl: '*', tr: '*', bl: '*', br: '*' },\n  },\n\n  // Stadium/pill shape\n  stadium: {\n    unicode: { tl: '(', tr: ')', bl: '(', br: ')' },\n    ascii: { tl: '(', tr: ')', bl: '(', br: ')' },\n  },\n\n  // Subroutine - double vertical bars\n  subroutine: {\n    unicode: { tl: '╟', tr: '╢', bl: '╟', br: '╢' },\n    ascii: { tl: '|', tr: '|', bl: '|', br: '|' },\n  },\n\n  // Cylinder/database\n  cylinder: {\n    unicode: { tl: '╭', tr: '╮', bl: '╰', br: '╯' },\n    ascii: { tl: '.', tr: '.', bl: \"'\", br: \"'\" },\n  },\n\n  // Asymmetric/flag - pointer on left side\n  asymmetric: {\n    unicode: { tl: '▷', tr: '┐', bl: '▷', br: '┘' },\n    ascii: { tl: '>', tr: '+', bl: '>', br: '+' },\n  },\n\n  // Trapezoid - wider at bottom (top corners slope inward)\n  trapezoid: {\n    unicode: { tl: '/', tr: '\\\\', bl: '└', br: '┘' },\n    ascii: { tl: '/', tr: '\\\\', bl: '+', br: '+' },\n  },\n\n  // Trapezoid-alt - wider at top (bottom corners slope inward)\n  'trapezoid-alt': {\n    unicode: { tl: '┌', tr: '┐', bl: '\\\\', br: '/' },\n    ascii: { tl: '+', tr: '+', bl: '\\\\', br: '/' },\n  },\n\n  // State diagram pseudostates (special handling, not corner-based)\n  'state-start': {\n    unicode: { tl: '●', tr: '●', bl: '●', br: '●' },\n    ascii: { tl: '*', tr: '*', bl: '*', br: '*' },\n  },\n  'state-end': {\n    unicode: { tl: '◉', tr: '◉', bl: '◉', br: '◉' },\n    ascii: { tl: '@', tr: '@', bl: '@', br: '@' },\n  },\n}\n\n/**\n * Get corner characters for a shape type.\n */\nexport function getCorners(shape: AsciiNodeShape, useAscii: boolean): CornerChars {\n  const corners = SHAPE_CORNERS[shape] ?? SHAPE_CORNERS.rectangle\n  return useAscii ? corners.ascii : corners.unicode\n}\n"
  },
  {
    "path": "src/ascii/shapes/diamond.ts",
    "content": "// ============================================================================\n// Diamond shape renderer — uses corner decorators instead of diagonals\n// ============================================================================\n\nimport type { ShapeRenderer } from './types.ts'\nimport { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle.ts'\nimport { getCorners } from './corners.ts'\n\n/**\n * Diamond shape renderer.\n * Uses diamond markers (◇) at corners to indicate decision node semantics.\n *\n * Renders as:\n *   ◇─────────◇\n *   │  Label  │\n *   ◇─────────◇\n */\nexport const diamondRenderer: ShapeRenderer = {\n  getDimensions: getBoxDimensions,\n\n  render(label, dimensions, options) {\n    const corners = getCorners('diamond', options.useAscii)\n    return renderBox(label, dimensions, corners, options.useAscii)\n  },\n\n  getAttachmentPoint: getBoxAttachmentPoint,\n}\n"
  },
  {
    "path": "src/ascii/shapes/hexagon.ts",
    "content": "// ============================================================================\n// Hexagon shape renderer — uses corner decorators instead of diagonals\n// ============================================================================\n\nimport type { ShapeRenderer } from './types.ts'\nimport { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle.ts'\nimport { getCorners } from './corners.ts'\n\n/**\n * Hexagon shape renderer.\n * Uses hexagon markers (⬡) at corners to indicate process node semantics.\n *\n * Renders as:\n *   ⬡─────────⬡\n *   │  Label  │\n *   ⬡─────────⬡\n */\nexport const hexagonRenderer: ShapeRenderer = {\n  getDimensions: getBoxDimensions,\n\n  render(label, dimensions, options) {\n    const corners = getCorners('hexagon', options.useAscii)\n    return renderBox(label, dimensions, corners, options.useAscii)\n  },\n\n  getAttachmentPoint: getBoxAttachmentPoint,\n}\n"
  },
  {
    "path": "src/ascii/shapes/index.ts",
    "content": "// ============================================================================\n// Shape registry — pluggable ASCII shape renderers\n// ============================================================================\n\nimport type { AsciiNodeShape, Canvas, DrawingCoord, Direction } from '../types.ts'\nimport type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions, ShapeRegistry } from './types.ts'\n\n// Import all shape renderers\nimport { rectangleRenderer } from './rectangle.ts'\nimport { diamondRenderer } from './diamond.ts'\nimport { circleRenderer } from './circle.ts'\nimport { stateStartRenderer, stateEndRenderer } from './state.ts'\nimport { roundedRenderer } from './rounded.ts'\nimport { stadiumRenderer } from './stadium.ts'\nimport { hexagonRenderer } from './hexagon.ts'\nimport {\n  subroutineRenderer,\n  doublecircleRenderer,\n  cylinderRenderer,\n  asymmetricRenderer,\n  trapezoidRenderer,\n  trapezoidAltRenderer,\n} from './special.ts'\n\n// Re-export types\nexport type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions, ShapeRegistry }\n\n/**\n * Global shape registry — maps shape types to their renderers.\n * Rectangle is the default fallback for unregistered shapes.\n */\nexport const shapeRegistry: ShapeRegistry = new Map<AsciiNodeShape, ShapeRenderer>([\n  // Core shapes\n  ['rectangle', rectangleRenderer],\n  ['rounded', roundedRenderer],\n  ['diamond', diamondRenderer],\n  ['stadium', stadiumRenderer],\n  ['circle', circleRenderer],\n\n  // Batch 1 additions\n  ['subroutine', subroutineRenderer],\n  ['doublecircle', doublecircleRenderer],\n  ['hexagon', hexagonRenderer],\n\n  // Batch 2 additions\n  ['cylinder', cylinderRenderer],\n  ['asymmetric', asymmetricRenderer],\n  ['trapezoid', trapezoidRenderer],\n  ['trapezoid-alt', trapezoidAltRenderer],\n\n  // State diagram pseudo-states\n  ['state-start', stateStartRenderer],\n  ['state-end', stateEndRenderer],\n])\n\n/**\n * Get the renderer for a shape type, falling back to rectangle.\n */\nexport function getShapeRenderer(shape: AsciiNodeShape): ShapeRenderer {\n  return shapeRegistry.get(shape) ?? rectangleRenderer\n}\n\n/**\n * Render a node shape to a canvas.\n * This is the main entry point for shape rendering.\n */\nexport function renderShape(\n  shape: AsciiNodeShape,\n  label: string,\n  options: ShapeRenderOptions\n): Canvas {\n  const renderer = getShapeRenderer(shape)\n  const dimensions = renderer.getDimensions(label, options)\n  return renderer.render(label, dimensions, options)\n}\n\n/**\n * Get dimensions for a shape given a label.\n * Used during layout to determine node size.\n */\nexport function getShapeDimensions(\n  shape: AsciiNodeShape,\n  label: string,\n  options: ShapeRenderOptions\n): ShapeDimensions {\n  const renderer = getShapeRenderer(shape)\n  return renderer.getDimensions(label, options)\n}\n\n/**\n * Get edge attachment point for a shape.\n */\nexport function getShapeAttachmentPoint(\n  shape: AsciiNodeShape,\n  dir: Direction,\n  dimensions: ShapeDimensions,\n  baseCoord: DrawingCoord\n): DrawingCoord {\n  const renderer = getShapeRenderer(shape)\n  return renderer.getAttachmentPoint(dir, dimensions, baseCoord)\n}\n"
  },
  {
    "path": "src/ascii/shapes/rectangle.ts",
    "content": "// ============================================================================\n// Rectangle shape renderer — standard box with corners\n// ============================================================================\n//\n// This module provides the base box rendering used by all rectangular shapes.\n// The renderBox() function accepts custom corner characters, allowing different\n// shapes to reuse the same rendering logic with different visual markers.\n\nimport type { Canvas, DrawingCoord, Direction } from '../types.ts'\nimport { Up, Down, Left, Right, UpperLeft, UpperRight, LowerLeft, LowerRight, Middle } from '../types.ts'\nimport { mkCanvas } from '../canvas.ts'\nimport { splitLines } from '../multiline-utils.ts'\nimport type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types.ts'\nimport { dirEquals } from '../edge-routing.ts'\nimport { type CornerChars, getCorners } from './corners.ts'\n\n// ============================================================================\n// Shared dimension calculation\n// ============================================================================\n\n/**\n * Calculate standard box dimensions for any rectangular shape.\n * Used by rectangle, circle, diamond, hexagon, etc.\n */\nexport function getBoxDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions {\n  const lines = splitLines(label)\n  const maxLineWidth = Math.max(...lines.map(l => l.length), 0)\n  const lineCount = lines.length\n\n  // Width: 2*padding + maxLineWidth + 2 border chars\n  const innerWidth = 2 * options.padding + maxLineWidth\n  const width = innerWidth + 2\n\n  // Height: lineCount + 2*padding + 2 border chars\n  // Ensure innerHeight is odd for symmetric vertical centering\n  const rawInnerHeight = lineCount + 2 * options.padding\n  const innerHeight = rawInnerHeight % 2 === 0 ? rawInnerHeight + 1 : rawInnerHeight\n  const height = innerHeight + 2\n\n  return {\n    width,\n    height,\n    labelArea: {\n      x: 1 + options.padding,\n      y: 1 + options.padding,\n      width: maxLineWidth,\n      height: lineCount,\n    },\n    // Grid layout: [border=1, content, border=1]\n    gridColumns: [1, innerWidth, 1],\n    gridRows: [1, innerHeight, 1],\n  }\n}\n\n// ============================================================================\n// Shared box rendering\n// ============================================================================\n\n/**\n * Render a box with custom corner characters.\n * This is the core rendering function used by all rectangular shapes.\n *\n * @param label - Text to display in the box\n * @param dimensions - Pre-calculated dimensions\n * @param corners - Corner characters (tl, tr, bl, br)\n * @param useAscii - Whether to use ASCII or Unicode for lines\n */\nexport function renderBox(\n  label: string,\n  dimensions: ShapeDimensions,\n  corners: CornerChars,\n  useAscii: boolean\n): Canvas {\n  const { width, height } = dimensions\n  const canvas = mkCanvas(width - 1, height - 1)\n\n  const from = { x: 0, y: 0 }\n  const to = { x: width - 1, y: height - 1 }\n\n  // Line characters\n  const hLine = useAscii ? '-' : '─'\n  const vLine = useAscii ? '|' : '│'\n\n  // Draw horizontal lines (top and bottom)\n  for (let x = from.x + 1; x < to.x; x++) {\n    canvas[x]![from.y] = hLine\n    canvas[x]![to.y] = hLine\n  }\n\n  // Draw vertical lines (left and right)\n  for (let y = from.y + 1; y < to.y; y++) {\n    canvas[from.x]![y] = vLine\n    canvas[to.x]![y] = vLine\n  }\n\n  // Draw corners\n  canvas[from.x]![from.y] = corners.tl\n  canvas[to.x]![from.y] = corners.tr\n  canvas[from.x]![to.y] = corners.bl\n  canvas[to.x]![to.y] = corners.br\n\n  // Center the multi-line label\n  const lines = splitLines(label)\n  const w = width - 1  // Match original grid-based width calculation\n  const h = height - 1\n  const centerY = Math.floor(h / 2)\n  const startY = centerY - Math.floor((lines.length - 1) / 2)\n\n  for (let i = 0; i < lines.length; i++) {\n    const line = lines[i]!\n    const textX = Math.floor(w / 2) - Math.ceil(line.length / 2) + 1\n    for (let j = 0; j < line.length; j++) {\n      const x = textX + j\n      const y = startY + i\n      if (x >= 0 && x < canvas.length && y >= 0 && y < canvas[0]!.length) {\n        canvas[x]![y] = line[j]!\n      }\n    }\n  }\n\n  return canvas\n}\n\n// ============================================================================\n// Shared attachment point calculation\n// ============================================================================\n\n/**\n * Calculate edge attachment point for rectangular shapes.\n * All box-based shapes use the same attachment logic.\n */\nexport function getBoxAttachmentPoint(\n  dir: Direction,\n  dimensions: ShapeDimensions,\n  baseCoord: DrawingCoord\n): DrawingCoord {\n  const { width, height } = dimensions\n  const centerX = baseCoord.x + Math.floor(width / 2)\n  const centerY = baseCoord.y + Math.floor(height / 2)\n\n  if (dirEquals(dir, Up)) return { x: centerX, y: baseCoord.y }\n  if (dirEquals(dir, Down)) return { x: centerX, y: baseCoord.y + height - 1 }\n  if (dirEquals(dir, Left)) return { x: baseCoord.x, y: centerY }\n  if (dirEquals(dir, Right)) return { x: baseCoord.x + width - 1, y: centerY }\n  if (dirEquals(dir, UpperLeft)) return { x: baseCoord.x, y: baseCoord.y }\n  if (dirEquals(dir, UpperRight)) return { x: baseCoord.x + width - 1, y: baseCoord.y }\n  if (dirEquals(dir, LowerLeft)) return { x: baseCoord.x, y: baseCoord.y + height - 1 }\n  if (dirEquals(dir, LowerRight)) return { x: baseCoord.x + width - 1, y: baseCoord.y + height - 1 }\n  // Middle\n  return { x: centerX, y: centerY }\n}\n\n// ============================================================================\n// Rectangle renderer\n// ============================================================================\n\n/**\n * Rectangle shape renderer — the default box shape.\n * Renders as:\n *   ┌─────────┐\n *   │  Label  │\n *   └─────────┘\n */\nexport const rectangleRenderer: ShapeRenderer = {\n  getDimensions: getBoxDimensions,\n\n  render(label: string, dimensions: ShapeDimensions, options: ShapeRenderOptions): Canvas {\n    const corners = getCorners('rectangle', options.useAscii)\n    return renderBox(label, dimensions, corners, options.useAscii)\n  },\n\n  getAttachmentPoint: getBoxAttachmentPoint,\n}\n"
  },
  {
    "path": "src/ascii/shapes/rounded.ts",
    "content": "// ============================================================================\n// Rounded rectangle shape renderer — uses rounded corner decorators\n// ============================================================================\n\nimport type { ShapeRenderer } from './types.ts'\nimport { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle.ts'\nimport { getCorners } from './corners.ts'\n\n/**\n * Rounded rectangle shape renderer.\n * Uses rounded corner markers (╭╮╰╯) to indicate soft edges.\n *\n * Renders as:\n *   ╭─────────╮\n *   │  Label  │\n *   ╰─────────╯\n */\nexport const roundedRenderer: ShapeRenderer = {\n  getDimensions: getBoxDimensions,\n\n  render(label, dimensions, options) {\n    const corners = getCorners('rounded', options.useAscii)\n    return renderBox(label, dimensions, corners, options.useAscii)\n  },\n\n  getAttachmentPoint: getBoxAttachmentPoint,\n}\n"
  },
  {
    "path": "src/ascii/shapes/special.ts",
    "content": "// ============================================================================\n// Special shape renderers — subroutine, doublecircle, cylinder, etc.\n// ============================================================================\n//\n// Some shapes have unique internal structure (subroutine, cylinder) and keep\n// custom rendering. Others use the corner decorator pattern for simplicity.\n\nimport type { Canvas, DrawingCoord, Direction } from '../types.ts'\nimport { Up, Down, Left, Right } from '../types.ts'\nimport { mkCanvas } from '../canvas.ts'\nimport { splitLines } from '../multiline-utils.ts'\nimport type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types.ts'\nimport { dirEquals } from '../edge-routing.ts'\nimport { getBoxDimensions, renderBox, getBoxAttachmentPoint } from './rectangle.ts'\nimport { getCorners } from './corners.ts'\n\n// ============================================================================\n// Subroutine — keeps custom double-border rendering\n// ============================================================================\n\n/**\n * Subroutine shape renderer — double-bordered rectangle.\n * Renders as:\n *   ┌┬─────────┬┐\n *   ││  Label  ││\n *   └┴─────────┴┘\n */\nexport const subroutineRenderer: ShapeRenderer = {\n  getDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions {\n    const lines = splitLines(label)\n    const maxLineWidth = Math.max(...lines.map(l => l.length), 0)\n    const lineCount = lines.length\n\n    const innerWidth = 2 * options.padding + maxLineWidth\n    const width = innerWidth + 4  // Double borders on each side\n    const innerHeight = lineCount + 2 * options.padding\n    const height = innerHeight + 2\n\n    return {\n      width,\n      height,\n      labelArea: {\n        x: 2 + options.padding,\n        y: 1 + options.padding,\n        width: maxLineWidth,\n        height: lineCount,\n      },\n      gridColumns: [2, innerWidth, 2],\n      gridRows: [1, innerHeight, 1],\n    }\n  },\n\n  render(label: string, dimensions: ShapeDimensions, options: ShapeRenderOptions): Canvas {\n    const { width, height } = dimensions\n    const canvas = mkCanvas(width - 1, height - 1)\n\n    const hChar = options.useAscii ? '-' : '─'\n    const vChar = options.useAscii ? '|' : '│'\n\n    // Top border\n    canvas[0]![0] = options.useAscii ? '+' : '┌'\n    canvas[1]![0] = options.useAscii ? '+' : '┬'\n    for (let x = 2; x < width - 2; x++) canvas[x]![0] = hChar\n    canvas[width - 2]![0] = options.useAscii ? '+' : '┬'\n    canvas[width - 1]![0] = options.useAscii ? '+' : '┐'\n\n    // Sides with double border\n    for (let y = 1; y < height - 1; y++) {\n      canvas[0]![y] = vChar\n      canvas[1]![y] = vChar\n      canvas[width - 2]![y] = vChar\n      canvas[width - 1]![y] = vChar\n    }\n\n    // Bottom border\n    canvas[0]![height - 1] = options.useAscii ? '+' : '└'\n    canvas[1]![height - 1] = options.useAscii ? '+' : '┴'\n    for (let x = 2; x < width - 2; x++) canvas[x]![height - 1] = hChar\n    canvas[width - 2]![height - 1] = options.useAscii ? '+' : '┴'\n    canvas[width - 1]![height - 1] = options.useAscii ? '+' : '┘'\n\n    // Center the label\n    const lines = splitLines(label)\n    const centerY = Math.floor(height / 2)\n    const startY = centerY - Math.floor((lines.length - 1) / 2)\n\n    for (let i = 0; i < lines.length; i++) {\n      const line = lines[i]!\n      const textX = Math.floor(width / 2) - Math.floor(line.length / 2)\n      for (let j = 0; j < line.length; j++) {\n        const x = textX + j\n        const y = startY + i\n        if (x > 1 && x < width - 2 && y > 0 && y < height - 1) {\n          canvas[x]![y] = line[j]!\n        }\n      }\n    }\n\n    return canvas\n  },\n\n  getAttachmentPoint: getBoxAttachmentPoint,\n}\n\n// ============================================================================\n// Double circle — uses corner decorators\n// ============================================================================\n\n/**\n * Double circle shape renderer.\n * Uses double circle markers (◎) at corners.\n *\n * Renders as:\n *   ◎─────────◎\n *   │  Label  │\n *   ◎─────────◎\n */\nexport const doublecircleRenderer: ShapeRenderer = {\n  getDimensions: getBoxDimensions,\n\n  render(label, dimensions, options) {\n    const corners = getCorners('doublecircle', options.useAscii)\n    return renderBox(label, dimensions, corners, options.useAscii)\n  },\n\n  getAttachmentPoint: getBoxAttachmentPoint,\n}\n\n// ============================================================================\n// Cylinder — keeps custom rendering for database appearance\n// ============================================================================\n\n/**\n * Cylinder shape renderer — database symbol.\n * Renders as:\n *   ╭─────╮\n *   │─────│\n *   │ DB  │\n *   │─────│\n *   ╰─────╯\n */\nexport const cylinderRenderer: ShapeRenderer = {\n  getDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions {\n    const lines = splitLines(label)\n    const maxLineWidth = Math.max(...lines.map(l => l.length), 0)\n    const lineCount = lines.length\n\n    const innerWidth = 2 * options.padding + maxLineWidth\n    const width = innerWidth + 2\n    const innerHeight = lineCount + 2 * options.padding + 2  // Extra for curved top/bottom\n    const height = innerHeight + 2\n\n    return {\n      width,\n      height,\n      labelArea: {\n        x: 1 + options.padding,\n        y: 2 + options.padding,\n        width: maxLineWidth,\n        height: lineCount,\n      },\n      gridColumns: [1, innerWidth, 1],\n      gridRows: [2, innerHeight - 2, 2],\n    }\n  },\n\n  render(label: string, dimensions: ShapeDimensions, options: ShapeRenderOptions): Canvas {\n    const { width, height } = dimensions\n    const canvas = mkCanvas(width - 1, height - 1)\n\n    const hChar = options.useAscii ? '-' : '─'\n    const vChar = options.useAscii ? '|' : '│'\n\n    // Top ellipse\n    canvas[0]![0] = options.useAscii ? '.' : '╭'\n    for (let x = 1; x < width - 1; x++) canvas[x]![0] = hChar\n    canvas[width - 1]![0] = options.useAscii ? '.' : '╮'\n\n    // Second row - bottom of top ellipse\n    canvas[0]![1] = vChar\n    for (let x = 1; x < width - 1; x++) canvas[x]![1] = hChar\n    canvas[width - 1]![1] = vChar\n\n    // Middle section\n    for (let y = 2; y < height - 2; y++) {\n      canvas[0]![y] = vChar\n      canvas[width - 1]![y] = vChar\n    }\n\n    // Second to last row - top of bottom ellipse\n    canvas[0]![height - 2] = vChar\n    for (let x = 1; x < width - 1; x++) canvas[x]![height - 2] = hChar\n    canvas[width - 1]![height - 2] = vChar\n\n    // Bottom ellipse\n    canvas[0]![height - 1] = options.useAscii ? '\\'' : '╰'\n    for (let x = 1; x < width - 1; x++) canvas[x]![height - 1] = hChar\n    canvas[width - 1]![height - 1] = options.useAscii ? '\\'' : '╯'\n\n    // Center the label\n    const lines = splitLines(label)\n    const centerY = Math.floor(height / 2)\n    const startY = centerY - Math.floor((lines.length - 1) / 2)\n\n    for (let i = 0; i < lines.length; i++) {\n      const line = lines[i]!\n      const textX = Math.floor(width / 2) - Math.floor(line.length / 2)\n      for (let j = 0; j < line.length; j++) {\n        const x = textX + j\n        const y = startY + i\n        if (x > 0 && x < width - 1 && y > 1 && y < height - 2) {\n          canvas[x]![y] = line[j]!\n        }\n      }\n    }\n\n    return canvas\n  },\n\n  getAttachmentPoint: getBoxAttachmentPoint,\n}\n\n// ============================================================================\n// Asymmetric (flag) — uses corner decorators\n// ============================================================================\n\n/**\n * Asymmetric (flag/banner) shape renderer.\n * Uses arrow markers (▷) on left corners.\n *\n * Renders as:\n *   ▷─────────┐\n *   │  Label  │\n *   ▷─────────┘\n */\nexport const asymmetricRenderer: ShapeRenderer = {\n  getDimensions: getBoxDimensions,\n\n  render(label, dimensions, options) {\n    const corners = getCorners('asymmetric', options.useAscii)\n    return renderBox(label, dimensions, corners, options.useAscii)\n  },\n\n  getAttachmentPoint: getBoxAttachmentPoint,\n}\n\n// ============================================================================\n// Trapezoid — uses corner decorators instead of diagonal sides\n// ============================================================================\n\n/**\n * Trapezoid shape renderer — wider at bottom.\n * Uses slope markers (◸◹) on top corners.\n *\n * Renders as:\n *   ◸─────────◹\n *   │  Label  │\n *   └─────────┘\n */\nexport const trapezoidRenderer: ShapeRenderer = {\n  getDimensions: getBoxDimensions,\n\n  render(label, dimensions, options) {\n    const corners = getCorners('trapezoid', options.useAscii)\n    return renderBox(label, dimensions, corners, options.useAscii)\n  },\n\n  getAttachmentPoint: getBoxAttachmentPoint,\n}\n\n// ============================================================================\n// Trapezoid-alt — uses corner decorators instead of diagonal sides\n// ============================================================================\n\n/**\n * Trapezoid-alt shape renderer — wider at top.\n * Uses slope markers (◺◿) on bottom corners.\n *\n * Renders as:\n *   ┌─────────┐\n *   │  Label  │\n *   ◺─────────◿\n */\nexport const trapezoidAltRenderer: ShapeRenderer = {\n  getDimensions: getBoxDimensions,\n\n  render(label, dimensions, options) {\n    const corners = getCorners('trapezoid-alt', options.useAscii)\n    return renderBox(label, dimensions, corners, options.useAscii)\n  },\n\n  getAttachmentPoint: getBoxAttachmentPoint,\n}\n"
  },
  {
    "path": "src/ascii/shapes/stadium.ts",
    "content": "// ============================================================================\n// Stadium (pill) shape renderer — special parentheses-based rendering\n// ============================================================================\n//\n// Stadium has unique rendering: single-line is inline `(Label)`, multi-line\n// uses parentheses or rounded corners. This differs from other shapes that\n// use corner decorators with box lines.\n\nimport type { Canvas, DrawingCoord, Direction } from '../types.ts'\nimport { mkCanvas } from '../canvas.ts'\nimport { splitLines } from '../multiline-utils.ts'\nimport type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types.ts'\nimport { getBoxAttachmentPoint } from './rectangle.ts'\n\n/**\n * Stadium (pill) shape renderer.\n *\n * Single-line:  ( Label )\n *\n * Multi-line unicode:\n *   ╭──────────╮\n *   │  Label   │\n *   ╰──────────╯\n *\n * Multi-line ASCII:\n *   (----------)\n *   (  Label   )\n *   (----------)\n */\nexport const stadiumRenderer: ShapeRenderer = {\n  getDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions {\n    const lines = splitLines(label)\n    const maxLineWidth = Math.max(...lines.map(l => l.length), 0)\n    const lineCount = lines.length\n\n    const innerWidth = 2 * options.padding + maxLineWidth\n    const width = innerWidth + 4  // Extra for rounded ends\n    const innerHeight = lineCount + 2 * options.padding\n    const height = Math.max(innerHeight + 2, 3)\n\n    return {\n      width,\n      height,\n      labelArea: {\n        x: 2 + options.padding,\n        y: 1 + options.padding,\n        width: maxLineWidth,\n        height: lineCount,\n      },\n      gridColumns: [2, innerWidth, 2],\n      gridRows: [1, innerHeight, 1],\n    }\n  },\n\n  render(label: string, dimensions: ShapeDimensions, options: ShapeRenderOptions): Canvas {\n    const { width, height } = dimensions\n    const canvas = mkCanvas(width - 1, height - 1)\n\n    const centerY = Math.floor(height / 2)\n    const hChar = options.useAscii ? '-' : '─'\n\n    if (height === 3) {\n      // Single row pill: (  Label  )\n      canvas[0]![centerY] = '('\n      canvas[width - 1]![centerY] = ')'\n    } else if (!options.useAscii) {\n      // Multi-row stadium with rounded corners (unicode)\n      canvas[0]![0] = '╭'\n      for (let x = 1; x < width - 1; x++) canvas[x]![0] = hChar\n      canvas[width - 1]![0] = '╮'\n\n      for (let y = 1; y < height - 1; y++) {\n        canvas[0]![y] = '│'\n        canvas[width - 1]![y] = '│'\n      }\n\n      canvas[0]![height - 1] = '╰'\n      for (let x = 1; x < width - 1; x++) canvas[x]![height - 1] = hChar\n      canvas[width - 1]![height - 1] = '╯'\n    } else {\n      // Multi-row stadium ASCII — parentheses on all sides\n      for (let y = 0; y < height; y++) {\n        canvas[0]![y] = '('\n        canvas[width - 1]![y] = ')'\n      }\n      for (let x = 1; x < width - 1; x++) {\n        canvas[x]![0] = hChar\n        canvas[x]![height - 1] = hChar\n      }\n    }\n\n    // Center the label\n    const lines = splitLines(label)\n    const startY = centerY - Math.floor((lines.length - 1) / 2)\n\n    for (let i = 0; i < lines.length; i++) {\n      const line = lines[i]!\n      const textX = Math.floor(width / 2) - Math.floor(line.length / 2)\n      for (let j = 0; j < line.length; j++) {\n        const x = textX + j\n        const y = startY + i\n        if (x > 0 && x < width - 1 && y >= 0 && y < height) {\n          canvas[x]![y] = line[j]!\n        }\n      }\n    }\n\n    return canvas\n  },\n\n  getAttachmentPoint: getBoxAttachmentPoint,\n}\n"
  },
  {
    "path": "src/ascii/shapes/state.ts",
    "content": "// ============================================================================\n// State pseudo-state renderers — UML start and end states\n// ============================================================================\n\nimport type { Canvas, DrawingCoord, Direction } from '../types.ts'\nimport { Up, Down, Left, Right, UpperLeft, UpperRight, LowerLeft, LowerRight } from '../types.ts'\nimport { mkCanvas } from '../canvas.ts'\nimport type { ShapeRenderer, ShapeDimensions, ShapeRenderOptions } from './types.ts'\nimport { dirEquals } from '../edge-routing.ts'\n\n/**\n * State start pseudo-state renderer — filled circle in rounded box.\n * Renders as:\n *   ╭───╮\n *   │ ● │   (Unicode)\n *   ╰───╯\n *\n *   .---.\n *   | * |   (ASCII)\n *   '---'\n *\n * This represents the UML initial pseudo-state.\n */\nexport const stateStartRenderer: ShapeRenderer = {\n  getDimensions(_label: string, _options: ShapeRenderOptions): ShapeDimensions {\n    // Start state is a 5x3 rounded box with centered symbol\n    const width = 5\n    const height = 3\n\n    return {\n      width,\n      height,\n      labelArea: { x: 2, y: 1, width: 1, height: 1 },\n      gridColumns: [1, 3, 1],\n      gridRows: [1, 1, 1],\n    }\n  },\n\n  render(_label: string, dimensions: ShapeDimensions, options: ShapeRenderOptions): Canvas {\n    const { width, height } = dimensions\n    const canvas = mkCanvas(width - 1, height - 1)\n\n    const centerX = Math.floor(width / 2)  // = 2\n\n    if (!options.useAscii) {\n      // Unicode rounded box with filled circle: ╭───╮ │ ● │ ╰───╯\n      canvas[0]![0] = '╭'\n      canvas[1]![0] = '─'\n      canvas[2]![0] = '─'\n      canvas[3]![0] = '─'\n      canvas[4]![0] = '╮'\n\n      canvas[0]![1] = '│'\n      canvas[centerX]![1] = '●'\n      canvas[4]![1] = '│'\n\n      canvas[0]![2] = '╰'\n      canvas[1]![2] = '─'\n      canvas[2]![2] = '─'\n      canvas[3]![2] = '─'\n      canvas[4]![2] = '╯'\n    } else {\n      // ASCII rounded box: .---. | * | '---'\n      canvas[0]![0] = '.'\n      canvas[1]![0] = '-'\n      canvas[2]![0] = '-'\n      canvas[3]![0] = '-'\n      canvas[4]![0] = '.'\n\n      canvas[0]![1] = '|'\n      canvas[centerX]![1] = '*'\n      canvas[4]![1] = '|'\n\n      canvas[0]![2] = '\\''\n      canvas[1]![2] = '-'\n      canvas[2]![2] = '-'\n      canvas[3]![2] = '-'\n      canvas[4]![2] = '\\''\n    }\n\n    return canvas\n  },\n\n  getAttachmentPoint(\n    dir: Direction,\n    dimensions: ShapeDimensions,\n    baseCoord: DrawingCoord\n  ): DrawingCoord {\n    const { width, height } = dimensions\n    const centerX = baseCoord.x + Math.floor(width / 2)\n    const centerY = baseCoord.y + Math.floor(height / 2)\n\n    if (dirEquals(dir, Up)) return { x: centerX, y: baseCoord.y }\n    if (dirEquals(dir, Down)) return { x: centerX, y: baseCoord.y + height - 1 }\n    if (dirEquals(dir, Left)) return { x: baseCoord.x, y: centerY }\n    if (dirEquals(dir, Right)) return { x: baseCoord.x + width - 1, y: centerY }\n    // All diagonals and middle point to center\n    return { x: centerX, y: centerY }\n  },\n}\n\n/**\n * State end pseudo-state renderer — bullseye in double-bordered box.\n * Renders as:\n *   ╔═══╗\n *   ║ ◎ ║   (Unicode)\n *   ╚═══╝\n *\n *   #===#\n *   # * #   (ASCII)\n *   #===#\n *\n * This represents the UML final state. The double border distinguishes it\n * from the start state's single rounded border.\n */\nexport const stateEndRenderer: ShapeRenderer = {\n  getDimensions(_label: string, _options: ShapeRenderOptions): ShapeDimensions {\n    // End state is a 5x3 double-bordered box with centered symbol\n    const width = 5\n    const height = 3\n\n    return {\n      width,\n      height,\n      labelArea: { x: 2, y: 1, width: 1, height: 1 },\n      gridColumns: [1, 3, 1],\n      gridRows: [1, 1, 1],\n    }\n  },\n\n  render(_label: string, dimensions: ShapeDimensions, options: ShapeRenderOptions): Canvas {\n    const { width, height } = dimensions\n    const canvas = mkCanvas(width - 1, height - 1)\n\n    const centerX = Math.floor(width / 2)  // = 2\n\n    if (!options.useAscii) {\n      // Unicode double-bordered box with bullseye: ╔═══╗ ║ ◎ ║ ╚═══╝\n      canvas[0]![0] = '╔'\n      canvas[1]![0] = '═'\n      canvas[2]![0] = '═'\n      canvas[3]![0] = '═'\n      canvas[4]![0] = '╗'\n\n      canvas[0]![1] = '║'\n      canvas[centerX]![1] = '◎'\n      canvas[4]![1] = '║'\n\n      canvas[0]![2] = '╚'\n      canvas[1]![2] = '═'\n      canvas[2]![2] = '═'\n      canvas[3]![2] = '═'\n      canvas[4]![2] = '╝'\n    } else {\n      // ASCII double-bordered box: #===# # * # #===#\n      canvas[0]![0] = '#'\n      canvas[1]![0] = '='\n      canvas[2]![0] = '='\n      canvas[3]![0] = '='\n      canvas[4]![0] = '#'\n\n      canvas[0]![1] = '#'\n      canvas[centerX]![1] = '*'\n      canvas[4]![1] = '#'\n\n      canvas[0]![2] = '#'\n      canvas[1]![2] = '='\n      canvas[2]![2] = '='\n      canvas[3]![2] = '='\n      canvas[4]![2] = '#'\n    }\n\n    return canvas\n  },\n\n  getAttachmentPoint(\n    dir: Direction,\n    dimensions: ShapeDimensions,\n    baseCoord: DrawingCoord\n  ): DrawingCoord {\n    const { width, height } = dimensions\n    const centerX = baseCoord.x + Math.floor(width / 2)\n    const centerY = baseCoord.y + Math.floor(height / 2)\n\n    if (dirEquals(dir, Up)) return { x: centerX, y: baseCoord.y }\n    if (dirEquals(dir, Down)) return { x: centerX, y: baseCoord.y + height - 1 }\n    if (dirEquals(dir, Left)) return { x: baseCoord.x, y: centerY }\n    if (dirEquals(dir, Right)) return { x: baseCoord.x + width - 1, y: centerY }\n    // All diagonals and middle point to center\n    return { x: centerX, y: centerY }\n  },\n}\n"
  },
  {
    "path": "src/ascii/shapes/types.ts",
    "content": "// ============================================================================\n// Shape renderer types — interface for pluggable ASCII shape renderers\n// ============================================================================\n\nimport type { Canvas, DrawingCoord, Direction, AsciiNodeShape } from '../types.ts'\n\n/**\n * Dimensions calculated for a shape, used by layout and rendering.\n */\nexport interface ShapeDimensions {\n  /** Total width in characters including borders */\n  width: number\n  /** Total height in characters including borders */\n  height: number\n  /** Label area bounds (where text can be placed) */\n  labelArea: {\n    x: number\n    y: number\n    width: number\n    height: number\n  }\n  /** Grid column widths for the 3-column layout [left, center, right] */\n  gridColumns: [number, number, number]\n  /** Grid row heights for the 3-row layout [top, middle, bottom] */\n  gridRows: [number, number, number]\n}\n\n/**\n * Options passed to shape renderers.\n */\nexport interface ShapeRenderOptions {\n  /** Use ASCII chars (+,-,|) vs Unicode box-drawing (┌,─,│) */\n  useAscii: boolean\n  /** Padding inside the shape */\n  padding: number\n}\n\n/**\n * Interface for pluggable shape renderers.\n * Each shape type implements this interface.\n */\nexport interface ShapeRenderer {\n  /**\n   * Calculate dimensions for this shape given a label.\n   * Used during layout to determine node size.\n   */\n  getDimensions(label: string, options: ShapeRenderOptions): ShapeDimensions\n\n  /**\n   * Render the shape to a canvas.\n   * Returns a standalone canvas containing just the shape.\n   */\n  render(\n    label: string,\n    dimensions: ShapeDimensions,\n    options: ShapeRenderOptions\n  ): Canvas\n\n  /**\n   * Get the edge attachment point for a given direction.\n   * Used by edge routing to determine where edges connect.\n   */\n  getAttachmentPoint(\n    dir: Direction,\n    dimensions: ShapeDimensions,\n    baseCoord: DrawingCoord\n  ): DrawingCoord\n}\n\n/**\n * Registry of shape renderers keyed by shape type.\n */\nexport type ShapeRegistry = Map<AsciiNodeShape, ShapeRenderer>\n"
  },
  {
    "path": "src/ascii/types.ts",
    "content": "// ============================================================================\n// ASCII renderer — type definitions\n//\n// Ported from AlexanderGrooff/mermaid-ascii (Go).\n// These types model the grid-based coordinate system, 2D text canvas,\n// and graph structures used by the ASCII/Unicode renderer.\n// ============================================================================\n\nimport type { NodeShape } from '../types.ts'\n\n// Re-export NodeShape for convenience\nexport type { NodeShape }\n\n/**\n * Shape type for ASCII rendering — maps parser shapes to ASCII renderers.\n * Most shapes from the parser are supported, with fallback to 'rectangle'.\n */\nexport type AsciiNodeShape = NodeShape\n\n/** Logical grid coordinate — nodes occupy 3x3 blocks on this grid. */\nexport interface GridCoord {\n  x: number\n  y: number\n}\n\n/** Character-level coordinate on the 2D text canvas. */\nexport interface DrawingCoord {\n  x: number\n  y: number\n}\n\n/**\n * Direction constants model positions on a node's 3x3 grid block.\n * Each node occupies grid cells [x..x+2, y..y+2].\n * Directions are offsets into that block, used for edge attachment points.\n *\n *   (0,0) UL   (1,0) Up   (2,0) UR\n *   (0,1) Left (1,1) Mid  (2,1) Right\n *   (0,2) LL   (1,2) Down (2,2) LR\n */\nexport interface Direction {\n  readonly x: number\n  readonly y: number\n}\n\nexport const Up: Direction         = { x: 1, y: 0 }\nexport const Down: Direction       = { x: 1, y: 2 }\nexport const Left: Direction       = { x: 0, y: 1 }\nexport const Right: Direction      = { x: 2, y: 1 }\nexport const UpperRight: Direction = { x: 2, y: 0 }\nexport const UpperLeft: Direction  = { x: 0, y: 0 }\nexport const LowerRight: Direction = { x: 2, y: 2 }\nexport const LowerLeft: Direction  = { x: 0, y: 2 }\nexport const Middle: Direction     = { x: 1, y: 1 }\n\n/** All named directions for iteration. */\nexport const ALL_DIRECTIONS: readonly Direction[] = [\n  Up, Down, Left, Right, UpperRight, UpperLeft, LowerRight, LowerLeft, Middle,\n]\n\n/**\n * 2D text canvas — column-major (canvas[x][y]).\n * Each cell holds a single character (or space).\n */\nexport type Canvas = string[][]\n\n/** A node in the ASCII graph, positioned on the grid. */\nexport interface AsciiNode {\n  /** Unique identity key — the original node ID from the parser (e.g. \"A\", \"B\"). */\n  name: string\n  /** Human-readable label for rendering inside the box (e.g. \"Web Server\"). */\n  displayLabel: string\n  /** Node shape from the parser (e.g. \"rectangle\", \"diamond\", \"circle\"). */\n  shape: AsciiNodeShape\n  index: number\n  gridCoord: GridCoord | null\n  drawingCoord: DrawingCoord | null\n  drawing: Canvas | null\n  drawn: boolean\n  styleClassName: string\n  styleClass: AsciiStyleClass\n}\n\n/** Style class for colored node text (ported from Go's classDef). */\nexport interface AsciiStyleClass {\n  name: string\n  styles: Record<string, string>\n}\n\n/** Edge line style for ASCII rendering. */\nexport type AsciiEdgeStyle = 'solid' | 'dotted' | 'thick'\n\n/** An edge in the ASCII graph, with a routed path. */\nexport interface AsciiEdge {\n  from: AsciiNode\n  to: AsciiNode\n  text: string\n  path: GridCoord[]\n  labelLine: GridCoord[]\n  startDir: Direction\n  endDir: Direction\n  /** Line style: solid (default), dotted (-.->) or thick (==>) */\n  style: AsciiEdgeStyle\n  /** Whether to render an arrowhead at the start (source end) of the edge */\n  hasArrowStart: boolean\n  /** Whether to render an arrowhead at the end (target end) of the edge */\n  hasArrowEnd: boolean\n  /** Bundle this edge belongs to (if any). Set during bundling analysis. */\n  bundle?: EdgeBundle\n  /**\n   * For bundled edges: path from source/target to the junction point.\n   * The full visual path is: pathToJunction + bundle.sharedPath (for fan-in)\n   * or bundle.sharedPath + pathToJunction (for fan-out).\n   */\n  pathToJunction?: GridCoord[]\n}\n\n/** A subgraph container with bounding box for rendering. */\nexport interface AsciiSubgraph {\n  name: string\n  nodes: AsciiNode[]\n  parent: AsciiSubgraph | null\n  children: AsciiSubgraph[]\n  minX: number\n  minY: number\n  maxX: number\n  maxY: number\n  /** Optional direction override for layout within this subgraph (LR or TD). */\n  direction?: 'LR' | 'TD'\n}\n\n/** Configuration for ASCII rendering. */\nexport interface AsciiConfig {\n  /** true = ASCII chars (+,-,|), false = Unicode box-drawing (┌,─,│). Default: false */\n  useAscii: boolean\n  /** Horizontal spacing between nodes. Default: 5 */\n  paddingX: number\n  /** Vertical spacing between nodes. Default: 5 */\n  paddingY: number\n  /** Padding inside node boxes. Default: 1 */\n  boxBorderPadding: number\n  /** Graph direction: \"LR\" or \"TD\". */\n  graphDirection: 'LR' | 'TD'\n}\n\n/** Full ASCII graph state used during layout and rendering. */\nexport interface AsciiGraph {\n  nodes: AsciiNode[]\n  edges: AsciiEdge[]\n  canvas: Canvas\n  /** Role canvas — tracks the role of each character for colored output. */\n  roleCanvas: RoleCanvas\n  /** Grid occupancy map — maps \"x,y\" keys to node references. */\n  grid: Map<string, AsciiNode>\n  columnWidth: Map<number, number>\n  rowHeight: Map<number, number>\n  subgraphs: AsciiSubgraph[]\n  config: AsciiConfig\n  /** Offset applied to all drawing coords to accommodate subgraph borders. */\n  offsetX: number\n  offsetY: number\n  /** Edge bundles for parallel link visualization. Set during bundling analysis. */\n  bundles: EdgeBundle[]\n}\n\n// ============================================================================\n// Coordinate helpers\n// ============================================================================\n\nexport function gridCoordEquals(a: GridCoord, b: GridCoord): boolean {\n  return a.x === b.x && a.y === b.y\n}\n\nexport function drawingCoordEquals(a: DrawingCoord, b: DrawingCoord): boolean {\n  return a.x === b.x && a.y === b.y\n}\n\n/** Apply a direction offset to a grid coordinate (move into the 3x3 block). */\nexport function gridCoordDirection(c: GridCoord, dir: Direction): GridCoord {\n  return { x: c.x + dir.x, y: c.y + dir.y }\n}\n\n/** Key for storing GridCoord in a Map. */\nexport function gridKey(c: GridCoord): string {\n  return `${c.x},${c.y}`\n}\n\n/** Default empty style class. */\nexport const EMPTY_STYLE: AsciiStyleClass = { name: '', styles: {} }\n\n// ============================================================================\n// Character role types for colored output\n// ============================================================================\n\n/**\n * Role of a character in the ASCII diagram, used for theming.\n * Each role maps to a different color when colors are enabled.\n */\nexport type CharRole =\n  | 'text'      // Node labels, edge labels\n  | 'border'    // Node box borders, subgraph borders\n  | 'line'      // Edge lines (paths between nodes)\n  | 'arrow'     // Arrowheads (▲▼◄► or ^v<>)\n  | 'corner'    // Corner characters at path bends\n  | 'junction'  // Junction characters (┬┴├┤ where edges meet boxes)\n\n/**\n * Role canvas — parallel to Canvas, tracks the role of each character.\n * Same column-major structure: roleCanvas[x][y] gives the role at (x, y).\n * null means the character has no role (whitespace).\n */\nexport type RoleCanvas = (CharRole | null)[][]\n\n/**\n * Theme colors for ASCII output — hex color strings.\n * Derived from the SVG theme system for visual consistency.\n */\nexport interface AsciiTheme {\n  /** Text color (node labels, edge labels) */\n  fg: string\n  /** Box border color (node borders, subgraph borders) */\n  border: string\n  /** Edge line color (paths between nodes) */\n  line: string\n  /** Arrowhead color (▲▼◄► or ^v<>) */\n  arrow: string\n  /** Theme accent color (optional, used by xycharts for series 0) */\n  accent?: string\n  /** Background color (optional, used by xycharts for dark-mode-aware shading) */\n  bg?: string\n  /** Corner character color (optional, defaults to line) */\n  corner?: string\n  /** Junction character color (optional, defaults to border) */\n  junction?: string\n}\n\n/** Color mode for output. */\nexport type ColorMode =\n  | 'none'      // No colors (plain text)\n  | 'ansi16'    // 16-color ANSI (basic terminals)\n  | 'ansi256'   // 256-color ANSI (xterm)\n  | 'truecolor' // 24-bit RGB (modern terminals)\n  | 'html'      // HTML <span> tags with inline color styles (browsers)\n\n// ============================================================================\n// Edge bundling types\n// ============================================================================\n\n/**\n * Edge bundle — groups edges that share a common source or target.\n * Used to visually merge parallel links before they reach the shared node.\n *\n * For fan-in (A & B --> C): multiple sources converge to one target.\n * For fan-out (A --> B & C): one source diverges to multiple targets.\n */\nexport interface EdgeBundle {\n  /** Bundle type: fan-in = many→one, fan-out = one→many */\n  type: 'fan-in' | 'fan-out'\n  /** Edges in this bundle */\n  edges: AsciiEdge[]\n  /** The common node (target for fan-in, source for fan-out) */\n  sharedNode: AsciiNode\n  /** The non-shared nodes (sources for fan-in, targets for fan-out) */\n  otherNodes: AsciiNode[]\n  /** Junction point where edges merge/split — set during routing */\n  junctionPoint: GridCoord | null\n  /** Path from junction to shared node (drawn once for all edges) */\n  sharedPath: GridCoord[]\n  /** Direction when entering/exiting the junction */\n  junctionDir: Direction\n  /** Direction when entering/exiting the shared node */\n  sharedNodeDir: Direction\n}\n"
  },
  {
    "path": "src/ascii/validate.ts",
    "content": "/**\n * ASCII Rendering Validation Utilities\n *\n * Provides validation functions for ASCII diagram output,\n * including diagonal line detection to ensure orthogonal-only routing.\n */\n\n/**\n * Characters that represent diagonal lines in ASCII and Unicode modes.\n * These should never appear in properly rendered diagrams.\n */\nexport const DIAGONAL_CHARS = {\n  ascii: ['/', '\\\\'],\n  unicode: ['\\u2571', '\\u2572'], // ╱ ╲\n  all: ['/', '\\\\', '\\u2571', '\\u2572'],\n} as const\n\n/**\n * Position of a diagonal character in ASCII output.\n */\nexport interface DiagonalPosition {\n  line: number\n  col: number\n  char: string\n}\n\n/**\n * Check if ASCII output contains any diagonal line characters.\n * Returns true if diagonals are found (which is an error condition).\n *\n * @param asciiOutput - The rendered ASCII diagram string\n * @returns true if diagonal characters are present, false otherwise\n */\nexport function hasDiagonalLines(asciiOutput: string): boolean {\n  return DIAGONAL_CHARS.all.some((char) => asciiOutput.includes(char))\n}\n\n/**\n * Find all diagonal line character positions in ASCII output.\n * Useful for debugging when diagonals are detected.\n *\n * Skips diagonal characters that appear inside node labels (between box borders).\n * This prevents false positives from labels like \"feature/auth\" or \"release/1.0\".\n *\n * @param asciiOutput - The rendered ASCII diagram string\n * @returns Array of positions where diagonal characters were found\n */\nexport function findDiagonalLines(asciiOutput: string): DiagonalPosition[] {\n  const positions: DiagonalPosition[] = []\n  const lines = asciiOutput.split('\\n')\n\n  // Box-drawing characters that indicate node boundaries\n  const boxBorders = new Set(['│', '┤', '├', '║', '┃', '|'])\n\n  for (let lineNum = 0; lineNum < lines.length; lineNum++) {\n    const line = lines[lineNum]!\n\n    // Find all box border positions in this line\n    const borderPositions: number[] = []\n    for (let col = 0; col < line.length; col++) {\n      if (boxBorders.has(line[col]!)) {\n        borderPositions.push(col)\n      }\n    }\n\n    for (let col = 0; col < line.length; col++) {\n      const char = line[col]!\n      if (DIAGONAL_CHARS.all.includes(char as '/' | '\\\\' | '╱' | '╲')) {\n        // Check if this position is inside a node (between two box borders)\n        // Find the nearest borders before and after this position\n        let insideNode = false\n        for (let i = 0; i < borderPositions.length - 1; i++) {\n          const leftBorder = borderPositions[i]!\n          const rightBorder = borderPositions[i + 1]!\n          if (col > leftBorder && col < rightBorder) {\n            // This diagonal char is between two borders - likely inside a node label\n            insideNode = true\n            break\n          }\n        }\n\n        if (!insideNode) {\n          positions.push({\n            line: lineNum + 1, // 1-indexed for human readability\n            col: col + 1,\n            char,\n          })\n        }\n      }\n    }\n  }\n\n  return positions\n}\n\n/**\n * Assert that ASCII output contains no diagonal lines.\n * Throws an error with detailed position information if diagonals are found.\n *\n * @param asciiOutput - The rendered ASCII diagram string\n * @param context - Optional context string for error message (e.g., diagram name)\n * @throws Error if diagonal characters are present\n */\nexport function assertNoDiagonals(asciiOutput: string, context?: string): void {\n  if (!hasDiagonalLines(asciiOutput)) {\n    return\n  }\n\n  const positions = findDiagonalLines(asciiOutput)\n  const contextStr = context ? ` in \"${context}\"` : ''\n  const positionStr = positions\n    .map((p) => `  Line ${p.line}, Col ${p.col}: '${p.char}'`)\n    .join('\\n')\n\n  throw new Error(\n    `Diagonal lines detected${contextStr}. ` +\n      `Edges must use orthogonal Manhattan routing (90° bends only).\\n` +\n      `Found ${positions.length} diagonal character(s):\\n${positionStr}`\n  )\n}\n"
  },
  {
    "path": "src/ascii/xychart.ts",
    "content": "// ============================================================================\n// ASCII renderer — XY Chart\n//\n// Renders xychart-beta diagrams to ASCII/Unicode text art.\n// Uses the parsed XYChart type directly (not PositionedXYChart) since\n// pixel coordinates don't map to character grids.\n//\n// Bar charts: █ (Unicode) or # (ASCII) block characters.\n// Line charts: continuous staircase routing with rounded corners (╭╮╰╯│─).\n//\n// Multi-series support: each series gets a distinct color from a palette.\n// ============================================================================\n\nimport { parseXYChart } from '../xychart/parser.ts'\nimport type { XYChart } from '../xychart/types.ts'\nimport type { AsciiConfig, AsciiTheme, ColorMode, CharRole, Canvas, RoleCanvas } from './types.ts'\nimport { colorizeText } from './ansi.ts'\nimport { getSeriesColor, CHART_ACCENT_FALLBACK } from '../xychart/colors.ts'\n\n// ============================================================================\n// Constants\n// ============================================================================\n\nconst PLOT_WIDTH = 60\nconst PLOT_HEIGHT = 20\n\n// Unicode box-drawing characters\nconst UNI = {\n  hLine: '─',\n  vLine: '│',\n  origin: '┼',\n  yTick: '┤',\n  xTick: '┬',\n  bar: '█',\n  grid: '·',\n  cornerTL: '╭',  // top-left: down+right\n  cornerTR: '╮',  // top-right: down+left\n  cornerBL: '╰',  // bottom-left: up+right\n  cornerBR: '╯',  // bottom-right: up+left\n} as const\n\n// ASCII fallback characters\nconst ASC = {\n  hLine: '-',\n  vLine: '|',\n  origin: '+',\n  yTick: '+',\n  xTick: '+',\n  bar: '#',\n  grid: '.',\n  cornerTL: '+',\n  cornerTR: '+',\n  cornerBL: '+',\n  cornerBR: '+',\n} as const\n\n// ============================================================================\n// Multi-series color support\n// ============================================================================\n\n/** Per-cell hex color override canvas. Parallel to RoleCanvas. */\ntype HexCanvas = (string | null)[][]\n\n/** Generate an array of hex colors, one per series. */\nfunction getSeriesColors(total: number, theme: AsciiTheme): string[] {\n  const accent = theme.accent ?? CHART_ACCENT_FALLBACK\n  if (total <= 1) return [accent]\n  return Array.from({ length: total }, (_, i) => getSeriesColor(i, accent, theme.bg))\n}\n\n/** Map a CharRole to its hex color from the theme (for canvasToString fallback). */\nfunction roleToHex(role: CharRole, theme: AsciiTheme): string {\n  switch (role) {\n    case 'text': return theme.fg\n    case 'border': return theme.border\n    case 'line': return theme.line\n    case 'arrow': return theme.arrow\n    case 'corner': return theme.corner ?? theme.line\n    case 'junction': return theme.junction ?? theme.border\n    default: return theme.fg\n  }\n}\n\n// ============================================================================\n// Public API\n// ============================================================================\n\nexport function renderXYChartAscii(\n  text: string,\n  config: AsciiConfig,\n  colorMode: ColorMode,\n  theme: AsciiTheme,\n): string {\n  const lines = text.split('\\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%'))\n  const chart = parseXYChart(lines)\n  const ch = config.useAscii ? ASC : UNI\n\n  if (chart.horizontal) {\n    return renderHorizontal(chart, ch, colorMode, theme)\n  }\n  return renderVertical(chart, ch, colorMode, theme)\n}\n\n// ============================================================================\n// Vertical chart layout + rendering\n// ============================================================================\n\nfunction renderVertical(\n  chart: XYChart,\n  ch: typeof UNI | typeof ASC,\n  colorMode: ColorMode,\n  theme: AsciiTheme,\n): string {\n  const dataCount = getDataCount(chart)\n  if (dataCount === 0) return ''\n\n  const yRange = chart.yAxis.range!\n  const yTicks = niceTickValues(yRange.min, yRange.max)\n  const yLabels = yTicks.map(v => formatTickValue(v))\n  const yGutter = Math.max(...yLabels.map(l => l.length)) + 1\n\n  const plotW = Math.max(PLOT_WIDTH, dataCount * 6)\n  const plotH = PLOT_HEIGHT\n  const bandW = Math.floor(plotW / dataCount)\n  const catLabels = getCategoryLabels(chart, dataCount)\n\n  // Canvas dimensions\n  const hasTitle = !!chart.title\n  const hasXTitle = !!chart.xAxis.title\n  const hasLegend = chart.series.length > 1\n  const titleRow = hasTitle ? 0 : -1\n  const plotTop = (hasTitle ? 2 : 0) + (hasLegend ? 1 : 0)\n  const plotLeft = yGutter + 1 // +1 for axis character\n  const totalW = plotLeft + bandW * dataCount + 2\n  const xAxisRow = plotTop + plotH\n  const xLabelRow = xAxisRow + 1\n  const xTitleRow = hasXTitle ? xLabelRow + 1 : -1\n  const totalH = xLabelRow + 1 + (hasXTitle ? 1 : 0) + (hasLegend && !hasTitle ? 0 : 0)\n\n  // Create canvas\n  const canvas = createCanvas(totalW, totalH)\n  const roles = createRoleCanvas(totalW, totalH)\n  const hexColors = createHexCanvas(totalW, totalH)\n\n  // Series colors\n  const seriesColors = getSeriesColors(chart.series.length, theme)\n\n  // Scales\n  const valueToRow = (v: number): number => {\n    const t = (v - yRange.min) / (yRange.max - yRange.min || 1)\n    return Math.round(t * (plotH - 1))\n  }\n  const bandCenter = (i: number): number => plotLeft + Math.floor(bandW * (i + 0.5))\n\n  // 1. Title\n  if (hasTitle && titleRow >= 0) {\n    writeText(canvas, roles, titleRow, Math.floor(totalW / 2 - chart.title!.length / 2), chart.title!, 'text')\n  }\n\n  // 2. Legend\n  if (hasLegend) {\n    const legendRow = hasTitle ? 1 : 0\n    drawLegend(canvas, roles, hexColors, chart, legendRow, totalW, ch, seriesColors)\n  }\n\n  // 3. Y-axis line + ticks + labels\n  for (let row = 0; row < plotH; row++) {\n    const displayRow = plotTop + (plotH - 1 - row)\n    set(canvas, roles, displayRow, plotLeft - 1, ch.vLine, 'border')\n  }\n  // Origin\n  set(canvas, roles, xAxisRow, plotLeft - 1, ch.origin, 'border')\n\n  for (const tick of yTicks) {\n    const row = valueToRow(tick)\n    if (row < 0 || row >= plotH) continue\n    const displayRow = plotTop + (plotH - 1 - row)\n    const label = formatTickValue(tick)\n    // Tick mark on axis\n    set(canvas, roles, displayRow, plotLeft - 1, row === 0 ? ch.origin : ch.yTick, 'border')\n    // Label\n    const labelStart = yGutter - label.length\n    writeText(canvas, roles, displayRow, Math.max(0, labelStart), label, 'text')\n  }\n\n  // 4. X-axis line + ticks + labels\n  for (let c = plotLeft; c < plotLeft + bandW * dataCount; c++) {\n    set(canvas, roles, xAxisRow, c, ch.hLine, 'border')\n  }\n  for (let i = 0; i < dataCount; i++) {\n    const cx = bandCenter(i)\n    set(canvas, roles, xAxisRow, cx, ch.xTick, 'border')\n    // Label below\n    const label = catLabels[i]!\n    const labelStart = cx - Math.floor(label.length / 2)\n    writeText(canvas, roles, xLabelRow, Math.max(0, labelStart), label, 'text')\n  }\n\n  // 5. X-axis title\n  if (hasXTitle && xTitleRow >= 0) {\n    const title = chart.xAxis.title!\n    writeText(canvas, roles, xTitleRow, Math.floor(totalW / 2 - title.length / 2), title, 'text')\n  }\n\n  // 6. Grid lines (subtle horizontal dots at y-tick positions)\n  for (const tick of yTicks) {\n    const row = valueToRow(tick)\n    if (row < 0 || row >= plotH) continue\n    const displayRow = plotTop + (plotH - 1 - row)\n    for (let c = plotLeft; c < plotLeft + bandW * dataCount; c++) {\n      if (get(canvas, displayRow, c) === ' ') {\n        set(canvas, roles, displayRow, c, ch.grid, 'line')\n      }\n    }\n  }\n\n  // 7. Bars — track global series index for per-series colors\n  const barEntries: { data: number[]; globalIdx: number }[] = []\n  for (let si = 0; si < chart.series.length; si++) {\n    if (chart.series[si]!.type === 'bar') barEntries.push({ data: chart.series[si]!.data, globalIdx: si })\n  }\n\n  if (barEntries.length > 0) {\n    const barCount = barEntries.length\n    const usable = Math.max(1, bandW - 2)\n    const singleBarW = Math.max(1, Math.min(Math.floor(usable / barCount), 8))\n    const groupW = singleBarW * barCount + (barCount - 1)\n    const baseRow = valueToRow(Math.max(0, yRange.min))\n\n    for (let bIdx = 0; bIdx < barEntries.length; bIdx++) {\n      const entry = barEntries[bIdx]!\n      const hexColor = seriesColors[entry.globalIdx]!\n      for (let i = 0; i < entry.data.length; i++) {\n        const cx = bandCenter(i)\n        const groupLeft = cx - Math.floor(groupW / 2)\n        const bx = groupLeft + bIdx * (singleBarW + 1)\n        const valRow = valueToRow(entry.data[i]!)\n        const fromRow = Math.min(baseRow, valRow)\n        const toRow = Math.max(baseRow, valRow)\n\n        for (let row = fromRow; row <= toRow; row++) {\n          const displayRow = plotTop + (plotH - 1 - row)\n          for (let c = bx; c < bx + singleBarW; c++) {\n            set(canvas, roles, displayRow, c, ch.bar, 'arrow', hexColors, hexColor)\n          }\n        }\n      }\n    }\n  }\n\n  // 8. Lines (staircase routing with rounded corners)\n  const lineEntries: { data: number[]; globalIdx: number }[] = []\n  for (let si = 0; si < chart.series.length; si++) {\n    if (chart.series[si]!.type === 'line') lineEntries.push({ data: chart.series[si]!.data, globalIdx: si })\n  }\n\n  for (const entry of lineEntries) {\n    if (entry.data.length === 0) continue\n    const hexColor = seriesColors[entry.globalIdx]!\n    drawStaircaseLine(canvas, roles, entry.data, bandCenter, valueToRow, plotTop, plotH, plotLeft, bandW * dataCount, ch, hexColors, hexColor)\n  }\n\n  return canvasToString(canvas, roles, hexColors, colorMode, theme)\n}\n\n// ============================================================================\n// Horizontal chart layout + rendering\n// ============================================================================\n\nfunction renderHorizontal(\n  chart: XYChart,\n  ch: typeof UNI | typeof ASC,\n  colorMode: ColorMode,\n  theme: AsciiTheme,\n): string {\n  const dataCount = getDataCount(chart)\n  if (dataCount === 0) return ''\n\n  const yRange = chart.yAxis.range!\n  const valueTicks = niceTickValues(yRange.min, yRange.max)\n  const catLabels = getCategoryLabels(chart, dataCount)\n  const catGutter = Math.max(...catLabels.map(l => l.length)) + 1\n\n  const plotW = Math.max(PLOT_WIDTH, 40)\n  const bandH = Math.max(2, Math.floor(PLOT_HEIGHT / dataCount))\n  const plotH = bandH * dataCount\n\n  const hasTitle = !!chart.title\n  const hasYTitle = !!chart.yAxis.title\n  const hasLegend = chart.series.length > 1\n  const plotTop = (hasTitle ? 2 : 0) + (hasLegend ? 1 : 0)\n  const plotLeft = catGutter + 1\n  const totalW = plotLeft + plotW + 2\n  const totalH = plotTop + plotH + 2 + (hasYTitle ? 1 : 0)\n  const xAxisRow = plotTop + plotH\n\n  const canvas = createCanvas(totalW, totalH)\n  const roles = createRoleCanvas(totalW, totalH)\n  const hexColors = createHexCanvas(totalW, totalH)\n\n  // Series colors\n  const seriesColors = getSeriesColors(chart.series.length, theme)\n\n  // Value scale (horizontal)\n  const valueToCol = (v: number): number => {\n    const t = (v - yRange.min) / (yRange.max - yRange.min || 1)\n    return plotLeft + Math.round(t * (plotW - 1))\n  }\n  const bandMid = (i: number): number => plotTop + Math.floor(bandH * (i + 0.5))\n\n  // Title\n  if (hasTitle) {\n    writeText(canvas, roles, 0, Math.floor(totalW / 2 - chart.title!.length / 2), chart.title!, 'text')\n  }\n\n  // Legend\n  if (hasLegend) {\n    const legendRow = hasTitle ? 1 : 0\n    drawLegend(canvas, roles, hexColors, chart, legendRow, totalW, ch, seriesColors)\n  }\n\n  // Y-axis (category axis on left)\n  for (let r = plotTop; r < plotTop + plotH; r++) {\n    set(canvas, roles, r, plotLeft - 1, ch.vLine, 'border')\n  }\n  set(canvas, roles, xAxisRow, plotLeft - 1, ch.origin, 'border')\n\n  for (let i = 0; i < dataCount; i++) {\n    const my = bandMid(i)\n    const label = catLabels[i]!\n    const labelStart = catGutter - label.length\n    writeText(canvas, roles, my, Math.max(0, labelStart), label, 'text')\n  }\n\n  // X-axis (value axis on bottom)\n  for (let c = plotLeft; c < plotLeft + plotW; c++) {\n    set(canvas, roles, xAxisRow, c, ch.hLine, 'border')\n  }\n  for (const tick of valueTicks) {\n    const cx = valueToCol(tick)\n    if (cx < plotLeft || cx >= plotLeft + plotW) continue\n    set(canvas, roles, xAxisRow, cx, ch.xTick, 'border')\n    const label = formatTickValue(tick)\n    writeText(canvas, roles, xAxisRow + 1, cx - Math.floor(label.length / 2), label, 'text')\n  }\n\n  // Y-axis title\n  if (hasYTitle) {\n    const title = chart.yAxis.title!\n    writeText(canvas, roles, totalH - 1, Math.floor(totalW / 2 - title.length / 2), title, 'text')\n  }\n\n  // Grid lines (vertical at value tick positions)\n  for (const tick of valueTicks) {\n    const cx = valueToCol(tick)\n    if (cx < plotLeft || cx >= plotLeft + plotW) continue\n    for (let r = plotTop; r < plotTop + plotH; r++) {\n      if (get(canvas, r, cx) === ' ') {\n        set(canvas, roles, r, cx, ch.grid, 'line')\n      }\n    }\n  }\n\n  // Bars (horizontal) — with per-series colors\n  const barEntries: { data: number[]; globalIdx: number }[] = []\n  for (let si = 0; si < chart.series.length; si++) {\n    if (chart.series[si]!.type === 'bar') barEntries.push({ data: chart.series[si]!.data, globalIdx: si })\n  }\n\n  if (barEntries.length > 0) {\n    const barCount = barEntries.length\n    const singleBarH = 1\n    const groupH = singleBarH * barCount + (barCount - 1)\n    const baseCol = valueToCol(Math.max(0, yRange.min))\n\n    for (let bIdx = 0; bIdx < barEntries.length; bIdx++) {\n      const entry = barEntries[bIdx]!\n      const hexColor = seriesColors[entry.globalIdx]!\n      for (let i = 0; i < entry.data.length; i++) {\n        const my = bandMid(i)\n        const groupTop = my - Math.floor(groupH / 2)\n        const by = groupTop + bIdx * (singleBarH + 1)\n        const valCol = valueToCol(entry.data[i]!)\n        const fromCol = Math.min(baseCol, valCol)\n        const toCol = Math.max(baseCol, valCol)\n\n        for (let r = by; r < by + singleBarH; r++) {\n          for (let c = fromCol; c <= toCol; c++) {\n            set(canvas, roles, r, c, ch.bar, 'arrow', hexColors, hexColor)\n          }\n        }\n      }\n    }\n  }\n\n  // Lines (horizontal staircase: value on x, category on y) — with per-series colors\n  const lineEntries: { data: number[]; globalIdx: number }[] = []\n  for (let si = 0; si < chart.series.length; si++) {\n    if (chart.series[si]!.type === 'line') lineEntries.push({ data: chart.series[si]!.data, globalIdx: si })\n  }\n\n  for (const entry of lineEntries) {\n    if (entry.data.length === 0) continue\n    const hexColor = seriesColors[entry.globalIdx]!\n    drawHorizontalStaircaseLine(canvas, roles, entry.data, bandMid, valueToCol, plotTop, plotH, plotLeft, plotW, ch, hexColors, hexColor)\n  }\n\n  return canvasToString(canvas, roles, hexColors, colorMode, theme)\n}\n\n// ============================================================================\n// Staircase line drawing — vertical charts\n//\n// Connects data points with flat segments (─) at each value's row,\n// vertical segments (│) between rows, and rounded corners (╭╮╰╯)\n// at transitions. The vertical step happens at the midpoint column\n// between adjacent data points.\n// ============================================================================\n\nfunction drawStaircaseLine(\n  canvas: Canvas,\n  roles: RoleCanvas,\n  data: number[],\n  bandCenter: (i: number) => number,\n  valueToRow: (v: number) => number,\n  plotTop: number,\n  plotH: number,\n  plotLeft: number,\n  plotTotalW: number,\n  ch: typeof UNI | typeof ASC,\n  hexCanvas?: HexCanvas,\n  hexColor?: string | null,\n): void {\n  if (data.length === 0) return\n\n  const points = data.map((v, i) => ({\n    col: bandCenter(i),\n    row: valueToRow(v),\n  }))\n\n  // Helper to draw on the canvas (row 0 = bottom, displayed inverted)\n  const drawAt = (col: number, row: number, char: string) => {\n    const displayRow = plotTop + (plotH - 1 - row)\n    if (displayRow >= 0 && col >= plotLeft && col < plotLeft + plotTotalW) {\n      set(canvas, roles, displayRow, col, char, 'arrow', hexCanvas, hexColor)\n    }\n  }\n\n  // Single point: just draw a flat segment\n  if (points.length === 1) {\n    drawAt(points[0]!.col, points[0]!.row, ch.hLine)\n    return\n  }\n\n  for (let i = 0; i < points.length - 1; i++) {\n    const p1 = points[i]!\n    const p2 = points[i + 1]!\n\n    if (p1.row === p2.row) {\n      // Flat: draw ─ across\n      for (let c = p1.col; c <= p2.col; c++) {\n        drawAt(c, p1.row, ch.hLine)\n      }\n      continue\n    }\n\n    const midCol = Math.round((p1.col + p2.col) / 2)\n    const goingUp = p2.row > p1.row\n\n    // 1. Flat at p1's row from p1.col to midCol-1\n    for (let c = p1.col; c < midCol; c++) {\n      drawAt(c, p1.row, ch.hLine)\n    }\n\n    // 2. Corner at (midCol, p1.row)\n    //    goingUp:   ─ from LEFT, │ going UP   → LEFT+TOP  = ╯ (cornerBR)\n    //    goingDown: ─ from LEFT, │ going DOWN  → LEFT+BOT  = ╮ (cornerTR)\n    if (goingUp) {\n      drawAt(midCol, p1.row, ch.cornerBR) // ╯\n    } else {\n      drawAt(midCol, p1.row, ch.cornerTR) // ╮\n    }\n\n    // 3. Vertical from p1.row to p2.row (exclusive of endpoints)\n    const minRow = Math.min(p1.row, p2.row)\n    const maxRow = Math.max(p1.row, p2.row)\n    for (let row = minRow + 1; row < maxRow; row++) {\n      drawAt(midCol, row, ch.vLine)\n    }\n\n    // 4. Corner at (midCol, p2.row)\n    //    goingUp:   │ from BOTTOM, ─ going RIGHT → BOT+RIGHT = ╭ (cornerTL)\n    //    goingDown: │ from TOP, ─ going RIGHT     → TOP+RIGHT = ╰ (cornerBL)\n    if (goingUp) {\n      drawAt(midCol, p2.row, ch.cornerTL) // ╭\n    } else {\n      drawAt(midCol, p2.row, ch.cornerBL) // ╰\n    }\n\n    // 5. Flat at p2's row from midCol+1 to p2.col\n    for (let c = midCol + 1; c <= p2.col; c++) {\n      drawAt(c, p2.row, ch.hLine)\n    }\n\n    // Leading flat for first segment (before p1.col)\n    if (i === 0) {\n      const leadStart = Math.max(plotLeft, p1.col - Math.floor((p2.col - p1.col) / 4))\n      for (let c = leadStart; c < p1.col; c++) {\n        drawAt(c, p1.row, ch.hLine)\n      }\n    }\n\n    // Trailing flat for last segment (after p2.col)\n    if (i === points.length - 2) {\n      const trailEnd = Math.min(plotLeft + plotTotalW - 1, p2.col + Math.floor((p2.col - p1.col) / 4))\n      for (let c = p2.col + 1; c <= trailEnd; c++) {\n        drawAt(c, p2.row, ch.hLine)\n      }\n    }\n  }\n}\n\n// ============================================================================\n// Staircase line drawing — horizontal charts\n//\n// Same staircase approach but with axes swapped:\n// data values map to columns (horizontal position) and categories map to\n// rows (vertical position). Flat segments are vertical (│), transitions\n// are horizontal (─), with the same rounded corners.\n// ============================================================================\n\nfunction drawHorizontalStaircaseLine(\n  canvas: Canvas,\n  roles: RoleCanvas,\n  data: number[],\n  bandMid: (i: number) => number,\n  valueToCol: (v: number) => number,\n  plotTop: number,\n  plotH: number,\n  plotLeft: number,\n  plotW: number,\n  ch: typeof UNI | typeof ASC,\n  hexCanvas?: HexCanvas,\n  hexColor?: string | null,\n): void {\n  if (data.length === 0) return\n\n  const points = data.map((v, i) => ({\n    row: bandMid(i),\n    col: valueToCol(v),\n  }))\n\n  const drawAt = (row: number, col: number, char: string) => {\n    if (row >= plotTop && row < plotTop + plotH && col >= plotLeft && col < plotLeft + plotW) {\n      set(canvas, roles, row, col, char, 'arrow', hexCanvas, hexColor)\n    }\n  }\n\n  if (points.length === 1) {\n    drawAt(points[0]!.row, points[0]!.col, ch.vLine)\n    return\n  }\n\n  for (let i = 0; i < points.length - 1; i++) {\n    const p1 = points[i]!\n    const p2 = points[i + 1]!\n\n    if (p1.col === p2.col) {\n      // Same value: draw │ down\n      for (let r = p1.row; r <= p2.row; r++) {\n        drawAt(r, p1.col, ch.vLine)\n      }\n      continue\n    }\n\n    const midRow = Math.round((p1.row + p2.row) / 2)\n    const goingRight = p2.col > p1.col\n\n    // 1. Vertical at p1's col from p1.row to midRow-1\n    for (let r = p1.row; r < midRow; r++) {\n      drawAt(r, p1.col, ch.vLine)\n    }\n\n    // 2. Corner at (midRow, p1.col)\n    //    goingRight: │ from TOP, ─ going RIGHT → TOP+RIGHT = ╰ (cornerBL)\n    //    goingLeft:  │ from TOP, ─ going LEFT  → TOP+LEFT  = ╯ (cornerBR)\n    if (goingRight) {\n      drawAt(midRow, p1.col, ch.cornerBL) // ╰\n    } else {\n      drawAt(midRow, p1.col, ch.cornerBR) // ╯\n    }\n\n    // 3. Horizontal from p1.col to p2.col (exclusive)\n    const minCol = Math.min(p1.col, p2.col)\n    const maxCol = Math.max(p1.col, p2.col)\n    for (let c = minCol + 1; c < maxCol; c++) {\n      drawAt(midRow, c, ch.hLine)\n    }\n\n    // 4. Corner at (midRow, p2.col)\n    //    goingRight: ─ from LEFT, │ going DOWN  → LEFT+BOT  = ╮ (cornerTR)\n    //    goingLeft:  ─ from RIGHT, │ going DOWN → RIGHT+BOT = ╭ (cornerTL)\n    if (goingRight) {\n      drawAt(midRow, p2.col, ch.cornerTR) // ╮\n    } else {\n      drawAt(midRow, p2.col, ch.cornerTL) // ╭\n    }\n\n    // 5. Vertical at p2's col from midRow+1 to p2.row\n    for (let r = midRow + 1; r <= p2.row; r++) {\n      drawAt(r, p2.col, ch.vLine)\n    }\n  }\n}\n\n// ============================================================================\n// Legend — shows series symbols with per-series colors\n// ============================================================================\n\nfunction drawLegend(\n  canvas: Canvas,\n  roles: RoleCanvas,\n  hexCanvas: HexCanvas,\n  chart: XYChart,\n  row: number,\n  totalW: number,\n  ch: typeof UNI | typeof ASC,\n  seriesColors: string[],\n): void {\n  // Build legend items with global series indices\n  type LegendItem = { symbol: string; label: string; globalIdx: number }\n  const items: LegendItem[] = []\n  let barIdx = 0, lineIdx = 0\n  for (let si = 0; si < chart.series.length; si++) {\n    const s = chart.series[si]!\n    if (s.type === 'bar') {\n      items.push({ symbol: ch.bar, label: `Bar ${barIdx + 1}`, globalIdx: si })\n      barIdx++\n    } else {\n      items.push({ symbol: ch.hLine, label: `Line ${lineIdx + 1}`, globalIdx: si })\n      lineIdx++\n    }\n  }\n\n  // Calculate total legend width: \"symbol space label  symbol space label ...\"\n  let totalLen = 0\n  for (let i = 0; i < items.length; i++) {\n    if (i > 0) totalLen += 2 // gap between items\n    totalLen += 1 + 1 + items[i]!.label.length // symbol + space + label\n  }\n\n  const startCol = Math.max(0, Math.floor(totalW / 2 - totalLen / 2))\n  let col = startCol\n\n  for (let i = 0; i < items.length; i++) {\n    if (i > 0) col += 2 // gap\n    const item = items[i]!\n    // Symbol with series-specific color\n    set(canvas, roles, row, col, item.symbol, 'arrow', hexCanvas, seriesColors[item.globalIdx])\n    col += 1\n    // Space (already ' ' from canvas init)\n    col += 1\n    // Label text\n    writeText(canvas, roles, row, col, item.label, 'text')\n    col += item.label.length\n  }\n}\n\n// ============================================================================\n// Canvas utilities\n// ============================================================================\n\nfunction createCanvas(width: number, height: number): Canvas {\n  return Array.from({ length: width }, () => Array.from({ length: height }, () => ' '))\n}\n\nfunction createRoleCanvas(width: number, height: number): RoleCanvas {\n  return Array.from({ length: width }, () => Array.from<CharRole | null>({ length: height }).fill(null))\n}\n\nfunction createHexCanvas(width: number, height: number): HexCanvas {\n  return Array.from({ length: width }, () => Array.from<string | null>({ length: height }).fill(null))\n}\n\nfunction set(\n  canvas: Canvas, roles: RoleCanvas, row: number, col: number,\n  char: string, role: CharRole,\n  hexCanvas?: HexCanvas, hex?: string | null,\n): void {\n  if (col >= 0 && col < canvas.length && row >= 0 && row < canvas[0]!.length) {\n    canvas[col]![row] = char\n    roles[col]![row] = role\n    if (hexCanvas && hex) hexCanvas[col]![row] = hex\n  }\n}\n\nfunction get(canvas: Canvas, row: number, col: number): string {\n  if (col >= 0 && col < canvas.length && row >= 0 && row < canvas[0]!.length) {\n    return canvas[col]![row]!\n  }\n  return ' '\n}\n\nfunction writeText(canvas: Canvas, roles: RoleCanvas, row: number, startCol: number, text: string, role: CharRole): void {\n  for (let i = 0; i < text.length; i++) {\n    set(canvas, roles, row, startCol + i, text[i]!, role)\n  }\n}\n\n// ============================================================================\n// Canvas → string (with per-cell hex color support)\n// ============================================================================\n\nfunction canvasToString(\n  canvas: Canvas,\n  roles: RoleCanvas,\n  hexCanvas: HexCanvas,\n  colorMode: ColorMode,\n  theme: AsciiTheme,\n): string {\n  if (canvas.length === 0) return ''\n  const height = canvas[0]!.length\n  const width = canvas.length\n  const lines: string[] = []\n\n  for (let row = 0; row < height; row++) {\n    const chars: string[] = []\n    const rowRoles: (CharRole | null)[] = []\n    const rowHex: (string | null)[] = []\n    for (let col = 0; col < width; col++) {\n      chars.push(canvas[col]![row]!)\n      rowRoles.push(roles[col]![row]!)\n      rowHex.push(hexCanvas[col]![row]!)\n    }\n    // Trim trailing spaces\n    let end = chars.length - 1\n    while (end >= 0 && chars[end] === ' ') end--\n    if (end < 0) {\n      lines.push('')\n    } else {\n      lines.push(colorizeRow(\n        chars.slice(0, end + 1),\n        rowRoles.slice(0, end + 1),\n        rowHex.slice(0, end + 1),\n        theme,\n        colorMode,\n      ))\n    }\n  }\n\n  // Trim trailing empty lines\n  while (lines.length > 0 && lines[lines.length - 1] === '') {\n    lines.pop()\n  }\n\n  return lines.join('\\n')\n}\n\n/**\n * Colorize a row of characters, using hex color overrides where available\n * and falling back to role-based theme colors otherwise.\n * Groups consecutive same-color characters for efficient escape sequences.\n */\nfunction colorizeRow(\n  chars: string[],\n  roles: (CharRole | null)[],\n  hexOverrides: (string | null)[],\n  theme: AsciiTheme,\n  mode: ColorMode,\n): string {\n  if (mode === 'none') return chars.join('')\n\n  let result = ''\n  let currentColor: string | null = null\n  let buffer = ''\n\n  for (let i = 0; i < chars.length; i++) {\n    const char = chars[i]!\n\n    if (char === ' ') {\n      // Flush buffer before whitespace\n      if (buffer.length > 0) {\n        result += currentColor ? colorizeText(buffer, currentColor, mode) : buffer\n        buffer = ''\n        currentColor = null\n      }\n      result += ' '\n      continue\n    }\n\n    // Effective color: hex override > role-based > null\n    const hexOvr = hexOverrides[i] ?? null\n    const roleVal = roles[i] ?? null\n    const color = hexOvr ?? (roleVal ? roleToHex(roleVal, theme) : null)\n\n    if (color === currentColor) {\n      buffer += char\n    } else {\n      // Flush previous group\n      if (buffer.length > 0) {\n        result += currentColor ? colorizeText(buffer, currentColor, mode) : buffer\n      }\n      buffer = char\n      currentColor = color\n    }\n  }\n\n  // Flush remaining\n  if (buffer.length > 0) {\n    result += currentColor ? colorizeText(buffer, currentColor, mode) : buffer\n  }\n\n  return result\n}\n\n// ============================================================================\n// Helpers (chart-level)\n// ============================================================================\n\nfunction getDataCount(chart: XYChart): number {\n  if (chart.xAxis.categories) return chart.xAxis.categories.length\n  for (const s of chart.series) {\n    if (s.data.length > 0) return s.data.length\n  }\n  return 0\n}\n\nfunction getCategoryLabels(chart: XYChart, count: number): string[] {\n  if (chart.xAxis.categories) return chart.xAxis.categories\n  if (chart.xAxis.range) {\n    const { min, max } = chart.xAxis.range\n    const step = count > 1 ? (max - min) / (count - 1) : 0\n    return Array.from({ length: count }, (_, i) => formatTickValue(min + step * i))\n  }\n  return Array.from({ length: count }, (_, i) => String(i + 1))\n}\n\n/** Generate nice tick values for a numeric range. */\nfunction niceTickValues(min: number, max: number): number[] {\n  const range = max - min\n  if (range <= 0) return [min]\n\n  const rawInterval = range / 6\n  const magnitude = Math.pow(10, Math.floor(Math.log10(rawInterval)))\n  const residual = rawInterval / magnitude\n  let niceInterval: number\n  if (residual <= 1.5) niceInterval = magnitude\n  else if (residual <= 3) niceInterval = 2 * magnitude\n  else if (residual <= 7) niceInterval = 5 * magnitude\n  else niceInterval = 10 * magnitude\n\n  const start = Math.ceil(min / niceInterval) * niceInterval\n  const ticks: number[] = []\n  for (let v = start; v <= max + niceInterval * 0.001; v += niceInterval) {\n    ticks.push(Math.round(v * 1e10) / 1e10)\n  }\n  return ticks\n}\n\nfunction formatTickValue(v: number): string {\n  if (Number.isInteger(v)) return String(v)\n  return v.toFixed(Math.abs(v) < 10 ? 1 : 0)\n}\n"
  },
  {
    "path": "src/browser.ts",
    "content": "// ============================================================================\n// Browser entry point for beautiful-mermaid\n//\n// Exposes renderMermaid and renderMermaidAscii on window.__mermaid so they\n// can be called from inline <script> tags in samples.html.\n//\n// Bundled via `Bun.build({ target: 'browser' })` in index.ts.\n// ============================================================================\n\nimport { renderMermaidSVGAsync } from './index.ts'\nimport { renderMermaidASCII, diagramColorsToAsciiTheme } from './ascii/index.ts'\nimport { THEMES } from './theme.ts'\nimport { getSeriesColor, CHART_ACCENT_FALLBACK } from './xychart/colors.ts'\n\ndeclare const window: unknown\n\n;(window as Record<string, unknown>).__mermaid = {\n  renderMermaidSVGAsync,\n  renderMermaidASCII,\n  diagramColorsToAsciiTheme,\n  THEMES,\n  getSeriesColor,\n  CHART_ACCENT_FALLBACK,\n}\n"
  },
  {
    "path": "src/class/layout.ts",
    "content": "/**\n * Class diagram layout engine (ELK.js).\n *\n * Each class box has 3 compartments:\n *   1. Header (class name + optional annotation)\n *   2. Attributes section\n *   3. Methods section\n */\n\nimport type { ElkNode, ElkExtendedEdge } from 'elkjs'\nimport type { ClassDiagram, ClassNode, ClassMember, PositionedClassDiagram, PositionedClassNode, PositionedClassRelationship } from './types.ts'\nimport type { RenderOptions, Point } from '../types.ts'\nimport { estimateTextWidth, estimateMonoTextWidth, FONT_SIZES, FONT_WEIGHTS } from '../styles.ts'\nimport { measureMultilineText } from '../text-metrics.ts'\nimport { elkLayoutSync } from '../elk-instance.ts'\n\n/** Layout constants for class diagrams */\nexport const CLS = {\n  padding: 40,\n  boxPadX: 8,\n  headerBaseHeight: 32,\n  annotationHeight: 16,\n  memberRowHeight: 20,\n  sectionPadY: 8,\n  emptySectionHeight: 8,\n  minWidth: 120,\n  memberFontSize: 11,\n  memberFontWeight: 400,\n  nodeSpacing: 40,\n  layerSpacing: 60,\n} as const\n\ntype ClassSizeMap = Map<string, { width: number; height: number; headerHeight: number; attrHeight: number; methodHeight: number }>\n\n/** Build ELK graph and size map from a class diagram. */\nfunction buildClassElkGraph(\n  diagram: ClassDiagram,\n  _options: RenderOptions\n): { elkGraph: ElkNode; classSizes: ClassSizeMap } {\n  const classSizes: ClassSizeMap = new Map()\n\n  for (const cls of diagram.classes) {\n    const headerHeight = cls.annotation\n      ? CLS.headerBaseHeight + CLS.annotationHeight\n      : CLS.headerBaseHeight\n\n    const attrHeight = cls.attributes.length > 0\n      ? cls.attributes.length * CLS.memberRowHeight + CLS.sectionPadY\n      : CLS.emptySectionHeight\n\n    const methodHeight = cls.methods.length > 0\n      ? cls.methods.length * CLS.memberRowHeight + CLS.sectionPadY\n      : CLS.emptySectionHeight\n\n    const headerTextW = estimateTextWidth(cls.label, FONT_SIZES.nodeLabel, FONT_WEIGHTS.nodeLabel)\n    const maxAttrW = maxMemberWidth(cls.attributes)\n    const maxMethodW = maxMemberWidth(cls.methods)\n    const width = Math.max(CLS.minWidth, headerTextW + CLS.boxPadX * 2, maxAttrW + CLS.boxPadX * 2, maxMethodW + CLS.boxPadX * 2)\n    const height = headerHeight + attrHeight + methodHeight\n\n    classSizes.set(cls.id, { width, height, headerHeight, attrHeight, methodHeight })\n  }\n\n  const elkGraph: ElkNode = {\n    id: 'root',\n    layoutOptions: {\n      'elk.algorithm': 'layered',\n      'elk.direction': 'DOWN',\n      'elk.spacing.nodeNode': String(CLS.nodeSpacing),\n      'elk.layered.spacing.nodeNodeBetweenLayers': String(CLS.layerSpacing),\n      'elk.padding': `[top=${CLS.padding},left=${CLS.padding},bottom=${CLS.padding},right=${CLS.padding}]`,\n      'elk.edgeRouting': 'ORTHOGONAL',\n      'elk.edgeLabels.placement': 'CENTER',\n    },\n    children: [],\n    edges: [],\n  }\n\n  for (const cls of diagram.classes) {\n    const size = classSizes.get(cls.id)!\n    elkGraph.children!.push({ id: cls.id, width: size.width, height: size.height })\n  }\n\n  for (let i = 0; i < diagram.relationships.length; i++) {\n    const rel = diagram.relationships[i]!\n    const edge: ElkExtendedEdge = { id: `e${i}`, sources: [rel.from], targets: [rel.to] }\n    if (rel.label) {\n      const metrics = measureMultilineText(rel.label, FONT_SIZES.edgeLabel, FONT_WEIGHTS.edgeLabel)\n      edge.labels = [{ text: rel.label, width: metrics.width + 8, height: metrics.height + 6 }]\n    }\n    elkGraph.edges!.push(edge)\n  }\n\n  return { elkGraph, classSizes }\n}\n\n/** Extract positioned classes and relationships from ELK result. */\nfunction extractClassLayout(\n  result: ElkNode,\n  diagram: ClassDiagram,\n  classSizes: ClassSizeMap\n): PositionedClassDiagram {\n  const classLookup = new Map<string, ClassNode>()\n  for (const cls of diagram.classes) classLookup.set(cls.id, cls)\n\n  const positionedClasses: PositionedClassNode[] = []\n  for (const child of result.children ?? []) {\n    const cls = classLookup.get(child.id)\n    if (cls) {\n      const size = classSizes.get(cls.id)!\n      positionedClasses.push({\n        id: cls.id,\n        label: cls.label,\n        annotation: cls.annotation,\n        attributes: cls.attributes,\n        methods: cls.methods,\n        x: child.x ?? 0,\n        y: child.y ?? 0,\n        width: child.width ?? size.width,\n        height: child.height ?? size.height,\n        headerHeight: size.headerHeight,\n        attrHeight: size.attrHeight,\n        methodHeight: size.methodHeight,\n      })\n    }\n  }\n\n  const relationships: PositionedClassRelationship[] = []\n  for (let i = 0; i < (result.edges?.length ?? 0); i++) {\n    const elkEdge = result.edges![i]!\n    const rel = diagram.relationships[i]!\n\n    const points: Point[] = []\n    if (elkEdge.sections && elkEdge.sections.length > 0) {\n      const section = elkEdge.sections[0]!\n      points.push({ x: section.startPoint.x, y: section.startPoint.y })\n      if (section.bendPoints) {\n        for (const bp of section.bendPoints) {\n          points.push({ x: bp.x, y: bp.y })\n        }\n      }\n      points.push({ x: section.endPoint.x, y: section.endPoint.y })\n    }\n\n    let labelPosition: Point | undefined\n    if (elkEdge.labels && elkEdge.labels.length > 0) {\n      const label = elkEdge.labels[0]!\n      if (label.x != null && label.y != null) {\n        labelPosition = {\n          x: label.x + (label.width ?? 0) / 2,\n          y: label.y + (label.height ?? 0) / 2,\n        }\n      }\n    }\n\n    relationships.push({\n      from: rel.from,\n      to: rel.to,\n      type: rel.type,\n      markerAt: rel.markerAt,\n      label: rel.label,\n      fromCardinality: rel.fromCardinality,\n      toCardinality: rel.toCardinality,\n      points,\n      labelPosition,\n    })\n  }\n\n  return {\n    width: result.width ?? 600,\n    height: result.height ?? 400,\n    classes: positionedClasses,\n    relationships,\n  }\n}\n\n/**\n * Lay out a parsed class diagram using ELK.js (synchronous).\n */\nexport function layoutClassDiagramSync(\n  diagram: ClassDiagram,\n  options: RenderOptions = {}\n): PositionedClassDiagram {\n  if (diagram.classes.length === 0) {\n    return { width: 0, height: 0, classes: [], relationships: [] }\n  }\n\n  const { elkGraph, classSizes } = buildClassElkGraph(diagram, options)\n  const result = elkLayoutSync(elkGraph)\n  return extractClassLayout(result, diagram, classSizes)\n}\n\n/** Calculate the max width of a list of class members (uses mono metrics) */\nfunction maxMemberWidth(members: ClassMember[]): number {\n  if (members.length === 0) return 0\n  let maxW = 0\n  for (const m of members) {\n    const text = memberToString(m)\n    const w = estimateMonoTextWidth(text, CLS.memberFontSize)\n    if (w > maxW) maxW = w\n  }\n  return maxW\n}\n\n/** Convert a class member to its display string */\nexport function memberToString(m: ClassMember): string {\n  const vis = m.visibility ? `${m.visibility} ` : ''\n  const name = m.isMethod ? `${m.name}(${m.params || ''})` : m.name\n  const type = m.type ? `: ${m.type}` : ''\n  return `${vis}${name}${type}`\n}\n"
  },
  {
    "path": "src/class/parser.ts",
    "content": "import type { ClassDiagram, ClassNode, ClassRelationship, ClassMember, RelationshipType, ClassNamespace } from './types.ts'\nimport { normalizeBrTags } from '../multiline-utils.ts'\n\n// ============================================================================\n// Class diagram parser\n//\n// Parses Mermaid classDiagram syntax into a ClassDiagram structure.\n//\n// Supported syntax:\n//   class Animal { +String name; +eat() void }\n//   class Shape { <<abstract>> }\n//   Animal <|-- Dog           (inheritance)\n//   Car *-- Engine            (composition)\n//   Car o-- Wheel             (aggregation)\n//   A --> B                   (association)\n//   A ..> B                   (dependency)\n//   A ..|> B                  (realization)\n//   A \"1\" --> \"*\" B : label   (with cardinality + label)\n//   Animal : +String name     (inline attribute)\n//   namespace MyNamespace { class A { } }\n// ============================================================================\n\n/**\n * Parse a Mermaid class diagram.\n * Expects the first line to be \"classDiagram\".\n */\nexport function parseClassDiagram(lines: string[]): ClassDiagram {\n  const diagram: ClassDiagram = {\n    classes: [],\n    relationships: [],\n    namespaces: [],\n  }\n\n  // Track classes by ID for deduplication\n  const classMap = new Map<string, ClassNode>()\n  // Track namespace nesting\n  let currentNamespace: ClassNamespace | null = null\n  // Track class body parsing\n  let currentClass: ClassNode | null = null\n  let braceDepth = 0\n\n  for (let i = 1; i < lines.length; i++) {\n    const line = lines[i]!\n\n    // --- Inside a class body block ---\n    if (currentClass && braceDepth > 0) {\n      if (line === '}') {\n        braceDepth--\n        if (braceDepth === 0) {\n          currentClass = null\n        }\n        continue\n      }\n\n      // Check for annotation like <<interface>>\n      const annotMatch = line.match(/^<<(\\w+)>>$/)\n      if (annotMatch) {\n        currentClass.annotation = annotMatch[1]!\n        continue\n      }\n\n      // Parse member: visibility, name, type, optional parens for method\n      const member = parseMember(line)\n      if (member) {\n        if (member.isMethod) {\n          currentClass.methods.push(member.member)\n        } else {\n          currentClass.attributes.push(member.member)\n        }\n      }\n      continue\n    }\n\n    // --- Namespace block start ---\n    const nsMatch = line.match(/^namespace\\s+(\\S+)\\s*\\{$/)\n    if (nsMatch) {\n      currentNamespace = { name: nsMatch[1]!, classIds: [] }\n      continue\n    }\n\n    // --- Namespace end ---\n    if (line === '}' && currentNamespace) {\n      diagram.namespaces.push(currentNamespace)\n      currentNamespace = null\n      continue\n    }\n\n    // --- Class block start: `class ClassName {` or `class ClassName` ---\n    const classBlockMatch = line.match(/^class\\s+(\\S+?)(?:\\s*~(\\w+)~)?\\s*\\{$/)\n    if (classBlockMatch) {\n      const id = classBlockMatch[1]!\n      const generic = classBlockMatch[2]\n      const cls = ensureClass(classMap, id)\n      if (generic) {\n        cls.label = `${id}<${generic}>`\n      }\n      currentClass = cls\n      braceDepth = 1\n      if (currentNamespace) {\n        currentNamespace.classIds.push(id)\n      }\n      continue\n    }\n\n    // --- Standalone class declaration (no body): `class ClassName` ---\n    const classOnlyMatch = line.match(/^class\\s+(\\S+?)(?:\\s*~(\\w+)~)?\\s*$/)\n    if (classOnlyMatch) {\n      const id = classOnlyMatch[1]!\n      const generic = classOnlyMatch[2]\n      const cls = ensureClass(classMap, id)\n      if (generic) {\n        cls.label = `${id}<${generic}>`\n      }\n      if (currentNamespace) {\n        currentNamespace.classIds.push(id)\n      }\n      continue\n    }\n\n    // --- Inline annotation: `class ClassName { <<interface>> }` (single line) ---\n    const inlineAnnotMatch = line.match(/^class\\s+(\\S+?)\\s*\\{\\s*<<(\\w+)>>\\s*\\}$/)\n    if (inlineAnnotMatch) {\n      const cls = ensureClass(classMap, inlineAnnotMatch[1]!)\n      cls.annotation = inlineAnnotMatch[2]!\n      continue\n    }\n\n    // --- Inline attribute: `ClassName : +String name` ---\n    const inlineAttrMatch = line.match(/^(\\S+?)\\s*:\\s*(.+)$/)\n    if (inlineAttrMatch) {\n      // Make sure this isn't a relationship line (those have arrows)\n      const rest = inlineAttrMatch[2]!\n      if (!rest.match(/<\\|--|--|\\*--|o--|-->|\\.\\.>|\\.\\.\\|>/)) {\n        const cls = ensureClass(classMap, inlineAttrMatch[1]!)\n        const member = parseMember(rest)\n        if (member) {\n          if (member.isMethod) {\n            cls.methods.push(member.member)\n          } else {\n            cls.attributes.push(member.member)\n          }\n        }\n        continue\n      }\n    }\n\n    // --- Relationship ---\n    // Pattern: [FROM] [\"card\"] ARROW [\"card\"] [TO] [: label]\n    // Arrows: <|--, *--, o--, -->, ..|>, ..>\n    // Can also be reversed: --o, --*, --|>\n    const rel = parseRelationship(line)\n    if (rel) {\n      // Ensure both classes exist\n      ensureClass(classMap, rel.from)\n      ensureClass(classMap, rel.to)\n      diagram.relationships.push(rel)\n      continue\n    }\n  }\n\n  diagram.classes = [...classMap.values()]\n  return diagram\n}\n\n/** Ensure a class exists in the map, creating a default if needed */\nfunction ensureClass(classMap: Map<string, ClassNode>, id: string): ClassNode {\n  let cls = classMap.get(id)\n  if (!cls) {\n    cls = { id, label: id, attributes: [], methods: [] }\n    classMap.set(id, cls)\n  }\n  return cls\n}\n\n/** Parse a class member line (attribute or method) */\nfunction parseMember(line: string): { member: ClassMember; isMethod: boolean } | null {\n  const trimmed = line.trim().replace(/;$/, '')\n  if (!trimmed) return null\n\n  // Extract visibility prefix\n  let visibility: ClassMember['visibility'] = ''\n  let rest = trimmed\n  if (/^[+\\-#~]/.test(rest)) {\n    visibility = rest[0] as ClassMember['visibility']\n    rest = rest.slice(1).trim()\n  }\n\n  // Check if it's a method (has parentheses)\n  const methodMatch = rest.match(/^(.+?)\\(([^)]*)\\)(?:\\s*(.+))?$/)\n  if (methodMatch) {\n    const name = methodMatch[1]!.trim()\n    const params = methodMatch[2]?.trim() || undefined // Store the parameter string\n    const type = methodMatch[3]?.trim()\n    // Check for static ($) or abstract (*) markers\n    const isStatic = name.endsWith('$') || rest.includes('$')\n    const isAbstract = name.endsWith('*') || rest.includes('*')\n    return {\n      member: {\n        visibility,\n        name: name.replace(/[$*]$/, ''),\n        type: type || undefined,\n        isStatic,\n        isAbstract,\n        isMethod: true,\n        params,\n      },\n      isMethod: true,\n    }\n  }\n\n  // It's an attribute: [Type] name or name Type\n  // Common patterns: \"String name\", \"+int age\", \"name\"\n  const parts = rest.split(/\\s+/)\n  let name: string\n  let type: string | undefined\n\n  if (parts.length >= 2) {\n    // \"Type name\" pattern\n    type = parts[0]\n    name = parts.slice(1).join(' ')\n  } else {\n    name = parts[0] ?? rest\n  }\n\n  const isStatic = name.endsWith('$')\n  const isAbstract = name.endsWith('*')\n\n  return {\n    member: {\n      visibility,\n      name: name.replace(/[$*]$/, ''),\n      type: type || undefined,\n      isStatic,\n      isAbstract,\n      isMethod: false,\n    },\n    isMethod: false,\n  }\n}\n\n/** Parse a relationship line into a ClassRelationship */\nfunction parseRelationship(line: string): ClassRelationship | null {\n  // Relationship regex — handles all arrow types with optional cardinality and labels\n  // Pattern: FROM [\"card\"] ARROW [\"card\"] TO [: label]\n  const match = line.match(\n    /^(\\S+?)\\s+(?:\"([^\"]*?)\"\\s+)?(<\\|--|<\\|\\.\\.|\\*--|o--|-->|--\\*|--o|--\\|>|\\.\\.>|\\.\\.\\|>|<--|<\\.\\.?|--)\\s+(?:\"([^\"]*?)\"\\s+)?(\\S+?)(?:\\s*:\\s*(.+))?$/\n  )\n  if (!match) return null\n\n  const from = match[1]!\n  const rawFromCardinality = match[2]\n  const fromCardinality = rawFromCardinality ? normalizeBrTags(rawFromCardinality) : undefined\n  const arrow = match[3]!.trim()\n  const rawToCardinality = match[4]\n  const toCardinality = rawToCardinality ? normalizeBrTags(rawToCardinality) : undefined\n  const to = match[5]!\n  const rawLabel = match[6]?.trim()\n  const label = rawLabel ? normalizeBrTags(rawLabel) : undefined\n\n  const parsed = parseArrow(arrow)\n  if (!parsed) return null\n\n  return { from, to, type: parsed.type, markerAt: parsed.markerAt, label, fromCardinality, toCardinality }\n}\n\n/**\n * Map arrow syntax to relationship type and marker placement side.\n * Prefix markers (`<|--`, `*--`, `o--`) place the UML shape at the 'from' end.\n * Suffix markers (`..|>`, `-->`, `..>`, `--*`, `--o`) place it at the 'to' end.\n */\nfunction parseArrow(arrow: string): { type: RelationshipType; markerAt: 'from' | 'to' } | null {\n  // Trim whitespace that might be captured by the regex\n  const a = arrow.trim()\n  switch (a) {\n    case '<|--': return { type: 'inheritance',  markerAt: 'from' }\n    case '--|>': return { type: 'inheritance',  markerAt: 'to' }\n    case '<|..': return { type: 'realization',  markerAt: 'from' }\n    case '..|>': return { type: 'realization',  markerAt: 'to' }\n    case '*--':  return { type: 'composition',  markerAt: 'from' }\n    case '--*':  return { type: 'composition',  markerAt: 'to' }\n    case 'o--':  return { type: 'aggregation',  markerAt: 'from' }\n    case '--o':  return { type: 'aggregation',  markerAt: 'to' }\n    case '-->':  return { type: 'association',  markerAt: 'to' }\n    case '<--':  return { type: 'association',  markerAt: 'from' }\n    case '..>':  return { type: 'dependency',   markerAt: 'to' }\n    case '<..':  return { type: 'dependency',   markerAt: 'from' }\n    case '--':   return { type: 'association',  markerAt: 'to' }\n    default:     return null\n  }\n}\n"
  },
  {
    "path": "src/class/renderer.ts",
    "content": "import type { PositionedClassDiagram, PositionedClassNode, PositionedClassRelationship, ClassMember, RelationshipType } from './types.ts'\nimport type { DiagramColors } from '../theme.ts'\nimport { svgOpenTag, buildStyleBlock } from '../theme.ts'\nimport { FONT_SIZES, FONT_WEIGHTS, STROKE_WIDTHS, estimateTextWidth, TEXT_BASELINE_SHIFT } from '../styles.ts'\nimport { CLS } from './layout.ts'\nimport { renderMultilineText, escapeXml as escapeXmlUtil } from '../multiline-utils.ts'\n\n// ============================================================================\n// Class diagram SVG renderer\n//\n// Renders positioned class diagrams to SVG.\n// All colors use CSS custom properties (var(--_xxx)) from the theme system.\n//\n// Render order:\n//   1. Relationship lines (behind boxes)\n//   2. Class boxes (header + attributes + methods compartments)\n//   3. Relationship endpoint markers (diamonds, triangles)\n//   4. Labels and cardinality\n// ============================================================================\n\n/** Font sizes specific to class diagrams */\nconst CLS_FONT = {\n  memberSize: 11,\n  memberWeight: 400,\n  annotationSize: 10,\n  annotationWeight: 500,\n} as const\n\n/**\n * Render a positioned class diagram as an SVG string.\n *\n * @param colors - DiagramColors with bg/fg and optional enrichment variables.\n * @param transparent - If true, renders with transparent background.\n */\nexport function renderClassSvg(\n  diagram: PositionedClassDiagram,\n  colors: DiagramColors,\n  font: string = 'Inter',\n  transparent: boolean = false\n): string {\n  const parts: string[] = []\n\n  // SVG root with CSS variables + style block (with mono font) + defs\n  parts.push(svgOpenTag(diagram.width, diagram.height, colors, transparent))\n  parts.push(buildStyleBlock(font, true))\n  parts.push('<defs>')\n  parts.push(relationshipMarkerDefs())\n  parts.push('</defs>')\n\n  // 1. Relationship lines (rendered behind boxes)\n  for (const rel of diagram.relationships) {\n    parts.push(renderRelationship(rel))\n  }\n\n  // 2. Class boxes\n  for (const cls of diagram.classes) {\n    parts.push(renderClassBox(cls))\n  }\n\n  // 3. Relationship labels and cardinality\n  for (const rel of diagram.relationships) {\n    parts.push(renderRelationshipLabels(rel))\n  }\n\n  parts.push('</svg>')\n  return parts.join('\\n')\n}\n\n// ============================================================================\n// Marker definitions\n// ============================================================================\n\n/**\n * Marker definitions for class relationship endpoints.\n * Each relationship type has a distinct marker:\n *   - inheritance: hollow triangle\n *   - composition: filled diamond\n *   - aggregation: hollow diamond\n *   - association: open arrow (simple >)\n *   - dependency: open arrow (simple >)\n *   - realization: hollow triangle (same as inheritance)\n *\n * Uses var(--_arrow) for fill/stroke and var(--bg) for hollow marker fills.\n */\nfunction relationshipMarkerDefs(): string {\n  return (\n    // Hollow triangle (inheritance, realization) — points at target\n    `  <marker id=\"cls-inherit\" markerWidth=\"12\" markerHeight=\"10\" refX=\"12\" refY=\"5\" orient=\"auto-start-reverse\">` +\n    `\\n    <polygon points=\"0 0, 12 5, 0 10\" fill=\"var(--bg)\" stroke=\"var(--_arrow)\" stroke-width=\"1.5\" />` +\n    `\\n  </marker>` +\n    // Filled diamond (composition) — points at source\n    `\\n  <marker id=\"cls-composition\" markerWidth=\"12\" markerHeight=\"10\" refX=\"0\" refY=\"5\" orient=\"auto-start-reverse\">` +\n    `\\n    <polygon points=\"6 0, 12 5, 6 10, 0 5\" fill=\"var(--_arrow)\" stroke=\"var(--_arrow)\" stroke-width=\"1\" />` +\n    `\\n  </marker>` +\n    // Hollow diamond (aggregation) — points at source\n    `\\n  <marker id=\"cls-aggregation\" markerWidth=\"12\" markerHeight=\"10\" refX=\"0\" refY=\"5\" orient=\"auto-start-reverse\">` +\n    `\\n    <polygon points=\"6 0, 12 5, 6 10, 0 5\" fill=\"var(--bg)\" stroke=\"var(--_arrow)\" stroke-width=\"1.5\" />` +\n    `\\n  </marker>` +\n    // Open arrow (association, dependency)\n    `\\n  <marker id=\"cls-arrow\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto-start-reverse\">` +\n    `\\n    <polyline points=\"0 0, 8 3, 0 6\" fill=\"none\" stroke=\"var(--_arrow)\" stroke-width=\"1.5\" />` +\n    `\\n  </marker>`\n  )\n}\n\n// ============================================================================\n// Class box rendering\n// ============================================================================\n\n/**\n * Render a class box with 3 compartments: header, attributes, methods.\n * Wrapped in <g class=\"class-node\"> with semantic data attributes.\n */\nfunction renderClassBox(cls: PositionedClassNode): string {\n  const { x, y, width, height, headerHeight, attrHeight, methodHeight } = cls\n  const parts: string[] = []\n\n  // Semantic wrapper with class metadata\n  // data-id: class identifier\n  // data-label: class name\n  // data-annotation: stereotype (interface, abstract, etc.)\n  const annotationAttr = cls.annotation ? ` data-annotation=\"${escapeAttr(cls.annotation)}\"` : ''\n  parts.push(\n    `<g class=\"class-node\" data-id=\"${escapeAttr(cls.id)}\" data-label=\"${escapeAttr(cls.label)}\"${annotationAttr}>`\n  )\n\n  // Outer rectangle (full box)\n  parts.push(\n    `  <rect x=\"${x}\" y=\"${y}\" width=\"${width}\" height=\"${height}\" ` +\n    `rx=\"0\" ry=\"0\" fill=\"var(--_node-fill)\" stroke=\"var(--_node-stroke)\" stroke-width=\"${STROKE_WIDTHS.outerBox}\" />`\n  )\n\n  // Header background\n  parts.push(\n    `  <rect x=\"${x}\" y=\"${y}\" width=\"${width}\" height=\"${headerHeight}\" ` +\n    `rx=\"0\" ry=\"0\" fill=\"var(--_group-hdr)\" stroke=\"var(--_node-stroke)\" stroke-width=\"${STROKE_WIDTHS.outerBox}\" />`\n  )\n\n  // Annotation (<<interface>>, <<abstract>>, etc.)\n  let nameY = y + headerHeight / 2\n  if (cls.annotation) {\n    const annotY = y + 12\n    parts.push(\n      `  <text x=\"${x + width / 2}\" y=\"${annotY}\" text-anchor=\"middle\" dy=\"${TEXT_BASELINE_SHIFT}\" ` +\n      `font-size=\"${CLS_FONT.annotationSize}\" font-weight=\"${CLS_FONT.annotationWeight}\" ` +\n      `font-style=\"italic\" fill=\"var(--_text-muted)\">&lt;&lt;${escapeXml(cls.annotation)}&gt;&gt;</text>`\n    )\n    nameY = y + headerHeight / 2 + 6\n  }\n\n  // Class name (supports multi-line via <br> tags)\n  parts.push(\n    '  ' + renderMultilineText(\n      cls.label,\n      x + width / 2,\n      nameY,\n      FONT_SIZES.nodeLabel,\n      `text-anchor=\"middle\" font-size=\"${FONT_SIZES.nodeLabel}\" font-weight=\"700\" fill=\"var(--_text)\"`\n    )\n  )\n\n  // Divider line between header and attributes\n  const attrTop = y + headerHeight\n  parts.push(\n    `  <line x1=\"${x}\" y1=\"${attrTop}\" x2=\"${x + width}\" y2=\"${attrTop}\" ` +\n    `stroke=\"var(--_node-stroke)\" stroke-width=\"${STROKE_WIDTHS.innerBox}\" />`\n  )\n\n  // Attributes\n  const memberRowH = 20\n  for (let i = 0; i < cls.attributes.length; i++) {\n    const member = cls.attributes[i]!\n    const memberY = attrTop + 4 + i * memberRowH + memberRowH / 2\n    parts.push('  ' + renderMember(member, x + CLS.boxPadX, memberY))\n  }\n\n  // Divider line between attributes and methods\n  const methodTop = attrTop + attrHeight\n  parts.push(\n    `  <line x1=\"${x}\" y1=\"${methodTop}\" x2=\"${x + width}\" y2=\"${methodTop}\" ` +\n    `stroke=\"var(--_node-stroke)\" stroke-width=\"${STROKE_WIDTHS.innerBox}\" />`\n  )\n\n  // Methods\n  for (let i = 0; i < cls.methods.length; i++) {\n    const member = cls.methods[i]!\n    const memberY = methodTop + 4 + i * memberRowH + memberRowH / 2\n    parts.push('  ' + renderMember(member, x + CLS.boxPadX, memberY))\n  }\n\n  parts.push('</g>')\n\n  return parts.join('\\n')\n}\n\n/**\n * Render a single class member with syntax highlighting.\n * Uses <tspan> elements to color each part of the member differently:\n *   - visibility symbol (+/-/#/~) → textFaint\n *   - member name (incl. parens for methods) → textSecondary\n *   - colon separator → textFaint\n *   - type annotation → textMuted\n */\nfunction renderMember(member: ClassMember, x: number, y: number): string {\n  const fontStyle = member.isAbstract ? ' font-style=\"italic\"' : ''\n  const decoration = member.isStatic ? ' text-decoration=\"underline\"' : ''\n\n  // Build tspan parts for syntax-highlighted member text\n  const spans: string[] = []\n\n  if (member.visibility) {\n    spans.push(`<tspan fill=\"var(--_text-faint)\">${escapeXml(member.visibility)} </tspan>`)\n  }\n\n  // Add parentheses for methods to distinguish from attributes, including parameters if present\n  const displayName = member.isMethod\n    ? `${member.name}(${member.params || ''})`\n    : member.name\n  spans.push(`<tspan fill=\"var(--_text-sec)\">${escapeXml(displayName)}</tspan>`)\n\n  if (member.type) {\n    spans.push(`<tspan fill=\"var(--_text-faint)\">: </tspan>`)\n    spans.push(`<tspan fill=\"var(--_text-muted)\">${escapeXml(member.type)}</tspan>`)\n  }\n\n  return (\n    `<text x=\"${x}\" y=\"${y}\" class=\"mono\" dy=\"${TEXT_BASELINE_SHIFT}\" ` +\n    `font-size=\"${CLS_FONT.memberSize}\" font-weight=\"${CLS_FONT.memberWeight}\"${fontStyle}${decoration}>` +\n    `${spans.join('')}</text>`\n  )\n}\n\n// ============================================================================\n// Relationship rendering\n// ============================================================================\n\n/**\n * Render a relationship line with appropriate markers and semantic attributes.\n * Includes data-* attributes for programmatic inspection.\n */\nfunction renderRelationship(rel: PositionedClassRelationship): string {\n  if (rel.points.length < 2) return ''\n\n  const pathData = rel.points.map(p => `${p.x},${p.y}`).join(' ')\n  const isDashed = rel.type === 'dependency' || rel.type === 'realization'\n  const dashArray = isDashed ? ' stroke-dasharray=\"6 4\"' : ''\n\n  // Determine markers based on relationship type and which end has the marker\n  const markers = getRelationshipMarkers(rel.type, rel.markerAt)\n\n  // Build semantic data attributes for relationship inspection:\n  // - class=\"class-relationship\": CSS targeting\n  // - data-from/data-to: source and target class IDs\n  // - data-type: relationship type (inheritance, composition, etc.)\n  // - data-marker-at: which end has the marker (from/to)\n  // - data-from-cardinality/data-to-cardinality: multiplicity if present\n  // - data-label: relationship label if present\n  const dataAttrs = [\n    'class=\"class-relationship\"',\n    `data-from=\"${escapeAttr(rel.from)}\"`,\n    `data-to=\"${escapeAttr(rel.to)}\"`,\n    `data-type=\"${rel.type}\"`,\n    `data-marker-at=\"${rel.markerAt}\"`,\n  ]\n  if (rel.label) {\n    dataAttrs.push(`data-label=\"${escapeAttr(rel.label)}\"`)\n  }\n  if (rel.fromCardinality) {\n    dataAttrs.push(`data-from-cardinality=\"${escapeAttr(rel.fromCardinality)}\"`)\n  }\n  if (rel.toCardinality) {\n    dataAttrs.push(`data-to-cardinality=\"${escapeAttr(rel.toCardinality)}\"`)\n  }\n\n  return (\n    `<polyline ${dataAttrs.join(' ')} points=\"${pathData}\" fill=\"none\" stroke=\"var(--_line)\" ` +\n    `stroke-width=\"${STROKE_WIDTHS.connector}\"${dashArray}${markers} />`\n  )\n}\n\n/**\n * Get marker-start/marker-end attributes for a relationship type.\n * Uses `markerAt` from the parser to place the marker on the correct end:\n *   - 'from' → marker-start (prefix arrows like `<|--`, `*--`, `o--`)\n *   - 'to'   → marker-end   (suffix arrows like `..|>`, `-->`, `--*`)\n */\nfunction getRelationshipMarkers(type: RelationshipType, markerAt: 'from' | 'to'): string {\n  const markerId = getMarkerDefId(type)\n  if (!markerId) return ''\n\n  if (markerAt === 'from') {\n    return ` marker-start=\"url(#${markerId})\"`\n  } else {\n    return ` marker-end=\"url(#${markerId})\"`\n  }\n}\n\n/** Map relationship type to its SVG marker definition ID */\nfunction getMarkerDefId(type: RelationshipType): string | null {\n  switch (type) {\n    case 'inheritance':\n    case 'realization':\n      return 'cls-inherit'\n    case 'composition':\n      return 'cls-composition'\n    case 'aggregation':\n      return 'cls-aggregation'\n    case 'association':\n    case 'dependency':\n      return 'cls-arrow'\n    default:\n      return null\n  }\n}\n\n/** Render relationship labels and cardinality text (supports multi-line) */\nfunction renderRelationshipLabels(rel: PositionedClassRelationship): string {\n  if (!rel.label && !rel.fromCardinality && !rel.toCardinality) return ''\n  if (rel.points.length < 2) return ''\n\n  const parts: string[] = []\n\n  // Label — prefer layout-computed position (collision-aware), fall back to midpoint\n  if (rel.label) {\n    const pos = rel.labelPosition ?? midpoint(rel.points)\n    parts.push(\n      renderMultilineText(rel.label, pos.x, pos.y - 8, FONT_SIZES.edgeLabel,\n        `font-size=\"${FONT_SIZES.edgeLabel}\" text-anchor=\"middle\" font-weight=\"${FONT_WEIGHTS.edgeLabel}\" fill=\"var(--_text-muted)\"`)\n    )\n  }\n\n  // From cardinality (near start)\n  if (rel.fromCardinality) {\n    const p = rel.points[0]!\n    const next = rel.points[1]!\n    const offset = cardinalityOffset(p, next)\n    parts.push(\n      renderMultilineText(rel.fromCardinality, p.x + offset.x, p.y + offset.y, FONT_SIZES.edgeLabel,\n        `font-size=\"${FONT_SIZES.edgeLabel}\" text-anchor=\"middle\" font-weight=\"${FONT_WEIGHTS.edgeLabel}\" fill=\"var(--_text-muted)\"`)\n    )\n  }\n\n  // To cardinality (near end)\n  if (rel.toCardinality) {\n    const p = rel.points[rel.points.length - 1]!\n    const prev = rel.points[rel.points.length - 2]!\n    const offset = cardinalityOffset(p, prev)\n    parts.push(\n      renderMultilineText(rel.toCardinality, p.x + offset.x, p.y + offset.y, FONT_SIZES.edgeLabel,\n        `font-size=\"${FONT_SIZES.edgeLabel}\" text-anchor=\"middle\" font-weight=\"${FONT_WEIGHTS.edgeLabel}\" fill=\"var(--_text-muted)\"`)\n    )\n  }\n\n  return parts.join('\\n')\n}\n\n/** Get the midpoint of a point array */\nfunction midpoint(points: Array<{ x: number; y: number }>): { x: number; y: number } {\n  if (points.length === 0) return { x: 0, y: 0 }\n  const mid = Math.floor(points.length / 2)\n  return points[mid]!\n}\n\n/** Calculate offset for cardinality label perpendicular to edge direction */\nfunction cardinalityOffset(\n  from: { x: number; y: number },\n  to: { x: number; y: number }\n): { x: number; y: number } {\n  const dx = to.x - from.x\n  const dy = to.y - from.y\n  // Place label perpendicular to the edge, 14px away\n  if (Math.abs(dx) > Math.abs(dy)) {\n    // Mostly horizontal — offset vertically\n    return { x: dx > 0 ? 14 : -14, y: -10 }\n  }\n  // Mostly vertical — offset horizontally\n  return { x: -14, y: dy > 0 ? 14 : -14 }\n}\n\n// ============================================================================\n// Utilities\n// ============================================================================\n\n// Use shared escapeXml from multiline-utils\nconst escapeXml = escapeXmlUtil\n\n/**\n * Escape a string for use as an XML/HTML attribute value.\n * Escapes quotes and ampersands to prevent attribute injection.\n */\nfunction escapeAttr(value: string): string {\n  return value\n    .replace(/&/g, '&amp;')\n    .replace(/\"/g, '&quot;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n}\n"
  },
  {
    "path": "src/class/types.ts",
    "content": "// ============================================================================\n// Class diagram types\n//\n// Models the parsed and positioned representations of a Mermaid class diagram.\n// Class diagrams show UML class relationships, inheritance, composition, etc.\n// ============================================================================\n\n/** Parsed class diagram — logical structure from mermaid text */\nexport interface ClassDiagram {\n  /** All class definitions */\n  classes: ClassNode[]\n  /** Relationships between classes */\n  relationships: ClassRelationship[]\n  /** Optional namespace groupings */\n  namespaces: ClassNamespace[]\n}\n\nexport interface ClassNode {\n  id: string\n  label: string\n  /** Annotation like <<interface>>, <<abstract>>, <<service>>, <<enumeration>> */\n  annotation?: string\n  /** Class attributes (fields/properties) */\n  attributes: ClassMember[]\n  /** Class methods (functions) */\n  methods: ClassMember[]\n}\n\nexport interface ClassMember {\n  /** Visibility: + public, - private, # protected, ~ package */\n  visibility: '+' | '-' | '#' | '~' | ''\n  /** Member name */\n  name: string\n  /** Type annotation (e.g., \"String\", \"int\", \"void\") */\n  type?: string\n  /** Whether the member is static (underlined in UML) */\n  isStatic?: boolean\n  /** Whether the member is abstract (italic in UML) */\n  isAbstract?: boolean\n  /** Whether the member is a method (renders with parentheses) */\n  isMethod?: boolean\n  /** Method parameters (e.g., \"data\", \"key, val\") — only for methods */\n  params?: string\n}\n\n/** Relationship types following UML conventions */\nexport type RelationshipType =\n  | 'inheritance'   // A <|-- B   (solid line, hollow triangle)\n  | 'composition'   // A *-- B    (solid line, filled diamond)\n  | 'aggregation'   // A o-- B    (solid line, hollow diamond)\n  | 'association'   // A --> B    (solid line, open arrow)\n  | 'dependency'    // A ..> B    (dashed line, open arrow)\n  | 'realization'   // A ..|> B   (dashed line, hollow triangle)\n\nexport interface ClassRelationship {\n  from: string\n  to: string\n  type: RelationshipType\n  /**\n   * Which end of the relationship line has the UML marker (triangle, diamond, arrow).\n   * Determined by the arrow syntax direction:\n   *   - Prefix markers like `<|--`, `*--`, `o--` → 'from' (marker on left/from side)\n   *   - Suffix markers like `..|>`, `-->`, `..>`, `--*`, `--o` → 'to' (marker on right/to side)\n   */\n  markerAt: 'from' | 'to'\n  /** Label on the relationship line */\n  label?: string\n  /** Cardinality at the \"from\" end (e.g., \"1\", \"*\", \"0..1\") */\n  fromCardinality?: string\n  /** Cardinality at the \"to\" end */\n  toCardinality?: string\n}\n\nexport interface ClassNamespace {\n  name: string\n  classIds: string[]\n}\n\n// ============================================================================\n// Positioned class diagram — ready for SVG rendering\n// ============================================================================\n\nexport interface PositionedClassDiagram {\n  width: number\n  height: number\n  classes: PositionedClassNode[]\n  relationships: PositionedClassRelationship[]\n}\n\nexport interface PositionedClassNode {\n  id: string\n  label: string\n  annotation?: string\n  attributes: ClassMember[]\n  methods: ClassMember[]\n  x: number\n  y: number\n  width: number\n  height: number\n  /** Height of the header section (name + annotation) */\n  headerHeight: number\n  /** Height of the attributes section */\n  attrHeight: number\n  /** Height of the methods section */\n  methodHeight: number\n}\n\nexport interface PositionedClassRelationship {\n  from: string\n  to: string\n  type: RelationshipType\n  /** Which end of the line has the UML marker — propagated from ClassRelationship */\n  markerAt: 'from' | 'to'\n  label?: string\n  fromCardinality?: string\n  toCardinality?: string\n  /** Path points from source to target */\n  points: Array<{ x: number; y: number }>\n  /** Dagre-computed label center position (avoids overlaps between nearby edges) */\n  labelPosition?: { x: number; y: number }\n}\n"
  },
  {
    "path": "src/elk-instance.ts",
    "content": "/**\n * Shared ELK instance singleton.\n *\n * Uses elk.bundled.js (pure synchronous JS, ~1.6 MB) for all environments.\n * The singleton is created lazily on first use and cached forever.\n *\n * ELK's FakeWorker wraps both postMessage and onmessage in setTimeout(0),\n * making the normal API fully async. To bypass this:\n *   1. During construction, we capture setTimeout(0) callbacks and flush them\n *      synchronously — this registers the layout algorithms immediately.\n *   2. For layout calls, we call dispatcher.saveDispatch() directly (skipping\n *      the FakeWorker's postMessage setTimeout) and intercept the result via\n *      rawWorker.onmessage (which the dispatcher calls synchronously).\n */\n\nimport type { ElkNode } from 'elkjs'\n// @ts-ignore — static import of bundled ELK\nimport ELKBundled from 'elkjs/lib/elk.bundled.js'\n\ninterface RawFakeWorker {\n  postMessage(msg: unknown): void\n  onmessage: ((e: { data: Record<string, unknown> }) => void) | null\n  dispatcher: {\n    saveDispatch(msg: { data: Record<string, unknown> }): void\n  }\n}\n\nlet elk: unknown = null\nlet rawWorker: RawFakeWorker | null = null\n\n/**\n * Ensure the ELK singleton exists.\n *\n * Patches setTimeout during construction to capture and synchronously flush\n * the algorithm registration callback that ELK queues via setTimeout(0).\n * Without this, layout calls fail with \"algorithm not found\" until the\n * next macrotask.\n */\nfunction ensureElk(): void {\n  if (elk) return\n\n  // Capture setTimeout(0) callbacks queued during ELK construction\n  const pending: (() => void)[] = []\n  const origSetTimeout = globalThis.setTimeout\n  // @ts-ignore — simplified signature for our interception\n  globalThis.setTimeout = (fn: () => void, delay?: number) => {\n    if (delay === 0) { pending.push(fn); return 0 }\n    return origSetTimeout(fn, delay)\n  }\n\n  // Bun defines `self` (= globalThis) but not `document`, which tricks\n  // elk-worker.min.js into taking the Web Worker branch instead of the\n  // CJS branch. Temporarily hide `self` so it exports {Worker: FakeWorker}.\n  const g = globalThis as Record<string, unknown>\n  const hadSelf = 'self' in g\n  const origSelf = g.self\n  if (hadSelf && typeof g.document === 'undefined') {\n    delete g.self\n  }\n\n  elk = new ELKBundled()\n\n  // Restore self\n  if (hadSelf) g.self = origSelf\n\n  // Restore setTimeout immediately\n  globalThis.setTimeout = origSetTimeout\n\n  // Flush captured callbacks synchronously — registers layout algorithms\n  pending.forEach(fn => fn())\n\n  // Cache the raw FakeWorker for elkLayoutSync()\n  rawWorker = (elk as unknown as { worker: { worker: RawFakeWorker } }).worker.worker\n}\n\n/**\n * Run ELK layout synchronously.\n *\n * Bypasses BOTH of ELK's setTimeout(0) wrappers:\n *   - FakeWorker.postMessage wraps dispatch in setTimeout(0) — bypassed by\n *     calling dispatcher.saveDispatch() directly\n *   - PromisedWorker.onmessage wraps receive in setTimeout(0) — bypassed by\n *     replacing rawWorker.onmessage with a direct interceptor\n */\nexport function elkLayoutSync(graph: ElkNode): ElkNode {\n  ensureElk()\n\n  let result: ElkNode | undefined\n  let error: unknown\n\n  // Replace onmessage to intercept the result synchronously\n  // (the dispatcher calls this directly, without setTimeout)\n  const origOnmessage = rawWorker!.onmessage\n  rawWorker!.onmessage = (answer: { data: Record<string, unknown> }) => {\n    if (answer.data.error) {\n      error = answer.data.error\n    } else {\n      result = answer.data.data as ElkNode\n    }\n  }\n\n  // Call dispatcher.saveDispatch directly — bypasses FakeWorker.postMessage's\n  // setTimeout(0) wrapper. The dispatcher processes the layout synchronously\n  // and calls rawWorker.onmessage with the result.\n  rawWorker!.dispatcher.saveDispatch({ data: { id: 0, cmd: 'layout', graph } as unknown as Record<string, unknown> })\n\n  // Restore original handler\n  rawWorker!.onmessage = origOnmessage\n\n  if (error) throw error\n  if (!result) throw new Error('ELK layout did not return synchronously')\n  return result\n}\n"
  },
  {
    "path": "src/er/layout.ts",
    "content": "/**\n * ER diagram layout engine (ELK.js).\n *\n * Each entity box has:\n *   1. Header (entity name)\n *   2. Attribute rows (type, name, keys)\n */\n\nimport type { ElkNode, ElkExtendedEdge } from 'elkjs'\nimport type { ErDiagram, ErEntity, PositionedErDiagram, PositionedErEntity, PositionedErRelationship } from './types.ts'\nimport type { RenderOptions, Point } from '../types.ts'\nimport { estimateTextWidth, estimateMonoTextWidth, FONT_SIZES, FONT_WEIGHTS } from '../styles.ts'\nimport { measureMultilineText } from '../text-metrics.ts'\nimport { elkLayoutSync } from '../elk-instance.ts'\n\n/** Layout constants for ER diagrams */\nconst ER = {\n  padding: 40,\n  boxPadX: 14,\n  headerHeight: 34,\n  rowHeight: 22,\n  minWidth: 140,\n  attrFontSize: 11,\n  attrFontWeight: 400,\n  nodeSpacing: 70,\n  layerSpacing: 90,\n} as const\n\ntype EntitySizeMap = Map<string, { width: number; height: number }>\n\n/** Build ELK graph and size map from an ER diagram. */\nfunction buildErElkGraph(\n  diagram: ErDiagram,\n  _options: RenderOptions\n): { elkGraph: ElkNode; entitySizes: EntitySizeMap } {\n  const entitySizes: EntitySizeMap = new Map()\n\n  for (const entity of diagram.entities) {\n    const headerTextW = estimateTextWidth(entity.label, FONT_SIZES.nodeLabel, FONT_WEIGHTS.nodeLabel)\n    let maxAttrW = 0\n    for (const attr of entity.attributes) {\n      const attrText = `${attr.type}  ${attr.name}${attr.keys.length > 0 ? '  ' + attr.keys.join(',') : ''}`\n      const w = estimateMonoTextWidth(attrText, ER.attrFontSize)\n      if (w > maxAttrW) maxAttrW = w\n    }\n    const width = Math.max(ER.minWidth, headerTextW + ER.boxPadX * 2, maxAttrW + ER.boxPadX * 2)\n    const height = ER.headerHeight + Math.max(entity.attributes.length, 1) * ER.rowHeight\n    entitySizes.set(entity.id, { width, height })\n  }\n\n  const elkGraph: ElkNode = {\n    id: 'root',\n    layoutOptions: {\n      'elk.algorithm': 'layered',\n      'elk.direction': 'RIGHT',\n      'elk.spacing.nodeNode': String(ER.nodeSpacing),\n      'elk.layered.spacing.nodeNodeBetweenLayers': String(ER.layerSpacing),\n      'elk.padding': `[top=${ER.padding},left=${ER.padding},bottom=${ER.padding},right=${ER.padding}]`,\n      'elk.edgeRouting': 'ORTHOGONAL',\n      'elk.edgeLabels.placement': 'CENTER',\n    },\n    children: [],\n    edges: [],\n  }\n\n  for (const entity of diagram.entities) {\n    const size = entitySizes.get(entity.id)!\n    elkGraph.children!.push({ id: entity.id, width: size.width, height: size.height })\n  }\n\n  for (let i = 0; i < diagram.relationships.length; i++) {\n    const rel = diagram.relationships[i]!\n    const metrics = measureMultilineText(rel.label, FONT_SIZES.edgeLabel, FONT_WEIGHTS.edgeLabel)\n    const edge: ElkExtendedEdge = { id: `e${i}`, sources: [rel.entity1], targets: [rel.entity2] }\n    if (rel.label) {\n      edge.labels = [{ text: rel.label, width: metrics.width + 8, height: metrics.height + 6 }]\n    }\n    elkGraph.edges!.push(edge)\n  }\n\n  return { elkGraph, entitySizes }\n}\n\n/** Extract positioned entities and relationships from ELK result. */\nfunction extractErLayout(\n  result: ElkNode,\n  diagram: ErDiagram,\n  entitySizes: EntitySizeMap\n): PositionedErDiagram {\n  const entityLookup = new Map<string, ErEntity>()\n  for (const entity of diagram.entities) entityLookup.set(entity.id, entity)\n\n  const positionedEntities: PositionedErEntity[] = []\n  for (const child of result.children ?? []) {\n    const entity = entityLookup.get(child.id)\n    if (entity) {\n      positionedEntities.push({\n        id: entity.id,\n        label: entity.label,\n        attributes: entity.attributes,\n        x: child.x ?? 0,\n        y: child.y ?? 0,\n        width: child.width ?? entitySizes.get(entity.id)!.width,\n        height: child.height ?? entitySizes.get(entity.id)!.height,\n        headerHeight: ER.headerHeight,\n        rowHeight: ER.rowHeight,\n      })\n    }\n  }\n\n  const relationships: PositionedErRelationship[] = []\n  for (let i = 0; i < (result.edges?.length ?? 0); i++) {\n    const elkEdge = result.edges![i]!\n    const rel = diagram.relationships[i]!\n\n    const points: Point[] = []\n    if (elkEdge.sections && elkEdge.sections.length > 0) {\n      const section = elkEdge.sections[0]!\n      points.push({ x: section.startPoint.x, y: section.startPoint.y })\n      if (section.bendPoints) {\n        for (const bp of section.bendPoints) {\n          points.push({ x: bp.x, y: bp.y })\n        }\n      }\n      points.push({ x: section.endPoint.x, y: section.endPoint.y })\n    }\n\n    relationships.push({\n      entity1: rel.entity1,\n      entity2: rel.entity2,\n      cardinality1: rel.cardinality1,\n      cardinality2: rel.cardinality2,\n      label: rel.label,\n      identifying: rel.identifying,\n      points,\n    })\n  }\n\n  return {\n    width: result.width ?? 600,\n    height: result.height ?? 400,\n    entities: positionedEntities,\n    relationships,\n  }\n}\n\n/**\n * Lay out a parsed ER diagram using ELK.js (synchronous).\n */\nexport function layoutErDiagramSync(\n  diagram: ErDiagram,\n  options: RenderOptions = {}\n): PositionedErDiagram {\n  if (diagram.entities.length === 0) {\n    return { width: 0, height: 0, entities: [], relationships: [] }\n  }\n\n  const { elkGraph, entitySizes } = buildErElkGraph(diagram, options)\n  const result = elkLayoutSync(elkGraph)\n  return extractErLayout(result, diagram, entitySizes)\n}\n"
  },
  {
    "path": "src/er/parser.ts",
    "content": "import type { ErDiagram, ErEntity, ErAttribute, ErRelationship, Cardinality } from './types.ts'\nimport { normalizeBrTags } from '../multiline-utils.ts'\n\n// ============================================================================\n// ER diagram parser\n//\n// Parses Mermaid erDiagram syntax into an ErDiagram structure.\n//\n// Supported syntax:\n//   CUSTOMER ||--o{ ORDER : places\n//   CUSTOMER {\n//     string name PK\n//     int age\n//     string email UK \"user email\"\n//   }\n//\n// Cardinality notation:\n//   ||  exactly one\n//   o|  zero or one (also |o)\n//   }|  one or more (also |{)\n//   o{  zero or more (also {o)\n//\n// Line style:\n//   --  identifying (solid line)\n//   ..  non-identifying (dashed line)\n// ============================================================================\n\n/**\n * Parse a Mermaid ER diagram.\n * Expects the first line to be \"erDiagram\".\n */\nexport function parseErDiagram(lines: string[]): ErDiagram {\n  const diagram: ErDiagram = {\n    entities: [],\n    relationships: [],\n  }\n\n  // Track entities by ID for deduplication\n  const entityMap = new Map<string, ErEntity>()\n  // Track entity body parsing\n  let currentEntity: ErEntity | null = null\n\n  for (let i = 1; i < lines.length; i++) {\n    const line = lines[i]!\n\n    // --- Inside entity body ---\n    if (currentEntity) {\n      if (line === '}') {\n        currentEntity = null\n        continue\n      }\n\n      // Attribute line: type name [PK|FK|UK] [\"comment\"]\n      const attr = parseAttribute(line)\n      if (attr) {\n        currentEntity.attributes.push(attr)\n      }\n      continue\n    }\n\n    // --- Entity block start: `ENTITY_NAME {` ---\n    const entityBlockMatch = line.match(/^(\\S+)\\s*\\{$/)\n    if (entityBlockMatch) {\n      const id = entityBlockMatch[1]!\n      const entity = ensureEntity(entityMap, id)\n      currentEntity = entity\n      continue\n    }\n\n    // --- Relationship: `ENTITY1 cardinality1--cardinality2 ENTITY2 : label` ---\n    const rel = parseRelationshipLine(line)\n    if (rel) {\n      // Ensure both entities exist\n      ensureEntity(entityMap, rel.entity1)\n      ensureEntity(entityMap, rel.entity2)\n      diagram.relationships.push(rel)\n      continue\n    }\n  }\n\n  diagram.entities = [...entityMap.values()]\n  return diagram\n}\n\n/** Ensure an entity exists in the map */\nfunction ensureEntity(entityMap: Map<string, ErEntity>, id: string): ErEntity {\n  let entity = entityMap.get(id)\n  if (!entity) {\n    entity = { id, label: id, attributes: [] }\n    entityMap.set(id, entity)\n  }\n  return entity\n}\n\n/** Parse an attribute line inside an entity block */\nfunction parseAttribute(line: string): ErAttribute | null {\n  // Format: type name [PK|FK|UK [...]] [\"comment\"]\n  const match = line.match(/^(\\S+)\\s+(\\S+)(?:\\s+(.+))?$/)\n  if (!match) return null\n\n  const type = match[1]!\n  const name = match[2]!\n  const rest = match[3]?.trim() ?? ''\n\n  // Extract key constraints (PK, FK, UK) and optional comment\n  const keys: ErAttribute['keys'] = []\n  let comment: string | undefined\n\n  // Extract quoted comment first (supports <br> tags)\n  const commentMatch = rest.match(/\"([^\"]*)\"/)\n  if (commentMatch) {\n    comment = normalizeBrTags(commentMatch[1]!)\n  }\n\n  // Extract key constraints\n  const restWithoutComment = rest.replace(/\"[^\"]*\"/, '').trim()\n  for (const part of restWithoutComment.split(/\\s+/)) {\n    const upper = part.toUpperCase()\n    if (upper === 'PK' || upper === 'FK' || upper === 'UK') {\n      keys.push(upper as 'PK' | 'FK' | 'UK')\n    }\n  }\n\n  return { type, name, keys, comment }\n}\n\n/**\n * Parse a relationship line.\n *\n * Cardinality symbols on each side of the line style:\n *   Left side (entity1):  ||  |o  o|  }|  |{  o{  {o\n *   Line:                 --  (identifying) or  ..  (non-identifying)\n *   Right side (entity2): ||  o|  |o  |{  }|  {o  o{\n *\n * Full pattern example: CUSTOMER ||--o{ ORDER : places\n */\nfunction parseRelationshipLine(line: string): ErRelationship | null {\n  // Match: ENTITY1 <cardinality_and_line> ENTITY2 : label\n  const match = line.match(/^(\\S+)\\s+([|o}{]+(?:--|\\.\\.)[|o}{]+)\\s+(\\S+)\\s*:\\s*(.+)$/)\n  if (!match) return null\n\n  const entity1 = match[1]!\n  const cardinalityStr = match[2]!\n  const entity2 = match[3]!\n  // Strip surrounding quotes if present, then normalize br tags\n  const rawLabel = match[4]!.trim().replace(/^[\"']|[\"']$/g, '')\n  const label = normalizeBrTags(rawLabel)\n\n  // Split the cardinality string into left side, line style, right side\n  const lineMatch = cardinalityStr.match(/^([|o}{]+)(--|\\.\\.?)([|o}{]+)$/)\n  if (!lineMatch) return null\n\n  const leftStr = lineMatch[1]!\n  const lineStyle = lineMatch[2]!\n  const rightStr = lineMatch[3]!\n\n  const cardinality1 = parseCardinality(leftStr)\n  const cardinality2 = parseCardinality(rightStr)\n  const identifying = lineStyle === '--'\n\n  if (!cardinality1 || !cardinality2) return null\n\n  return { entity1, entity2, cardinality1, cardinality2, label, identifying }\n}\n\n/** Parse a cardinality notation string into a Cardinality type */\nfunction parseCardinality(str: string): Cardinality | null {\n  // Normalize: sort the characters to handle both orders (e.g., |o and o|)\n  const sorted = str.split('').sort().join('')\n\n  // Exact one: || → sorted \"||\"\n  if (sorted === '||') return 'one'\n  // Zero or one: o| or |o → sorted \"o|\" (o=111 < |=124 in char codes)\n  if (sorted === 'o|') return 'zero-one'\n  // One or more: }| or |{ → sorted \"|}\" or \"{|\"\n  if (sorted === '|}' || sorted === '{|') return 'many'\n  // Zero or more: o{ or {o → sorted \"{o\" or \"o{\"\n  if (sorted === '{o' || sorted === 'o{') return 'zero-many'\n\n  return null\n}\n"
  },
  {
    "path": "src/er/renderer.ts",
    "content": "import type { PositionedErDiagram, PositionedErEntity, PositionedErRelationship, ErAttribute, Cardinality } from './types.ts'\nimport type { DiagramColors } from '../theme.ts'\nimport { svgOpenTag, buildStyleBlock } from '../theme.ts'\nimport { FONT_SIZES, FONT_WEIGHTS, STROKE_WIDTHS, estimateTextWidth, TEXT_BASELINE_SHIFT } from '../styles.ts'\nimport { renderMultilineText, escapeXml as escapeXmlUtil } from '../multiline-utils.ts'\nimport { measureMultilineText } from '../text-metrics.ts'\n\n// ============================================================================\n// ER diagram SVG renderer\n//\n// Renders positioned ER diagrams to SVG.\n// All colors use CSS custom properties (var(--_xxx)) from the theme system.\n//\n// Render order:\n//   1. Relationship lines (behind boxes)\n//   2. Entity boxes (header + attribute rows)\n//   3. Cardinality markers (crow's foot notation)\n//   4. Relationship labels\n// ============================================================================\n\n/** Font sizes specific to ER diagrams */\nconst ER_FONT = {\n  attrSize: 11,\n  attrWeight: 400,\n  keySize: 9,\n  keyWeight: 600,\n} as const\n\n/**\n * Render a positioned ER diagram as an SVG string.\n *\n * @param colors - DiagramColors with bg/fg and optional enrichment variables.\n * @param transparent - If true, renders with transparent background.\n */\nexport function renderErSvg(\n  diagram: PositionedErDiagram,\n  colors: DiagramColors,\n  font: string = 'Inter',\n  transparent: boolean = false\n): string {\n  const parts: string[] = []\n\n  // SVG root with CSS variables + style block (with mono font) + defs\n  parts.push(svgOpenTag(diagram.width, diagram.height, colors, transparent))\n  parts.push(buildStyleBlock(font, true))\n  parts.push('<defs>')\n  parts.push('</defs>') // No marker defs — we draw crow's foot inline\n\n  // 1. Relationship lines\n  for (const rel of diagram.relationships) {\n    parts.push(renderRelationshipLine(rel))\n  }\n\n  // 2. Entity boxes\n  for (const entity of diagram.entities) {\n    parts.push(renderEntityBox(entity))\n  }\n\n  // 3. Cardinality markers at relationship endpoints\n  for (const rel of diagram.relationships) {\n    parts.push(renderCardinality(rel))\n  }\n\n  // 4. Relationship labels\n  for (const rel of diagram.relationships) {\n    parts.push(renderRelationshipLabel(rel))\n  }\n\n  parts.push('</svg>')\n  return parts.join('\\n')\n}\n\n// ============================================================================\n// Entity box rendering\n// ============================================================================\n\n/**\n * Render an entity box with header and attribute rows.\n * Wrapped in <g class=\"entity\"> with semantic data attributes.\n */\nfunction renderEntityBox(entity: PositionedErEntity): string {\n  const { id, x, y, width, height, headerHeight, rowHeight, label, attributes } = entity\n  const parts: string[] = []\n\n  // Semantic wrapper with entity metadata\n  parts.push(\n    `<g class=\"entity\" data-id=\"${escapeAttr(id)}\" data-label=\"${escapeAttr(label)}\">`\n  )\n\n  // Outer rectangle\n  parts.push(\n    `  <rect x=\"${x}\" y=\"${y}\" width=\"${width}\" height=\"${height}\" ` +\n    `rx=\"0\" ry=\"0\" fill=\"var(--_node-fill)\" stroke=\"var(--_node-stroke)\" stroke-width=\"${STROKE_WIDTHS.outerBox}\" />`\n  )\n\n  // Header background\n  parts.push(\n    `  <rect x=\"${x}\" y=\"${y}\" width=\"${width}\" height=\"${headerHeight}\" ` +\n    `rx=\"0\" ry=\"0\" fill=\"var(--_group-hdr)\" stroke=\"var(--_node-stroke)\" stroke-width=\"${STROKE_WIDTHS.outerBox}\" />`\n  )\n\n  // Entity name (supports multi-line via <br> tags)\n  parts.push(\n    '  ' + renderMultilineText(\n      label,\n      x + width / 2,\n      y + headerHeight / 2,\n      FONT_SIZES.nodeLabel,\n      `text-anchor=\"middle\" font-size=\"${FONT_SIZES.nodeLabel}\" font-weight=\"700\" fill=\"var(--_text)\"`\n    )\n  )\n\n  // Divider\n  const attrTop = y + headerHeight\n  parts.push(\n    `  <line x1=\"${x}\" y1=\"${attrTop}\" x2=\"${x + width}\" y2=\"${attrTop}\" ` +\n    `stroke=\"var(--_node-stroke)\" stroke-width=\"${STROKE_WIDTHS.innerBox}\" />`\n  )\n\n  // Attribute rows\n  for (let i = 0; i < attributes.length; i++) {\n    const attr = attributes[i]!\n    const rowY = attrTop + i * rowHeight + rowHeight / 2\n    parts.push('  ' + renderAttribute(attr, x, rowY, width).replace(/\\n/g, '\\n  '))\n  }\n\n  // Empty row placeholder when no attributes\n  if (attributes.length === 0) {\n    parts.push(\n      `  <text x=\"${x + width / 2}\" y=\"${attrTop + rowHeight / 2}\" text-anchor=\"middle\" dy=\"${TEXT_BASELINE_SHIFT}\" ` +\n      `font-size=\"${ER_FONT.attrSize}\" fill=\"var(--_text-faint)\" font-style=\"italic\">(no attributes)</text>`\n    )\n  }\n\n  parts.push('</g>')\n  return parts.join('\\n')\n}\n\n/**\n * Render a single attribute row with monospace syntax highlighting.\n * Layout: [PK badge]  type  name  (left-aligned in mono, name right-aligned)\n * Uses <tspan> elements for per-part coloring, matching the class diagram style.\n *\n * Key badge uses var(--_key-badge) for background tint.\n * Comments are shown as tooltips via SVG <title> element.\n */\nfunction renderAttribute(attr: ErAttribute, boxX: number, y: number, boxWidth: number): string {\n  const parts: string[] = []\n\n  // Wrap in a group if there's a comment (for tooltip support)\n  const hasComment = attr.comment && attr.comment.length > 0\n  if (hasComment) {\n    // Replace <br> with newlines for tooltip display\n    const tooltipText = attr.comment!.replace(/<br\\s*\\/?>/gi, '\\n')\n    parts.push(`<g><title>${escapeXml(tooltipText)}</title>`)\n  }\n\n  // Key badges on the left (keep proportional font — they're visual tags, not code)\n  let keyWidth = 0\n  if (attr.keys.length > 0) {\n    const keyText = attr.keys.join(',')\n    keyWidth = estimateTextWidth(keyText, ER_FONT.keySize, ER_FONT.keyWeight) + 8\n    parts.push(\n      `<rect x=\"${boxX + 6}\" y=\"${y - 7}\" width=\"${keyWidth}\" height=\"14\" rx=\"2\" ry=\"2\" ` +\n      `fill=\"var(--_key-badge)\" />`\n    )\n    parts.push(\n      `<text x=\"${boxX + 6 + keyWidth / 2}\" y=\"${y}\" text-anchor=\"middle\" dy=\"${TEXT_BASELINE_SHIFT}\" ` +\n      `font-size=\"${ER_FONT.keySize}\" font-weight=\"${ER_FONT.keyWeight}\" fill=\"var(--_text-sec)\">${attr.keys.join(',')}</text>`\n    )\n  }\n\n  // Type (left-aligned after keys, monospace with syntax highlighting)\n  const typeX = boxX + 8 + (keyWidth > 0 ? keyWidth + 6 : 0)\n  parts.push(\n    `<text x=\"${typeX}\" y=\"${y}\" class=\"mono\" dy=\"${TEXT_BASELINE_SHIFT}\" ` +\n    `font-size=\"${ER_FONT.attrSize}\" font-weight=\"${ER_FONT.attrWeight}\">` +\n    `<tspan fill=\"var(--_text-muted)\">${escapeXml(attr.type)}</tspan></text>`\n  )\n\n  // Name (right-aligned, monospace with syntax highlighting)\n  const nameX = boxX + boxWidth - 8\n  parts.push(\n    `<text x=\"${nameX}\" y=\"${y}\" class=\"mono\" text-anchor=\"end\" dy=\"${TEXT_BASELINE_SHIFT}\" ` +\n    `font-size=\"${ER_FONT.attrSize}\" font-weight=\"${ER_FONT.attrWeight}\">` +\n    `<tspan fill=\"var(--_text-sec)\">${escapeXml(attr.name)}</tspan></text>`\n  )\n\n  // Close the group if we opened one\n  if (hasComment) {\n    parts.push('</g>')\n  }\n\n  return parts.join('\\n')\n}\n\n// ============================================================================\n// Relationship rendering\n// ============================================================================\n\n/**\n * Render a relationship line with semantic data attributes.\n */\nfunction renderRelationshipLine(rel: PositionedErRelationship): string {\n  if (rel.points.length < 2) return ''\n\n  const pathData = rel.points.map(p => `${p.x},${p.y}`).join(' ')\n  const dashArray = !rel.identifying ? ' stroke-dasharray=\"6 4\"' : ''\n\n  // Semantic data attributes for relationship inspection\n  const labelAttr = rel.label ? ` data-label=\"${escapeAttr(rel.label)}\"` : ''\n  const dataAttrs = [\n    'class=\"er-relationship\"',\n    `data-entity1=\"${escapeAttr(rel.entity1)}\"`,\n    `data-entity2=\"${escapeAttr(rel.entity2)}\"`,\n    `data-cardinality1=\"${rel.cardinality1}\"`,\n    `data-cardinality2=\"${rel.cardinality2}\"`,\n    `data-identifying=\"${rel.identifying}\"`,\n  ]\n\n  return (\n    `<polyline ${dataAttrs.join(' ')}${labelAttr} points=\"${pathData}\" fill=\"none\" stroke=\"var(--_line)\" ` +\n    `stroke-width=\"${STROKE_WIDTHS.connector}\"${dashArray} />`\n  )\n}\n\n/** Render a relationship label at the midpoint (supports multi-line) */\nfunction renderRelationshipLabel(rel: PositionedErRelationship): string {\n  if (!rel.label || rel.points.length < 2) return ''\n\n  const mid = midpoint(rel.points)\n  const metrics = measureMultilineText(rel.label, FONT_SIZES.edgeLabel, FONT_WEIGHTS.edgeLabel)\n\n  // Background pill for readability\n  const bgW = metrics.width + 8\n  const bgH = metrics.height + 6\n\n  return (\n    `<rect x=\"${mid.x - bgW / 2}\" y=\"${mid.y - bgH / 2}\" width=\"${bgW}\" height=\"${bgH}\" rx=\"2\" ry=\"2\" ` +\n    `fill=\"var(--bg)\" stroke=\"var(--_inner-stroke)\" stroke-width=\"0.5\" />` +\n    `\\n${renderMultilineText(rel.label, mid.x, mid.y, FONT_SIZES.edgeLabel,\n      `text-anchor=\"middle\" font-size=\"${FONT_SIZES.edgeLabel}\" font-weight=\"${FONT_WEIGHTS.edgeLabel}\" fill=\"var(--_text-muted)\"`)}`\n  )\n}\n\n/**\n * Render crow's foot cardinality markers at both endpoints of a relationship.\n *\n * Crow's foot notation:\n *   'one':       ─║─   (single vertical line)\n *   'zero-one':  ─o║─  (circle + single line)\n *   'many':      ─╢─   (crow's foot + single line)\n *   'zero-many': ─o╣─  (circle + crow's foot)\n */\nfunction renderCardinality(rel: PositionedErRelationship): string {\n  if (rel.points.length < 2) return ''\n  const parts: string[] = []\n\n  // Entity1 side (first point, direction toward second point)\n  const p1 = rel.points[0]!\n  const p2 = rel.points[1]!\n  parts.push(renderCrowsFoot(p1, p2, rel.cardinality1))\n\n  // Entity2 side (last point, direction toward second-to-last point)\n  const pN = rel.points[rel.points.length - 1]!\n  const pN1 = rel.points[rel.points.length - 2]!\n  parts.push(renderCrowsFoot(pN, pN1, rel.cardinality2))\n\n  return parts.join('\\n')\n}\n\n/**\n * Render a crow's foot marker at a given endpoint.\n * `point` is the endpoint, `toward` gives the direction the line comes from.\n */\nfunction renderCrowsFoot(\n  point: { x: number; y: number },\n  toward: { x: number; y: number },\n  cardinality: Cardinality\n): string {\n  const parts: string[] = []\n  const sw = STROKE_WIDTHS.connector + 0.25\n\n  // Calculate direction from toward → point (unit vector)\n  const dx = point.x - toward.x\n  const dy = point.y - toward.y\n  const len = Math.sqrt(dx * dx + dy * dy)\n  if (len === 0) return ''\n  const ux = dx / len\n  const uy = dy / len\n\n  // Perpendicular direction\n  const px = -uy\n  const py = ux\n\n  // Marker sits 4px from the endpoint, extending 12px back along the edge\n  const tipX = point.x - ux * 4\n  const tipY = point.y - uy * 4\n  const backX = point.x - ux * 16\n  const backY = point.y - uy * 16\n\n  // Single line: always present for 'one' and part of others\n  const hasOneLine = cardinality === 'one' || cardinality === 'zero-one'\n  const hasCrowsFoot = cardinality === 'many' || cardinality === 'zero-many'\n  const hasCircle = cardinality === 'zero-one' || cardinality === 'zero-many'\n\n  // Draw single vertical line (perpendicular to edge) at the tip\n  if (hasOneLine) {\n    const halfW = 6\n    parts.push(\n      `<line x1=\"${tipX + px * halfW}\" y1=\"${tipY + py * halfW}\" ` +\n      `x2=\"${tipX - px * halfW}\" y2=\"${tipY - py * halfW}\" ` +\n      `stroke=\"var(--_line)\" stroke-width=\"${sw}\" />`\n    )\n    // Second line slightly back for \"exactly one\" emphasis\n    const line2X = tipX - ux * 4\n    const line2Y = tipY - uy * 4\n    parts.push(\n      `<line x1=\"${line2X + px * halfW}\" y1=\"${line2Y + py * halfW}\" ` +\n      `x2=\"${line2X - px * halfW}\" y2=\"${line2Y - py * halfW}\" ` +\n      `stroke=\"var(--_line)\" stroke-width=\"${sw}\" />`\n    )\n  }\n\n  // Crow's foot (three lines fanning out from tip)\n  if (hasCrowsFoot) {\n    const fanW = 7\n    // Center line\n    const cfTipX = tipX\n    const cfTipY = tipY\n    // Three lines from tip to back, fanning out\n    parts.push(\n      // Top fan line\n      `<line x1=\"${cfTipX + px * fanW}\" y1=\"${cfTipY + py * fanW}\" ` +\n      `x2=\"${backX}\" y2=\"${backY}\" ` +\n      `stroke=\"var(--_line)\" stroke-width=\"${sw}\" />`\n    )\n    parts.push(\n      // Center line\n      `<line x1=\"${cfTipX}\" y1=\"${cfTipY}\" ` +\n      `x2=\"${backX}\" y2=\"${backY}\" ` +\n      `stroke=\"var(--_line)\" stroke-width=\"${sw}\" />`\n    )\n    parts.push(\n      // Bottom fan line\n      `<line x1=\"${cfTipX - px * fanW}\" y1=\"${cfTipY - py * fanW}\" ` +\n      `x2=\"${backX}\" y2=\"${backY}\" ` +\n      `stroke=\"var(--_line)\" stroke-width=\"${sw}\" />`\n    )\n  }\n\n  // Circle (for zero variants)\n  if (hasCircle) {\n    const circleOffset = hasCrowsFoot ? 20 : 12\n    const circleX = point.x - ux * circleOffset\n    const circleY = point.y - uy * circleOffset\n    parts.push(\n      `<circle cx=\"${circleX}\" cy=\"${circleY}\" r=\"4\" ` +\n      `fill=\"var(--bg)\" stroke=\"var(--_line)\" stroke-width=\"${sw}\" />`\n    )\n  }\n\n  return parts.join('\\n')\n}\n\n/** Compute the arc-length midpoint of a polyline path.\n *  Walks along each segment, finds the point at exactly 50% of total path length.\n *  This ensures the label sits ON the path even for orthogonal routes with bends,\n *  unlike the naive first/last geometric center which floats in space for L/Z shapes. */\nfunction midpoint(points: Array<{ x: number; y: number }>): { x: number; y: number } {\n  if (points.length === 0) return { x: 0, y: 0 }\n  if (points.length === 1) return points[0]!\n\n  // Compute total path length\n  let totalLen = 0\n  for (let i = 1; i < points.length; i++) {\n    const dx = points[i]!.x - points[i - 1]!.x\n    const dy = points[i]!.y - points[i - 1]!.y\n    totalLen += Math.sqrt(dx * dx + dy * dy)\n  }\n\n  if (totalLen === 0) return points[0]!\n\n  // Walk to 50% of total length, interpolating within the segment that crosses the halfway mark\n  const halfLen = totalLen / 2\n  let walked = 0\n  for (let i = 1; i < points.length; i++) {\n    const dx = points[i]!.x - points[i - 1]!.x\n    const dy = points[i]!.y - points[i - 1]!.y\n    const segLen = Math.sqrt(dx * dx + dy * dy)\n    if (walked + segLen >= halfLen) {\n      const t = segLen > 0 ? (halfLen - walked) / segLen : 0\n      return {\n        x: points[i - 1]!.x + dx * t,\n        y: points[i - 1]!.y + dy * t,\n      }\n    }\n    walked += segLen\n  }\n\n  return points[points.length - 1]!\n}\n\n// ============================================================================\n// Utilities\n// ============================================================================\n\n// Use shared escapeXml from multiline-utils\nconst escapeXml = escapeXmlUtil\n\n/**\n * Escape a string for use as an XML/HTML attribute value.\n */\nfunction escapeAttr(value: string): string {\n  return value\n    .replace(/&/g, '&amp;')\n    .replace(/\"/g, '&quot;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n}\n"
  },
  {
    "path": "src/er/types.ts",
    "content": "// ============================================================================\n// ER diagram types\n//\n// Models the parsed and positioned representations of a Mermaid ER diagram.\n// ER diagrams show database entities, their attributes, and relationships.\n// ============================================================================\n\n/** Parsed ER diagram — logical structure from mermaid text */\nexport interface ErDiagram {\n  /** All entity definitions */\n  entities: ErEntity[]\n  /** Relationships between entities */\n  relationships: ErRelationship[]\n}\n\nexport interface ErEntity {\n  id: string\n  /** Display name (same as id unless aliased) */\n  label: string\n  /** Entity attributes (columns) */\n  attributes: ErAttribute[]\n}\n\nexport interface ErAttribute {\n  /** Data type (string, int, varchar, etc.) */\n  type: string\n  /** Attribute name */\n  name: string\n  /** Key constraints: PK, FK, UK */\n  keys: Array<'PK' | 'FK' | 'UK'>\n  /** Optional comment */\n  comment?: string\n}\n\n/**\n * Cardinality notation (crow's foot):\n *   'one'       ||  exactly one\n *   'zero-one'  |o  zero or one\n *   'many'      }|  one or more\n *   'zero-many' o{  zero or more\n */\nexport type Cardinality = 'one' | 'zero-one' | 'many' | 'zero-many'\n\nexport interface ErRelationship {\n  entity1: string\n  entity2: string\n  /** Cardinality at entity1's end */\n  cardinality1: Cardinality\n  /** Cardinality at entity2's end */\n  cardinality2: Cardinality\n  /** Relationship verb/label (e.g., \"places\", \"contains\") */\n  label: string\n  /** Whether the relationship is identifying (solid line) or non-identifying (dashed) */\n  identifying: boolean\n}\n\n// ============================================================================\n// Positioned ER diagram — ready for SVG rendering\n// ============================================================================\n\nexport interface PositionedErDiagram {\n  width: number\n  height: number\n  entities: PositionedErEntity[]\n  relationships: PositionedErRelationship[]\n}\n\nexport interface PositionedErEntity {\n  id: string\n  label: string\n  attributes: ErAttribute[]\n  x: number\n  y: number\n  width: number\n  height: number\n  /** Height of the header row */\n  headerHeight: number\n  /** Height per attribute row */\n  rowHeight: number\n}\n\nexport interface PositionedErRelationship {\n  entity1: string\n  entity2: string\n  cardinality1: Cardinality\n  cardinality2: Cardinality\n  label: string\n  identifying: boolean\n  /** Path points from entity1 to entity2 */\n  points: Array<{ x: number; y: number }>\n}\n"
  },
  {
    "path": "src/index.ts",
    "content": "// ============================================================================\n// beautiful-mermaid — public API\n//\n// Renders Mermaid diagrams to styled SVG strings.\n// Framework-agnostic, no DOM required. Pure TypeScript.\n//\n// Supported diagram types:\n//   - Flowcharts (graph TD / flowchart LR)\n//   - State diagrams (stateDiagram-v2)\n//   - Sequence diagrams (sequenceDiagram)\n//   - Class diagrams (classDiagram)\n//   - ER diagrams (erDiagram)\n//\n// Theming uses CSS custom properties (--bg, --fg, + optional enrichment).\n// See src/theme.ts for the full variable system.\n//\n// Usage:\n//   import { renderMermaidSVG } from 'beautiful-mermaid'\n//   const svg = renderMermaidSVG('graph TD\\n  A --> B')\n// ============================================================================\n\nexport type { RenderOptions, MermaidGraph, PositionedGraph } from './types.ts'\nexport type { DiagramColors, ThemeName } from './theme.ts'\nexport { fromShikiTheme, THEMES, DEFAULTS } from './theme.ts'\nexport { parseMermaid } from './parser.ts'\nexport { renderMermaidASCII, renderMermaidAscii } from './ascii/index.ts'\nexport type { AsciiRenderOptions } from './ascii/index.ts'\n\nimport { decodeXML } from 'entities'\nimport { parseMermaid } from './parser.ts'\nimport { layoutGraphSync } from './layout.ts'\nimport { renderSvg } from './renderer.ts'\nimport type { RenderOptions } from './types.ts'\nimport type { DiagramColors } from './theme.ts'\nimport { DEFAULTS } from './theme.ts'\n\nimport { parseSequenceDiagram } from './sequence/parser.ts'\nimport { layoutSequenceDiagram } from './sequence/layout.ts'\nimport { renderSequenceSvg } from './sequence/renderer.ts'\nimport { parseClassDiagram } from './class/parser.ts'\nimport { layoutClassDiagramSync } from './class/layout.ts'\nimport { renderClassSvg } from './class/renderer.ts'\nimport { parseErDiagram } from './er/parser.ts'\nimport { layoutErDiagramSync } from './er/layout.ts'\nimport { renderErSvg } from './er/renderer.ts'\nimport { parseXYChart } from './xychart/parser.ts'\nimport { layoutXYChart } from './xychart/layout.ts'\nimport { renderXYChartSvg } from './xychart/renderer.ts'\n\n/**\n * Detect the diagram type from the mermaid source text.\n * Returns the type keyword used for routing to the correct pipeline.\n */\nfunction detectDiagramType(text: string): 'flowchart' | 'sequence' | 'class' | 'er' | 'xychart' {\n  const firstLine = text.trim().split(/[\\n;]/)[0]?.trim().toLowerCase() ?? ''\n\n  if (/^xychart(-beta)?\\b/.test(firstLine)) return 'xychart'\n  if (/^sequencediagram\\s*$/.test(firstLine)) return 'sequence'\n  if (/^classdiagram\\s*$/.test(firstLine)) return 'class'\n  if (/^erdiagram\\s*$/.test(firstLine)) return 'er'\n\n  // Default: flowchart/state (handled by parseMermaid internally)\n  return 'flowchart'\n}\n\n/**\n * Build a DiagramColors object from render options.\n * Uses DEFAULTS for bg/fg when not provided, and passes through\n * optional enrichment colors (line, accent, muted, surface, border).\n */\nfunction buildColors(options: RenderOptions): DiagramColors {\n  return {\n    bg: options.bg ?? DEFAULTS.bg,\n    fg: options.fg ?? DEFAULTS.fg,\n    line: options.line,\n    accent: options.accent,\n    muted: options.muted,\n    surface: options.surface,\n    border: options.border,\n  }\n}\n\n/**\n * Render Mermaid diagram text to an SVG string — synchronously.\n *\n * Uses elk.bundled.js with a direct FakeWorker bypass (no setTimeout(0) delay).\n * The ELK singleton is created lazily on first use and cached forever.\n *\n * Use this in React components with useMemo() to avoid flash:\n *   const svg = useMemo(() => renderMermaidSVG(code, opts), [code])\n *\n * @param text - Mermaid source text\n * @param options - Rendering options (colors, font, spacing)\n * @returns A self-contained SVG string\n *\n * @example\n * ```ts\n * const svg = renderMermaidSVG('graph TD\\n  A --> B')\n *\n * // With theme\n * const svg = renderMermaidSVG('graph TD\\n  A --> B', {\n *   bg: '#1a1b26', fg: '#a9b1d6'\n * })\n *\n * // With CSS variables (for live theme switching)\n * const svg = renderMermaidSVG('graph TD\\n  A --> B', {\n *   bg: 'var(--background)', fg: 'var(--foreground)', transparent: true\n * })\n * ```\n */\nexport function renderMermaidSVG(\n  text: string,\n  options: RenderOptions = {}\n): string {\n  // Decode XML entities that may leak from markdown parsers (e.g. rehype-raw).\n  // Without this, escapeXml() double-encodes them: &lt; → &amp;lt; → literal \"&lt;\" in SVG.\n  text = decodeXML(text)\n\n  const colors = buildColors(options)\n  const font = options.font ?? 'Inter'\n  const transparent = options.transparent ?? false\n  const diagramType = detectDiagramType(text)\n\n  const lines = text.split('\\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%'))\n\n  switch (diagramType) {\n    case 'sequence': {\n      const diagram = parseSequenceDiagram(lines)\n      const positioned = layoutSequenceDiagram(diagram, options)\n      return renderSequenceSvg(positioned, colors, font, transparent)\n    }\n    case 'class': {\n      const diagram = parseClassDiagram(lines)\n      const positioned = layoutClassDiagramSync(diagram, options)\n      return renderClassSvg(positioned, colors, font, transparent)\n    }\n    case 'er': {\n      const diagram = parseErDiagram(lines)\n      const positioned = layoutErDiagramSync(diagram, options)\n      return renderErSvg(positioned, colors, font, transparent)\n    }\n    case 'xychart': {\n      const chart = parseXYChart(lines)\n      const positioned = layoutXYChart(chart, options)\n      return renderXYChartSvg(positioned, colors, font, transparent, options.interactive ?? false)\n    }\n    case 'flowchart':\n    default: {\n      const graph = parseMermaid(text)\n      const positioned = layoutGraphSync(graph, options)\n      return renderSvg(positioned, colors, font, transparent)\n    }\n  }\n}\n\n/**\n * Render Mermaid diagram text to an SVG string — async.\n *\n * Same result as renderMermaidSVG() but returns a Promise.\n * Useful in async contexts (server handlers, data loaders, etc.)\n */\nexport async function renderMermaidSVGAsync(\n  text: string,\n  options: RenderOptions = {}\n): Promise<string> {\n  return renderMermaidSVG(text, options)\n}\n\n// ---------------------------------------------------------------------------\n// Backward-compatible aliases\n// ---------------------------------------------------------------------------\n\n/** @deprecated Use `renderMermaidSVG` */\nexport const renderMermaidSync = renderMermaidSVG\n\n/** @deprecated Use `renderMermaidSVGAsync` */\nexport const renderMermaid = renderMermaidSVGAsync\n"
  },
  {
    "path": "src/layout-engine.ts",
    "content": "/**\n * Layout engine for beautiful-mermaid (ELK.js based).\n *\n * Converts MermaidGraph to ELK's JSON format, runs layout, and converts\n * the result back to PositionedGraph. This is the core layout engine used\n * by all graph-based diagram types (flowcharts, state, ER, class).\n *\n * ELK (Eclipse Layout Kernel) features:\n *   - Native orthogonal edge routing (no post-processing needed)\n *   - Proper handling of compound nodes (subgraphs)\n *   - Support for disconnected graphs\n *   - Direction overrides per subgraph\n *   - Sophisticated algorithms for complex graphs\n *\n * Uses elk.bundled.js (pure synchronous JS, no WASM/Workers).\n * Safe for Electron, Node, and browser environments.\n */\n\nimport type { ElkNode, ElkExtendedEdge, LayoutOptions } from 'elkjs'\nimport type {\n  MermaidGraph,\n  MermaidSubgraph,\n  MermaidEdge,\n  Direction,\n  PositionedGraph,\n  PositionedNode,\n  PositionedEdge,\n  PositionedGroup,\n  Point,\n  RenderOptions,\n} from './types.ts'\nimport { FONT_SIZES, FONT_WEIGHTS, NODE_PADDING, ARROW_HEAD } from './styles.ts'\nimport { measureMultilineText } from './text-metrics.ts'\nimport { elkLayoutSync } from './elk-instance.ts'\nimport { clipEdgeToShape } from './shape-clipping.ts'\n\n// ============================================================================\n// Layout options\n// ============================================================================\n\n/** Default render options (layout-only) */\nconst DEFAULTS = {\n  font: 'Inter',\n  padding: 40,\n  nodeSpacing: 28,\n  layerSpacing: 48,\n  mergeEdges: true,\n  thoroughness: 3,\n} as const\n\n/** Convert Mermaid direction to ELK direction */\nfunction directionToElk(dir: MermaidGraph['direction']): string {\n  switch (dir) {\n    case 'LR': return 'RIGHT'\n    case 'RL': return 'LEFT'\n    case 'BT': return 'UP'\n    case 'TD':\n    case 'TB':\n    default: return 'DOWN'\n  }\n}\n\n// ============================================================================\n// Node sizing (same logic as Dagre adapter)\n// ============================================================================\n\nfunction estimateNodeSize(id: string, label: string, shape: string): { width: number; height: number } {\n  const metrics = measureMultilineText(label, FONT_SIZES.nodeLabel, FONT_WEIGHTS.nodeLabel)\n\n  let width = metrics.width + NODE_PADDING.horizontal * 2\n  let height = metrics.height + NODE_PADDING.vertical * 2\n\n  if (shape === 'diamond') {\n    const side = Math.max(width, height) + NODE_PADDING.diamondExtra\n    width = side\n    height = side\n  }\n\n  if (shape === 'circle' || shape === 'doublecircle') {\n    const diameter = Math.ceil(Math.sqrt(width * width + height * height)) + 8\n    width = shape === 'doublecircle' ? diameter + 12 : diameter\n    height = width\n  }\n\n  if (shape === 'hexagon') {\n    width += NODE_PADDING.horizontal\n  }\n\n  if (shape === 'trapezoid' || shape === 'trapezoid-alt') {\n    width += NODE_PADDING.horizontal\n  }\n\n  if (shape === 'asymmetric') {\n    width += 12\n  }\n\n  if (shape === 'cylinder') {\n    height += 14\n  }\n\n  if (shape === 'state-start' || shape === 'state-end') {\n    return { width: 28, height: 28 }\n  }\n\n  width = Math.max(width, 60)\n  height = Math.max(height, 36)\n\n  return { width, height }\n}\n\n// ============================================================================\n// Graph conversion: MermaidGraph → ELK JSON\n// ============================================================================\n\ninterface ElkGraphNode extends ElkNode {\n  children?: ElkGraphNode[]\n  edges?: ElkExtendedEdge[]\n}\n\n/**\n * Tracks port-to-edge mappings for hierarchical port edges.\n * Used to combine external and internal edge sections during extraction.\n */\ninterface HierarchicalEdgeInfo {\n  originalIndex: number\n  externalEdgeId: string\n  internalEdgeId: string\n  subgraphId: string\n  direction: 'incoming' | 'outgoing'\n}\n\n/**\n * Convert a MermaidGraph to ELK's nested JSON input format.\n *\n * Uses SEPARATE hierarchy handling for proper subgraph direction override support.\n * Cross-hierarchy edges use hierarchical ports to connect external and internal sections.\n */\nfunction mermaidToElk(\n  graph: MermaidGraph,\n  opts: Required<Pick<RenderOptions, 'font' | 'padding' | 'nodeSpacing' | 'layerSpacing'>>\n): ElkGraphNode {\n  // Collect all node IDs that belong to subgraphs\n  const subgraphNodeIds = new Set<string>()\n  const subgraphIds = new Set<string>()\n  for (const sg of graph.subgraphs) {\n    subgraphIds.add(sg.id)\n    collectSubgraphNodeIds(sg, subgraphNodeIds, subgraphIds)\n  }\n\n  // Build node-to-subgraph mapping for edge distribution\n  const nodeToSubgraph = buildNodeToSubgraphMap(graph.subgraphs)\n\n  // Classify edges into three categories:\n  // 1. Internal edges (both endpoints in same subgraph)\n  // 2. Root-level edges (neither endpoint in a subgraph)\n  // 3. Cross-hierarchy edges (endpoints in different levels)\n  const edgesBySubgraph = new Map<string | null, Array<{ index: number; edge: typeof graph.edges[0] }>>()\n  edgesBySubgraph.set(null, []) // Root-level edges\n\n  // Track cross-hierarchy edges for hierarchical port creation\n  const crossHierarchyEdges: Array<{\n    index: number\n    edge: typeof graph.edges[0]\n    sourceSubgraph: string | undefined\n    targetSubgraph: string | undefined\n  }> = []\n\n  for (let i = 0; i < graph.edges.length; i++) {\n    const edge = graph.edges[i]!\n    const sourceSubgraph = nodeToSubgraph.get(edge.source)\n    const targetSubgraph = nodeToSubgraph.get(edge.target)\n\n    if (sourceSubgraph && sourceSubgraph === targetSubgraph) {\n      // Internal edge: both endpoints in same subgraph\n      if (!edgesBySubgraph.has(sourceSubgraph)) {\n        edgesBySubgraph.set(sourceSubgraph, [])\n      }\n      edgesBySubgraph.get(sourceSubgraph)!.push({ index: i, edge })\n    } else if (!sourceSubgraph && !targetSubgraph) {\n      // Root-level edge: neither endpoint in a subgraph\n      edgesBySubgraph.get(null)!.push({ index: i, edge })\n    } else {\n      // Cross-hierarchy edge: need hierarchical ports\n      crossHierarchyEdges.push({ index: i, edge, sourceSubgraph, targetSubgraph })\n    }\n  }\n\n  // Determine if we need SEPARATE hierarchy handling\n  // We use SEPARATE when any subgraph has a direction override\n  const hasDirectionOverride = graph.subgraphs.some(sg => sg.direction !== undefined)\n\n  // Build the root ELK graph\n  const elkGraph: ElkGraphNode = {\n    id: 'root',\n    layoutOptions: {\n      'elk.algorithm': 'layered',\n      'elk.direction': directionToElk(graph.direction),\n      'elk.spacing.nodeNode': String(opts.nodeSpacing),\n      'elk.layered.spacing.nodeNodeBetweenLayers': String(opts.layerSpacing),\n      'elk.spacing.edgeEdge': '12',\n      'elk.layered.spacing.edgeEdgeBetweenLayers': '12',\n      'elk.layered.spacing.edgeNodeBetweenLayers': '12',\n      'elk.padding': `[top=${opts.padding},left=${opts.padding},bottom=${opts.padding},right=${opts.padding}]`,\n      'elk.edgeRouting': 'ORTHOGONAL',\n      'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',\n      'elk.contentAlignment': 'H_CENTER V_CENTER',\n      'elk.layered.thoroughness': String(DEFAULTS.thoroughness),\n      'elk.layered.highDegreeNodes.treatment': 'true',\n      'elk.layered.highDegreeNodes.threshold': '8',\n      'elk.layered.compaction.postCompaction.strategy': 'LEFT_RIGHT_CONSTRAINT_LOCKING',\n      'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',\n      'elk.layered.wrapping.strategy': 'OFF',\n      // Use SEPARATE when subgraphs have direction overrides (enables proper direction handling)\n      // Use INCLUDE_CHILDREN otherwise (simpler cross-hierarchy edge routing)\n      'elk.hierarchyHandling': hasDirectionOverride ? 'SEPARATE' : 'INCLUDE_CHILDREN',\n    },\n    children: [],\n    edges: [],\n  }\n\n  // Track hierarchical ports per subgraph for cross-hierarchy edges\n  const subgraphPorts = new Map<string, Array<{\n    portId: string\n    edgeIndex: number\n    direction: 'incoming' | 'outgoing'\n    internalNodeId: string\n  }>>()\n\n  // Process cross-hierarchy edges to create port entries\n  if (hasDirectionOverride) {\n    for (const { index, edge, sourceSubgraph, targetSubgraph } of crossHierarchyEdges) {\n      // Handle outgoing edges from subgraph\n      if (sourceSubgraph) {\n        const portId = `${sourceSubgraph}_out_${index}`\n        if (!subgraphPorts.has(sourceSubgraph)) {\n          subgraphPorts.set(sourceSubgraph, [])\n        }\n        subgraphPorts.get(sourceSubgraph)!.push({\n          portId,\n          edgeIndex: index,\n          direction: 'outgoing',\n          internalNodeId: edge.source,\n        })\n      }\n\n      // Handle incoming edges to subgraph\n      if (targetSubgraph) {\n        const portId = `${targetSubgraph}_in_${index}`\n        if (!subgraphPorts.has(targetSubgraph)) {\n          subgraphPorts.set(targetSubgraph, [])\n        }\n        subgraphPorts.get(targetSubgraph)!.push({\n          portId,\n          edgeIndex: index,\n          direction: 'incoming',\n          internalNodeId: edge.target,\n        })\n      }\n    }\n  }\n\n  // Add top-level nodes (those not in any subgraph)\n  for (const [id, node] of graph.nodes) {\n    if (!subgraphNodeIds.has(id) && !subgraphIds.has(id)) {\n      const size = estimateNodeSize(id, node.label, node.shape)\n      elkGraph.children!.push({\n        id,\n        width: size.width,\n        height: size.height,\n        labels: [{ text: node.label }],\n      })\n    }\n  }\n\n  // Add subgraphs as compound nodes with children and their internal edges\n  for (const sg of graph.subgraphs) {\n    elkGraph.children!.push(subgraphToElk(sg, graph, opts, edgesBySubgraph, subgraphPorts))\n  }\n\n  // Add root-level edges\n  for (const { index, edge } of edgesBySubgraph.get(null)!) {\n    const elkEdge: ElkExtendedEdge = {\n      id: `e${index}`,\n      sources: [edge.source],\n      targets: [edge.target],\n    }\n    if (edge.label) {\n      const metrics = measureMultilineText(edge.label, FONT_SIZES.edgeLabel, FONT_WEIGHTS.edgeLabel)\n      elkEdge.labels = [{\n        text: edge.label,\n        width: metrics.width + 8,\n        height: metrics.height + 6,\n        layoutOptions: {\n          'elk.edgeLabels.inline': 'true',\n          'elk.edgeLabels.placement': 'CENTER',\n        },\n      }]\n    }\n    elkGraph.edges!.push(elkEdge)\n  }\n\n  // Add cross-hierarchy edges (using ports when SEPARATE, direct when INCLUDE_CHILDREN)\n  for (const { index, edge, sourceSubgraph, targetSubgraph } of crossHierarchyEdges) {\n    const elkEdge: ElkExtendedEdge = {\n      id: `e${index}`,\n      sources: hasDirectionOverride && sourceSubgraph ? [`${sourceSubgraph}_out_${index}`] : [edge.source],\n      targets: hasDirectionOverride && targetSubgraph ? [`${targetSubgraph}_in_${index}`] : [edge.target],\n    }\n    if (edge.label) {\n      const metrics = measureMultilineText(edge.label, FONT_SIZES.edgeLabel, FONT_WEIGHTS.edgeLabel)\n      elkEdge.labels = [{\n        text: edge.label,\n        width: metrics.width + 8,\n        height: metrics.height + 6,\n        layoutOptions: {\n          'elk.edgeLabels.inline': 'true',\n          'elk.edgeLabels.placement': 'CENTER',\n        },\n      }]\n    }\n    elkGraph.edges!.push(elkEdge)\n  }\n\n  return elkGraph\n}\n\n/**\n * Convert a MermaidSubgraph to an ELK compound node.\n * Includes internal edges (edges where both endpoints are in this subgraph)\n * so that the subgraph's direction override is respected by ELK.\n *\n * When using SEPARATE hierarchy handling (for direction override support),\n * also adds hierarchical ports for cross-hierarchy edges.\n */\nfunction subgraphToElk(\n  sg: MermaidSubgraph,\n  graph: MermaidGraph,\n  opts: Required<Pick<RenderOptions, 'font' | 'padding' | 'nodeSpacing' | 'layerSpacing'>>,\n  edgesBySubgraph: Map<string | null, Array<{ index: number; edge: MermaidEdge }>>,\n  subgraphPorts: Map<string, Array<{\n    portId: string\n    edgeIndex: number\n    direction: 'incoming' | 'outgoing'\n    internalNodeId: string\n  }>>\n): ElkGraphNode {\n  const layoutOptions: LayoutOptions = {\n    'elk.algorithm': 'layered',\n    'elk.padding': '[top=44,left=16,bottom=16,right=16]', // Top = headerHeight(28) + gap(16) to match bottom padding\n    'elk.edgeRouting': 'ORTHOGONAL',\n    'elk.contentAlignment': 'H_CENTER V_CENTER',\n    'elk.spacing.edgeEdge': '12',\n    'elk.layered.spacing.edgeEdgeBetweenLayers': '12',\n    'elk.layered.spacing.edgeNodeBetweenLayers': '12',\n    'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',\n    'elk.layered.spacing.nodeNodeBetweenLayers': String(opts.layerSpacing),\n    'elk.spacing.nodeNode': String(opts.nodeSpacing),\n  }\n\n  // Apply direction override if specified\n  if (sg.direction) {\n    layoutOptions['elk.direction'] = directionToElk(sg.direction)\n  }\n\n  const elkNode: ElkGraphNode = {\n    id: sg.id,\n    layoutOptions,\n    labels: sg.label ? [{ text: sg.label }] : undefined,\n    children: [],\n    edges: [],\n  }\n\n  // Add hierarchical ports for cross-hierarchy edges (when using SEPARATE)\n  const ports = subgraphPorts.get(sg.id) ?? []\n  if (ports.length > 0) {\n    // ELK supports ports but types don't include it\n    (elkNode as unknown as Record<string, unknown>).ports = ports.map(p => ({\n      id: p.portId,\n      // Port side is determined by ELK based on edge direction\n    }))\n  }\n\n  // Add direct child nodes\n  for (const nodeId of sg.nodeIds) {\n    const node = graph.nodes.get(nodeId)\n    if (node) {\n      const size = estimateNodeSize(nodeId, node.label, node.shape)\n      elkNode.children!.push({\n        id: nodeId,\n        width: size.width,\n        height: size.height,\n        labels: [{ text: node.label }],\n      })\n    }\n  }\n\n  // Add nested subgraphs recursively\n  for (const child of sg.children) {\n    elkNode.children!.push(subgraphToElk(child, graph, opts, edgesBySubgraph, subgraphPorts))\n  }\n\n  // Add internal edges (edges where both endpoints are in this subgraph)\n  const internalEdges = edgesBySubgraph.get(sg.id) ?? []\n  for (const { index, edge } of internalEdges) {\n    const elkEdge: ElkExtendedEdge = {\n      id: `e${index}`,\n      sources: [edge.source],\n      targets: [edge.target],\n    }\n    if (edge.label) {\n      const metrics = measureMultilineText(edge.label, FONT_SIZES.edgeLabel, FONT_WEIGHTS.edgeLabel)\n      elkEdge.labels = [{\n        text: edge.label,\n        width: metrics.width + 8,\n        height: metrics.height + 6,\n        layoutOptions: {\n          'elk.edgeLabels.inline': 'true',\n          'elk.edgeLabels.placement': 'CENTER',\n        },\n      }]\n    }\n    elkNode.edges!.push(elkEdge)\n  }\n\n  // Add internal edge segments for hierarchical ports (port → node or node → port)\n  // These connect the boundary ports to actual internal nodes\n  for (const port of ports) {\n    const internalEdgeId = `e${port.edgeIndex}_internal`\n    const elkEdge: ElkExtendedEdge = port.direction === 'incoming'\n      ? { id: internalEdgeId, sources: [port.portId], targets: [port.internalNodeId] }\n      : { id: internalEdgeId, sources: [port.internalNodeId], targets: [port.portId] }\n    elkNode.edges!.push(elkEdge)\n  }\n\n  return elkNode\n}\n\n/** Recursively collect all node IDs that belong to any subgraph */\nfunction collectSubgraphNodeIds(sg: MermaidSubgraph, nodeIds: Set<string>, subgraphIds: Set<string>): void {\n  for (const id of sg.nodeIds) {\n    nodeIds.add(id)\n  }\n  for (const child of sg.children) {\n    subgraphIds.add(child.id)\n    collectSubgraphNodeIds(child, nodeIds, subgraphIds)\n  }\n}\n\n/**\n * Build a mapping from node ID to its containing subgraph ID.\n * For nested subgraphs, maps to the innermost containing subgraph.\n * Nodes not in any subgraph are not included in the map.\n */\nfunction buildNodeToSubgraphMap(subgraphs: MermaidSubgraph[]): Map<string, string> {\n  const map = new Map<string, string>()\n\n  function traverse(sg: MermaidSubgraph): void {\n    // Map all direct child nodes to this subgraph\n    for (const nodeId of sg.nodeIds) {\n      map.set(nodeId, sg.id)\n    }\n    // Recursively process nested subgraphs (they override parent mapping)\n    for (const child of sg.children) {\n      traverse(child)\n    }\n  }\n\n  for (const sg of subgraphs) {\n    traverse(sg)\n  }\n\n  return map\n}\n\n// ============================================================================\n// Result conversion: ELK output → PositionedGraph\n// ============================================================================\n\n/**\n * Convert ELK layout result to our PositionedGraph format.\n */\n/** Margin routing info for cross-hierarchy edges */\ninterface MarginInfo {\n  leftX: number\n  rightX: number\n}\n\n/** Recursively flatten all group bounding boxes (including nested children) */\nfunction flattenGroupBounds(groups: PositionedGroup[]): Array<{ x: number; y: number; right: number; bottom: number }> {\n  const bounds: Array<{ x: number; y: number; right: number; bottom: number }> = []\n  for (const g of groups) {\n    bounds.push({ x: g.x, y: g.y, right: g.x + g.width, bottom: g.y + g.height })\n    bounds.push(...flattenGroupBounds(g.children))\n  }\n  return bounds\n}\n\nfunction elkToPositioned(\n  elkResult: ElkNode,\n  graph: MermaidGraph,\n  mergeEdges: boolean = false\n): PositionedGraph {\n  const nodes: PositionedNode[] = []\n  const edges: PositionedEdge[] = []\n  const groups: PositionedGroup[] = []\n\n  // Build set of subgraph IDs for distinguishing compound nodes from leaf nodes\n  const subgraphIds = new Set<string>()\n  for (const sg of graph.subgraphs) {\n    collectAllSubgraphIds(sg, subgraphIds)\n  }\n\n  // Extract nodes and groups recursively\n  extractNodesAndGroups(elkResult, graph, subgraphIds, nodes, groups, 0, 0)\n\n  // Compute margin positions for cross-hierarchy edge routing.\n  // Margins sit outside all group bounding boxes so edges don't cross through subgraphs.\n  const allBounds = flattenGroupBounds(groups)\n  const margins: MarginInfo | undefined = allBounds.length > 0\n    ? {\n        leftX: Math.min(...allBounds.map(b => b.x)) - 20,\n        rightX: Math.max(...allBounds.map(b => b.right)) + 20,\n      }\n    : undefined\n\n  // Extract edges recursively from all levels (root and subgraphs)\n  // Edges are distributed to subgraphs for direction override to work,\n  // so we need to collect them from all children with proper offsets\n  extractEdgesRecursively(elkResult, graph, edges, 0, 0, margins)\n\n  // Snap same-layer nodes to the same position along the flow axis.\n  // ELK's orthogonal routing staggers nodes within a layer to create room for\n  // edge bends, but this looks bad. We fix it by aligning layers, then let\n  // edge bundling and clipping recalculate edge paths from corrected positions.\n  alignLayerNodes(nodes, edges, graph.direction)\n\n  // Bundle fan-out/fan-in edge paths into shared trunks when mergeEdges is enabled\n  if (mergeEdges) {\n    bundleEdgePaths(edges, nodes, groups, graph.direction)\n  }\n\n  // Apply shape-aware edge clipping for non-rectangular shapes.\n  // ELK treats all nodes as rectangles, so we need to clip edge endpoints\n  // to the actual shape boundaries (e.g., diamond vertices).\n  const nodeMap = new Map(nodes.map(n => [n.id, n]))\n  for (const edge of edges) {\n    const sourceNode = nodeMap.get(edge.source)\n    const targetNode = nodeMap.get(edge.target)\n\n    if (sourceNode) {\n      edge.points = clipEdgeToShape(edge.points, sourceNode, true)\n    }\n    if (targetNode) {\n      edge.points = clipEdgeToShape(edge.points, targetNode, false)\n    }\n  }\n\n  // Calculate final bounds including all edge points\n  // ELK should include edges in its dimensions, but we verify and expand if needed\n  let width = elkResult.width ?? 800\n  let height = elkResult.height ?? 600\n  const arrowMargin = ARROW_HEAD.width\n  const padding = DEFAULTS.padding\n\n  for (const edge of edges) {\n    for (const p of edge.points) {\n      width = Math.max(width, p.x + arrowMargin + padding)\n      height = Math.max(height, p.y + arrowMargin + padding)\n    }\n    if (edge.labelPosition) {\n      width = Math.max(width, edge.labelPosition.x + 60 + padding)\n      height = Math.max(height, edge.labelPosition.y + 20 + padding)\n    }\n  }\n\n  return {\n    width,\n    height,\n    nodes,\n    edges,\n    groups,\n  }\n}\n\n/**\n * Recursively extract positioned nodes and groups from ELK result.\n */\nfunction extractNodesAndGroups(\n  elkNode: ElkNode,\n  graph: MermaidGraph,\n  subgraphIds: Set<string>,\n  nodes: PositionedNode[],\n  groups: PositionedGroup[],\n  offsetX: number,\n  offsetY: number\n): void {\n  if (!elkNode.children) return\n\n  for (const child of elkNode.children) {\n    const x = (child.x ?? 0) + offsetX\n    const y = (child.y ?? 0) + offsetY\n    const width = child.width ?? 0\n    const height = child.height ?? 0\n\n    if (subgraphIds.has(child.id)) {\n      // This is a subgraph/group\n      const childGroups: PositionedGroup[] = []\n\n      // Recursively process children\n      extractNodesAndGroups(child, graph, subgraphIds, nodes, childGroups, x, y)\n\n      const mermaidSg = findSubgraph(graph.subgraphs, child.id)\n      groups.push({\n        id: child.id,\n        label: mermaidSg?.label ?? '',\n        x,\n        y,\n        width,\n        height,\n        children: childGroups,\n      })\n    } else {\n      // This is a leaf node\n      const mNode = graph.nodes.get(child.id)\n      if (mNode) {\n        // Resolve inline styles from nodeStyles map and classDefs\n        const inlineStyle = resolveNodeStyle(child.id, graph)\n\n        nodes.push({\n          id: child.id,\n          label: mNode.label,\n          shape: mNode.shape,\n          x,\n          y,\n          width,\n          height,\n          inlineStyle,\n        })\n      }\n\n      // Also check for nested children (shouldn't happen for leaf nodes, but be safe)\n      if (child.children && child.children.length > 0) {\n        extractNodesAndGroups(child, graph, subgraphIds, nodes, groups, x, y)\n      }\n    }\n  }\n}\n\n/**\n * Edge segment extracted from ELK result.\n * Used to combine external and internal segments of hierarchical edges.\n */\ninterface EdgeSegment {\n  edgeIndex: number\n  isInternal: boolean  // true for port-to-node segments (e.g., \"e3_internal\")\n  points: Point[]\n  labelPosition?: Point\n}\n\n/**\n * Calculate the midpoint along a polyline path.\n * Walks the path to find the point at half the total length.\n */\nfunction calculatePathMidpoint(points: Point[]): Point {\n  if (points.length === 0) return { x: 0, y: 0 }\n  if (points.length === 1) return points[0]!\n\n  // Calculate total length\n  let totalLength = 0\n  for (let i = 1; i < points.length; i++) {\n    const dx = points[i]!.x - points[i - 1]!.x\n    const dy = points[i]!.y - points[i - 1]!.y\n    totalLength += Math.sqrt(dx * dx + dy * dy)\n  }\n\n  // Walk to halfway point\n  let remaining = totalLength / 2\n  for (let i = 1; i < points.length; i++) {\n    const dx = points[i]!.x - points[i - 1]!.x\n    const dy = points[i]!.y - points[i - 1]!.y\n    const segLen = Math.sqrt(dx * dx + dy * dy)\n    if (remaining <= segLen) {\n      const t = remaining / segLen\n      return {\n        x: points[i - 1]!.x + t * dx,\n        y: points[i - 1]!.y + t * dy,\n      }\n    }\n    remaining -= segLen\n  }\n\n  return points[points.length - 1]!\n}\n\n/**\n * Recursively extract edges from ELK result including those inside subgraphs.\n * Edges are distributed to subgraphs for direction override to work,\n * so we need to collect them from all levels with proper coordinate offsets.\n *\n * For hierarchical edges (cross-hierarchy with ports), combines external and\n * internal segments into a single continuous edge path.\n */\nfunction extractEdgesRecursively(\n  elkNode: ElkNode,\n  graph: MermaidGraph,\n  edges: PositionedEdge[],\n  offsetX: number,\n  offsetY: number,\n  margins?: MarginInfo\n): void {\n  // First pass: collect all edge segments\n  const segments = new Map<number, { external?: EdgeSegment; incoming?: EdgeSegment; outgoing?: EdgeSegment }>()\n  collectEdgeSegments(elkNode, segments, 0, 0)\n\n  // Track margin-routed edge count for spacing offsets\n  let marginEdgeIndex = 0\n\n  // Second pass: combine segments and create positioned edges\n  for (const [edgeIndex, seg] of segments) {\n    const originalEdge = graph.edges[edgeIndex]\n    if (!originalEdge) continue\n\n    // Combine points from all segments in correct order:\n    // - For incoming cross-hierarchy (external → subgraph): external then incoming\n    // - For outgoing cross-hierarchy (subgraph → external): outgoing then external\n    // - For both (subgraph A → subgraph B): outgoing → external → incoming\n    const allPoints: Point[] = []\n\n    // First: outgoing internal segment (source node → exit port)\n    if (seg.outgoing && seg.outgoing.points.length > 0) {\n      allPoints.push(...seg.outgoing.points)\n    }\n\n    // Second: external segment (exit port → entry port, or source → entry port, or exit port → target)\n    if (seg.external && seg.external.points.length > 0) {\n      if (allPoints.length > 0) {\n        // Skip first point to avoid duplicate at outgoing port\n        allPoints.push(...seg.external.points.slice(1))\n      } else {\n        allPoints.push(...seg.external.points)\n      }\n    }\n\n    // Third: incoming internal segment (entry port → target node)\n    if (seg.incoming && seg.incoming.points.length > 0) {\n      if (allPoints.length > 0) {\n        // Skip first point to avoid duplicate at incoming port\n        allPoints.push(...seg.incoming.points.slice(1))\n      } else {\n        allPoints.push(...seg.incoming.points)\n      }\n    }\n\n    // Label position: use ELK's inline label position (on-edge with collision avoidance)\n    // Fall back to midpoint for hierarchical edges or when ELK position unavailable\n    let labelPosition: Point | undefined\n    if (originalEdge.label && allPoints.length >= 2) {\n      const elkLabelPos = seg.external?.labelPosition\n      labelPosition = elkLabelPos ?? calculatePathMidpoint(allPoints)\n    }\n\n    // Ensure all edge segments are orthogonal (horizontal or vertical only).\n    // In SEPARATE hierarchy mode, ELK may produce diagonal segments for\n    // cross-hierarchy edges where it only returns start/end points without\n    // proper orthogonal bend points.\n    // When margins are available, route through the diagram margins instead\n    // of Z-paths through the middle (which cross through subgraphs).\n    const orthogonalPoints = orthogonalizeEdgePoints(allPoints, margins, marginEdgeIndex)\n    if (orthogonalPoints !== allPoints) {\n      marginEdgeIndex++\n    }\n\n    // Recalculate label position for margin-routed edges\n    if (originalEdge.label && orthogonalPoints !== allPoints && orthogonalPoints.length >= 2) {\n      labelPosition = calculatePathMidpoint(orthogonalPoints)\n    }\n\n    edges.push({\n      source: originalEdge.source,\n      target: originalEdge.target,\n      label: originalEdge.label,\n      style: originalEdge.style,\n      hasArrowStart: originalEdge.hasArrowStart,\n      hasArrowEnd: originalEdge.hasArrowEnd,\n      points: orthogonalPoints,\n      labelPosition,\n      inlineStyle: resolveEdgeStyle(edgeIndex, graph),\n    })\n  }\n}\n\n/**\n * Post-process edge points to ensure all segments are purely orthogonal.\n *\n * When ELK uses SEPARATE hierarchy handling (required for subgraph direction\n * overrides), cross-hierarchy edges may only get start/end coordinates without\n * intermediate bend points, producing diagonal lines.\n *\n * When margins are provided, routes diagonal segments through the left or right\n * margin of the diagram (outside all subgraphs). Alternates sides and adds\n * spacing offsets to prevent overlapping parallel edges.\n *\n * Without margins, falls back to Z-path through the vertical midpoint.\n *\n * Returns the original array reference (identity) if no changes were needed,\n * so callers can detect whether routing was applied.\n */\nfunction orthogonalizeEdgePoints(\n  points: Point[],\n  margins?: MarginInfo,\n  edgeIndex: number = 0\n): Point[] {\n  if (points.length < 2) return points\n\n  // Check if any segment needs orthogonalization\n  let needsWork = false\n  for (let i = 1; i < points.length; i++) {\n    const dx = Math.abs(points[i]!.x - points[i - 1]!.x)\n    const dy = Math.abs(points[i]!.y - points[i - 1]!.y)\n    if (dx > 1 && dy > 1) { needsWork = true; break }\n  }\n  if (!needsWork) return points\n\n  const EDGE_SPACING = 12\n  const result: Point[] = [points[0]!]\n\n  for (let i = 1; i < points.length; i++) {\n    const prev = result[result.length - 1]!\n    const curr = points[i]!\n    const dx = Math.abs(curr.x - prev.x)\n    const dy = Math.abs(curr.y - prev.y)\n\n    if (dx > 1 && dy > 1) {\n      if (margins) {\n        // Margin routing: exit horizontally → travel vertically along margin → enter horizontally\n        // Alternate left/right margins and offset for parallel edge spacing\n        const useRight = edgeIndex % 2 === 0\n        const offset = Math.floor(edgeIndex / 2) * EDGE_SPACING\n        const marginX = useRight\n          ? margins.rightX + offset\n          : margins.leftX - offset\n\n        result.push({ x: marginX, y: prev.y })\n        result.push({ x: marginX, y: curr.y })\n      } else {\n        // Fallback: Z-path through vertical midpoint\n        const midY = (prev.y + curr.y) / 2\n        result.push({ x: prev.x, y: midY })\n        result.push({ x: curr.x, y: midY })\n      }\n    }\n\n    result.push(curr)\n  }\n\n  return result\n}\n\n/**\n * Recursively collect edge segments from ELK result.\n */\nfunction collectEdgeSegments(\n  elkNode: ElkNode,\n  segments: Map<number, { external?: EdgeSegment; incoming?: EdgeSegment; outgoing?: EdgeSegment }>,\n  offsetX: number,\n  offsetY: number\n): void {\n  if (elkNode.edges) {\n    for (const elkEdge of elkNode.edges) {\n      // Parse edge ID: \"e{index}\" or \"e{index}_internal\"\n      const isInternal = elkEdge.id.endsWith('_internal')\n      const edgeIndex = parseInt(elkEdge.id.substring(1), 10)\n      if (isNaN(edgeIndex)) continue\n\n      // Extract points\n      const points: Point[] = []\n      if (elkEdge.sections && elkEdge.sections.length > 0) {\n        const section = elkEdge.sections[0]!\n        points.push({\n          x: section.startPoint.x + offsetX,\n          y: section.startPoint.y + offsetY,\n        })\n        if (section.bendPoints) {\n          for (const bp of section.bendPoints) {\n            points.push({ x: bp.x + offsetX, y: bp.y + offsetY })\n          }\n        }\n        points.push({\n          x: section.endPoint.x + offsetX,\n          y: section.endPoint.y + offsetY,\n        })\n      }\n\n      // Extract label position\n      let labelPosition: Point | undefined\n      if (elkEdge.labels && elkEdge.labels.length > 0) {\n        const label = elkEdge.labels[0]!\n        if (label.x != null && label.y != null) {\n          labelPosition = {\n            x: label.x + (label.width ?? 0) / 2 + offsetX,\n            y: label.y + (label.height ?? 0) / 2 + offsetY,\n          }\n        }\n      }\n\n      // Store segment\n      if (!segments.has(edgeIndex)) {\n        segments.set(edgeIndex, {})\n      }\n      const seg = segments.get(edgeIndex)!\n\n      if (isInternal) {\n        // Determine if this is an incoming or outgoing internal segment\n        // by checking if source is a port (incoming) or target is a port (outgoing)\n        const source = elkEdge.sources?.[0] ?? ''\n        const target = elkEdge.targets?.[0] ?? ''\n        const sourceIsPort = source.includes('_in_') || source.includes('_out_')\n        const targetIsPort = target.includes('_in_') || target.includes('_out_')\n\n        if (sourceIsPort) {\n          // Port → node: incoming internal segment\n          seg.incoming = { edgeIndex, isInternal, points, labelPosition }\n        } else if (targetIsPort) {\n          // Node → port: outgoing internal segment\n          seg.outgoing = { edgeIndex, isInternal, points, labelPosition }\n        }\n      } else {\n        seg.external = { edgeIndex, isInternal, points, labelPosition }\n      }\n    }\n  }\n\n  // Recurse into children with accumulated offset\n  if (elkNode.children) {\n    for (const child of elkNode.children) {\n      collectEdgeSegments(child, segments, offsetX + (child.x ?? 0), offsetY + (child.y ?? 0))\n    }\n  }\n}\n\n/** Find a subgraph by ID in a nested structure */\nfunction findSubgraph(subgraphs: MermaidSubgraph[], id: string): MermaidSubgraph | undefined {\n  for (const sg of subgraphs) {\n    if (sg.id === id) return sg\n    const found = findSubgraph(sg.children, id)\n    if (found) return found\n  }\n  return undefined\n}\n\n/** Recursively collect all subgraph IDs */\nfunction collectAllSubgraphIds(sg: MermaidSubgraph, out: Set<string>): void {\n  out.add(sg.id)\n  for (const child of sg.children) {\n    collectAllSubgraphIds(child, out)\n  }\n}\n\n/**\n * Resolve inline styles for a node from classDefs and nodeStyles.\n * Class styles are applied first, then explicit style directives override.\n */\nfunction resolveNodeStyle(\n  nodeId: string,\n  graph: MermaidGraph\n): Record<string, string> | undefined {\n  let result: Record<string, string> | undefined\n\n  // First, apply class styles (if node has a class assignment)\n  const className = graph.classAssignments.get(nodeId)\n  if (className) {\n    const classDef = graph.classDefs.get(className)\n    if (classDef) {\n      result = { ...classDef }\n    }\n  }\n\n  // Then, apply explicit style directives (override class styles)\n  const nodeStyle = graph.nodeStyles.get(nodeId)\n  if (nodeStyle) {\n    result = result ? { ...result, ...nodeStyle } : { ...nodeStyle }\n  }\n\n  return result\n}\n\n/**\n * Resolve inline styles for an edge from linkStyles map.\n * Default link style is applied first, then index-specific overrides.\n */\nfunction resolveEdgeStyle(\n  edgeIndex: number,\n  graph: MermaidGraph\n): Record<string, string> | undefined {\n  let result: Record<string, string> | undefined\n\n  const defaultStyle = graph.linkStyles.get('default')\n  if (defaultStyle) {\n    result = { ...defaultStyle }\n  }\n\n  const indexStyle = graph.linkStyles.get(edgeIndex)\n  if (indexStyle) {\n    result = result ? { ...result, ...indexStyle } : { ...indexStyle }\n  }\n\n  return result\n}\n\n// ============================================================================\n// Layer alignment — snap same-layer nodes to a uniform position\n// ============================================================================\n\n/**\n * ELK's orthogonal edge routing staggers nodes within the same layer to create\n * space for edge bends. This post-processing step groups nodes into layers and\n * snaps them to the same flow-axis coordinate (Y for TD/TB, X for LR/RL).\n *\n * Grouping uses proximity along the flow axis: within a layer, ELK's stagger\n * is always less than layerSpacing (bounded by edge routing channels), while\n * adjacent layers are separated by at least layerSpacing + nodeHeight.\n * A threshold of 0.75 * layerSpacing cleanly separates these cases.\n *\n * Directly connected nodes (sharing an edge) are never merged into the same\n * layer group as an additional safety check.\n *\n * Edge endpoints connected to shifted nodes are adjusted proportionally.\n * Intermediate bend points are left unchanged — edge bundling or clipping\n * will recalculate them afterwards.\n */\nfunction alignLayerNodes(\n  nodes: PositionedNode[],\n  edges: PositionedEdge[],\n  direction: Direction\n): void {\n  if (nodes.length === 0) return\n\n  const isHorizontal = direction === 'LR' || direction === 'RL'\n\n  // Build set of directly-connected node pairs.\n  // Nodes connected by an edge must not be merged into the same layer.\n  const connectedPairs = new Set<string>()\n  for (const edge of edges) {\n    connectedPairs.add(`${edge.source}:${edge.target}`)\n    connectedPairs.add(`${edge.target}:${edge.source}`)\n  }\n\n  // ELK's stagger creates small gaps between adjacent nodes in the same layer\n  // (typically edgeEdge spacing = 12px per routing channel). Adjacent layers\n  // are separated by at least layerSpacing (48px). We use single-linkage\n  // clustering: a node joins the current layer if the gap from the previous\n  // node (in sorted order) is within threshold, AND it has no direct edge to\n  // any node already in the layer.\n  const THRESHOLD = DEFAULTS.layerSpacing * 0.6\n\n  // Sort nodes by flow-axis position\n  const sorted = [...nodes].sort((a, b) =>\n    isHorizontal ? a.x - b.x : a.y - b.y\n  )\n\n  const layers: PositionedNode[][] = []\n  let currentLayer: PositionedNode[] = [sorted[0]!]\n\n  for (let i = 1; i < sorted.length; i++) {\n    const pos = isHorizontal ? sorted[i]!.x : sorted[i]!.y\n    const prevPos = isHorizontal ? sorted[i - 1]!.x : sorted[i - 1]!.y\n    // Single-linkage: compare with previous node, not layer start\n    const gap = pos - prevPos\n    // Check if this node is connected to any node already in the current layer\n    const hasEdgeToLayer = currentLayer.some(n =>\n      connectedPairs.has(`${n.id}:${sorted[i]!.id}`)\n    )\n    if (gap <= THRESHOLD && !hasEdgeToLayer) {\n      currentLayer.push(sorted[i]!)\n    } else {\n      layers.push(currentLayer)\n      currentLayer = [sorted[i]!]\n    }\n  }\n  layers.push(currentLayer)\n\n  // Snap each layer's nodes to the layer's center position\n  const deltas = new Map<string, number>() // nodeId → shift amount\n\n  for (const layer of layers) {\n    if (layer.length <= 1) continue\n\n    const positions = layer.map(n => isHorizontal ? n.x : n.y)\n    const min = Math.min(...positions)\n    const max = Math.max(...positions)\n    if (max - min <= 1) continue // Already aligned\n\n    // Use the center of the range as the snap target\n    const target = (min + max) / 2\n\n    for (const node of layer) {\n      const oldPos = isHorizontal ? node.x : node.y\n      const delta = target - oldPos\n      if (Math.abs(delta) > 0.5) {\n        if (isHorizontal) {\n          node.x = target\n        } else {\n          node.y = target\n        }\n        deltas.set(node.id, delta)\n      }\n    }\n  }\n\n  if (deltas.size === 0) return\n\n  // Build node lookup for edge adjustment\n  const nodeMap = new Map(nodes.map(n => [n.id, n]))\n\n  // Adjust edge endpoints to match shifted node positions\n  for (const edge of edges) {\n    if (edge.points.length < 2) continue\n\n    const srcDelta = deltas.get(edge.source)\n    const tgtDelta = deltas.get(edge.target)\n\n    if (srcDelta != null) {\n      // Shift first point and any subsequent points in the initial vertical/horizontal run\n      const first = edge.points[0]!\n      if (isHorizontal) {\n        first.x += srcDelta\n        // Shift second point if it's part of a straight vertical exit\n        if (edge.points.length > 1 && edge.points[1]!.x === first.x - srcDelta) {\n          edge.points[1]!.x += srcDelta\n        }\n      } else {\n        first.y += srcDelta\n        if (edge.points.length > 1 && edge.points[1]!.y === first.y - srcDelta) {\n          edge.points[1]!.y += srcDelta\n        }\n      }\n    }\n\n    if (tgtDelta != null) {\n      const last = edge.points[edge.points.length - 1]!\n      if (isHorizontal) {\n        last.x += tgtDelta\n        if (edge.points.length > 1) {\n          const prev = edge.points[edge.points.length - 2]!\n          if (prev.x === last.x - tgtDelta) prev.x += tgtDelta\n        }\n      } else {\n        last.y += tgtDelta\n        if (edge.points.length > 1) {\n          const prev = edge.points[edge.points.length - 2]!\n          if (prev.y === last.y - tgtDelta) prev.y += tgtDelta\n        }\n      }\n    }\n  }\n}\n\n// ============================================================================\n// Edge bundling — merge fan-out / fan-in edge paths into shared trunks\n// ============================================================================\n\n/**\n * Find all groups (outermost first) that geometrically contain the given point.\n */\nfunction findGroupsContainingPoint(\n  x: number, y: number,\n  groups: PositionedGroup[]\n): PositionedGroup[] {\n  const result: PositionedGroup[] = []\n  for (const g of groups) {\n    if (x >= g.x && x <= g.x + g.width && y >= g.y && y <= g.y + g.height) {\n      result.push(g)\n      result.push(...findGroupsContainingPoint(x, y, g.children))\n    }\n  }\n  return result\n}\n\n/**\n * If `junction` falls inside a group that doesn't contain the reference node,\n * move it just outside the outermost such group boundary.\n */\nfunction adjustJunctionForGroups(\n  junctionMain: number,  // the junction coordinate along the flow axis (Y for TD, X for LR)\n  refX: number,          // reference node center X (for finding its groups)\n  refY: number,          // reference node center Y\n  groups: PositionedGroup[],\n  direction: Direction\n): number {\n  const GAP = 12\n  const isLR = direction === 'LR'\n  const isRL = direction === 'RL'\n  const isBT = direction === 'BT'\n  const isHorizontal = isLR || isRL\n\n  // Groups containing the reference node\n  const refGroupIds = new Set(findGroupsContainingPoint(refX, refY, groups).map(g => g.id))\n\n  // Check where the junction point would be along the trunk\n  const probeX = isHorizontal ? junctionMain : refX\n  const probeY = isHorizontal ? refY : junctionMain\n  const junctionGroups = findGroupsContainingPoint(probeX, probeY, groups)\n\n  // Find outermost group containing the junction but NOT the reference node\n  const crossingGroup = junctionGroups.find(g => !refGroupIds.has(g.id))\n  if (!crossingGroup) return junctionMain\n\n  // Move junction just outside this group\n  if (isLR) return crossingGroup.x - GAP\n  if (isRL) return crossingGroup.x + crossingGroup.width + GAP\n  if (isBT) return crossingGroup.y + crossingGroup.height + GAP\n  return crossingGroup.y - GAP // TD\n}\n\n/**\n * Bundle fan-out and fan-in edge paths so they share a common trunk segment.\n *\n * For fan-out (one source → N targets), all edges exit the source at the same\n * point, travel along a shared trunk, then branch to their individual targets.\n * The overlapping trunk segments render as a single visible line.\n *\n * Junction points are placed outside subgraph boundaries so branches split\n * before entering a group, not inside it.\n *\n * Constraints: edges in a bundle must share the same style and have no labels.\n * Self-loops and backward edges (against the graph direction) are excluded.\n */\nfunction bundleEdgePaths(\n  edges: PositionedEdge[],\n  nodes: PositionedNode[],\n  groups: PositionedGroup[],\n  direction: Direction\n): void {\n  const nodeMap = new Map(nodes.map(n => [n.id, n]))\n  const processed = new Set<PositionedEdge>()\n\n  const isLR = direction === 'LR'\n  const isRL = direction === 'RL'\n  const isBT = direction === 'BT'\n  const isHorizontal = isLR || isRL\n\n  // --- Fan-out: group edges by shared source ---\n  const fanOutGroups = new Map<string, PositionedEdge[]>()\n  for (const edge of edges) {\n    if (edge.source === edge.target) continue\n    if (!fanOutGroups.has(edge.source)) fanOutGroups.set(edge.source, [])\n    fanOutGroups.get(edge.source)!.push(edge)\n  }\n\n  for (const [sourceId, group] of fanOutGroups) {\n    if (group.length < 2) continue\n\n    const style = group[0]!.style\n    if (group.some(e => e.label || e.style !== style)) continue\n\n    const source = nodeMap.get(sourceId)\n    if (!source) continue\n\n    // Only bundle edges going in the forward direction\n    const forward = group.filter(e => {\n      const t = nodeMap.get(e.target)\n      if (!t) return false\n      if (isLR) return t.x > source.x + source.width\n      if (isRL) return t.x + t.width < source.x\n      if (isBT) return t.y + t.height < source.y\n      return t.y > source.y + source.height // TD/TB\n    })\n    if (forward.length < 2) continue\n\n    const targets = forward.map(e => ({ edge: e, node: nodeMap.get(e.target)! }))\n    const srcCX = source.x + source.width / 2\n    const srcCY = source.y + source.height / 2\n\n    if (isHorizontal) {\n      const exitX = isLR ? source.x + source.width : source.x\n      const exitY = srcCY\n\n      const nearestX = isLR\n        ? Math.min(...targets.map(t => t.node.x))\n        : Math.max(...targets.map(t => t.node.x + t.node.width))\n      let junctionX = exitX + (nearestX - exitX) / 2\n      junctionX = adjustJunctionForGroups(junctionX, srcCX, srcCY, groups, direction)\n\n      for (const { edge, node: target } of targets) {\n        const entryX = isLR ? target.x : target.x + target.width\n        const entryY = target.y + target.height / 2\n        edge.points = [\n          { x: exitX, y: exitY },\n          { x: junctionX, y: exitY },\n          { x: junctionX, y: entryY },\n          { x: entryX, y: entryY },\n        ]\n        processed.add(edge)\n      }\n    } else {\n      const exitX = srcCX\n      const exitY = isBT ? source.y : source.y + source.height\n\n      const nearestY = isBT\n        ? Math.max(...targets.map(t => t.node.y + t.node.height))\n        : Math.min(...targets.map(t => t.node.y))\n      let junctionY = exitY + (nearestY - exitY) / 2\n      junctionY = adjustJunctionForGroups(junctionY, srcCX, srcCY, groups, direction)\n\n      for (const { edge, node: target } of targets) {\n        const entryX = target.x + target.width / 2\n        const entryY = isBT ? target.y + target.height : target.y\n        edge.points = [\n          { x: exitX, y: exitY },\n          { x: exitX, y: junctionY },\n          { x: entryX, y: junctionY },\n          { x: entryX, y: entryY },\n        ]\n        processed.add(edge)\n      }\n    }\n  }\n\n  // --- Fan-in: group edges by shared target (skip already-bundled edges) ---\n  const fanInGroups = new Map<string, PositionedEdge[]>()\n  for (const edge of edges) {\n    if (processed.has(edge) || edge.source === edge.target) continue\n    if (!fanInGroups.has(edge.target)) fanInGroups.set(edge.target, [])\n    fanInGroups.get(edge.target)!.push(edge)\n  }\n\n  for (const [targetId, group] of fanInGroups) {\n    if (group.length < 2) continue\n\n    const style = group[0]!.style\n    if (group.some(e => e.label || e.style !== style)) continue\n\n    const target = nodeMap.get(targetId)\n    if (!target) continue\n\n    const forward = group.filter(e => {\n      const s = nodeMap.get(e.source)\n      if (!s) return false\n      if (isLR) return s.x + s.width < target.x\n      if (isRL) return s.x > target.x + target.width\n      if (isBT) return s.y > target.y + target.height\n      return s.y + s.height < target.y // TD/TB\n    })\n    if (forward.length < 2) continue\n\n    const sources = forward.map(e => ({ edge: e, node: nodeMap.get(e.source)! }))\n    const tgtCX = target.x + target.width / 2\n    const tgtCY = target.y + target.height / 2\n\n    if (isHorizontal) {\n      const entryX = isLR ? target.x : target.x + target.width\n      const entryY = tgtCY\n\n      const farthestX = isLR\n        ? Math.max(...sources.map(s => s.node.x + s.node.width))\n        : Math.min(...sources.map(s => s.node.x))\n      let junctionX = farthestX + (entryX - farthestX) / 2\n      junctionX = adjustJunctionForGroups(junctionX, tgtCX, tgtCY, groups, direction)\n\n      for (const { edge, node: src } of sources) {\n        const exitX = isLR ? src.x + src.width : src.x\n        const exitY = src.y + src.height / 2\n        edge.points = [\n          { x: exitX, y: exitY },\n          { x: junctionX, y: exitY },\n          { x: junctionX, y: entryY },\n          { x: entryX, y: entryY },\n        ]\n      }\n    } else {\n      const entryX = tgtCX\n      const entryY = isBT ? target.y + target.height : target.y\n\n      const farthestY = isBT\n        ? Math.min(...sources.map(s => s.node.y))\n        : Math.max(...sources.map(s => s.node.y + s.node.height))\n      let junctionY = farthestY + (entryY - farthestY) / 2\n      junctionY = adjustJunctionForGroups(junctionY, tgtCX, tgtCY, groups, direction)\n\n      for (const { edge, node: src } of sources) {\n        const exitX = src.x + src.width / 2\n        const exitY = isBT ? src.y : src.y + src.height\n        edge.points = [\n          { x: exitX, y: exitY },\n          { x: exitX, y: junctionY },\n          { x: entryX, y: junctionY },\n          { x: entryX, y: entryY },\n        ]\n      }\n    }\n  }\n}\n\n// ============================================================================\n// Public API\n// ============================================================================\n\n/**\n * Lay out a parsed MermaidGraph using ELK.js (synchronous).\n * Returns a fully positioned graph ready for rendering.\n */\nexport function layoutGraphSync(\n  graph: MermaidGraph,\n  options: RenderOptions = {}\n): PositionedGraph {\n  const opts = { ...DEFAULTS, ...options }\n  const elkGraph = mermaidToElk(graph, opts)\n  const result = elkLayoutSync(elkGraph)\n  return elkToPositioned(result, graph, DEFAULTS.mergeEdges)\n}\n\n/**\n * Convert MermaidGraph to ELK format (for benchmarking conversion overhead).\n */\nexport function convertToElkFormat(\n  graph: MermaidGraph,\n  options: RenderOptions = {}\n): ElkNode {\n  const opts = { ...DEFAULTS, ...options }\n  return mermaidToElk(graph, opts)\n}\n"
  },
  {
    "path": "src/layout.ts",
    "content": "/**\n * Layout module for flowchart and state diagrams.\n *\n * Uses ELK.js for graph layout — battle-tested, full subgraph support,\n * orthogonal edge routing, and direction overrides.\n */\n\nexport { layoutGraphSync } from './layout-engine.ts'\n"
  },
  {
    "path": "src/multiline-utils.ts",
    "content": "// ============================================================================\n// Multi-line Text Rendering Utilities\n//\n// Shared utilities for rendering multi-line text in SVG using <tspan> elements.\n// Supports inline formatting: <b>, <i>, <u>, <s> mapped to SVG attributes.\n// Used across all diagram types (flowcharts, state, sequence, class, ER).\n// ============================================================================\n\nimport { LINE_HEIGHT_RATIO } from './text-metrics.ts'\n\n/**\n * Normalize label text: strip surrounding quotes, convert <br> tags and\n * literal \\n sequences to newline characters. Strips unsupported HTML tags\n * but preserves formatting tags (<b>, <i>, <u>, <s>) for SVG rendering.\n */\nexport function normalizeBrTags(label: string): string {\n  // Strip surrounding double quotes (Mermaid uses them for special chars in labels)\n  const unquoted = label.startsWith('\"') && label.endsWith('\"') ? label.slice(1, -1) : label\n  return unquoted\n    .replace(/<br\\s*\\/?>/gi, '\\n')\n    .replace(/\\\\n/g, '\\n')\n    .replace(/<\\/?(?:sub|sup|small|mark)\\s*>/gi, '')\n    // Markdown formatting → HTML tags (order matters: ** before *)\n    .replace(/\\*\\*(.+?)\\*\\*/g, '<b>$1</b>')\n    .replace(/(?<!\\*)\\*([^\\s*](?:[^*]*[^\\s*])?)\\*(?!\\*)/g, '<i>$1</i>')\n    .replace(/~~(.+?)~~/g, '<s>$1</s>')\n}\n\n/**\n * Strip all inline formatting tags from text, keeping only plain text.\n * Used for text measurement where tag characters shouldn't affect width.\n */\nexport function stripFormattingTags(text: string): string {\n  return text.replace(/<\\/?(?:b|strong|i|em|u|s|del)\\s*>/gi, '')\n}\n\n/**\n * Escape special XML characters in text content.\n */\nexport function escapeXml(text: string): string {\n  return text\n    .replace(/&/g, '&amp;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n    .replace(/\"/g, '&quot;')\n    .replace(/'/g, '&#39;')\n}\n\n// ============================================================================\n// Inline formatting: <b>, <i>, <u>, <s> → SVG tspan attributes\n// ============================================================================\n\ninterface StyledSegment {\n  text: string\n  bold: boolean\n  italic: boolean\n  underline: boolean\n  strikethrough: boolean\n}\n\n/** Regex to match opening/closing formatting tags */\nconst FORMAT_TAG_REGEX = /<(\\/)?(?:(b|strong)|(i|em)|(u)|(s|del))\\s*>/gi\n\n/**\n * Parse a line of text into styled segments based on inline formatting tags.\n * Supports nesting: `<b>bold <i>both</i> bold</b>`.\n */\nfunction parseInlineFormatting(line: string): StyledSegment[] {\n  const segments: StyledSegment[] = []\n  let bold = false, italic = false, underline = false, strikethrough = false\n  let lastIndex = 0\n\n  // Reset lastIndex for global regex\n  FORMAT_TAG_REGEX.lastIndex = 0\n\n  let match: RegExpExecArray | null\n  while ((match = FORMAT_TAG_REGEX.exec(line)) !== null) {\n    // Capture text before this tag\n    if (match.index > lastIndex) {\n      segments.push({ text: line.slice(lastIndex, match.index), bold, italic, underline, strikethrough })\n    }\n    lastIndex = match.index + match[0].length\n\n    const isClosing = Boolean(match[1])\n    // match[2] = b|strong, match[3] = i|em, match[4] = u, match[5] = s|del\n    if (match[2]) bold = !isClosing\n    else if (match[3]) italic = !isClosing\n    else if (match[4]) underline = !isClosing\n    else if (match[5]) strikethrough = !isClosing\n  }\n\n  // Remaining text after last tag\n  if (lastIndex < line.length) {\n    segments.push({ text: line.slice(lastIndex), bold, italic, underline, strikethrough })\n  }\n\n  return segments\n}\n\n/** Check if a line contains any formatting tags */\nconst HAS_FORMAT_TAGS = /<\\/?(?:b|strong|i|em|u|s|del)\\s*>/i\n\n/**\n * Render a line's content as SVG, with inline formatting applied as tspan attributes.\n * Returns raw SVG content (no wrapping tspan — caller provides positioning).\n */\nfunction renderLineContent(line: string): string {\n  // Fast path: no formatting tags\n  if (!HAS_FORMAT_TAGS.test(line)) return escapeXml(line)\n\n  const segments = parseInlineFormatting(line)\n  if (segments.length === 0) return ''\n\n  // If all segments are unstyled, just escape\n  const allPlain = segments.every(s => !s.bold && !s.italic && !s.underline && !s.strikethrough)\n  if (allPlain) return segments.map(s => escapeXml(s.text)).join('')\n\n  return segments.map(seg => {\n    const escaped = escapeXml(seg.text)\n    if (!seg.bold && !seg.italic && !seg.underline && !seg.strikethrough) return escaped\n\n    const attrs: string[] = []\n    if (seg.bold) attrs.push('font-weight=\"bold\"')\n    if (seg.italic) attrs.push('font-style=\"italic\"')\n    // SVG text-decoration can combine values\n    const deco: string[] = []\n    if (seg.underline) deco.push('underline')\n    if (seg.strikethrough) deco.push('line-through')\n    if (deco.length) attrs.push(`text-decoration=\"${deco.join(' ')}\"`)\n\n    return `<tspan ${attrs.join(' ')}>${escaped}</tspan>`\n  }).join('')\n}\n\n// ============================================================================\n// Multi-line text rendering\n// ============================================================================\n\n/**\n * Render a multi-line text element with proper vertical centering.\n *\n * For single-line text, returns a simple <text> element.\n * For multi-line text (containing \\n), returns <text> with <tspan> children.\n * Inline formatting tags (<b>, <i>, <u>, <s>) are rendered as SVG attributes.\n *\n * @param text - The text to render (may contain \\n and formatting tags)\n * @param cx - Center x coordinate\n * @param cy - Center y coordinate\n * @param fontSize - Font size in pixels\n * @param attrs - Additional SVG attributes (e.g., 'text-anchor=\"middle\" fill=\"var(--_text)\"')\n * @param baselineShift - Baseline shift for vertical alignment (default 0.35)\n * @returns SVG text element string\n */\nexport function renderMultilineText(\n  text: string,\n  cx: number,\n  cy: number,\n  fontSize: number,\n  attrs: string,\n  baselineShift: number = 0.35\n): string {\n  const lines = text.split('\\n')\n\n  // Single line — simple text element\n  if (lines.length === 1) {\n    const dy = fontSize * baselineShift\n    return `<text x=\"${cx}\" y=\"${cy}\" ${attrs} dy=\"${dy}\">${renderLineContent(text)}</text>`\n  }\n\n  // Multi-line — use tspan elements with vertical centering\n  const lineHeight = fontSize * LINE_HEIGHT_RATIO\n  // First line dy: shift up by (n-1)/2 line heights, then add baseline shift\n  const firstDy = -((lines.length - 1) / 2) * lineHeight + fontSize * baselineShift\n\n  const tspans = lines.map((line, i) => {\n    const dy = i === 0 ? firstDy : lineHeight\n    return `<tspan x=\"${cx}\" dy=\"${dy}\">${renderLineContent(line)}</tspan>`\n  }).join('')\n\n  return `<text x=\"${cx}\" y=\"${cy}\" ${attrs}>${tspans}</text>`\n}\n\n/**\n * Render a multi-line text element with a background rectangle (pill).\n *\n * Used for edge labels that need a background for readability.\n *\n * @param text - The text to render (may contain \\n)\n * @param cx - Center x coordinate\n * @param cy - Center y coordinate\n * @param textWidth - Pre-calculated text width (max line width)\n * @param textHeight - Pre-calculated text height (lines × lineHeight)\n * @param fontSize - Font size in pixels\n * @param padding - Padding around text\n * @param textAttrs - SVG attributes for the text element\n * @param bgAttrs - SVG attributes for the background rect\n * @returns SVG elements string (rect + text)\n */\nexport function renderMultilineTextWithBackground(\n  text: string,\n  cx: number,\n  cy: number,\n  textWidth: number,\n  textHeight: number,\n  fontSize: number,\n  padding: number,\n  textAttrs: string,\n  bgAttrs: string\n): string {\n  const bgWidth = textWidth + padding * 2\n  const bgHeight = textHeight + padding * 2\n\n  const rect = `<rect x=\"${cx - bgWidth / 2}\" y=\"${cy - bgHeight / 2}\" ` +\n    `width=\"${bgWidth}\" height=\"${bgHeight}\" ${bgAttrs} />`\n\n  const textEl = renderMultilineText(text, cx, cy, fontSize, textAttrs)\n\n  return `${rect}\\n${textEl}`\n}\n"
  },
  {
    "path": "src/parser.ts",
    "content": "import type { MermaidGraph, MermaidNode, MermaidEdge, MermaidSubgraph, Direction, NodeShape, EdgeStyle } from './types.ts'\nimport { normalizeBrTags } from './multiline-utils.ts'\n\n// ============================================================================\n// Mermaid parser — flowcharts and state diagrams\n//\n// Supports:\n//   Flowcharts: graph TD / flowchart LR\n//   State diagrams: stateDiagram-v2\n//\n// Line-by-line regex approach — the grammar is regular enough\n// that we don't need a grammar generator or full parser combinator.\n// ============================================================================\n\n/**\n * Parse Mermaid text into a logical graph structure.\n * Auto-detects diagram type (flowchart or state diagram).\n * Throws on invalid/unsupported input.\n */\nexport function parseMermaid(text: string): MermaidGraph {\n  const lines = text.split('\\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%'))\n\n  if (lines.length === 0) {\n    throw new Error('Empty mermaid diagram')\n  }\n\n  // Detect diagram type from header\n  const header = lines[0]!\n\n  // State diagram: \"stateDiagram-v2\" or \"stateDiagram\"\n  if (/^stateDiagram(-v2)?\\s*$/i.test(header)) {\n    return parseStateDiagram(lines)\n  }\n\n  // Flowchart: \"graph TD\" or \"flowchart LR\"\n  return parseFlowchart(lines)\n}\n\n// ============================================================================\n// Flowchart parser\n// ============================================================================\n\nfunction parseFlowchart(lines: string[]): MermaidGraph {\n  const headerMatch = lines[0]!.match(/^(?:graph|flowchart)\\s+(TD|TB|LR|BT|RL)\\s*$/i)\n  if (!headerMatch) {\n    throw new Error(`Invalid mermaid header: \"${lines[0]}\". Expected \"graph TD\", \"flowchart LR\", \"stateDiagram-v2\", etc.`)\n  }\n\n  const direction = headerMatch[1]!.toUpperCase() as Direction\n\n  const graph: MermaidGraph = {\n    direction,\n    nodes: new Map(),\n    edges: [],\n    subgraphs: [],\n    classDefs: new Map(),\n    classAssignments: new Map(),\n    nodeStyles: new Map(),\n    linkStyles: new Map(),\n  }\n\n  // Subgraph stack for nested subgraphs.\n  const subgraphStack: MermaidSubgraph[] = []\n\n  for (let i = 1; i < lines.length; i++) {\n    const line = lines[i]!\n\n    // --- classDef: `classDef name prop:val,prop:val` ---\n    const classDefMatch = line.match(/^classDef\\s+(\\w+)\\s+(.+)$/)\n    if (classDefMatch) {\n      const name = classDefMatch[1]!\n      const propsStr = classDefMatch[2]!\n      const props = parseStyleProps(propsStr)\n      graph.classDefs.set(name, props)\n      continue\n    }\n\n    // --- class assignment: `class A,B className` ---\n    const classAssignMatch = line.match(/^class\\s+([\\w,-]+)\\s+(\\w+)$/)\n    if (classAssignMatch) {\n      const nodeIds = classAssignMatch[1]!.split(',').map(s => s.trim())\n      const className = classAssignMatch[2]!\n      for (const id of nodeIds) {\n        graph.classAssignments.set(id, className)\n      }\n      continue\n    }\n\n    // --- style statement: `style A,B fill:#f00,stroke:#333` ---\n    const styleMatch = line.match(/^style\\s+([\\w,-]+)\\s+(.+)$/)\n    if (styleMatch) {\n      const nodeIds = styleMatch[1]!.split(',').map(s => s.trim())\n      const props = parseStyleProps(styleMatch[2]!)\n      for (const id of nodeIds) {\n        graph.nodeStyles.set(id, { ...graph.nodeStyles.get(id), ...props })\n      }\n      continue\n    }\n\n    // --- linkStyle: `linkStyle 0 stroke:#f00` or `linkStyle default stroke:#f00` ---\n    const linkStyleMatch = line.match(/^linkStyle\\s+(default|[\\d,\\s]+)\\s+(.+)$/)\n    if (linkStyleMatch) {\n      const target = linkStyleMatch[1]!.trim()\n      const props = parseStyleProps(linkStyleMatch[2]!)\n      if (target === 'default') {\n        graph.linkStyles.set('default', { ...graph.linkStyles.get('default'), ...props })\n      } else {\n        const indices = target.split(',').map(s => parseInt(s.trim(), 10))\n        for (const idx of indices) {\n          if (!isNaN(idx)) {\n            graph.linkStyles.set(idx, { ...graph.linkStyles.get(idx), ...props })\n          }\n        }\n      }\n      continue\n    }\n\n    // --- direction override inside subgraph: `direction LR` ---\n    const dirMatch = line.match(/^direction\\s+(TD|TB|LR|BT|RL)\\s*$/i)\n    if (dirMatch && subgraphStack.length > 0) {\n      subgraphStack[subgraphStack.length - 1]!.direction = dirMatch[1]!.toUpperCase() as Direction\n      continue\n    }\n\n    // --- subgraph start: `subgraph Label` or `subgraph id [Label]` ---\n    const subgraphMatch = line.match(/^subgraph\\s+(.+)$/)\n    if (subgraphMatch) {\n      const rest = subgraphMatch[1]!.trim()\n      // Check for \"subgraph id [Label]\" form\n      // ID can contain hyphens (e.g. \"us-east\"), so use [\\w-]+ not \\w+\n      const bracketMatch = rest.match(/^([\\w-]+)\\s*\\[(.+)\\]$/)\n      let id: string\n      let label: string\n      if (bracketMatch) {\n        id = bracketMatch[1]!\n        label = normalizeBrTags(bracketMatch[2]!)\n      } else {\n        // Use the label text as id (slugified)\n        label = normalizeBrTags(rest)\n        id = rest.replace(/\\s+/g, '_').replace(/[^\\w]/g, '')\n      }\n      const sg: MermaidSubgraph = { id, label, nodeIds: [], children: [] }\n      subgraphStack.push(sg)\n      continue\n    }\n\n    // --- subgraph end ---\n    if (line === 'end') {\n      const completed = subgraphStack.pop()\n      if (completed) {\n        if (subgraphStack.length > 0) {\n          subgraphStack[subgraphStack.length - 1]!.children.push(completed)\n        } else {\n          graph.subgraphs.push(completed)\n        }\n      }\n      continue\n    }\n\n    // --- Edge/node definitions ---\n    parseEdgeLine(line, graph, subgraphStack)\n  }\n\n  return graph\n}\n\n// ============================================================================\n// State diagram parser\n//\n// Supported syntax:\n//   stateDiagram-v2\n//   s1 : Description\n//   state \"Description\" as s1\n//   s1 --> s2 : label\n//   [*] --> s1            (start pseudostate)\n//   s1 --> [*]            (end pseudostate)\n//   state CompositeState {\n//     inner1 --> inner2\n//   }\n// ============================================================================\n\nfunction parseStateDiagram(lines: string[]): MermaidGraph {\n  const graph: MermaidGraph = {\n    direction: 'TD',\n    nodes: new Map(),\n    edges: [],\n    subgraphs: [],\n    classDefs: new Map(),\n    classAssignments: new Map(),\n    nodeStyles: new Map(),\n    linkStyles: new Map(),\n  }\n\n  // Track composite state nesting (like subgraphs)\n  const compositeStack: MermaidSubgraph[] = []\n  // Track all composite state IDs to avoid creating duplicate nodes\n  const compositeStateIds = new Set<string>()\n  // Counter for unique [*] pseudostate IDs\n  let startCount = 0\n  let endCount = 0\n\n  for (let i = 1; i < lines.length; i++) {\n    const line = lines[i]!\n\n    // --- direction override ---\n    const dirMatch = line.match(/^direction\\s+(TD|TB|LR|BT|RL)\\s*$/i)\n    if (dirMatch) {\n      if (compositeStack.length > 0) {\n        compositeStack[compositeStack.length - 1]!.direction = dirMatch[1]!.toUpperCase() as Direction\n      } else {\n        graph.direction = dirMatch[1]!.toUpperCase() as Direction\n      }\n      continue\n    }\n\n    // --- linkStyle: `linkStyle 0 stroke:#f00` or `linkStyle default stroke:#f00` ---\n    const linkStyleMatch = line.match(/^linkStyle\\s+(default|[\\d,\\s]+)\\s+(.+)$/)\n    if (linkStyleMatch) {\n      const target = linkStyleMatch[1]!.trim()\n      const props = parseStyleProps(linkStyleMatch[2]!)\n      if (target === 'default') {\n        graph.linkStyles.set('default', { ...graph.linkStyles.get('default'), ...props })\n      } else {\n        const indices = target.split(',').map(s => parseInt(s.trim(), 10))\n        for (const idx of indices) {\n          if (!isNaN(idx)) {\n            graph.linkStyles.set(idx, { ...graph.linkStyles.get(idx), ...props })\n          }\n        }\n      }\n      continue\n    }\n\n    // --- composite state start: `state CompositeState {` ---\n    const compositeMatch = line.match(/^state\\s+(?:\"([^\"]+)\"\\s+as\\s+)?([\\w\\p{L}]+)\\s*\\{$/u)\n    if (compositeMatch) {\n      const label = compositeMatch[1] ?? compositeMatch[2]!\n      const id = compositeMatch[2]!\n      const sg: MermaidSubgraph = { id, label, nodeIds: [], children: [] }\n      compositeStack.push(sg)\n      // Track this ID to avoid creating a duplicate node for the composite state\n      compositeStateIds.add(id)\n      // Remove any existing node that was created when parsing transitions before\n      // this composite state definition (e.g., \"A --> Processing\" before \"state Processing {\")\n      graph.nodes.delete(id)\n      continue\n    }\n\n    // --- composite state end ---\n    if (line === '}') {\n      const completed = compositeStack.pop()\n      if (completed) {\n        if (compositeStack.length > 0) {\n          compositeStack[compositeStack.length - 1]!.children.push(completed)\n        } else {\n          graph.subgraphs.push(completed)\n        }\n      }\n      continue\n    }\n\n    // --- state alias: `state \"Description\" as s1` (without brace) ---\n    const stateAliasMatch = line.match(/^state\\s+\"([^\"]+)\"\\s+as\\s+([\\w\\p{L}]+)\\s*$/u)\n    if (stateAliasMatch) {\n      const label = normalizeBrTags(stateAliasMatch[1]!)\n      const id = stateAliasMatch[2]!\n      registerStateNode(graph, compositeStack, { id, label, shape: 'rounded' })\n      continue\n    }\n\n    // --- transition: `s1 --> s2` or `s1 --> s2 : label` or `[*] --> s1` ---\n    const transitionMatch = line.match(/^(\\[\\*\\]|[\\w\\p{L}-]+)\\s*(-->)\\s*(\\[\\*\\]|[\\w\\p{L}-]+)(?:\\s*:\\s*(.+))?$/u)\n    if (transitionMatch) {\n      let sourceId = transitionMatch[1]!\n      let targetId = transitionMatch[3]!\n      const rawTransitionLabel = transitionMatch[4]?.trim()\n      const edgeLabel = rawTransitionLabel ? normalizeBrTags(rawTransitionLabel) : undefined\n\n      // Handle [*] pseudostates — each occurrence gets a unique ID\n      if (sourceId === '[*]') {\n        startCount++\n        sourceId = `_start${startCount > 1 ? startCount : ''}`\n        registerStateNode(graph, compositeStack, { id: sourceId, label: '', shape: 'state-start' })\n      } else if (!compositeStateIds.has(sourceId)) {\n        // Only create a node if this isn't a composite state\n        ensureStateNode(graph, compositeStack, sourceId)\n      }\n\n      if (targetId === '[*]') {\n        endCount++\n        targetId = `_end${endCount > 1 ? endCount : ''}`\n        registerStateNode(graph, compositeStack, { id: targetId, label: '', shape: 'state-end' })\n      } else if (!compositeStateIds.has(targetId)) {\n        // Only create a node if this isn't a composite state\n        ensureStateNode(graph, compositeStack, targetId)\n      }\n\n      graph.edges.push({\n        source: sourceId,\n        target: targetId,\n        label: edgeLabel,\n        style: 'solid',\n        hasArrowStart: false,\n        hasArrowEnd: true,\n      })\n      continue\n    }\n\n    // --- state description: `s1 : Description` ---\n    const stateDescMatch = line.match(/^([\\w\\p{L}-]+)\\s*:\\s*(.+)$/u)\n    if (stateDescMatch) {\n      const id = stateDescMatch[1]!\n      const label = normalizeBrTags(stateDescMatch[2]!.trim())\n      registerStateNode(graph, compositeStack, { id, label, shape: 'rounded' })\n      continue\n    }\n  }\n\n  return graph\n}\n\n/** Register a state node and track in composite state if applicable */\nfunction registerStateNode(\n  graph: MermaidGraph,\n  compositeStack: MermaidSubgraph[],\n  node: MermaidNode\n): void {\n  const isNew = !graph.nodes.has(node.id)\n  if (isNew) {\n    graph.nodes.set(node.id, node)\n  }\n  if (compositeStack.length > 0) {\n    const current = compositeStack[compositeStack.length - 1]!\n    if (!current.nodeIds.includes(node.id)) {\n      current.nodeIds.push(node.id)\n    }\n  }\n}\n\n/** Ensure a state node exists with default rounded shape */\nfunction ensureStateNode(\n  graph: MermaidGraph,\n  compositeStack: MermaidSubgraph[],\n  id: string\n): void {\n  if (!graph.nodes.has(id)) {\n    registerStateNode(graph, compositeStack, { id, label: id, shape: 'rounded' })\n  } else {\n    // Track in composite if applicable\n    if (compositeStack.length > 0) {\n      const current = compositeStack[compositeStack.length - 1]!\n      if (!current.nodeIds.includes(id)) {\n        current.nodeIds.push(id)\n      }\n    }\n  }\n}\n\n// ============================================================================\n// Shared utilities\n// ============================================================================\n\n/** Parse \"fill:#f00,stroke:#333\" style property strings into a Record */\nfunction parseStyleProps(propsStr: string): Record<string, string> {\n  // Strip trailing semicolons — Mermaid tolerates them (e.g. `stroke:#f00;`)\n  const cleaned = propsStr.replace(/;\\s*$/, '')\n  const props: Record<string, string> = {}\n  for (const pair of cleaned.split(',')) {\n    const colonIdx = pair.indexOf(':')\n    if (colonIdx > 0) {\n      const key = pair.slice(0, colonIdx).trim()\n      const val = pair.slice(colonIdx + 1).trim()\n      if (key && val) {\n        props[key] = val\n      }\n    }\n  }\n  return props\n}\n\n// ============================================================================\n// Flowchart edge line parser\n//\n// Handles chained edges like: A[Label] --> B(Label) -.-> C{Label}\n// Also handles & parallel links: A & B --> C & D\n// ============================================================================\n\n/**\n * Arrow regex — matches all arrow operators with optional labels.\n *\n * Supported operators:\n *   -->  ---       solid arrow / solid line\n *   -.-> -.-       dotted arrow / dotted line\n *   ==>  ===       thick arrow / thick line\n *   <--> <-.-> <==>  bidirectional variants\n *\n * Optional label: -->|label text|\n */\nconst ARROW_REGEX = /^(<)?(-->|-.->|==>|---|-\\.-|===)(?:\\|([^|]*)\\|)?/\n\n/**\n * Text-embedded label regex — matches \"-- label -->\", \"-. label .->\", \"== label ==>\" syntax.\n * Tried as fallback when ARROW_REGEX doesn't match.\n *\n * Based on PR #36 by @liuxiaopai-ai (https://github.com/lukilabs/beautiful-mermaid/pull/36)\n */\nconst TEXT_ARROW_REGEX = /^(<)?(--|-\\.|==)\\s+(.+?)\\s+(-->|---|\\.\\->|-\\.\\-|==>|===)/\n\n/**\n * Node shape patterns — ordered from most specific delimiters to least.\n * Multi-char delimiters must be tried before single-char to avoid false matches.\n */\nconst NODE_PATTERNS: Array<{ regex: RegExp; shape: NodeShape }> = [\n  // Triple delimiters (must be first)\n  { regex: /^([\\w-]+)\\(\\(\\((.+?)\\)\\)\\)/, shape: 'doublecircle' },  // A(((text)))\n\n  // Double delimiters with mixed brackets\n  { regex: /^([\\w-]+)\\(\\[(.+?)\\]\\)/,     shape: 'stadium' },       // A([text])\n  { regex: /^([\\w-]+)\\(\\((.+?)\\)\\)/,     shape: 'circle' },        // A((text))\n  { regex: /^([\\w-]+)\\[\\[(.+?)\\]\\]/,     shape: 'subroutine' },    // A[[text]]\n  { regex: /^([\\w-]+)\\[\\((.+?)\\)\\]/,     shape: 'cylinder' },      // A[(text)]\n\n  // Trapezoid variants — must come before plain [text]\n  { regex: /^([\\w-]+)\\[\\/(.+?)\\\\\\]/,     shape: 'trapezoid' },     // A[/text\\]\n  { regex: /^([\\w-]+)\\[\\\\(.+?)\\/\\]/,     shape: 'trapezoid-alt' }, // A[\\text/]\n\n  // Asymmetric flag shape\n  { regex: /^([\\w-]+)>(.+?)\\]/,          shape: 'asymmetric' },    // A>text]\n\n  // Double curly braces (hexagon) — must come before single {text}\n  { regex: /^([\\w-]+)\\{\\{(.+?)\\}\\}/,     shape: 'hexagon' },       // A{{text}}\n\n  // Single-char delimiters (last — most common, least specific)\n  { regex: /^([\\w-]+)\\[(.+?)\\]/,         shape: 'rectangle' },     // A[text]\n  { regex: /^([\\w-]+)\\((.+?)\\)/,         shape: 'rounded' },       // A(text)\n  { regex: /^([\\w-]+)\\{(.+?)\\}/,         shape: 'diamond' },       // A{text}\n]\n\n/** Regex for a bare node reference (just an ID, no shape brackets) */\nconst BARE_NODE_REGEX = /^([\\w-]+)/\n\n/** Regex for ::: class shorthand suffix — matches :::className immediately after a node */\nconst CLASS_SHORTHAND_REGEX = /^:::([\\w][\\w-]*)/\n\n/**\n * Parse a line that contains node definitions and edges.\n * Handles chaining: A --> B --> C produces edges A→B and B→C.\n * Handles parallel links: A & B --> C & D produces 4 edges.\n */\nfunction parseEdgeLine(\n  line: string,\n  graph: MermaidGraph,\n  subgraphStack: MermaidSubgraph[]\n): void {\n  let remaining = line.trim()\n\n  // Parse the first node group (possibly with & separators)\n  const firstGroup = consumeNodeGroup(remaining, graph, subgraphStack)\n  if (!firstGroup || firstGroup.ids.length === 0) return\n\n  remaining = firstGroup.remaining.trim()\n  let prevGroupIds = firstGroup.ids\n\n  // Parse arrow + node-group pairs until the line is exhausted\n  while (remaining.length > 0) {\n    let hasArrowStart: boolean\n    let style: EdgeStyle\n    let hasArrowEnd: boolean\n    let edgeLabel: string | undefined\n\n    const arrowMatch = remaining.match(ARROW_REGEX)\n    if (arrowMatch) {\n      hasArrowStart = Boolean(arrowMatch[1])\n      const arrowOp = arrowMatch[2]!\n      const rawEdgeLabel = arrowMatch[3]?.trim()\n      edgeLabel = rawEdgeLabel ? normalizeBrTags(rawEdgeLabel) : undefined\n      remaining = remaining.slice(arrowMatch[0].length).trim()\n      style = arrowStyleFromOp(arrowOp)\n      hasArrowEnd = arrowOp.endsWith('>')\n    } else {\n      // Fallback: text-embedded label syntax (-- Yes -->, -. Maybe .->, == Sure ==>)\n      const textMatch = remaining.match(TEXT_ARROW_REGEX)\n      if (!textMatch) break\n      hasArrowStart = Boolean(textMatch[1])\n      const rawLabel = textMatch[3]!.trim()\n      edgeLabel = rawLabel ? normalizeBrTags(rawLabel) : undefined\n      const openOp = textMatch[2]!\n      const closeOp = textMatch[4]!\n      remaining = remaining.slice(textMatch[0].length).trim()\n      style = textArrowStyleFromOps(openOp, closeOp)\n      hasArrowEnd = closeOp.endsWith('>')\n    }\n\n    // Parse the next node group\n    const nextGroup = consumeNodeGroup(remaining, graph, subgraphStack)\n    if (!nextGroup || nextGroup.ids.length === 0) break\n\n    remaining = nextGroup.remaining.trim()\n\n    // Emit Cartesian product of edges: every source × every target\n    for (const sourceId of prevGroupIds) {\n      for (const targetId of nextGroup.ids) {\n        graph.edges.push({\n          source: sourceId,\n          target: targetId,\n          label: edgeLabel,\n          style,\n          hasArrowStart,\n          hasArrowEnd,\n        })\n      }\n    }\n\n    prevGroupIds = nextGroup.ids\n  }\n}\n\ninterface ConsumedNodeGroup {\n  ids: string[]\n  remaining: string\n}\n\n/**\n * Consume one or more nodes separated by `&`.\n * E.g. \"A & B & C --> ...\" returns ids: ['A', 'B', 'C']\n */\nfunction consumeNodeGroup(\n  text: string,\n  graph: MermaidGraph,\n  subgraphStack: MermaidSubgraph[]\n): ConsumedNodeGroup | null {\n  const first = consumeNode(text, graph, subgraphStack)\n  if (!first) return null\n\n  const ids = [first.id]\n  let remaining = first.remaining.trim()\n\n  // Check for & separators\n  while (remaining.startsWith('&')) {\n    remaining = remaining.slice(1).trim()\n    const next = consumeNode(remaining, graph, subgraphStack)\n    if (!next) break\n    ids.push(next.id)\n    remaining = next.remaining.trim()\n  }\n\n  return { ids, remaining }\n}\n\ninterface ConsumedNode {\n  id: string\n  remaining: string\n}\n\n/**\n * Try to consume a node definition from the start of `text`.\n * If the node has a shape+label (e.g. A[Text]), it's registered in the graph.\n * If it's a bare reference (e.g. A), we look it up or create a default.\n * Also handles ::: class shorthand suffix.\n */\nfunction consumeNode(\n  text: string,\n  graph: MermaidGraph,\n  subgraphStack: MermaidSubgraph[]\n): ConsumedNode | null {\n  let id: string | null = null\n  let remaining: string = text\n\n  // Try each node pattern (shape-qualified)\n  for (const { regex, shape } of NODE_PATTERNS) {\n    const match = text.match(regex)\n    if (match) {\n      id = match[1]!\n      const label = normalizeBrTags(match[2]!)\n      registerNode(graph, subgraphStack, { id, label, shape })\n      remaining = text.slice(match[0].length)\n      break\n    }\n  }\n\n  // Bare node reference — only register if node doesn't exist yet.\n  // If it already exists, do NOT track it in the current subgraph;\n  // nodes belong to the subgraph where they're first defined.\n  if (id === null) {\n    const bareMatch = text.match(BARE_NODE_REGEX)\n    if (bareMatch) {\n      id = bareMatch[1]!\n      if (!graph.nodes.has(id)) {\n        registerNode(graph, subgraphStack, { id, label: id, shape: 'rectangle' })\n      }\n      remaining = text.slice(bareMatch[0].length)\n    }\n  }\n\n  if (id === null) return null\n\n  // Check for ::: class shorthand suffix immediately after the node\n  const classMatch = remaining.match(CLASS_SHORTHAND_REGEX)\n  if (classMatch) {\n    graph.classAssignments.set(id, classMatch[1]!)\n    remaining = remaining.slice(classMatch[0].length)\n  }\n\n  return { id, remaining }\n}\n\n/** Register a node in the graph and track it in the current subgraph */\nfunction registerNode(\n  graph: MermaidGraph,\n  subgraphStack: MermaidSubgraph[],\n  node: MermaidNode\n): void {\n  const isNew = !graph.nodes.has(node.id)\n  if (isNew) {\n    graph.nodes.set(node.id, node)\n  }\n  trackInSubgraph(subgraphStack, node.id)\n}\n\n/** Add node ID to the innermost subgraph if we're inside one */\nfunction trackInSubgraph(subgraphStack: MermaidSubgraph[], nodeId: string): void {\n  if (subgraphStack.length > 0) {\n    const current = subgraphStack[subgraphStack.length - 1]!\n    if (!current.nodeIds.includes(nodeId)) {\n      current.nodeIds.push(nodeId)\n    }\n  }\n}\n\n/** Map arrow operator string to edge style (ignoring direction) */\nfunction arrowStyleFromOp(op: string): EdgeStyle {\n  if (op === '-.->') return 'dotted'\n  if (op === '-.-') return 'dotted'\n  if (op === '==>') return 'thick'\n  if (op === '===') return 'thick'\n  // '-->'' and '---' are both solid\n  return 'solid'\n}\n\n/** Map text-embedded arrow open/close operators to edge style */\nfunction textArrowStyleFromOps(openOp: string, closeOp: string): EdgeStyle {\n  if (openOp === '-.' || closeOp === '.->' || closeOp === '-.-') return 'dotted'\n  if (openOp === '==' || closeOp === '==>' || closeOp === '===') return 'thick'\n  return 'solid'\n}\n"
  },
  {
    "path": "src/renderer.ts",
    "content": "import type { PositionedGraph, PositionedNode, PositionedEdge, PositionedGroup, Point } from './types.ts'\nimport type { DiagramColors } from './theme.ts'\nimport { svgOpenTag, buildStyleBlock } from './theme.ts'\nimport { FONT_SIZES, FONT_WEIGHTS, STROKE_WIDTHS, ARROW_HEAD, estimateTextWidth, TEXT_BASELINE_SHIFT } from './styles.ts'\nimport { measureMultilineText } from './text-metrics.ts'\nimport { renderMultilineText, renderMultilineTextWithBackground, escapeXml } from './multiline-utils.ts'\n\n// ============================================================================\n// SVG renderer — converts a PositionedGraph into an SVG string.\n//\n// Pure string concatenation, no DOM manipulation.\n// Renders back-to-front: groups → edges → arrow heads → edge labels → nodes → node labels.\n//\n// All colors are referenced via CSS custom properties (var(--_xxx)) defined\n// in the <style> block. The caller provides bg/fg (+ optional enrichment\n// colors) via DiagramColors, which are set as inline CSS variables on the\n// <svg> tag. See src/theme.ts for the full variable system.\n//\n// Style spec:\n// - All corners rx=0 ry=0 (sharp)\n// - Stroke widths: outer box 1px, inner box 0.75px, connectors 0.75px\n// - Arrow heads: filled triangles, 8px wide × 4.8px tall\n// - Dashed edges: stroke-dasharray=\"4 4\"\n// - Font: Inter with weight per element type\n// ============================================================================\n\n/**\n * Render a positioned graph as an SVG string.\n *\n * @param colors - DiagramColors with bg/fg and optional enrichment variables.\n *                 These are set as CSS custom properties on the <svg> tag.\n *                 All element colors reference derived --_xxx variables.\n * @param transparent - If true, renders with transparent background.\n */\nexport function renderSvg(\n  graph: PositionedGraph,\n  colors: DiagramColors,\n  font: string = 'Inter',\n  transparent: boolean = false\n): string {\n  const parts: string[] = []\n\n  // SVG root with CSS variables + style block + defs\n  parts.push(svgOpenTag(graph.width, graph.height, colors, transparent))\n  parts.push(buildStyleBlock(font, false))\n  parts.push('<defs>')\n  parts.push(arrowMarkerDefs())\n  // Per-color arrow markers for edges with custom stroke via linkStyle\n  const customStrokeColors = new Set<string>()\n  for (const edge of graph.edges) {\n    if (edge.inlineStyle?.stroke) {\n      customStrokeColors.add(edge.inlineStyle.stroke)\n    }\n  }\n  for (const color of customStrokeColors) {\n    parts.push(arrowMarkerDefsForColor(color))\n  }\n  parts.push('</defs>')\n\n  // 1. Subgraph backgrounds (group rectangles with header bands)\n  for (const group of graph.groups) {\n    parts.push(renderGroup(group, font))\n  }\n\n  // 2. Edges (polylines — rendered behind nodes)\n  // Each edge is a <polyline> with semantic data-* attributes\n  for (const edge of graph.edges) {\n    parts.push(renderEdge(edge))\n  }\n\n  // 3. Edge labels (positioned at midpoint of edge)\n  // Each label is wrapped in <g class=\"edge-label\">\n  for (const edge of graph.edges) {\n    if (edge.label) {\n      parts.push(renderEdgeLabel(edge, font))\n    }\n  }\n\n  // 4. Nodes (shape + label wrapped in <g class=\"node\">)\n  for (const node of graph.nodes) {\n    parts.push(renderNode(node, font))\n  }\n\n  parts.push('</svg>')\n\n  return parts.join('\\n')\n}\n\n// ============================================================================\n// Arrow marker definitions\n// ============================================================================\n\n/**\n * Reusable arrow head markers — both forward (end) and reverse (start) variants.\n * The reverse marker uses orient=\"auto-start-reverse\" to flip automatically.\n * Arrow color uses var(--_arrow) CSS variable.\n */\nfunction arrowMarkerDefs(): string {\n  const w = ARROW_HEAD.width\n  const h = ARROW_HEAD.height\n  // Arrow polygons have both fill and a thin stroke for better definition at small sizes\n  const arrowStyle = 'fill=\"var(--_arrow)\" stroke=\"var(--_arrow)\" stroke-width=\"0.75\" stroke-linejoin=\"round\"'\n  // Pull arrowhead back slightly (refX = w - 1) to prevent clipping at node boundaries\n  const refX = w - 1\n  return (\n    // Forward arrow (marker-end) — orient=\"auto\" ensures arrow points along line direction\n    `  <marker id=\"arrowhead\" markerWidth=\"${w}\" markerHeight=\"${h}\" refX=\"${refX}\" refY=\"${h / 2}\" orient=\"auto\">` +\n    `\\n    <polygon points=\"0 0, ${w} ${h / 2}, 0 ${h}\" ${arrowStyle} />` +\n    `\\n  </marker>` +\n    // Reverse arrow (marker-start) — refX=1 so it sits at the line start with slight offset, auto-start-reverse flips it\n    `\\n  <marker id=\"arrowhead-start\" markerWidth=\"${w}\" markerHeight=\"${h}\" refX=\"1\" refY=\"${h / 2}\" orient=\"auto-start-reverse\">` +\n    `\\n    <polygon points=\"${w} 0, 0 ${h / 2}, ${w} ${h}\" ${arrowStyle} />` +\n    `\\n  </marker>`\n  )\n}\n\n/**\n * Generate arrow markers tinted to a specific color (for linkStyle stroke overrides).\n * IDs are suffixed with a sanitized color string to avoid collisions.\n */\nfunction arrowMarkerDefsForColor(color: string): string {\n  const w = ARROW_HEAD.width\n  const h = ARROW_HEAD.height\n  const escaped = escapeAttr(color)\n  const arrowStyle = `fill=\"${escaped}\" stroke=\"${escaped}\" stroke-width=\"0.75\" stroke-linejoin=\"round\"`\n  const refX = w - 1\n  const suffix = markerSuffix(color)\n  return (\n    `  <marker id=\"arrowhead-${suffix}\" markerWidth=\"${w}\" markerHeight=\"${h}\" refX=\"${refX}\" refY=\"${h / 2}\" orient=\"auto\">` +\n    `\\n    <polygon points=\"0 0, ${w} ${h / 2}, 0 ${h}\" ${arrowStyle} />` +\n    `\\n  </marker>` +\n    `\\n  <marker id=\"arrowhead-start-${suffix}\" markerWidth=\"${w}\" markerHeight=\"${h}\" refX=\"1\" refY=\"${h / 2}\" orient=\"auto-start-reverse\">` +\n    `\\n    <polygon points=\"${w} 0, 0 ${h / 2}, ${w} ${h}\" ${arrowStyle} />` +\n    `\\n  </marker>`\n  )\n}\n\n/** Sanitize a color value into a collision-free SVG ID suffix.\n *  Non-alphanumeric chars are hex-encoded so distinct inputs never collapse\n *  (e.g. \"var(--line-1)\" → \"var28--line2d129\", \"var(--line1)\" → \"var28--line129\"). */\nfunction markerSuffix(color: string): string {\n  return color.replace(/[^a-zA-Z0-9]/g, (ch) => ch.charCodeAt(0).toString(16))\n}\n\n// ============================================================================\n// Group rendering (subgraph backgrounds)\n// ============================================================================\n\nfunction renderGroup(group: PositionedGroup, font: string): string {\n  const headerHeight = FONT_SIZES.groupHeader + 16\n  const parts: string[] = []\n\n  // Opening <g> with semantic attributes for subgraph identification\n  // data-id: original Mermaid subgraph ID\n  // data-label: display label (may differ from ID)\n  parts.push(\n    `<g class=\"subgraph\" data-id=\"${escapeAttr(group.id)}\" data-label=\"${escapeAttr(group.label)}\">`\n  )\n\n  // Outer rectangle\n  parts.push(\n    `  <rect x=\"${group.x}\" y=\"${group.y}\" width=\"${group.width}\" height=\"${group.height}\" ` +\n    `rx=\"0\" ry=\"0\" fill=\"var(--_group-fill)\" stroke=\"var(--_node-stroke)\" stroke-width=\"${STROKE_WIDTHS.outerBox}\" />`\n  )\n\n  // Header band\n  parts.push(\n    `  <rect x=\"${group.x}\" y=\"${group.y}\" width=\"${group.width}\" height=\"${headerHeight}\" ` +\n    `rx=\"0\" ry=\"0\" fill=\"var(--_group-hdr)\" stroke=\"var(--_node-stroke)\" stroke-width=\"${STROKE_WIDTHS.outerBox}\" />`\n  )\n\n  // Header label (supports multi-line via <br> tags)\n  parts.push(\n    '  ' + renderMultilineText(\n      group.label,\n      group.x + 12,\n      group.y + headerHeight / 2,\n      FONT_SIZES.groupHeader,\n      `font-size=\"${FONT_SIZES.groupHeader}\" font-weight=\"${FONT_WEIGHTS.groupHeader}\" fill=\"var(--_text-sec)\"`\n    )\n  )\n\n  // Render nested groups recursively (inside this group)\n  for (const child of group.children) {\n    parts.push(renderGroup(child, font))\n  }\n\n  parts.push('</g>')\n\n  return parts.join('\\n')\n}\n\n// ============================================================================\n// Edge rendering\n// ============================================================================\n\nfunction renderEdge(edge: PositionedEdge): string {\n  if (edge.points.length < 2) return ''\n\n  const pathData = pointsToPolylinePath(edge.points)\n  const dashArray = edge.style === 'dotted' ? ' stroke-dasharray=\"4 4\"' : ''\n  const baseStrokeWidth = edge.style === 'thick' ? STROKE_WIDTHS.connector * 2 : STROKE_WIDTHS.connector\n  const strokeColor = escapeAttr(edge.inlineStyle?.stroke ?? 'var(--_line)')\n  const strokeWidth = escapeAttr(edge.inlineStyle?.['stroke-width'] ?? String(baseStrokeWidth))\n\n  // Build marker attributes based on arrow direction flags\n  // Use color-specific markers when edge has a custom stroke from linkStyle\n  const suffix = edge.inlineStyle?.stroke ? `-${markerSuffix(edge.inlineStyle.stroke)}` : ''\n  let markers = ''\n  if (edge.hasArrowEnd) markers += ` marker-end=\"url(#arrowhead${suffix})\"`\n  if (edge.hasArrowStart) markers += ` marker-start=\"url(#arrowhead-start${suffix})\"`\n\n  // Semantic data attributes for edge identification and inspection:\n  // - class=\"edge\": CSS targeting and type identification\n  // - data-from/data-to: source and target node IDs\n  // - data-style: edge style (solid, dotted, thick)\n  // - data-arrow-start/end: arrow presence flags\n  // - data-label: edge label if present (for quick lookup without traversing DOM)\n  const dataAttrs = [\n    'class=\"edge\"',\n    `data-from=\"${escapeAttr(edge.source)}\"`,\n    `data-to=\"${escapeAttr(edge.target)}\"`,\n    `data-style=\"${edge.style}\"`,\n    `data-arrow-start=\"${edge.hasArrowStart}\"`,\n    `data-arrow-end=\"${edge.hasArrowEnd}\"`,\n  ]\n  if (edge.label) {\n    dataAttrs.push(`data-label=\"${escapeAttr(edge.label)}\"`)\n  }\n\n  return (\n    `<polyline ${dataAttrs.join(' ')} points=\"${pathData}\" fill=\"none\" stroke=\"${strokeColor}\" ` +\n    `stroke-width=\"${strokeWidth}\"${dashArray}${markers} />`\n  )\n}\n\n/** Convert points to SVG polyline points attribute: \"x1,y1 x2,y2 ...\" */\nfunction pointsToPolylinePath(points: Point[]): string {\n  return points.map(p => `${p.x},${p.y}`).join(' ')\n}\n\nfunction renderEdgeLabel(edge: PositionedEdge, font: string): string {\n  // Use layout-computed label position when available (layout-aware, avoids collisions).\n  // Fall back to geometric midpoint of the edge polyline.\n  const mid = edge.labelPosition ?? edgeMidpoint(edge.points)\n  const label = edge.label!\n  const padding = 8\n\n  // Measure text (works for both single and multi-line)\n  const metrics = measureMultilineText(label, FONT_SIZES.edgeLabel, FONT_WEIGHTS.edgeLabel)\n\n  // Wrap in <g class=\"edge-label\"> with reference to the edge it belongs to\n  const content = renderMultilineTextWithBackground(\n    label,\n    mid.x,\n    mid.y,\n    metrics.width,\n    metrics.height,\n    FONT_SIZES.edgeLabel,\n    padding,\n    // Use --_text-sec for better contrast (was --_text-muted)\n    `text-anchor=\"middle\" font-size=\"${FONT_SIZES.edgeLabel}\" font-weight=\"${FONT_WEIGHTS.edgeLabel}\" fill=\"var(--_text-sec)\"`,\n    // Increased stroke width from 0.5 to 1 for better label separation from edges\n    `rx=\"2\" ry=\"2\" fill=\"var(--bg)\" stroke=\"var(--_inner-stroke)\" stroke-width=\"1\"`\n  )\n\n  // Semantic wrapper: links label to its edge via data-from/data-to\n  return (\n    `<g class=\"edge-label\" data-from=\"${escapeAttr(edge.source)}\" data-to=\"${escapeAttr(edge.target)}\" data-label=\"${escapeAttr(label)}\">\\n` +\n    `  ${content.replace(/\\n/g, '\\n  ')}\\n` +\n    `</g>`\n  )\n}\n\n/** Get the midpoint of a polyline (by walking segments) */\nfunction edgeMidpoint(points: Point[]): Point {\n  if (points.length === 0) return { x: 0, y: 0 }\n  if (points.length === 1) return points[0]!\n\n  // Calculate total length\n  let totalLength = 0\n  for (let i = 1; i < points.length; i++) {\n    totalLength += dist(points[i - 1]!, points[i]!)\n  }\n\n  // Walk to the halfway point\n  let remaining = totalLength / 2\n  for (let i = 1; i < points.length; i++) {\n    const segLen = dist(points[i - 1]!, points[i]!)\n    if (remaining <= segLen) {\n      const t = remaining / segLen\n      return {\n        x: points[i - 1]!.x + t * (points[i]!.x - points[i - 1]!.x),\n        y: points[i - 1]!.y + t * (points[i]!.y - points[i - 1]!.y),\n      }\n    }\n    remaining -= segLen\n  }\n\n  return points[points.length - 1]!\n}\n\nfunction dist(a: Point, b: Point): number {\n  return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2)\n}\n\n// ============================================================================\n// Node rendering\n// ============================================================================\n\n/**\n * Render a complete node: shape + label wrapped in a semantic <g> element.\n *\n * The group includes data attributes for:\n * - data-id: original Mermaid node ID (for edge matching)\n * - data-label: display label text\n * - data-shape: shape type (rectangle, diamond, circle, etc.)\n */\nfunction renderNode(node: PositionedNode, font: string): string {\n  const shape = renderNodeShape(node)\n  const label = renderNodeLabel(node, font)\n\n  // Combine shape and label inside a semantic group\n  // This enables reliable node identification without heuristics\n  const parts: string[] = []\n  parts.push(\n    `<g class=\"node\" data-id=\"${escapeAttr(node.id)}\" data-label=\"${escapeAttr(node.label)}\" data-shape=\"${node.shape}\">`\n  )\n  parts.push(`  ${shape.replace(/\\n/g, '\\n  ')}`)\n  if (label) {\n    parts.push(`  ${label.replace(/\\n/g, '\\n  ')}`)\n  }\n  parts.push('</g>')\n\n  return parts.join('\\n')\n}\n\nfunction renderNodeShape(node: PositionedNode): string {\n  const { x, y, width, height, shape, inlineStyle } = node\n\n  // Resolve fill and stroke — inline styles (from mermaid `style` directives)\n  // override the CSS variable defaults. When no inline style is present, the\n  // CSS variable handles theming automatically via color-mix() derivation.\n  const fill = escapeAttr(inlineStyle?.fill ?? 'var(--_node-fill)')\n  const stroke = escapeAttr(inlineStyle?.stroke ?? 'var(--_node-stroke)')\n  const sw = escapeAttr(inlineStyle?.['stroke-width'] ?? String(STROKE_WIDTHS.innerBox))\n\n  switch (shape) {\n    case 'diamond':\n      return renderDiamond(x, y, width, height, fill, stroke, sw)\n    case 'rounded':\n      return renderRoundedRect(x, y, width, height, fill, stroke, sw)\n    case 'stadium':\n      return renderStadium(x, y, width, height, fill, stroke, sw)\n    case 'circle':\n      return renderCircle(x, y, width, height, fill, stroke, sw)\n    case 'subroutine':\n      return renderSubroutine(x, y, width, height, fill, stroke, sw)\n    case 'doublecircle':\n      return renderDoubleCircle(x, y, width, height, fill, stroke, sw)\n    case 'hexagon':\n      return renderHexagon(x, y, width, height, fill, stroke, sw)\n    case 'cylinder':\n      return renderCylinder(x, y, width, height, fill, stroke, sw)\n    case 'asymmetric':\n      return renderAsymmetric(x, y, width, height, fill, stroke, sw)\n    case 'trapezoid':\n      return renderTrapezoid(x, y, width, height, fill, stroke, sw)\n    case 'trapezoid-alt':\n      return renderTrapezoidAlt(x, y, width, height, fill, stroke, sw)\n    case 'state-start':\n      return renderStateStart(x, y, width, height)\n    case 'state-end':\n      return renderStateEnd(x, y, width, height)\n    case 'rectangle':\n    default:\n      return renderRect(x, y, width, height, fill, stroke, sw)\n  }\n}\n\n// --- Basic shapes ---\n\nfunction renderRect(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string): string {\n  return (\n    `<rect x=\"${x}\" y=\"${y}\" width=\"${w}\" height=\"${h}\" ` +\n    `rx=\"0\" ry=\"0\" fill=\"${fill}\" stroke=\"${stroke}\" stroke-width=\"${sw}\" />`\n  )\n}\n\nfunction renderRoundedRect(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string): string {\n  return (\n    `<rect x=\"${x}\" y=\"${y}\" width=\"${w}\" height=\"${h}\" ` +\n    `rx=\"6\" ry=\"6\" fill=\"${fill}\" stroke=\"${stroke}\" stroke-width=\"${sw}\" />`\n  )\n}\n\nfunction renderStadium(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string): string {\n  const r = h / 2\n  return (\n    `<rect x=\"${x}\" y=\"${y}\" width=\"${w}\" height=\"${h}\" ` +\n    `rx=\"${r}\" ry=\"${r}\" fill=\"${fill}\" stroke=\"${stroke}\" stroke-width=\"${sw}\" />`\n  )\n}\n\nfunction renderCircle(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string): string {\n  const cx = x + w / 2\n  const cy = y + h / 2\n  const r = Math.min(w, h) / 2\n  return (\n    `<circle cx=\"${cx}\" cy=\"${cy}\" r=\"${r}\" ` +\n    `fill=\"${fill}\" stroke=\"${stroke}\" stroke-width=\"${sw}\" />`\n  )\n}\n\nfunction renderDiamond(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string): string {\n  const cx = x + w / 2\n  const cy = y + h / 2\n  const hw = w / 2\n  const hh = h / 2\n  const points = [\n    `${cx},${cy - hh}`,   // top\n    `${cx + hw},${cy}`,   // right\n    `${cx},${cy + hh}`,   // bottom\n    `${cx - hw},${cy}`,   // left\n  ].join(' ')\n\n  return (\n    `<polygon points=\"${points}\" fill=\"${fill}\" stroke=\"${stroke}\" stroke-width=\"${sw}\" />`\n  )\n}\n\n// --- Batch 1 shapes ---\n\n/** Subroutine: rectangle with double vertical borders on left and right */\nfunction renderSubroutine(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string): string {\n  const inset = 8 // distance from edge to inner vertical line\n  return (\n    `<rect x=\"${x}\" y=\"${y}\" width=\"${w}\" height=\"${h}\" ` +\n    `rx=\"0\" ry=\"0\" fill=\"${fill}\" stroke=\"${stroke}\" stroke-width=\"${sw}\" />` +\n    `\\n<line x1=\"${x + inset}\" y1=\"${y}\" x2=\"${x + inset}\" y2=\"${y + h}\" ` +\n    `stroke=\"${stroke}\" stroke-width=\"${sw}\" />` +\n    `\\n<line x1=\"${x + w - inset}\" y1=\"${y}\" x2=\"${x + w - inset}\" y2=\"${y + h}\" ` +\n    `stroke=\"${stroke}\" stroke-width=\"${sw}\" />`\n  )\n}\n\n/** Double circle: two concentric circles with a gap between them */\nfunction renderDoubleCircle(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string): string {\n  const cx = x + w / 2\n  const cy = y + h / 2\n  const outerR = Math.min(w, h) / 2\n  const innerR = outerR - 5 // 5px gap between rings\n  return (\n    `<circle cx=\"${cx}\" cy=\"${cy}\" r=\"${outerR}\" ` +\n    `fill=\"${fill}\" stroke=\"${stroke}\" stroke-width=\"${sw}\" />` +\n    `\\n<circle cx=\"${cx}\" cy=\"${cy}\" r=\"${innerR}\" ` +\n    `fill=\"${fill}\" stroke=\"${stroke}\" stroke-width=\"${sw}\" />`\n  )\n}\n\n/** Hexagon: 6-point polygon with flat top/bottom and angled sides */\nfunction renderHexagon(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string): string {\n  const inset = h / 4 // horizontal inset for the angled sides\n  const points = [\n    `${x + inset},${y}`,           // top-left\n    `${x + w - inset},${y}`,       // top-right\n    `${x + w},${y + h / 2}`,       // mid-right\n    `${x + w - inset},${y + h}`,   // bottom-right\n    `${x + inset},${y + h}`,       // bottom-left\n    `${x},${y + h / 2}`,           // mid-left\n  ].join(' ')\n\n  return `<polygon points=\"${points}\" fill=\"${fill}\" stroke=\"${stroke}\" stroke-width=\"${sw}\" />`\n}\n\n// --- Batch 2 shapes ---\n\n/** Cylinder / database: top ellipse cap + body rect + bottom ellipse */\nfunction renderCylinder(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string): string {\n  const ry = 7 // ellipse vertical radius for the cap\n  const cx = x + w / 2\n  const bodyTop = y + ry\n  const bodyH = h - 2 * ry\n\n  return (\n    // Body rectangle (no top border — covered by top ellipse)\n    `<rect x=\"${x}\" y=\"${bodyTop}\" width=\"${w}\" height=\"${bodyH}\" ` +\n    `fill=\"${fill}\" stroke=\"none\" />` +\n    // Left and right body borders\n    `\\n<line x1=\"${x}\" y1=\"${bodyTop}\" x2=\"${x}\" y2=\"${bodyTop + bodyH}\" stroke=\"${stroke}\" stroke-width=\"${sw}\" />` +\n    `\\n<line x1=\"${x + w}\" y1=\"${bodyTop}\" x2=\"${x + w}\" y2=\"${bodyTop + bodyH}\" stroke=\"${stroke}\" stroke-width=\"${sw}\" />` +\n    // Bottom ellipse (half visible)\n    `\\n<ellipse cx=\"${cx}\" cy=\"${y + h - ry}\" rx=\"${w / 2}\" ry=\"${ry}\" ` +\n    `fill=\"${fill}\" stroke=\"${stroke}\" stroke-width=\"${sw}\" />` +\n    // Top ellipse (full, on top)\n    `\\n<ellipse cx=\"${cx}\" cy=\"${bodyTop}\" rx=\"${w / 2}\" ry=\"${ry}\" ` +\n    `fill=\"${fill}\" stroke=\"${stroke}\" stroke-width=\"${sw}\" />`\n  )\n}\n\n/** Asymmetric / flag: rectangle with a pointed left edge */\nfunction renderAsymmetric(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string): string {\n  const indent = 12 // how far the point indents\n  const points = [\n    `${x + indent},${y}`,       // top-left (indented)\n    `${x + w},${y}`,            // top-right\n    `${x + w},${y + h}`,        // bottom-right\n    `${x + indent},${y + h}`,   // bottom-left (indented)\n    `${x},${y + h / 2}`,        // left point\n  ].join(' ')\n\n  return `<polygon points=\"${points}\" fill=\"${fill}\" stroke=\"${stroke}\" stroke-width=\"${sw}\" />`\n}\n\n/** Trapezoid [/text\\]: wider bottom, narrower top */\nfunction renderTrapezoid(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string): string {\n  const inset = w * 0.15 // top edge is narrower by this amount on each side\n  const points = [\n    `${x + inset},${y}`,         // top-left (indented)\n    `${x + w - inset},${y}`,     // top-right (indented)\n    `${x + w},${y + h}`,         // bottom-right (full width)\n    `${x},${y + h}`,             // bottom-left (full width)\n  ].join(' ')\n\n  return `<polygon points=\"${points}\" fill=\"${fill}\" stroke=\"${stroke}\" stroke-width=\"${sw}\" />`\n}\n\n/** Trapezoid-alt [\\text/]: wider top, narrower bottom */\nfunction renderTrapezoidAlt(x: number, y: number, w: number, h: number, fill: string, stroke: string, sw: string): string {\n  const inset = w * 0.15 // bottom edge is narrower\n  const points = [\n    `${x},${y}`,                     // top-left (full width)\n    `${x + w},${y}`,                 // top-right (full width)\n    `${x + w - inset},${y + h}`,     // bottom-right (indented)\n    `${x + inset},${y + h}`,         // bottom-left (indented)\n  ].join(' ')\n\n  return `<polygon points=\"${points}\" fill=\"${fill}\" stroke=\"${stroke}\" stroke-width=\"${sw}\" />`\n}\n\n// --- Batch 3: State diagram pseudostates ---\n\n/** State start: small filled circle using primary text color */\nfunction renderStateStart(x: number, y: number, w: number, h: number): string {\n  const cx = x + w / 2\n  const cy = y + h / 2\n  const r = Math.min(w, h) / 2 - 2\n  return `<circle cx=\"${cx}\" cy=\"${cy}\" r=\"${r}\" fill=\"var(--_text)\" stroke=\"none\" />`\n}\n\n/** State end: bullseye — outer ring + inner filled circle using primary text color */\nfunction renderStateEnd(x: number, y: number, w: number, h: number): string {\n  const cx = x + w / 2\n  const cy = y + h / 2\n  const outerR = Math.min(w, h) / 2 - 2\n  const innerR = outerR - 4\n  return (\n    `<circle cx=\"${cx}\" cy=\"${cy}\" r=\"${outerR}\" ` +\n    `fill=\"none\" stroke=\"var(--_text)\" stroke-width=\"${STROKE_WIDTHS.innerBox * 2}\" />` +\n    `\\n<circle cx=\"${cx}\" cy=\"${cy}\" r=\"${innerR}\" fill=\"var(--_text)\" stroke=\"none\" />`\n  )\n}\n\n// ============================================================================\n// Node label rendering\n// ============================================================================\n\nfunction renderNodeLabel(node: PositionedNode, font: string): string {\n  // State pseudostates have no label\n  if (node.shape === 'state-start' || node.shape === 'state-end') {\n    if (!node.label) return ''\n  }\n\n  const cx = node.x + node.width / 2\n  const cy = node.y + node.height / 2\n\n  // Resolve text color — inline styles can override the CSS variable default\n  const textColor = escapeAttr(node.inlineStyle?.color ?? 'var(--_text)')\n\n  return renderMultilineText(\n    node.label,\n    cx,\n    cy,\n    FONT_SIZES.nodeLabel,\n    `text-anchor=\"middle\" font-size=\"${FONT_SIZES.nodeLabel}\" font-weight=\"${FONT_WEIGHTS.nodeLabel}\" fill=\"${textColor}\"`\n  )\n}\n\n// ============================================================================\n// Utilities\n// ============================================================================\n\n/**\n * Escape a string for use as an XML/HTML attribute value.\n * Escapes quotes and ampersands to prevent attribute injection.\n */\nfunction escapeAttr(value: string): string {\n  return value\n    .replace(/&/g, '&amp;')\n    .replace(/\"/g, '&quot;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n}\n"
  },
  {
    "path": "src/sequence/layout.ts",
    "content": "import type { SequenceDiagram, PositionedSequenceDiagram, PositionedActor, Lifeline, PositionedMessage, Activation, PositionedBlock, PositionedNote } from './types.ts'\nimport type { RenderOptions } from '../types.ts'\nimport { estimateTextWidth, FONT_SIZES, FONT_WEIGHTS } from '../styles.ts'\n\n// ============================================================================\n// Sequence diagram layout engine\n//\n// Custom timeline-based layout (no ELK — sequence diagrams aren't graphs).\n//\n// Layout strategy:\n//   1. Space actors horizontally based on label widths + min gap\n//   2. Stack messages vertically in chronological order\n//   3. Track activation boxes via a stack\n//   4. Position blocks (loop/alt/opt) as background rectangles\n//   5. Position notes next to their target actors\n// ============================================================================\n\n/** Layout constants specific to sequence diagrams */\nconst SEQ = {\n  /** Padding around the entire diagram */\n  padding: 30,\n  /** Minimum gap between actor centers */\n  actorGap: 140,\n  /** Actor box height */\n  actorHeight: 40,\n  /** Horizontal padding inside actor boxes */\n  actorPadX: 16,\n  /** Vertical space between actor boxes and first message */\n  headerGap: 20,\n  /** Vertical space per message row */\n  messageRowHeight: 40,\n  /** Extra vertical space for self-messages (they loop back) */\n  selfMessageHeight: 30,\n  /** Activation box width (narrow rectangle on lifeline) */\n  activationWidth: 10,\n  /** Block padding (loop/alt borders) */\n  blockPadX: 10,\n  blockPadTop: 40,\n  blockPadBottom: 8,\n  /** Extra vertical space before the first message in a block (room for the header label) */\n  blockHeaderExtra: 28,\n  /** Extra vertical space before a message at a divider boundary (room for else/and label) */\n  dividerExtra: 24,\n  /** Note dimensions */\n  noteWidth: 60,\n  notePadX: 12,\n  notePadY: 6,\n  noteGap: 10,\n} as const\n\n/**\n * Lay out a parsed sequence diagram.\n * Returns a fully positioned diagram ready for SVG rendering.\n */\nexport function layoutSequenceDiagram(\n  diagram: SequenceDiagram,\n  _options: RenderOptions = {}\n): PositionedSequenceDiagram {\n  if (diagram.actors.length === 0) {\n    return { width: 0, height: 0, actors: [], lifelines: [], messages: [], activations: [], blocks: [], notes: [] }\n  }\n\n  // 1. Calculate actor widths and assign horizontal positions (center X)\n  const actorWidths = diagram.actors.map(a => {\n    const textW = estimateTextWidth(a.label, FONT_SIZES.nodeLabel, FONT_WEIGHTS.nodeLabel)\n    return Math.max(textW + SEQ.actorPadX * 2, 80)\n  })\n\n  // Build actor center X positions with minimum gap\n  const actorCenterX: number[] = []\n  let currentX = SEQ.padding + actorWidths[0]! / 2\n  for (let i = 0; i < diagram.actors.length; i++) {\n    if (i > 0) {\n      const minGap = Math.max(SEQ.actorGap, (actorWidths[i - 1]! + actorWidths[i]!) / 2 + 40)\n      currentX += minGap\n    }\n    actorCenterX.push(currentX)\n  }\n\n  // Build actor ID → index lookup\n  const actorIndex = new Map<string, number>()\n  for (let i = 0; i < diagram.actors.length; i++) {\n    actorIndex.set(diagram.actors[i]!.id, i)\n  }\n\n  // 2. Position actors at the top\n  const actorY = SEQ.padding\n  const actors: PositionedActor[] = diagram.actors.map((a, i) => ({\n    id: a.id,\n    label: a.label,\n    type: a.type,\n    x: actorCenterX[i]!,\n    y: actorY,\n    width: actorWidths[i]!,\n    height: SEQ.actorHeight,\n  }))\n\n  // 3. Stack messages vertically\n  let messageY = actorY + SEQ.actorHeight + SEQ.headerGap\n  const messages: PositionedMessage[] = []\n\n  // Pre-scan blocks to determine which message indices need extra vertical\n  // space for block headers (e.g. \"alt [Valid credentials]\") or divider\n  // labels (e.g. \"[else Invalid]\"). Without this, messages inside blocks\n  // overlap with the header/divider text that sits above them.\n  const extraSpaceBefore = new Map<number, number>()\n  for (const block of diagram.blocks) {\n    // First message in the block needs room for the block header label\n    const prev = extraSpaceBefore.get(block.startIndex) ?? 0\n    extraSpaceBefore.set(block.startIndex, Math.max(prev, SEQ.blockHeaderExtra))\n\n    // Each divider (else/and) needs room for the divider label\n    for (const div of block.dividers) {\n      const prevDiv = extraSpaceBefore.get(div.index) ?? 0\n      extraSpaceBefore.set(div.index, Math.max(prevDiv, SEQ.dividerExtra))\n    }\n  }\n\n  // Pre-group notes by the message index they follow, so we can position\n  // them inline during the message stacking loop (avoids overlap bugs).\n  const notesByAfterIndex = new Map<number, typeof diagram.notes>()\n  for (const note of diagram.notes) {\n    const list = notesByAfterIndex.get(note.afterIndex) ?? []\n    list.push(note)\n    notesByAfterIndex.set(note.afterIndex, list)\n  }\n  const positionedNotes: PositionedNote[] = []\n\n  // Track activation stack per actor: array of { startY, depth } objects\n  // Depth is used to offset nested activations horizontally for visual clarity\n  const activationStacks = new Map<string, { startY: number; depth: number }[]>()\n  const activations: Activation[] = []\n  const nestingOffset = 4 // Horizontal offset per nesting level\n\n  for (let msgIdx = 0; msgIdx < diagram.messages.length; msgIdx++) {\n    const msg = diagram.messages[msgIdx]!\n    const fromIdx = actorIndex.get(msg.from) ?? 0\n    const toIdx = actorIndex.get(msg.to) ?? 0\n    const isSelf = msg.from === msg.to\n\n    // Add extra vertical space if this message sits below a block header or divider\n    const extra = extraSpaceBefore.get(msgIdx) ?? 0\n    if (extra > 0) messageY += extra\n\n    const x1 = actorCenterX[fromIdx]!\n    const x2 = actorCenterX[toIdx]!\n\n    messages.push({\n      from: msg.from,\n      to: msg.to,\n      label: msg.label,\n      lineStyle: msg.lineStyle,\n      arrowHead: msg.arrowHead,\n      x1, x2,\n      y: messageY,\n      isSelf,\n    })\n\n    // Handle activation - track nesting depth for visual offset\n    if (msg.activate) {\n      if (!activationStacks.has(msg.to)) {\n        activationStacks.set(msg.to, [])\n      }\n      const stack = activationStacks.get(msg.to)!\n      const depth = stack.length // Current depth before pushing\n      stack.push({ startY: messageY, depth })\n    }\n\n    if (msg.deactivate) {\n      const stack = activationStacks.get(msg.from)\n      if (stack && stack.length > 0) {\n        const { startY, depth } = stack.pop()!\n        const idx = actorIndex.get(msg.from) ?? 0\n        // Offset nested activations to the right for visual distinction\n        const xOffset = depth * nestingOffset\n        activations.push({\n          actorId: msg.from,\n          x: actorCenterX[idx]! - SEQ.activationWidth / 2 + xOffset,\n          topY: startY,\n          bottomY: messageY,\n          width: SEQ.activationWidth,\n        })\n      }\n    }\n\n    // Advance messageY past the message itself\n    messageY += isSelf ? SEQ.selfMessageHeight + SEQ.messageRowHeight : SEQ.messageRowHeight\n\n    // Position notes that appear after this message.\n    // Notes start below the self-message loop (if self) or below the arrow,\n    // and consecutive notes stack vertically. If notes extend beyond the\n    // normal message advance, push messageY further so subsequent messages\n    // don't overlap.\n    const notesForMsg = notesByAfterIndex.get(msgIdx)\n    if (notesForMsg && notesForMsg.length > 0) {\n      // Self-message loops extend selfMessageHeight below msg.y;\n      // normal arrows sit at msg.y with no extension below.\n      const selfLoopExtra = isSelf ? SEQ.selfMessageHeight : 0\n      let noteY = messages[msgIdx]!.y + selfLoopExtra + 8\n\n      for (const note of notesForMsg) {\n        const noteW = Math.max(\n          SEQ.noteWidth,\n          estimateTextWidth(note.text, FONT_SIZES.edgeLabel, FONT_WEIGHTS.edgeLabel) + SEQ.notePadX * 2\n        )\n        const noteH = FONT_SIZES.edgeLabel + SEQ.notePadY * 2\n\n        // X positioning based on actor position and note type\n        const firstActorIdx = actorIndex.get(note.actorIds[0] ?? '') ?? 0\n        let noteX: number\n        if (note.position === 'left') {\n          noteX = actorCenterX[firstActorIdx]! - actorWidths[firstActorIdx]! / 2 - noteW - SEQ.noteGap\n        } else if (note.position === 'right') {\n          noteX = actorCenterX[firstActorIdx]! + actorWidths[firstActorIdx]! / 2 + SEQ.noteGap\n        } else {\n          // over — center between first and last actor\n          if (note.actorIds.length > 1) {\n            const lastActorIdx = actorIndex.get(note.actorIds[note.actorIds.length - 1] ?? '') ?? firstActorIdx\n            noteX = (actorCenterX[firstActorIdx]! + actorCenterX[lastActorIdx]!) / 2 - noteW / 2\n          } else {\n            noteX = actorCenterX[firstActorIdx]! - noteW / 2\n          }\n        }\n\n        positionedNotes.push({\n          text: note.text,\n          x: noteX,\n          y: noteY,\n          width: noteW,\n          height: noteH,\n          position: note.position,\n          actors: note.actorIds,\n        })\n\n        noteY += noteH + 4 // Stack next note below with gap\n      }\n\n      // Push messageY forward if notes extended beyond the normal advance.\n      // Add half a row height so the next message's label (rendered at msg.y - 6)\n      // has clearance from the last note's bottom edge.\n      messageY = Math.max(messageY, noteY + SEQ.messageRowHeight / 2)\n    }\n  }\n\n  // Close any unclosed activations (preserving depth for offset)\n  for (const [actorId, stack] of activationStacks) {\n    for (const { startY, depth } of stack) {\n      const idx = actorIndex.get(actorId) ?? 0\n      const xOffset = depth * nestingOffset\n      activations.push({\n        actorId,\n        x: actorCenterX[idx]! - SEQ.activationWidth / 2 + xOffset,\n        topY: startY,\n        bottomY: messageY - SEQ.messageRowHeight / 2,\n        width: SEQ.activationWidth,\n      })\n    }\n  }\n\n  // 4. Position blocks (loop/alt/opt)\n  const blocks: PositionedBlock[] = diagram.blocks.map(block => {\n    // Block spans from the Y of startIndex to endIndex messages\n    const startMsg = messages[block.startIndex]\n    const endMsg = messages[block.endIndex]\n    const blockTop = (startMsg?.y ?? messageY) - SEQ.blockPadTop\n    const blockBottom = (endMsg?.y ?? messageY) + SEQ.blockPadBottom + 12\n\n    // Block width spans all actors involved in its messages\n    const involvedActors = new Set<number>()\n    for (let mi = block.startIndex; mi <= block.endIndex; mi++) {\n      const m = diagram.messages[mi]\n      if (m) {\n        involvedActors.add(actorIndex.get(m.from) ?? 0)\n        involvedActors.add(actorIndex.get(m.to) ?? 0)\n      }\n    }\n    // Fallback: span all actors if none involved\n    if (involvedActors.size === 0) {\n      for (let ai = 0; ai < diagram.actors.length; ai++) involvedActors.add(ai)\n    }\n    const minIdx = Math.min(...involvedActors)\n    const maxIdx = Math.max(...involvedActors)\n    const blockLeft = actorCenterX[minIdx]! - actorWidths[minIdx]! / 2 - SEQ.blockPadX\n    const blockRight = actorCenterX[maxIdx]! + actorWidths[maxIdx]! / 2 + SEQ.blockPadX\n\n    // Position dividers — offset from message Y so the divider label text\n    // (rendered at divider.y + 14 in the renderer) clears the message label\n    // (rendered at msg.y - 6).\n    //\n    // Default offset 28 gives ~8px baseline clearance, which is sufficient\n    // when the divider label (left-aligned at block edge) and message label\n    // (centered between actors) don't share horizontal space. When they DO\n    // overlap horizontally (e.g. long divider labels like \"[Account locked]\"\n    // next to centered message labels like \"403 Forbidden\"), we increase the\n    // offset to 36 so text bounding boxes have ~5px visual clearance.\n    const dividers = block.dividers.map(d => {\n      const msg = messages[d.index]\n      const msgY = msg?.y ?? messageY\n      let offset = 28\n\n      // Dynamic overlap detection: increase offset when the divider label\n      // and message label occupy the same horizontal region, which would\n      // cause vertical text overlap at the default 8px baseline gap.\n      if (d.label && msg?.label) {\n        const divLabelText = `[${d.label}]`\n        const divLabelW = estimateTextWidth(divLabelText, FONT_SIZES.edgeLabel, FONT_WEIGHTS.edgeLabel)\n        const divLabelLeft = blockLeft + 8\n        const divLabelRight = divLabelLeft + divLabelW\n\n        const msgLabelW = estimateTextWidth(msg.label, FONT_SIZES.edgeLabel, FONT_WEIGHTS.edgeLabel)\n        // Self-messages render labels at x1 + 36 (left-aligned); normal\n        // messages center the label between the two actor lifelines.\n        const msgLabelLeft = msg.isSelf\n          ? msg.x1 + 36\n          : (msg.x1 + msg.x2) / 2 - msgLabelW / 2\n        const msgLabelRight = msgLabelLeft + msgLabelW\n\n        if (divLabelRight > msgLabelLeft && divLabelLeft < msgLabelRight) {\n          offset = 36\n        }\n      }\n\n      return { y: msgY - offset, label: d.label }\n    })\n\n    return {\n      type: block.type,\n      label: block.label,\n      x: blockLeft,\n      y: blockTop,\n      width: blockRight - blockLeft,\n      height: blockBottom - blockTop,\n      dividers,\n    }\n  })\n\n  // 5. Notes — already positioned inline during the message stacking loop\n  //    (step 3) to properly account for self-message loops and vertical stacking.\n  const notes = positionedNotes\n\n  // 6. Bounding-box post-processing\n  //\n  // Notes positioned \"left of\" the first actor or \"right of\" the last actor\n  // can extend beyond the actor-based viewport. Compute the true bounding box\n  // across all positioned elements, then shift everything right if anything\n  // extends left of the desired padding margin and expand the width to fit.\n  const diagramBottom = messageY + SEQ.padding\n\n  // Find global X extents across actors, blocks, notes, and message labels\n  let globalMinX: number = SEQ.padding // actors already start at SEQ.padding\n  let globalMaxX = 0\n  for (const a of actors) {\n    globalMinX = Math.min(globalMinX, a.x - a.width / 2)\n    globalMaxX = Math.max(globalMaxX, a.x + a.width / 2)\n  }\n  for (const b of blocks) {\n    globalMinX = Math.min(globalMinX, b.x)\n    globalMaxX = Math.max(globalMaxX, b.x + b.width)\n  }\n  for (const n of notes) {\n    globalMinX = Math.min(globalMinX, n.x)\n    globalMaxX = Math.max(globalMaxX, n.x + n.width)\n  }\n  // Include self-message labels in bounding box — they extend to the right of the actor\n  // and could be clipped if not accounted for in the SVG width\n  for (const m of messages) {\n    if (m.isSelf && m.label) {\n      const loopW = 30 // matches renderer loopW\n      const labelPadding = 8\n      const labelLeft = m.x1 + loopW + labelPadding\n      const labelWidth = estimateTextWidth(m.label, FONT_SIZES.edgeLabel, FONT_WEIGHTS.edgeLabel)\n      globalMaxX = Math.max(globalMaxX, labelLeft + labelWidth + 8) // +8 for safety margin\n    }\n  }\n\n  // If elements extend left of the desired padding, shift everything right\n  const shiftX = globalMinX < SEQ.padding ? SEQ.padding - globalMinX : 0\n  if (shiftX > 0) {\n    for (const a of actors) a.x += shiftX\n    for (const m of messages) { m.x1 += shiftX; m.x2 += shiftX }\n    for (const act of activations) act.x += shiftX\n    for (const b of blocks) { b.x += shiftX; }\n    for (const n of notes) n.x += shiftX\n    // Also shift actor center X array (used for lifelines below)\n    for (let i = 0; i < actorCenterX.length; i++) actorCenterX[i]! += shiftX\n  }\n\n  // 7. Calculate final lifelines (after shift so X positions are correct)\n  const lifelines: Lifeline[] = diagram.actors.map((a, i) => ({\n    actorId: a.id,\n    x: actorCenterX[i]!,\n    topY: actorY + SEQ.actorHeight,\n    bottomY: diagramBottom - SEQ.padding,\n  }))\n\n  // 8. Calculate diagram dimensions from the bounding box\n  const diagramWidth = globalMaxX + shiftX + SEQ.padding\n  const diagramHeight = diagramBottom\n\n  return {\n    width: Math.max(diagramWidth, 200),\n    height: Math.max(diagramHeight, 100),\n    actors,\n    lifelines,\n    messages,\n    activations,\n    blocks,\n    notes,\n  }\n}\n"
  },
  {
    "path": "src/sequence/parser.ts",
    "content": "import type { SequenceDiagram, Actor, Message, Block, Note } from './types.ts'\nimport { normalizeBrTags } from '../multiline-utils.ts'\n\n// ============================================================================\n// Sequence diagram parser\n//\n// Parses Mermaid sequenceDiagram syntax into a SequenceDiagram structure.\n//\n// Supported syntax:\n//   participant A as Alice\n//   actor B as Bob\n//   A->>B: Solid arrow\n//   A-->>B: Dashed arrow\n//   A-)B: Open arrow\n//   A--)B: Dashed open arrow\n//   A->>+B: Activate target\n//   A-->>-B: Deactivate source\n//   loop Label ... end\n//   alt Label ... else Label ... end\n//   opt Label ... end\n//   par Label ... and Label ... end\n//   Note left of A: Text\n//   Note right of A: Text\n//   Note over A,B: Text\n// ============================================================================\n\n/**\n * Parse a Mermaid sequence diagram.\n * Expects the first line to be \"sequenceDiagram\".\n */\nexport function parseSequenceDiagram(lines: string[]): SequenceDiagram {\n  const diagram: SequenceDiagram = {\n    actors: [],\n    messages: [],\n    blocks: [],\n    notes: [],\n  }\n\n  // Track actor IDs to auto-create actors referenced in messages\n  const actorIds = new Set<string>()\n  // Track block nesting with a stack\n  const blockStack: Array<{ type: Block['type']; label: string; startIndex: number; dividers: Block['dividers'] }> = []\n\n  for (let i = 1; i < lines.length; i++) {\n    const line = lines[i]!\n\n    // --- Participant / Actor declaration ---\n    // \"participant A as Alice\" or \"participant Alice\"\n    // \"actor B as Bob\" or \"actor Bob\"\n    const actorMatch = line.match(/^(participant|actor)\\s+(\\S+?)(?:\\s+as\\s+(.+))?$/)\n    if (actorMatch) {\n      const type = actorMatch[1] as 'participant' | 'actor'\n      const id = actorMatch[2]!\n      const rawLabel = actorMatch[3]?.trim() ?? id\n      const label = normalizeBrTags(rawLabel)\n      if (!actorIds.has(id)) {\n        actorIds.add(id)\n        diagram.actors.push({ id, label, type })\n      }\n      continue\n    }\n\n    // --- Note ---\n    // \"Note left of A: text\" / \"Note right of A: text\" / \"Note over A,B: text\"\n    const noteMatch = line.match(/^Note\\s+(left of|right of|over)\\s+([^:]+):\\s*(.+)$/i)\n    if (noteMatch) {\n      const posStr = noteMatch[1]!.toLowerCase()\n      const actorsStr = noteMatch[2]!.trim()\n      const text = normalizeBrTags(noteMatch[3]!.trim())\n      const noteActorIds = actorsStr.split(',').map(s => s.trim())\n\n      // Ensure actors exist\n      for (const aid of noteActorIds) {\n        ensureActor(diagram, actorIds, aid)\n      }\n\n      let position: 'left' | 'right' | 'over' = 'over'\n      if (posStr === 'left of') position = 'left'\n      else if (posStr === 'right of') position = 'right'\n\n      diagram.notes.push({\n        actorIds: noteActorIds,\n        text,\n        position,\n        afterIndex: diagram.messages.length - 1,\n      })\n      continue\n    }\n\n    // --- Block start: loop, alt, opt, par, critical, break, rect ---\n    const blockMatch = line.match(/^(loop|alt|opt|par|critical|break|rect)\\s*(.*)$/)\n    if (blockMatch) {\n      const blockType = blockMatch[1] as Block['type']\n      const rawBlockLabel = blockMatch[2]?.trim() ?? ''\n      const label = normalizeBrTags(rawBlockLabel)\n      blockStack.push({\n        type: blockType,\n        label,\n        startIndex: diagram.messages.length,\n        dividers: [],\n      })\n      continue\n    }\n\n    // --- Block divider: else, and ---\n    const dividerMatch = line.match(/^(else|and)\\s*(.*)$/)\n    if (dividerMatch && blockStack.length > 0) {\n      const rawDividerLabel = dividerMatch[2]?.trim() ?? ''\n      const label = normalizeBrTags(rawDividerLabel)\n      blockStack[blockStack.length - 1]!.dividers.push({\n        index: diagram.messages.length,\n        label,\n      })\n      continue\n    }\n\n    // --- Block end ---\n    if (line === 'end' && blockStack.length > 0) {\n      const completed = blockStack.pop()!\n      diagram.blocks.push({\n        type: completed.type,\n        label: completed.label,\n        startIndex: completed.startIndex,\n        endIndex: Math.max(diagram.messages.length - 1, completed.startIndex),\n        dividers: completed.dividers,\n      })\n      continue\n    }\n\n    // --- Message ---\n    // Patterns: A->>B, A-->>B, A-)B, A--)B, with optional +/- activation\n    // Format: FROM ARROW TO: LABEL\n    const msgMatch = line.match(\n      /^(\\S+?)\\s*(--?>?>|--?[)x]|--?>>|--?>)\\s*([+-]?)(\\S+?)\\s*:\\s*(.+)$/\n    )\n    if (msgMatch) {\n      const from = msgMatch[1]!\n      const arrow = msgMatch[2]!\n      const activationMark = msgMatch[3]\n      const to = msgMatch[4]!\n      const label = normalizeBrTags(msgMatch[5]!.trim())\n\n      // Ensure both actors exist\n      ensureActor(diagram, actorIds, from)\n      ensureActor(diagram, actorIds, to)\n\n      // Determine line style and arrow head from the arrow operator\n      const lineStyle = arrow.startsWith('--') ? 'dashed' : 'solid'\n      // \">>\" = filled arrow, \")\" or \">\" alone = open arrow, \"x\" = cross (treat as filled)\n      const arrowHead = arrow.includes('>>') || arrow.includes('x') ? 'filled' : 'open'\n\n      const msg: Message = {\n        from,\n        to,\n        label,\n        lineStyle,\n        arrowHead,\n      }\n\n      // Activation/deactivation via +/- prefix on target\n      if (activationMark === '+') msg.activate = true\n      if (activationMark === '-') msg.deactivate = true\n\n      diagram.messages.push(msg)\n      continue\n    }\n\n    // --- Simplified message format: A->>B: Label (fallback with more relaxed regex) ---\n    const simpleMsgMatch = line.match(\n      /^(\\S+?)\\s*(->>|-->>|-\\)|--\\)|-x|--x|->|-->)\\s*([+-]?)(\\S+?)\\s*:\\s*(.+)$/\n    )\n    if (simpleMsgMatch) {\n      const from = simpleMsgMatch[1]!\n      const arrow = simpleMsgMatch[2]!\n      const activationMark = simpleMsgMatch[3]\n      const to = simpleMsgMatch[4]!\n      const label = normalizeBrTags(simpleMsgMatch[5]!.trim())\n\n      ensureActor(diagram, actorIds, from)\n      ensureActor(diagram, actorIds, to)\n\n      const lineStyle = arrow.startsWith('--') ? 'dashed' : 'solid'\n      const arrowHead = arrow.includes('>>') || arrow.includes('x') ? 'filled' : 'open'\n\n      const msg: Message = { from, to, label, lineStyle, arrowHead }\n      if (activationMark === '+') msg.activate = true\n      if (activationMark === '-') msg.deactivate = true\n\n      diagram.messages.push(msg)\n      continue\n    }\n\n    // --- activate / deactivate explicit commands ---\n    // These are handled implicitly via +/- on messages but can also appear standalone\n    // For now, we skip explicit activate/deactivate lines (they affect rendering only)\n  }\n\n  return diagram\n}\n\n/** Ensure an actor exists, creating a default participant if not */\nfunction ensureActor(diagram: SequenceDiagram, actorIds: Set<string>, id: string): void {\n  if (!actorIds.has(id)) {\n    actorIds.add(id)\n    diagram.actors.push({ id, label: id, type: 'participant' })\n  }\n}\n"
  },
  {
    "path": "src/sequence/renderer.ts",
    "content": "import type { PositionedSequenceDiagram, PositionedActor, Lifeline, PositionedMessage, Activation, PositionedBlock, PositionedNote } from './types.ts'\nimport type { DiagramColors } from '../theme.ts'\nimport { svgOpenTag, buildStyleBlock } from '../theme.ts'\nimport { FONT_SIZES, FONT_WEIGHTS, STROKE_WIDTHS, ARROW_HEAD, estimateTextWidth, TEXT_BASELINE_SHIFT } from '../styles.ts'\nimport { renderMultilineText, escapeXml as escapeXmlUtil } from '../multiline-utils.ts'\n\n// ============================================================================\n// Sequence diagram SVG renderer\n//\n// Renders a positioned sequence diagram to SVG string.\n// All colors use CSS custom properties (var(--_xxx)) from the theme system.\n//\n// Render order (back to front):\n//   1. Block backgrounds (loop/alt/opt)\n//   2. Lifelines (dashed vertical lines)\n//   3. Activation boxes\n//   4. Messages (arrows with labels)\n//   5. Notes\n//   6. Actor boxes (at top)\n// ============================================================================\n\n/**\n * Render a positioned sequence diagram as an SVG string.\n *\n * @param colors - DiagramColors with bg/fg and optional enrichment variables.\n * @param transparent - If true, renders with transparent background.\n */\nexport function renderSequenceSvg(\n  diagram: PositionedSequenceDiagram,\n  colors: DiagramColors,\n  font: string = 'Inter',\n  transparent: boolean = false\n): string {\n  const parts: string[] = []\n\n  // SVG root with CSS variables + style block + defs\n  parts.push(svgOpenTag(diagram.width, diagram.height, colors, transparent))\n  parts.push(buildStyleBlock(font, false))\n  parts.push('<defs>')\n\n  // Arrow marker definitions\n  parts.push(arrowMarkerDefs())\n  parts.push('</defs>')\n\n  // 1. Block backgrounds (loop/alt/opt rectangles)\n  for (const block of diagram.blocks) {\n    parts.push(renderBlock(block))\n  }\n\n  // 2. Lifelines (dashed vertical lines from actor to bottom)\n  for (const lifeline of diagram.lifelines) {\n    parts.push(renderLifeline(lifeline))\n  }\n\n  // 3. Activation boxes\n  for (const activation of diagram.activations) {\n    parts.push(renderActivation(activation))\n  }\n\n  // 4. Messages (horizontal arrows with labels)\n  for (const message of diagram.messages) {\n    parts.push(renderMessage(message))\n  }\n\n  // 5. Notes\n  for (const note of diagram.notes) {\n    parts.push(renderNote(note))\n  }\n\n  // 6. Actor boxes at top (rendered last so they're on top)\n  for (const actor of diagram.actors) {\n    parts.push(renderActor(actor))\n  }\n\n  parts.push('</svg>')\n  return parts.join('\\n')\n}\n\n// ============================================================================\n// Arrow marker definitions\n// ============================================================================\n\nfunction arrowMarkerDefs(): string {\n  const w = ARROW_HEAD.width\n  const h = ARROW_HEAD.height\n  return (\n    `  <marker id=\"seq-arrow\" markerWidth=\"${w}\" markerHeight=\"${h}\" refX=\"${w}\" refY=\"${h / 2}\" orient=\"auto-start-reverse\">` +\n    `\\n    <polygon points=\"0 0, ${w} ${h / 2}, 0 ${h}\" fill=\"var(--_arrow)\" />` +\n    `\\n  </marker>` +\n    // Open arrow head (just lines, no fill)\n    `\\n  <marker id=\"seq-arrow-open\" markerWidth=\"${w}\" markerHeight=\"${h}\" refX=\"${w}\" refY=\"${h / 2}\" orient=\"auto-start-reverse\">` +\n    `\\n    <polyline points=\"0 0, ${w} ${h / 2}, 0 ${h}\" fill=\"none\" stroke=\"var(--_arrow)\" stroke-width=\"1\" />` +\n    `\\n  </marker>`\n  )\n}\n\n// ============================================================================\n// Component renderers\n// ============================================================================\n\n/**\n * Render an actor box (participant = rectangle, actor = stick figure).\n * Wrapped in <g class=\"actor\"> with semantic data attributes.\n */\nfunction renderActor(actor: PositionedActor): string {\n  const { id, x, y, width, height, label, type } = actor\n  const parts: string[] = []\n\n  // Semantic wrapper with actor metadata\n  parts.push(\n    `<g class=\"actor\" data-id=\"${escapeAttr(id)}\" data-label=\"${escapeAttr(label)}\" data-type=\"${type}\">`\n  )\n\n  if (type === 'actor') {\n    // Circle-person icon: outer circle + head circle + shoulders arc.\n    // Defined in a 24×24 coordinate space, scaled to 90% of the actor box height\n    // and centered both horizontally and vertically within the box.\n    // Stroke width is inverse-scaled so the visual thickness matches STROKE_WIDTHS.outerBox.\n    const s = (height / 24) * 0.9\n    const tx = x - 12 * s            // center icon horizontally on actor.x\n    const ty = y + (height - 24 * s) / 2  // center icon vertically in actor box\n    const sw = STROKE_WIDTHS.outerBox / s  // compensate for scale transform\n    const iconStroke = 'var(--_line)'      // use line color for actor icon strokes\n\n    parts.push(\n      `  <g transform=\"translate(${tx},${ty}) scale(${s})\">` +\n      // Outer circle\n      `\\n    <path d=\"M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z\" fill=\"none\" stroke=\"${iconStroke}\" stroke-width=\"${sw}\" />` +\n      // Head\n      `\\n    <path d=\"M15 10C15 11.6569 13.6569 13 12 13C10.3431 13 9 11.6569 9 10C9 8.34315 10.3431 7 12 7C13.6569 7 15 8.34315 15 10Z\" fill=\"none\" stroke=\"${iconStroke}\" stroke-width=\"${sw}\" />` +\n      // Shoulders\n      `\\n    <path d=\"M5.62842 18.3563C7.08963 17.0398 9.39997 16 12 16C14.6 16 16.9104 17.0398 18.3716 18.3563\" fill=\"none\" stroke=\"${iconStroke}\" stroke-width=\"${sw}\" />` +\n      `\\n  </g>`\n    )\n    // Label below the icon (supports multi-line)\n    parts.push(\n      '  ' + renderMultilineText(label, x, y + height + 14, FONT_SIZES.nodeLabel,\n        `font-size=\"${FONT_SIZES.nodeLabel}\" text-anchor=\"middle\" font-weight=\"${FONT_WEIGHTS.nodeLabel}\" fill=\"var(--_text)\"`)\n    )\n  } else {\n    // Participant: rectangle box with label (supports multi-line)\n    const boxX = x - width / 2\n    parts.push(\n      `  <rect x=\"${boxX}\" y=\"${y}\" width=\"${width}\" height=\"${height}\" rx=\"4\" ry=\"4\" ` +\n      `fill=\"var(--_node-fill)\" stroke=\"var(--_node-stroke)\" stroke-width=\"${STROKE_WIDTHS.outerBox}\" />`\n    )\n    parts.push(\n      '  ' + renderMultilineText(label, x, y + height / 2, FONT_SIZES.nodeLabel,\n        `font-size=\"${FONT_SIZES.nodeLabel}\" text-anchor=\"middle\" font-weight=\"${FONT_WEIGHTS.nodeLabel}\" fill=\"var(--_text)\"`)\n    )\n  }\n\n  parts.push('</g>')\n  return parts.join('\\n')\n}\n\n/**\n * Render a lifeline (dashed vertical line from actor to bottom).\n * Includes data-actor to link to its actor.\n */\nfunction renderLifeline(lifeline: Lifeline): string {\n  return (\n    `<line class=\"lifeline\" data-actor=\"${escapeAttr(lifeline.actorId)}\" ` +\n    `x1=\"${lifeline.x}\" y1=\"${lifeline.topY}\" x2=\"${lifeline.x}\" y2=\"${lifeline.bottomY}\" ` +\n    `stroke=\"var(--_line)\" stroke-width=\"0.75\" stroke-dasharray=\"6 4\" />`\n  )\n}\n\n/**\n * Render an activation box (narrow filled rectangle on lifeline).\n * Includes data-actor to link to its actor.\n */\nfunction renderActivation(activation: Activation): string {\n  return (\n    `<rect class=\"activation\" data-actor=\"${escapeAttr(activation.actorId)}\" ` +\n    `x=\"${activation.x}\" y=\"${activation.topY}\" width=\"${activation.width}\" height=\"${activation.bottomY - activation.topY}\" ` +\n    `fill=\"var(--_node-fill)\" stroke=\"var(--_node-stroke)\" stroke-width=\"${STROKE_WIDTHS.innerBox}\" />`\n  )\n}\n\n/**\n * Render a message arrow with label.\n * Wrapped in <g class=\"message\"> with semantic data attributes.\n */\nfunction renderMessage(msg: PositionedMessage): string {\n  const parts: string[] = []\n  const dashArray = msg.lineStyle === 'dashed' ? ' stroke-dasharray=\"6 4\"' : ''\n  const markerId = msg.arrowHead === 'filled' ? 'seq-arrow' : 'seq-arrow-open'\n\n  // Semantic wrapper with message metadata\n  parts.push(\n    `<g class=\"message\" data-from=\"${escapeAttr(msg.from)}\" data-to=\"${escapeAttr(msg.to)}\" ` +\n    `data-label=\"${escapeAttr(msg.label)}\" data-line-style=\"${msg.lineStyle}\" ` +\n    `data-arrow-head=\"${msg.arrowHead}\" data-self=\"${msg.isSelf}\">`\n  )\n\n  if (msg.isSelf) {\n    // Self-message: curved loop going right and back\n    // Loop dimensions - loopH is fixed, loopW provides minimum clearance\n    const loopW = 30\n    const loopH = 20\n    const labelPadding = 8 // Space between loop and label\n    parts.push(\n      `  <polyline points=\"${msg.x1},${msg.y} ${msg.x1 + loopW},${msg.y} ${msg.x1 + loopW},${msg.y + loopH} ${msg.x2},${msg.y + loopH}\" ` +\n      `fill=\"none\" stroke=\"var(--_line)\" stroke-width=\"${STROKE_WIDTHS.connector}\"${dashArray} marker-end=\"url(#${markerId})\" />`\n    )\n    // Label to the right of the loop (supports multi-line)\n    parts.push(\n      '  ' + renderMultilineText(msg.label, msg.x1 + loopW + labelPadding, msg.y + loopH / 2, FONT_SIZES.edgeLabel,\n        `font-size=\"${FONT_SIZES.edgeLabel}\" text-anchor=\"start\" font-weight=\"${FONT_WEIGHTS.edgeLabel}\" fill=\"var(--_text-muted)\"`)\n    )\n  } else {\n    // Normal message: horizontal arrow\n    parts.push(\n      `  <line x1=\"${msg.x1}\" y1=\"${msg.y}\" x2=\"${msg.x2}\" y2=\"${msg.y}\" ` +\n      `stroke=\"var(--_line)\" stroke-width=\"${STROKE_WIDTHS.connector}\"${dashArray} marker-end=\"url(#${markerId})\" />`\n    )\n    // Label above the arrow, centered (supports multi-line)\n    const midX = (msg.x1 + msg.x2) / 2\n    parts.push(\n      '  ' + renderMultilineText(msg.label, midX, msg.y - 10, FONT_SIZES.edgeLabel,\n        `font-size=\"${FONT_SIZES.edgeLabel}\" text-anchor=\"middle\" font-weight=\"${FONT_WEIGHTS.edgeLabel}\" fill=\"var(--_text-muted)\"`)\n    )\n  }\n\n  parts.push('</g>')\n  return parts.join('\\n')\n}\n\n/**\n * Render a block background (loop/alt/opt).\n * Wrapped in <g class=\"block\"> with semantic data attributes.\n */\nfunction renderBlock(block: PositionedBlock): string {\n  const parts: string[] = []\n\n  // Semantic wrapper with block metadata\n  const labelAttr = block.label ? ` data-label=\"${escapeAttr(block.label)}\"` : ''\n  parts.push(\n    `<g class=\"block\" data-type=\"${escapeAttr(block.type)}\"${labelAttr}>`\n  )\n\n  // Outer rectangle\n  parts.push(\n    `  <rect x=\"${block.x}\" y=\"${block.y}\" width=\"${block.width}\" height=\"${block.height}\" ` +\n    `rx=\"0\" ry=\"0\" fill=\"none\" stroke=\"var(--_node-stroke)\" stroke-width=\"${STROKE_WIDTHS.outerBox}\" />`\n  )\n\n  // Type label tab (top-left corner)\n  // For multi-line block labels, we use the first line for the tab but show full label\n  const labelText = `${block.type}${block.label ? ` [${block.label}]` : ''}`\n  const firstLine = labelText.split('\\n')[0]!\n  const tabWidth = estimateTextWidth(firstLine, FONT_SIZES.edgeLabel, FONT_WEIGHTS.groupHeader) + 16\n  const tabHeight = 18\n\n  parts.push(\n    `  <rect x=\"${block.x}\" y=\"${block.y}\" width=\"${tabWidth}\" height=\"${tabHeight}\" ` +\n    `fill=\"var(--_group-hdr)\" stroke=\"var(--_node-stroke)\" stroke-width=\"${STROKE_WIDTHS.outerBox}\" />`\n  )\n  // Block type label (supports multi-line via <br> tags)\n  parts.push(\n    '  ' + renderMultilineText(\n      labelText,\n      block.x + 6,\n      block.y + tabHeight / 2,\n      FONT_SIZES.edgeLabel,\n      `font-size=\"${FONT_SIZES.edgeLabel}\" font-weight=\"${FONT_WEIGHTS.groupHeader}\" fill=\"var(--_text-sec)\"`\n    )\n  )\n\n  // Divider lines (for alt/else, par/and)\n  for (const divider of block.dividers) {\n    parts.push(\n      `  <line x1=\"${block.x}\" y1=\"${divider.y}\" x2=\"${block.x + block.width}\" y2=\"${divider.y}\" ` +\n      `stroke=\"var(--_line)\" stroke-width=\"0.75\" stroke-dasharray=\"6 4\" />`\n    )\n    if (divider.label) {\n      // Divider label supports multi-line\n      parts.push(\n        '  ' + renderMultilineText(`[${divider.label}]`, block.x + 8, divider.y + 14, FONT_SIZES.edgeLabel,\n          `font-size=\"${FONT_SIZES.edgeLabel}\" text-anchor=\"start\" font-weight=\"${FONT_WEIGHTS.edgeLabel}\" fill=\"var(--_text-muted)\"`)\n      )\n    }\n  }\n\n  parts.push('</g>')\n  return parts.join('\\n')\n}\n\n/**\n * Render a note box.\n * Wrapped in <g class=\"note\"> with semantic data attributes.\n */\nfunction renderNote(note: PositionedNote): string {\n  // Dog-ear note: polygon with clipped top-right corner + fold triangle\n  const foldSize = 6\n  const { x, y, width: w, height: h } = note\n\n  // Build actor reference attribute if present\n  const actorsAttr = note.actors && note.actors.length > 0\n    ? ` data-actors=\"${note.actors.map(escapeAttr).join(',')}\"`\n    : ''\n  const positionAttr = note.position ? ` data-position=\"${escapeAttr(note.position)}\"` : ''\n\n  // Note body: polygon with top-right corner cut off\n  //   (x,y) → (x+w-fold,y) → (x+w,y+fold) → (x+w,y+h) → (x,y+h)\n  const bodyPoints = [\n    `${x},${y}`,\n    `${x + w - foldSize},${y}`,\n    `${x + w},${y + foldSize}`,\n    `${x + w},${y + h}`,\n    `${x},${y + h}`,\n  ].join(' ')\n\n  return (\n    `<g class=\"note\"${positionAttr}${actorsAttr}>` +\n    // Note body with bg fill and clipped corner\n    `\\n  <polygon points=\"${bodyPoints}\" ` +\n    `fill=\"var(--bg)\" stroke=\"var(--_node-stroke)\" stroke-width=\"${STROKE_WIDTHS.innerBox}\" />` +\n    // Fold triangle (the folded-over corner)\n    `\\n  <polygon points=\"${x + w - foldSize},${y} ${x + w},${y + foldSize} ${x + w - foldSize},${y + foldSize}\" ` +\n    `fill=\"var(--_inner-stroke)\" stroke=\"var(--_node-stroke)\" stroke-width=\"${STROKE_WIDTHS.innerBox}\" />` +\n    // Note text (supports multi-line)\n    `\\n  ${renderMultilineText(note.text, x + w / 2, y + h / 2, FONT_SIZES.edgeLabel,\n      `font-size=\"${FONT_SIZES.edgeLabel}\" text-anchor=\"middle\" font-weight=\"${FONT_WEIGHTS.edgeLabel}\" fill=\"var(--_text-muted)\"`)}` +\n    `\\n</g>`\n  )\n}\n\n// ============================================================================\n// Utilities\n// ============================================================================\n\n// Use shared escapeXml from multiline-utils\nconst escapeXml = escapeXmlUtil\n\n/**\n * Escape a string for use as an XML/HTML attribute value.\n */\nfunction escapeAttr(value: string): string {\n  return value\n    .replace(/&/g, '&amp;')\n    .replace(/\"/g, '&quot;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n}\n"
  },
  {
    "path": "src/sequence/types.ts",
    "content": "// ============================================================================\n// Sequence diagram types\n//\n// Models the parsed and positioned representations of a Mermaid sequence diagram.\n// Sequence diagrams show actor interactions over time (vertical timeline).\n// ============================================================================\n\n/** Parsed sequence diagram — logical structure from mermaid text */\nexport interface SequenceDiagram {\n  /** Ordered list of actors/participants */\n  actors: Actor[]\n  /** Messages between actors in chronological order */\n  messages: Message[]\n  /** Structural blocks (loop, alt, opt, par, critical) */\n  blocks: Block[]\n  /** Notes attached to actors */\n  notes: Note[]\n}\n\nexport interface Actor {\n  id: string\n  label: string\n  /** 'participant' renders as a box, 'actor' renders as a stick figure */\n  type: 'participant' | 'actor'\n}\n\nexport interface Message {\n  from: string\n  to: string\n  label: string\n  /** Arrow style: solid line or dashed line */\n  lineStyle: 'solid' | 'dashed'\n  /** Arrow head: filled (closed) or open */\n  arrowHead: 'filled' | 'open'\n  /** Activate the target lifeline (+) */\n  activate?: boolean\n  /** Deactivate the source lifeline (-) */\n  deactivate?: boolean\n}\n\nexport interface Block {\n  /** Block type keyword */\n  type: 'loop' | 'alt' | 'opt' | 'par' | 'critical' | 'break' | 'rect'\n  /** Label for the block header */\n  label: string\n  /** Index of the first message inside this block */\n  startIndex: number\n  /** Index of the last message inside this block (inclusive) */\n  endIndex: number\n  /** For alt/par blocks: indices where \"else\"/\"and\" dividers appear (message indices) */\n  dividers: Array<{ index: number; label: string }>\n}\n\nexport interface Note {\n  /** Which actor(s) the note is attached to */\n  actorIds: string[]\n  /** Note text content */\n  text: string\n  /** Position relative to the actor(s) */\n  position: 'left' | 'right' | 'over'\n  /** Message index after which this note appears */\n  afterIndex: number\n}\n\n// ============================================================================\n// Positioned sequence diagram — ready for SVG rendering\n// ============================================================================\n\nexport interface PositionedSequenceDiagram {\n  width: number\n  height: number\n  actors: PositionedActor[]\n  lifelines: Lifeline[]\n  messages: PositionedMessage[]\n  activations: Activation[]\n  blocks: PositionedBlock[]\n  notes: PositionedNote[]\n}\n\nexport interface PositionedActor {\n  id: string\n  label: string\n  type: 'participant' | 'actor'\n  /** Center x of the actor box */\n  x: number\n  /** Top y of the actor box */\n  y: number\n  width: number\n  height: number\n}\n\n/** Vertical dashed line from actor to bottom of diagram */\nexport interface Lifeline {\n  actorId: string\n  x: number\n  topY: number\n  bottomY: number\n}\n\nexport interface PositionedMessage {\n  from: string\n  to: string\n  label: string\n  lineStyle: 'solid' | 'dashed'\n  arrowHead: 'filled' | 'open'\n  /** Start point (from actor's lifeline) */\n  x1: number\n  /** End point (to actor's lifeline) */\n  x2: number\n  /** Vertical position */\n  y: number\n  /** Whether this is a self-message (same actor) */\n  isSelf: boolean\n}\n\n/** Narrow rectangle on a lifeline showing active processing */\nexport interface Activation {\n  actorId: string\n  x: number\n  topY: number\n  bottomY: number\n  width: number\n}\n\nexport interface PositionedBlock {\n  type: Block['type']\n  label: string\n  x: number\n  y: number\n  width: number\n  height: number\n  /** Divider lines within the block (for alt/par) */\n  dividers: Array<{ y: number; label: string }>\n}\n\nexport interface PositionedNote {\n  text: string\n  x: number\n  y: number\n  width: number\n  height: number\n  /** Actor IDs this note is attached to (for SVG attribution) */\n  actors?: string[]\n  /** Note position relative to actors (for SVG attribution) */\n  position?: 'left' | 'right' | 'over'\n}\n"
  },
  {
    "path": "src/shape-clipping.ts",
    "content": "/**\n * Shape-aware edge clipping utilities.\n *\n * ELK.js treats all nodes as rectangles for edge routing. For non-rectangular\n * shapes like diamonds, this causes edges to terminate at the bounding box\n * boundary instead of the actual shape vertices.\n *\n * This module provides utilities to clip edge endpoints to actual shape\n * boundaries after ELK layout is complete.\n */\n\nimport type { Point, PositionedNode } from './types.ts'\n\n/**\n * Clip an edge endpoint to the actual shape boundary of a node.\n *\n * @param points - The edge points array\n * @param node - The node to clip to\n * @param isStart - True if clipping the start point (source), false for end (target)\n * @returns New points array with clipped endpoint\n */\nexport function clipEdgeToShape(\n  points: Point[],\n  node: PositionedNode,\n  isStart: boolean\n): Point[] {\n  if (points.length < 2) return points\n\n  // Only clip non-rectangular shapes\n  if (node.shape === 'rectangle' || node.shape === 'rounded' || node.shape === 'stadium') {\n    return points\n  }\n\n  const result = [...points]\n\n  if (node.shape === 'diamond') {\n    if (isStart) {\n      result[0] = clipToDiamond(points[0]!, points[1]!, node)\n    } else {\n      const lastIdx = points.length - 1\n      result[lastIdx] = clipToDiamond(points[lastIdx]!, points[lastIdx - 1]!, node)\n    }\n  }\n  // Future: add clipping for hexagon, circle, etc.\n\n  return result\n}\n\n/**\n * Clip a point to the diamond shape boundary using ray-polygon intersection.\n *\n * Diamond vertices are at the midpoints of the bounding box sides:\n * - Top: (cx, y)\n * - Right: (x + w, cy)\n * - Bottom: (cx, y + h)\n * - Left: (x, cy)\n *\n * For orthogonal edges, we extend the final segment as a ray and find where\n * it intersects the diamond boundary. This preserves orthogonality while\n * ensuring the edge terminates at the actual diamond shape.\n *\n * @param endpoint - The edge endpoint to clip\n * @param adjacent - The adjacent point (to determine ray direction)\n * @param node - The diamond node\n * @returns Clipped point on the diamond boundary\n */\nfunction clipToDiamond(endpoint: Point, adjacent: Point, node: PositionedNode): Point {\n  const cx = node.x + node.width / 2\n  const cy = node.y + node.height / 2\n  const halfW = node.width / 2\n  const halfH = node.height / 2\n\n  // Diamond vertices\n  const top: Point = { x: cx, y: node.y }\n  const right: Point = { x: node.x + node.width, y: cy }\n  const bottom: Point = { x: cx, y: node.y + node.height }\n  const left: Point = { x: node.x, y: cy }\n\n  // Determine approach direction from adjacent point\n  const dx = endpoint.x - adjacent.x\n  const dy = endpoint.y - adjacent.y\n\n  // For orthogonal edges, one of dx or dy will be ~0\n  const isVertical = Math.abs(dx) < Math.abs(dy)\n\n  if (isVertical) {\n    // Vertical ray at x = endpoint.x\n    const rayX = endpoint.x\n\n    if (dy > 0) {\n      // Coming from above (moving down) → intersect with top half of diamond\n      // Top half edges: left-top (left → top) and top-right (top → right)\n      if (rayX <= cx) {\n        // Intersect with left-top edge (from left vertex to top vertex)\n        return intersectVerticalRayWithEdge(rayX, left, top) ?? top\n      } else {\n        // Intersect with top-right edge (from top vertex to right vertex)\n        return intersectVerticalRayWithEdge(rayX, top, right) ?? top\n      }\n    } else {\n      // Coming from below (moving up) → intersect with bottom half of diamond\n      // Bottom half edges: left-bottom (bottom → left) and bottom-right (right → bottom)\n      if (rayX <= cx) {\n        // Intersect with bottom-left edge (from bottom vertex to left vertex)\n        return intersectVerticalRayWithEdge(rayX, bottom, left) ?? bottom\n      } else {\n        // Intersect with bottom-right edge (from right vertex to bottom vertex)\n        return intersectVerticalRayWithEdge(rayX, right, bottom) ?? bottom\n      }\n    }\n  } else {\n    // Horizontal ray at y = endpoint.y\n    const rayY = endpoint.y\n\n    if (dx > 0) {\n      // Coming from left (moving right) → intersect with left half of diamond\n      // Left half edges: top-left (top → left) and left-bottom (left → bottom)\n      if (rayY <= cy) {\n        // Intersect with top-left edge (from top vertex to left vertex)\n        return intersectHorizontalRayWithEdge(rayY, top, left) ?? left\n      } else {\n        // Intersect with left-bottom edge (from left vertex to bottom vertex)\n        return intersectHorizontalRayWithEdge(rayY, left, bottom) ?? left\n      }\n    } else {\n      // Coming from right (moving left) → intersect with right half of diamond\n      // Right half edges: top-right (top → right) and right-bottom (right → bottom)\n      if (rayY <= cy) {\n        // Intersect with top-right edge (from top vertex to right vertex)\n        return intersectHorizontalRayWithEdge(rayY, top, right) ?? right\n      } else {\n        // Intersect with right-bottom edge (from right vertex to bottom vertex)\n        return intersectHorizontalRayWithEdge(rayY, right, bottom) ?? right\n      }\n    }\n  }\n}\n\n/**\n * Find intersection of a horizontal ray (y = rayY) with a line segment.\n * Returns the intersection point or null if no intersection.\n */\nfunction intersectHorizontalRayWithEdge(rayY: number, p1: Point, p2: Point): Point | null {\n  const dy = p2.y - p1.y\n  if (Math.abs(dy) < 0.001) {\n    // Edge is horizontal, no single intersection\n    return null\n  }\n\n  const t = (rayY - p1.y) / dy\n  if (t < 0 || t > 1) {\n    // Intersection outside the edge segment\n    return null\n  }\n\n  const x = p1.x + t * (p2.x - p1.x)\n  return { x, y: rayY }\n}\n\n/**\n * Find intersection of a vertical ray (x = rayX) with a line segment.\n * Returns the intersection point or null if no intersection.\n */\nfunction intersectVerticalRayWithEdge(rayX: number, p1: Point, p2: Point): Point | null {\n  const dx = p2.x - p1.x\n  if (Math.abs(dx) < 0.001) {\n    // Edge is vertical, no single intersection\n    return null\n  }\n\n  const t = (rayX - p1.x) / dx\n  if (t < 0 || t > 1) {\n    // Intersection outside the edge segment\n    return null\n  }\n\n  const y = p1.y + t * (p2.y - p1.y)\n  return { x: rayX, y }\n}\n"
  },
  {
    "path": "src/styles.ts",
    "content": "// ============================================================================\n// Font metrics — character width estimates for Inter at different sizes.\n// Used to approximate text bounding boxes without DOM measurement.\n// These are calibrated for Inter's typical glyph widths.\n//\n// NOTE: Theme/color system has moved to src/theme.ts. This file only\n// contains font metrics, spacing constants, and stroke widths.\n// ============================================================================\n\nimport { measureTextWidth } from './text-metrics'\n\n/** Average character width in px at the given font size and weight (proportional font) */\nexport function estimateTextWidth(text: string, fontSize: number, fontWeight: number): number {\n  // Delegate to variable-width character measurement for better accuracy\n  // with mixed character sets (Latin narrow/wide, CJK, emoji, etc.)\n  return measureTextWidth(text, fontSize, fontWeight)\n}\n\n/** Average character width in px for monospace fonts (uniform glyph width) */\nexport function estimateMonoTextWidth(text: string, fontSize: number): number {\n  // Monospace fonts have uniform character width — 0.6 of fontSize matches actual\n  // glyph widths for JetBrains Mono / SF Mono / Fira Code at small sizes (11px).\n  // Previous value of 0.55 underestimated widths, causing class member labels to\n  // extend beyond their box boundaries.\n  return text.length * fontSize * 0.6\n}\n\n/** Monospace font family used for code-like text (class members, types) */\nexport const MONO_FONT = \"'JetBrains Mono'\" as const\n\n/** Full CSS fallback chain for monospace text */\nexport const MONO_FONT_STACK = `${MONO_FONT}, 'SF Mono', 'Fira Code', ui-monospace, monospace` as const\n\n/** Fixed font sizes used in the renderer (in px) */\nexport const FONT_SIZES = {\n  /** Node label text */\n  nodeLabel: 13,\n  /** Edge label text */\n  edgeLabel: 11,\n  /** Subgraph header text */\n  groupHeader: 12,\n} as const\n\n/** Font weights used per element type */\nexport const FONT_WEIGHTS = {\n  nodeLabel: 500,\n  edgeLabel: 400,\n  groupHeader: 600,\n} as const\n\n// ============================================================================\n// Spacing & sizing constants\n// ============================================================================\n\n/** Vertical gap between a subgraph header band and the content area below it (px).\n * Without this, nested subgraph headers sit flush against their parent's header band.\n * Increased from 8 to 12 to provide more clearance for edges routing near headers. */\nexport const GROUP_HEADER_CONTENT_PAD = 12\n\n/** Padding inside node shapes */\nexport const NODE_PADDING = {\n  /** Horizontal padding inside rectangles/rounded/stadium (increased from 16 for better label fit) */\n  horizontal: 20,\n  /** Vertical padding inside rectangles/rounded/stadium */\n  vertical: 10,\n  /** Extra padding for diamond shapes (they need more space due to rotation) */\n  diamondExtra: 24,\n} as const\n\n/** Stroke widths per element type (in px) */\nexport const STROKE_WIDTHS = {\n  outerBox: 1,\n  innerBox: 0.75,\n  /** Edge connector stroke (increased from 0.75 for better visibility) */\n  connector: 1,\n} as const\n\n/**\n * Vertical shift applied to all text elements for font-agnostic centering.\n *\n * Instead of relying on `dominant-baseline=\"central\"` (which each font interprets\n * differently based on its own ascent/descent metrics), we use the default alphabetic\n * baseline and shift down by 0.35em. This places the optical center of text at the\n * y coordinate, regardless of font family (Inter, JetBrains Mono, system fallbacks).\n *\n * The 0.35em value approximates the distance from alphabetic baseline to visual\n * center of Latin text. Using `em` units ensures it scales with font size.\n */\nexport const TEXT_BASELINE_SHIFT = '0.35em' as const\n\n/** Arrow head dimensions — matches spec: 8px wide × ~5px tall */\nexport const ARROW_HEAD = {\n  width: 8,\n  height: 5,\n} as const\n\n"
  },
  {
    "path": "src/text-metrics.ts",
    "content": "// ============================================================================\n// Text Metrics — Variable-width character measurement for SVG layout\n// ============================================================================\n//\n// Provides font-agnostic text width estimation using character class buckets.\n// More accurate than uniform character width for proportional fonts.\n//\n// Width ratios are normalized where 1.0 = average lowercase letter.\n// Final pixel width = sum(charWidths) * fontSize * baseRatio\n// ============================================================================\n\n/**\n * Narrow characters - visually thin glyphs.\n * Note: '1' is included because in proportional fonts (like Inter), it's\n * significantly narrower than other digits which use tabular/uniform width.\n */\nconst NARROW_CHARS = new Set(['i', 'l', 't', 'f', 'j', 'I', '1', '!', '|', '.', ',', ':', ';', \"'\"])\n\n/**\n * Wide characters - visually wide glyphs\n */\nconst WIDE_CHARS = new Set(['W', 'M', 'w', 'm', '@', '%'])\n\n/**\n * Very wide characters - widest Latin glyphs\n */\nconst VERY_WIDE_CHARS = new Set(['W', 'M'])\n\n/**\n * Semi-narrow punctuation - brackets and slashes are narrower than letters\n * but wider than narrow chars like dots/commas\n */\nconst SEMI_NARROW_PUNCT = new Set(['(', ')', '[', ']', '{', '}', '/', '\\\\', '-', '\"', '`'])\n\n/**\n * Check if a code point is a combining diacritical mark (zero-width overlay)\n */\nfunction isCombiningMark(code: number): boolean {\n  // Combining Diacritical Marks: U+0300–U+036F\n  // Combining Diacritical Marks Extended: U+1AB0–U+1AFF\n  // Combining Diacritical Marks Supplement: U+1DC0–U+1DFF\n  // Combining Diacritical Marks for Symbols: U+20D0–U+20FF\n  // Combining Half Marks: U+FE20–U+FE2F\n  return (\n    (code >= 0x0300 && code <= 0x036f) ||\n    (code >= 0x1ab0 && code <= 0x1aff) ||\n    (code >= 0x1dc0 && code <= 0x1dff) ||\n    (code >= 0x20d0 && code <= 0x20ff) ||\n    (code >= 0xfe20 && code <= 0xfe2f)\n  )\n}\n\n/**\n * Check if a code point is fullwidth (CJK, emoji, etc.)\n * These characters occupy approximately 2x the width of Latin letters.\n */\nfunction isFullwidth(code: number): boolean {\n  // CJK Radicals Supplement: U+2E80–U+2EFF\n  // Kangxi Radicals: U+2F00–U+2FDF\n  // CJK Symbols and Punctuation: U+3000–U+303F\n  // Hiragana: U+3040–U+309F\n  // Katakana: U+30A0–U+30FF\n  // Bopomofo: U+3100–U+312F\n  // Hangul Compatibility Jamo: U+3130–U+318F\n  // Kanbun: U+3190–U+319F\n  // Bopomofo Extended: U+31A0–U+31BF\n  // CJK Strokes: U+31C0–U+31EF\n  // Katakana Phonetic Extensions: U+31F0–U+31FF\n  // Enclosed CJK Letters and Months: U+3200–U+32FF\n  // CJK Compatibility: U+3300–U+33FF\n  // CJK Unified Ideographs Extension A: U+3400–U+4DBF\n  // CJK Unified Ideographs: U+4E00–U+9FFF\n  // Hangul Syllables: U+AC00–U+D7AF\n  // CJK Compatibility Ideographs: U+F900–U+FAFF\n  // Halfwidth and Fullwidth Forms (fullwidth part): U+FF00–U+FF60, U+FFE0–U+FFE6\n  // CJK Unified Ideographs Extension B+: U+20000–U+2A6DF (and beyond)\n\n  return (\n    (code >= 0x1100 && code <= 0x115f) || // Hangul Jamo\n    (code >= 0x2e80 && code <= 0x2eff) || // CJK Radicals Supplement\n    (code >= 0x2f00 && code <= 0x2fdf) || // Kangxi Radicals\n    (code >= 0x3000 && code <= 0x303f) || // CJK Symbols and Punctuation\n    (code >= 0x3040 && code <= 0x309f) || // Hiragana\n    (code >= 0x30a0 && code <= 0x30ff) || // Katakana\n    (code >= 0x3100 && code <= 0x312f) || // Bopomofo\n    (code >= 0x3130 && code <= 0x318f) || // Hangul Compatibility Jamo\n    (code >= 0x3190 && code <= 0x31ff) || // Kanbun + extensions\n    (code >= 0x3200 && code <= 0x33ff) || // Enclosed CJK + Compatibility\n    (code >= 0x3400 && code <= 0x4dbf) || // CJK Extension A\n    (code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs\n    (code >= 0xac00 && code <= 0xd7af) || // Hangul Syllables\n    (code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility Ideographs\n    (code >= 0xff00 && code <= 0xff60) || // Fullwidth ASCII\n    (code >= 0xffe0 && code <= 0xffe6) || // Fullwidth symbols\n    code >= 0x20000 // CJK Extension B and beyond\n  )\n}\n\n/**\n * Regex for emoji detection using Unicode property escapes.\n * Uses Emoji_Presentation and Extended_Pictographic (not just Emoji)\n * because \\p{Emoji} includes digits and # which we don't want as fullwidth.\n */\nconst EMOJI_REGEX = /\\p{Emoji_Presentation}|\\p{Extended_Pictographic}/u\n\n/**\n * Check if a character is an emoji (fullwidth)\n */\nfunction isEmoji(char: string): boolean {\n  return EMOJI_REGEX.test(char)\n}\n\n/**\n * Get the relative width of a single character.\n *\n * Returns a normalized width ratio where:\n * - 0.0 = zero-width (combining marks)\n * - 0.3 = space\n * - 0.4 = narrow (i, l, t, f, j, I, 1)\n * - 0.8 = semi-narrow (r)\n * - 1.0 = average lowercase\n * - 1.2 = wide lowercase / uppercase\n * - 1.5 = very wide (W, M)\n * - 2.0 = fullwidth (CJK, emoji)\n */\nexport function getCharWidth(char: string): number {\n  const code = char.codePointAt(0)\n  if (code === undefined) return 0\n\n  // Zero-width: combining diacritical marks\n  if (isCombiningMark(code)) return 0\n\n  // Fullwidth: CJK, emoji\n  if (isFullwidth(code) || isEmoji(char)) return 2.0\n\n  // Space\n  if (char === ' ') return 0.3\n\n  // Very wide Latin\n  if (VERY_WIDE_CHARS.has(char)) return 1.5\n\n  // Wide Latin\n  if (WIDE_CHARS.has(char)) return 1.2\n\n  // Narrow Latin\n  if (NARROW_CHARS.has(char)) return 0.4\n\n  // Semi-narrow punctuation (brackets, slashes, hyphens)\n  if (SEMI_NARROW_PUNCT.has(char)) return 0.5\n\n  // Semi-narrow letter\n  if (char === 'r') return 0.8\n\n  // Uppercase (slightly wider than lowercase on average)\n  if (code >= 65 && code <= 90) return 1.2\n\n  // Digits (uniform width in most fonts)\n  if (code >= 48 && code <= 57) return 1.0\n\n  // Default: average lowercase width\n  return 1.0\n}\n\n/**\n * Measure the pixel width of a text string.\n *\n * Uses character class buckets for more accurate width estimation\n * than uniform character width assumptions.\n *\n * @param text - The text to measure\n * @param fontSize - Font size in pixels\n * @param fontWeight - Font weight (affects width slightly)\n * @returns Estimated width in pixels\n */\nexport function measureTextWidth(text: string, fontSize: number, fontWeight: number): number {\n  // Base ratio calibrated for Inter font family\n  // Heavier weights are slightly wider\n  // Added +0.02 buffer to prevent edge truncation of characters like 's' at line ends\n  const baseRatio = fontWeight >= 600 ? 0.60 : fontWeight >= 500 ? 0.57 : 0.54\n\n  let totalWidth = 0\n\n  // Iterate over code points (handles surrogate pairs for emoji/CJK)\n  for (const char of text) {\n    totalWidth += getCharWidth(char)\n  }\n\n  // Add minimum padding to prevent truncation at text boundaries\n  // Increased from 0.1 to 0.15 for better label separation and collision prevention\n  const minPadding = fontSize * 0.15\n  return totalWidth * fontSize * baseRatio + minPadding\n}\n\n// ============================================================================\n// Multi-line Text Measurement\n// ============================================================================\n\n/** Standard line height ratio for multi-line text (1.3 = 130% of font size) */\nexport const LINE_HEIGHT_RATIO = 1.3\n\n/** Metrics for multi-line text measurement */\nexport interface MultilineMetrics {\n  /** Maximum line width in pixels */\n  width: number\n  /** Total height in pixels (lines × lineHeight) */\n  height: number\n  /** Individual lines after splitting */\n  lines: string[]\n  /** Computed line height in pixels */\n  lineHeight: number\n}\n\n/**\n * Measure multi-line text dimensions.\n *\n * Splits text on newlines and returns the maximum width across all lines,\n * total height based on line count, and the split lines for rendering.\n *\n * @param text - The text to measure (may contain \\n)\n * @param fontSize - Font size in pixels\n * @param fontWeight - Font weight (affects width slightly)\n * @returns Metrics including width, height, lines array, and lineHeight\n */\nexport function measureMultilineText(\n  text: string,\n  fontSize: number,\n  fontWeight: number\n): MultilineMetrics {\n  const lines = text.split('\\n')\n  const lineHeight = fontSize * LINE_HEIGHT_RATIO\n\n  // Width = max of all line widths\n  let maxWidth = 0\n  for (const line of lines) {\n    const plain = line.replace(/<\\/?(?:b|strong|i|em|u|s|del)\\s*>/gi, '')\n    const w = measureTextWidth(plain, fontSize, fontWeight)\n    if (w > maxWidth) maxWidth = w\n  }\n\n  return {\n    width: maxWidth,\n    height: lines.length * lineHeight,\n    lines,\n    lineHeight,\n  }\n}\n"
  },
  {
    "path": "src/theme.ts",
    "content": "// ============================================================================\n// Theme system — CSS custom property-based theming for mermaid SVG diagrams.\n//\n// Architecture:\n//   - Two required variables: --bg (background) and --fg (foreground)\n//   - Five optional enrichment variables: --line, --accent, --muted, --surface, --border\n//   - Unset optionals fall back to color-mix() derivations from bg + fg\n//   - All derived values computed in a <style> block inside the SVG\n//\n// This means the SVG is a function of its CSS variables. The caller provides\n// colors, and the SVG adapts. No light/dark mode detection needed.\n// ============================================================================\n\n// ============================================================================\n// Types\n// ============================================================================\n\n/**\n * Diagram color configuration.\n *\n * Required: bg + fg give you a clean mono diagram.\n * Optional: line, accent, muted, surface, border bring in richer color\n * from Shiki themes or custom palettes. Each falls back to a color-mix()\n * derivation from bg + fg if not set.\n */\nexport interface DiagramColors {\n  /** Background color → CSS variable --bg */\n  bg: string\n  /** Foreground / primary text color → CSS variable --fg */\n  fg: string\n\n  // -- Optional enrichment (each falls back to color-mix from bg+fg) --\n\n  /** Edge/connector color → CSS variable --line */\n  line?: string\n  /** Arrow heads, highlights, special nodes → CSS variable --accent */\n  accent?: string\n  /** Secondary text, edge labels → CSS variable --muted */\n  muted?: string\n  /** Node/box fill tint → CSS variable --surface */\n  surface?: string\n  /** Node/group stroke color → CSS variable --border */\n  border?: string\n}\n\n// ============================================================================\n// Defaults\n// ============================================================================\n\n/** Default bg/fg when no colors are provided (zinc light) */\nexport const DEFAULTS: Readonly<{ bg: string; fg: string }> = {\n  bg: '#FFFFFF',\n  fg: '#27272A',\n} as const\n\n// ============================================================================\n// color-mix() weights for derived CSS variables\n//\n// When an optional enrichment variable is NOT set, we compute the derived\n// value by mixing --fg into --bg at these percentages. This produces a\n// coherent mono hierarchy on any bg/fg combination.\n// ============================================================================\n\nexport const MIX = {\n  /** Primary text: near-full fg */\n  text:         100, // just use --fg directly\n  /** Secondary text (group headers): fg mixed at 60% */\n  textSec:      60,\n  /** Muted text (edge labels, notes): fg mixed at 40% */\n  textMuted:    40,\n  /** Faint text (de-emphasized): fg mixed at 25% */\n  textFaint:    25,\n  /** Edge/connector lines: fg mixed at 50% for clear visibility */\n  line:         50,\n  /** Arrow head fill: fg mixed at 85% for clear visibility */\n  arrow:        85,\n  /** Node fill tint: fg mixed at 3% */\n  nodeFill:     3,\n  /** Node/group stroke: fg mixed at 20% */\n  nodeStroke:   20,\n  /** Group header band tint: fg mixed at 5% */\n  groupHeader:  5,\n  /** Inner divider strokes: fg mixed at 12% */\n  innerStroke:  12,\n  /** Key badge background opacity (ER diagrams) */\n  keyBadge:     10,\n} as const\n\n// ============================================================================\n// Well-known theme palettes\n//\n// Curated bg/fg pairs (+ optional enrichment) for popular editor themes.\n// Users can also extract from Shiki theme objects via fromShikiTheme().\n// ============================================================================\n\nexport const THEMES: Record<string, DiagramColors> = {\n  'zinc-light': {\n    bg: '#FFFFFF', fg: '#27272A',\n  },\n  'zinc-dark': {\n    bg: '#18181B', fg: '#FAFAFA',\n  },\n  'tokyo-night': {\n    bg: '#1a1b26', fg: '#a9b1d6',\n    line: '#3d59a1', accent: '#7aa2f7', muted: '#565f89',\n  },\n  'tokyo-night-storm': {\n    bg: '#24283b', fg: '#a9b1d6',\n    line: '#3d59a1', accent: '#7aa2f7', muted: '#565f89',\n  },\n  'tokyo-night-light': {\n    bg: '#d5d6db', fg: '#343b58',\n    line: '#34548a', accent: '#34548a', muted: '#9699a3',\n  },\n  'catppuccin-mocha': {\n    bg: '#1e1e2e', fg: '#cdd6f4',\n    line: '#585b70', accent: '#cba6f7', muted: '#6c7086',\n  },\n  'catppuccin-latte': {\n    bg: '#eff1f5', fg: '#4c4f69',\n    line: '#9ca0b0', accent: '#8839ef', muted: '#9ca0b0',\n  },\n  'nord': {\n    bg: '#2e3440', fg: '#d8dee9',\n    line: '#4c566a', accent: '#88c0d0', muted: '#616e88',\n  },\n  'nord-light': {\n    bg: '#eceff4', fg: '#2e3440',\n    line: '#aab1c0', accent: '#5e81ac', muted: '#7b88a1',\n  },\n  'dracula': {\n    bg: '#282a36', fg: '#f8f8f2',\n    line: '#6272a4', accent: '#bd93f9', muted: '#6272a4',\n  },\n  'github-light': {\n    bg: '#ffffff', fg: '#1f2328',\n    line: '#d1d9e0', accent: '#0969da', muted: '#59636e',\n  },\n  'github-dark': {\n    bg: '#0d1117', fg: '#e6edf3',\n    line: '#3d444d', accent: '#4493f8', muted: '#9198a1',\n  },\n  'solarized-light': {\n    bg: '#fdf6e3', fg: '#657b83',\n    line: '#93a1a1', accent: '#268bd2', muted: '#93a1a1',\n  },\n  'solarized-dark': {\n    bg: '#002b36', fg: '#839496',\n    line: '#586e75', accent: '#268bd2', muted: '#586e75',\n  },\n  'one-dark': {\n    bg: '#282c34', fg: '#abb2bf',\n    line: '#4b5263', accent: '#c678dd', muted: '#5c6370',\n  },\n} as const\n\nexport type ThemeName = keyof typeof THEMES\n\n// ============================================================================\n// Shiki theme extraction\n//\n// Extracts DiagramColors from a Shiki ThemeRegistrationResolved object.\n// This provides native compatibility with any VS Code / TextMate theme.\n// ============================================================================\n\n/**\n * Minimal subset of Shiki's ThemeRegistrationResolved that we need.\n * We don't import from shiki to avoid a hard dependency.\n */\ninterface ShikiThemeLike {\n  type?: string\n  colors?: Record<string, string>\n  tokenColors?: Array<{\n    scope?: string | string[]\n    settings?: { foreground?: string }\n  }>\n}\n\n/**\n * Extract diagram colors from a Shiki theme object.\n * Works with any VS Code / TextMate theme loaded by Shiki.\n *\n * Maps editor UI colors to diagram roles:\n *   editor.background         → bg\n *   editor.foreground         → fg\n *   editorLineNumber.fg       → line (optional)\n *   focusBorder / keyword     → accent (optional)\n *   comment token             → muted (optional)\n *   editor.selectionBackground→ surface (optional)\n *   editorWidget.border       → border (optional)\n *\n * @example\n * ```ts\n * import { getSingletonHighlighter } from 'shiki'\n * import { fromShikiTheme } from 'beautiful-mermaid'\n *\n * const hl = await getSingletonHighlighter({ themes: ['tokyo-night'] })\n * const colors = fromShikiTheme(hl.getTheme('tokyo-night'))\n * const svg = renderMermaidSVG(code, colors)\n * ```\n */\nexport function fromShikiTheme(theme: ShikiThemeLike): DiagramColors {\n  const c = theme.colors ?? {}\n  const dark = theme.type === 'dark'\n\n  // Helper: find a token color by scope name\n  const tokenColor = (scope: string): string | undefined =>\n    theme.tokenColors?.find(t =>\n      Array.isArray(t.scope) ? t.scope.includes(scope) : t.scope === scope\n    )?.settings?.foreground\n\n  return {\n    bg: c['editor.background'] ?? (dark ? '#1e1e1e' : '#ffffff'),\n    fg: c['editor.foreground'] ?? (dark ? '#d4d4d4' : '#333333'),\n    line:    c['editorLineNumber.foreground'] ?? undefined,\n    accent:  c['focusBorder'] ?? tokenColor('keyword') ?? undefined,\n    muted:   tokenColor('comment') ?? c['editorLineNumber.foreground'] ?? undefined,\n    surface: c['editor.selectionBackground'] ?? undefined,\n    border:  c['editorWidget.border'] ?? undefined,\n  }\n}\n\n// ============================================================================\n// SVG style block — the CSS variable derivation system\n//\n// Generates the <style> content that maps user-facing variables (--bg, --fg,\n// --line, etc.) to internal derived variables (--_text, --_line, etc.) using\n// color-mix() fallbacks.\n// ============================================================================\n\n/**\n * Build the CSS variable derivation rules for the SVG <style> block.\n *\n * When an optional variable (--line, --accent, etc.) is set on the SVG or\n * a parent element, it's used directly. When unset, the fallback computes\n * a blended value from --fg and --bg using color-mix().\n */\nexport function buildStyleBlock(font: string, hasMonoFont: boolean): string {\n  const fontImports = [\n    `@import url('https://fonts.googleapis.com/css2?family=${encodeURIComponent(font)}:wght@400;500;600;700&amp;display=swap');`,\n    ...(hasMonoFont\n      ? [`@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&amp;display=swap');`]\n      : []),\n  ]\n\n  // Derived CSS variables: use override if set, else mix from bg+fg.\n  // The --_ prefix signals \"private/derived\" — not meant for external override.\n  const derivedVars = `\n    /* Derived from --bg and --fg (overridable via --line, --accent, etc.) */\n    --_text:          var(--fg);\n    --_text-sec:      var(--muted, color-mix(in srgb, var(--fg) ${MIX.textSec}%, var(--bg)));\n    --_text-muted:    var(--muted, color-mix(in srgb, var(--fg) ${MIX.textMuted}%, var(--bg)));\n    --_text-faint:    color-mix(in srgb, var(--fg) ${MIX.textFaint}%, var(--bg));\n    --_line:          var(--line, color-mix(in srgb, var(--fg) ${MIX.line}%, var(--bg)));\n    --_arrow:         var(--accent, color-mix(in srgb, var(--fg) ${MIX.arrow}%, var(--bg)));\n    --_node-fill:     var(--surface, color-mix(in srgb, var(--fg) ${MIX.nodeFill}%, var(--bg)));\n    --_node-stroke:   var(--border, color-mix(in srgb, var(--fg) ${MIX.nodeStroke}%, var(--bg)));\n    --_group-fill:    var(--bg);\n    --_group-hdr:     color-mix(in srgb, var(--fg) ${MIX.groupHeader}%, var(--bg));\n    --_inner-stroke:  color-mix(in srgb, var(--fg) ${MIX.innerStroke}%, var(--bg));\n    --_key-badge:     color-mix(in srgb, var(--fg) ${MIX.keyBadge}%, var(--bg));`\n\n  return [\n    '<style>',\n    `  ${fontImports.join('\\n  ')}`,\n    `  text { font-family: '${font}', system-ui, sans-serif; }`,\n    ...(hasMonoFont ? [`  .mono { font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace; }`] : []),\n    `  svg {${derivedVars}`,\n    `  }`,\n    '</style>',\n  ].join('\\n')\n}\n\n/**\n * Build the SVG opening tag with CSS variables set as inline styles.\n * Only includes optional variables that are actually provided — unset ones\n * will fall back to the color-mix() derivations in the <style> block.\n *\n * @param transparent - If true, omits the background style for transparent SVGs\n */\nexport function svgOpenTag(\n  width: number,\n  height: number,\n  colors: DiagramColors,\n  transparent?: boolean,\n): string {\n  // Build the style string with only the provided color variables\n  const vars = [\n    `--bg:${colors.bg}`,\n    `--fg:${colors.fg}`,\n    colors.line    ? `--line:${colors.line}` : '',\n    colors.accent  ? `--accent:${colors.accent}` : '',\n    colors.muted   ? `--muted:${colors.muted}` : '',\n    colors.surface ? `--surface:${colors.surface}` : '',\n    colors.border  ? `--border:${colors.border}` : '',\n  ].filter(Boolean).join(';')\n\n  const bgStyle = transparent ? '' : ';background:var(--bg)'\n\n  return (\n    `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 ${width} ${height}\" ` +\n    `width=\"${width}\" height=\"${height}\" style=\"${vars}${bgStyle}\">`\n  )\n}\n"
  },
  {
    "path": "src/types.ts",
    "content": "// ============================================================================\n// Parsed graph — logical structure extracted from Mermaid text\n// ============================================================================\n\nexport interface MermaidGraph {\n  direction: Direction\n  nodes: Map<string, MermaidNode>\n  edges: MermaidEdge[]\n  subgraphs: MermaidSubgraph[]\n  classDefs: Map<string, Record<string, string>>\n  /** Maps node IDs to their class names (from `class X className` or `:::className` shorthand) */\n  classAssignments: Map<string, string>\n  /** Maps node IDs to inline styles (from `style X fill:#f00,stroke:#333`) */\n  nodeStyles: Map<string, Record<string, string>>\n  /** Maps edge indices (or 'default') to inline styles from `linkStyle` directives */\n  linkStyles: Map<number | 'default', Record<string, string>>\n}\n\nexport type Direction = 'TD' | 'TB' | 'LR' | 'BT' | 'RL'\n\nexport interface MermaidNode {\n  id: string\n  label: string\n  shape: NodeShape\n}\n\nexport type NodeShape =\n  | 'rectangle'\n  | 'rounded'\n  | 'diamond'\n  | 'stadium'\n  | 'circle'\n  // Batch 1 additions\n  | 'subroutine'     // [[text]]  — double-bordered rectangle\n  | 'doublecircle'   // (((text))) — concentric circles\n  | 'hexagon'        // {{text}}  — six-sided polygon\n  // Batch 2 additions\n  | 'cylinder'       // [(text)]  — database cylinder\n  | 'asymmetric'     // >text]    — flag/banner shape\n  | 'trapezoid'      // [/text\\]  — wider bottom\n  | 'trapezoid-alt'  // [\\text/]  — wider top\n  // Batch 3 state diagram pseudostates\n  | 'state-start'    // filled circle (start pseudostate)\n  | 'state-end'      // bullseye circle (end pseudostate)\n\nexport interface MermaidEdge {\n  source: string\n  target: string\n  label?: string\n  style: EdgeStyle\n  /** Whether to render an arrowhead at the start (source end) of the edge */\n  hasArrowStart: boolean\n  /** Whether to render an arrowhead at the end (target end) of the edge */\n  hasArrowEnd: boolean\n}\n\nexport type EdgeStyle = 'solid' | 'dotted' | 'thick'\n\nexport interface MermaidSubgraph {\n  id: string\n  label: string\n  nodeIds: string[]\n  children: MermaidSubgraph[]\n  /** Optional direction override for this subgraph's internal layout */\n  direction?: Direction\n}\n\n// ============================================================================\n// Positioned graph — after ELK layout, ready for SVG rendering\n// ============================================================================\n\nexport interface PositionedGraph {\n  width: number\n  height: number\n  nodes: PositionedNode[]\n  edges: PositionedEdge[]\n  groups: PositionedGroup[]\n}\n\nexport interface PositionedNode {\n  id: string\n  label: string\n  shape: NodeShape\n  x: number\n  y: number\n  width: number\n  height: number\n  /** Inline styles resolved from classDef + explicit `style` statements — override theme defaults */\n  inlineStyle?: Record<string, string>\n}\n\nexport interface PositionedEdge {\n  source: string\n  target: string\n  label?: string\n  style: EdgeStyle\n  hasArrowStart: boolean\n  hasArrowEnd: boolean\n  /** Full path including bends — array of {x, y} points */\n  points: Point[]\n  /** Layout-computed label center position (avoids label-label collisions) */\n  labelPosition?: Point\n  /** Inline styles resolved from `linkStyle` directives — override theme defaults */\n  inlineStyle?: Record<string, string>\n}\n\nexport interface Point {\n  x: number\n  y: number\n}\n\nexport interface PositionedGroup {\n  id: string\n  label: string\n  x: number\n  y: number\n  width: number\n  height: number\n  children: PositionedGroup[]\n}\n\n// ============================================================================\n// Render options — user-facing configuration\n//\n// Color theming uses CSS custom properties: --bg and --fg are required,\n// optional enrichment variables (--line, --accent, --muted, --surface,\n// --border) add richer color from Shiki themes or custom palettes.\n// See src/theme.ts for the full variable system.\n// ============================================================================\n\nexport interface RenderOptions {\n  /** Background color → CSS variable --bg. Default: '#FFFFFF' */\n  bg?: string\n  /** Foreground / primary text color → CSS variable --fg. Default: '#27272A' */\n  fg?: string\n\n  // -- Optional enrichment colors (fall back to color-mix from bg/fg) --\n\n  /** Edge/connector color → CSS variable --line */\n  line?: string\n  /** Arrow heads, highlights → CSS variable --accent */\n  accent?: string\n  /** Secondary text, edge labels → CSS variable --muted */\n  muted?: string\n  /** Node/box fill tint → CSS variable --surface */\n  surface?: string\n  /** Node/group stroke color → CSS variable --border */\n  border?: string\n\n  /** Font family for all text. Default: 'Inter' */\n  font?: string\n  /** Canvas padding in px. Default: 40 */\n  padding?: number\n  /** Horizontal spacing between sibling nodes. Default: 24 */\n  nodeSpacing?: number\n  /** Vertical spacing between layers. Default: 40 */\n  layerSpacing?: number\n  /** Spacing between disconnected components. Default: nodeSpacing (24) */\n  componentSpacing?: number\n  /** Render with transparent background (no background style on SVG). Default: false */\n  transparent?: boolean\n  /** Enable hover tooltips on chart data points (xychart only). Default: false */\n  interactive?: boolean\n}\n"
  },
  {
    "path": "src/xychart/colors.ts",
    "content": "// ============================================================================\n// XY Chart — shared color palette\n//\n// Generates monochromatic shades from the theme accent color.\n// Series 0 = accent (or blue fallback). Series 1+ are darker/lighter\n// shades of the same hue with subtle hue drift to stay in the same\n// color family (like navy ↔ cyan from blue).\n//\n// Used by both the SVG and ASCII renderers.\n// ============================================================================\n\n/** Default accent for charts when the theme doesn't provide one. */\nexport const CHART_ACCENT_FALLBACK = '#3b82f6' // blue-500\n\n// ---------------------------------------------------------------------------\n// HSL ↔ Hex conversion\n// ---------------------------------------------------------------------------\n\nfunction hexToHsl(hex: string): [number, number, number] {\n  const h = hex.replace('#', '')\n  const ri = parseInt(h.substring(0, 2), 16) / 255\n  const gi = parseInt(h.substring(2, 4), 16) / 255\n  const bi = parseInt(h.substring(4, 6), 16) / 255\n\n  const max = Math.max(ri, gi, bi)\n  const min = Math.min(ri, gi, bi)\n  const l = (max + min) / 2\n\n  if (max === min) return [0, 0, l * 100]\n\n  const d = max - min\n  const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)\n\n  let hue: number\n  if (max === ri) hue = ((gi - bi) / d + (gi < bi ? 6 : 0)) / 6\n  else if (max === gi) hue = ((bi - ri) / d + 2) / 6\n  else hue = ((ri - gi) / d + 4) / 6\n\n  return [hue * 360, s * 100, l * 100]\n}\n\nfunction hslToHex(h: number, s: number, l: number): string {\n  const si = s / 100\n  const li = l / 100\n\n  const c = (1 - Math.abs(2 * li - 1)) * si\n  const x = c * (1 - Math.abs(((h / 60) % 2) - 1))\n  const m = li - c / 2\n\n  let r: number, g: number, b: number\n  if (h < 60) { r = c; g = x; b = 0 }\n  else if (h < 120) { r = x; g = c; b = 0 }\n  else if (h < 180) { r = 0; g = c; b = x }\n  else if (h < 240) { r = 0; g = x; b = c }\n  else if (h < 300) { r = x; g = 0; b = c }\n  else { r = c; g = 0; b = x }\n\n  const toHex = (v: number) => Math.round((v + m) * 255).toString(16).padStart(2, '0')\n  return `#${toHex(r)}${toHex(g)}${toHex(b)}`\n}\n\n// ---------------------------------------------------------------------------\n// Hex ↔ RGB conversion\n// ---------------------------------------------------------------------------\n\nfunction hexToRgb(hex: string): [number, number, number] {\n  const h = hex.replace('#', '')\n  return [\n    parseInt(h.substring(0, 2), 16),\n    parseInt(h.substring(2, 4), 16),\n    parseInt(h.substring(4, 6), 16),\n  ]\n}\n\nfunction rgbToHex(r: number, g: number, b: number): string {\n  const toHex = (v: number) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')\n  return `#${toHex(r)}${toHex(g)}${toHex(b)}`\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/** Check whether a string is a valid 6-digit hex color (e.g. \"#3b82f6\"). */\nexport function isValidHex(color: string): boolean {\n  return /^#[0-9a-fA-F]{6}$/.test(color)\n}\n\n/**\n * Detect whether a background color is dark (lightness < 50%).\n */\nexport function isDarkBackground(bgHex: string): boolean {\n  return hexToHsl(bgHex)[2] < 50\n}\n\n/**\n * Mix two hex colors in RGB space.\n * `ratio` controls how much of `fgHex` shows: 0 = pure bg, 1 = pure fg.\n * Equivalent to alpha-compositing fg over bg at the given opacity.\n */\nexport function mixHexColors(bgHex: string, fgHex: string, ratio: number): string {\n  const [br, bg, bb] = hexToRgb(bgHex)\n  const [fr, fg, fb] = hexToRgb(fgHex)\n  const inv = 1 - ratio\n  return rgbToHex(br * inv + fr * ratio, bg * inv + fg * ratio, bb * inv + fb * ratio)\n}\n\n/**\n * Get the hex color for a series index.\n * Index 0 returns the accent color as-is.\n * Index 1+ alternate between darker and lighter shades of the same hue\n * with subtle hue drift (±8-12° per tier) to stay in the same family.\n *\n * When `bgColor` is provided, shade direction adapts to the background:\n *   - Light bg: odd = darker, even = lighter (default)\n *   - Dark bg:  odd = lighter, even = darker (so shades stay visible)\n */\nexport function getSeriesColor(index: number, accentColor: string, bgColor?: string): string {\n  if (index === 0) return accentColor\n  // Fall back to defaults when inputs aren't valid hex (e.g. CSS variable refs like \"var(--accent)\")\n  const safeAccent = isValidHex(accentColor) ? accentColor : CHART_ACCENT_FALLBACK\n  const safeBg = bgColor && isValidHex(bgColor) ? bgColor : undefined\n  const [h, s] = hexToHsl(safeAccent)\n  const chartS = Math.max(55, Math.min(85, s))\n\n  const tier = Math.ceil(index / 2)\n  const oddIndex = index % 2 === 1\n\n  // On dark backgrounds, flip: odd = lighter, even = darker\n  const dark = safeBg && isDarkBackground(safeBg) ? !oddIndex : oddIndex\n  const l = dark\n    ? Math.max(25, 48 - tier * 13)\n    : Math.min(78, 55 + tier * 11)\n\n  // Subtle hue drift: darker shades shift slightly negative, lighter shift positive\n  const hShift = (dark ? -8 : 12) * tier\n  const newH = ((h + hShift) % 360 + 360) % 360\n\n  return hslToHex(newH, chartS, l)\n}\n"
  },
  {
    "path": "src/xychart/layout.ts",
    "content": "import type {\n  XYChart, PositionedXYChart, PositionedAxis, AxisTick,\n  PositionedBar, PositionedLine, GridLine, PlotArea, LegendItem,\n} from './types.ts'\nimport type { RenderOptions } from '../types.ts'\nimport { estimateTextWidth } from '../styles.ts'\n\n// ============================================================================\n// XY Chart layout engine\n//\n// Computes pixel coordinates for all chart elements. No dagre needed —\n// direct coordinate-space mapping for axes, bars, lines, and grid.\n// ============================================================================\n\n/** Layout constants — aligned with Chart.js default proportions */\nconst XY = {\n  plotWidth: 600,\n  plotHeight: 340,\n  padding: 22,\n  titleFontSize: 18,\n  titleFontWeight: 600,\n  titleHeight: 42,\n  axisLabelFontSize: 14,\n  axisLabelFontWeight: 400,\n  axisTitleFontSize: 15,\n  axisTitleFontWeight: 500,\n  xLabelHeight: 38,\n  yLabelWidth: 58,\n  yLabelGap: 18,\n  axisTitlePad: 30,\n  tickLength: 4,\n  barPadRatio: 0.2,\n  barGroupGap: 0,\n  maxBarWidth: 40,\n  legendFontSize: 14,\n  legendFontWeight: 400,\n  legendHeight: 28,\n  legendSwatchW: 14,\n  legendSwatchH: 14,\n  legendGap: 6,\n  legendItemGap: 16,\n} as const\n\n/**\n * Lay out a parsed XY chart by computing pixel coordinates.\n */\nexport function layoutXYChart(\n  chart: XYChart,\n  _options: RenderOptions = {}\n): PositionedXYChart {\n  if (chart.horizontal) return layoutHorizontal(chart)\n  return layoutVertical(chart)\n}\n\n// ============================================================================\n// Vertical layout (default)\n// ============================================================================\n\nfunction layoutVertical(chart: XYChart): PositionedXYChart {\n  const hasTitle = !!chart.title\n  const hasXTitle = !!chart.xAxis.title\n  const hasYTitle = !!chart.yAxis.title\n  const hasLegend = chart.series.length > 1\n\n  // Compute y-axis label width from tick labels\n  const yRange = chart.yAxis.range!\n  const yTicks = niceTickValues(yRange.min, yRange.max)\n  const maxYLabelWidth = Math.max(\n    ...yTicks.map(v => estimateTextWidth(formatTickValue(v), XY.axisLabelFontSize, XY.axisLabelFontWeight)),\n    XY.yLabelWidth\n  )\n\n  // Margins\n  const top = XY.padding + (hasTitle ? XY.titleHeight : 0) + (hasLegend ? XY.legendHeight : 0)\n  const bottom = XY.padding + XY.xLabelHeight + (hasXTitle ? XY.axisTitlePad : 0)\n  const left = XY.padding + maxYLabelWidth + XY.yLabelGap + (hasYTitle ? XY.axisTitlePad : 0)\n  const right = XY.padding\n\n  const plotW = XY.plotWidth\n  const plotH = XY.plotHeight\n  const totalW = left + plotW + right\n  const totalH = top + plotH + bottom\n\n  const plotArea: PlotArea = { x: left, y: top, width: plotW, height: plotH }\n\n  // Scales\n  const dataCount = getDataCount(chart)\n  const xScale = (i: number) => left + (i + 0.5) * (plotW / dataCount)\n  const bandWidth = plotW / dataCount\n  const yScale = (v: number) => {\n    const t = (v - yRange.min) / (yRange.max - yRange.min || 1)\n    return top + plotH - t * plotH\n  }\n\n  // X-axis ticks\n  const xTicks = buildXTicks(chart, xScale, top + plotH, bandWidth)\n\n  // Y-axis ticks\n  const yAxisTicks: AxisTick[] = yTicks.map(v => ({\n    label: formatTickValue(v),\n    x: left, y: yScale(v),\n    tx: left - XY.tickLength, ty: yScale(v),\n    labelX: left - XY.yLabelGap, labelY: yScale(v),\n    textAnchor: 'end' as const,\n  }))\n\n  // Grid lines (horizontal at each y tick)\n  const gridLines: GridLine[] = yTicks.map(v => ({\n    x1: left, y1: yScale(v), x2: left + plotW, y2: yScale(v),\n  }))\n\n  // Category labels for data attributes\n  const catLabels = getCategoryLabels(chart, dataCount)\n\n  // Global color index map: each series gets a unique color index regardless of type\n  const colorMap = chart.series.map((_, i) => i)\n\n  // Bars\n  const bars = layoutBars(chart, xScale, yScale, bandWidth, yRange.min, catLabels, colorMap)\n\n  // Lines\n  const lines = layoutLines(chart, xScale, yScale, catLabels, colorMap)\n\n  // Legend\n  const legendY = XY.padding + (hasTitle ? XY.titleHeight : 0) + XY.legendHeight / 2\n  const legend = hasLegend ? buildLegendItems(chart, totalW / 2, legendY, colorMap) : []\n\n  // Axis lines\n  const xAxisLine = { x1: left, y1: top + plotH, x2: left + plotW, y2: top + plotH }\n  const yAxisLine = { x1: left, y1: top, x2: left, y2: top + plotH }\n\n  // Axis titles\n  const xAxisObj: PositionedAxis = {\n    ticks: xTicks,\n    line: xAxisLine,\n    ...(hasXTitle ? { title: { text: chart.xAxis.title!, x: left + plotW / 2, y: totalH - XY.padding } } : {}),\n  }\n  const yAxisObj: PositionedAxis = {\n    ticks: yAxisTicks,\n    line: yAxisLine,\n    ...(hasYTitle ? { title: { text: chart.yAxis.title!, x: XY.padding + 4, y: top + plotH / 2, rotate: -90 } } : {}),\n  }\n\n  // Title\n  const titleObj = hasTitle ? { text: chart.title!, x: totalW / 2, y: XY.padding + XY.titleFontSize } : undefined\n\n  return { width: totalW, height: totalH, title: titleObj, xAxis: xAxisObj, yAxis: yAxisObj, plotArea, bars, lines, gridLines, legend }\n}\n\n// ============================================================================\n// Horizontal layout\n// ============================================================================\n\nfunction layoutHorizontal(chart: XYChart): PositionedXYChart {\n  const hasTitle = !!chart.title\n  const hasXTitle = !!chart.xAxis.title\n  const hasYTitle = !!chart.yAxis.title\n  const hasLegend = chart.series.length > 1\n\n  // In horizontal mode: categories go on y-axis (left side), values go on x-axis (bottom)\n  const yRange = chart.yAxis.range!\n  const valueTicks = niceTickValues(yRange.min, yRange.max)\n\n  // Compute category label widths for left margin\n  const dataCount = getDataCount(chart)\n  const catLabels = getCategoryLabels(chart, dataCount)\n  const maxCatLabelWidth = Math.max(\n    ...catLabels.map(l => estimateTextWidth(l, XY.axisLabelFontSize, XY.axisLabelFontWeight)),\n    40\n  )\n\n  const top = XY.padding + (hasTitle ? XY.titleHeight : 0) + (hasLegend ? XY.legendHeight : 0)\n  const bottom = XY.padding + XY.xLabelHeight + (hasYTitle ? XY.axisTitlePad : 0)\n  const left = XY.padding + maxCatLabelWidth + XY.yLabelGap + (hasXTitle ? XY.axisTitlePad : 0)\n  const right = XY.padding\n\n  const plotW = XY.plotWidth\n  const plotH = XY.plotHeight\n  const totalW = left + plotW + right\n  const totalH = top + plotH + bottom\n\n  const plotArea: PlotArea = { x: left, y: top, width: plotW, height: plotH }\n\n  // Value scale (horizontal: left to right)\n  const valueScale = (v: number) => {\n    const t = (v - yRange.min) / (yRange.max - yRange.min || 1)\n    return left + t * plotW\n  }\n\n  // Category scale (vertical: top to bottom)\n  const bandHeight = plotH / dataCount\n  const catScale = (i: number) => top + (i + 0.5) * bandHeight\n\n  // X-axis (bottom): value ticks\n  const xTicks: AxisTick[] = valueTicks.map(v => ({\n    label: formatTickValue(v),\n    x: valueScale(v), y: top + plotH,\n    tx: valueScale(v), ty: top + plotH + XY.tickLength,\n    labelX: valueScale(v), labelY: top + plotH + 18,\n    textAnchor: 'middle' as const,\n  }))\n\n  // Y-axis (left): category ticks\n  const yTicks: AxisTick[] = catLabels.map((label, i) => ({\n    label,\n    x: left, y: catScale(i),\n    tx: left - XY.tickLength, ty: catScale(i),\n    labelX: left - XY.yLabelGap, labelY: catScale(i),\n    textAnchor: 'end' as const,\n  }))\n\n  // Grid lines (vertical at each value tick)\n  const gridLines: GridLine[] = valueTicks.map(v => ({\n    x1: valueScale(v), y1: top, x2: valueScale(v), y2: top + plotH,\n  }))\n\n  // Global color index map\n  const colorMap = chart.series.map((_, i) => i)\n\n  // Bars (horizontal)\n  const barSeries = chart.series.filter(s => s.type === 'bar')\n  const barCount = barSeries.length\n  const bars: PositionedBar[] = []\n  if (barCount > 0) {\n    const usable = bandHeight * (1 - XY.barPadRatio)\n    const rawBarH = barCount > 1 ? (usable - (barCount - 1) * XY.barGroupGap) / barCount : usable\n    const singleBarH = Math.min(rawBarH, XY.maxBarWidth)\n    const groupH = barCount > 1\n      ? singleBarH * barCount + XY.barGroupGap * (barCount - 1)\n      : singleBarH\n    let bIdx = 0\n    let seriesArrayIdx = 0\n    for (const s of chart.series) {\n      if (s.type !== 'bar') { seriesArrayIdx++; continue }\n      for (let i = 0; i < s.data.length; i++) {\n        const cy = catScale(i)\n        const groupTop = cy - groupH / 2\n        const by = groupTop + bIdx * (singleBarH + XY.barGroupGap)\n        const valX = valueScale(Math.max(s.data[i]!, yRange.min))\n        const baseX = valueScale(Math.max(0, yRange.min))\n        bars.push({\n          x: Math.min(baseX, valX),\n          y: by,\n          width: Math.abs(valX - baseX),\n          height: singleBarH,\n          value: s.data[i]!,\n          label: catLabels[i]!,\n          seriesIndex: bIdx,\n          colorIndex: colorMap[seriesArrayIdx]!,\n        })\n      }\n      bIdx++\n      seriesArrayIdx++\n    }\n  }\n\n  // Lines (horizontal: value on x, category index on y)\n  const lines: PositionedLine[] = []\n  let lineIdx = 0\n  let lineSeriesIdx = 0\n  for (const s of chart.series) {\n    if (s.type !== 'line') { lineSeriesIdx++; continue }\n    const points = s.data.map((v, i) => ({ x: valueScale(v), y: catScale(i), value: v, label: catLabels[i]! }))\n    lines.push({ points, seriesIndex: lineIdx, colorIndex: colorMap[lineSeriesIdx]! })\n    lineIdx++\n    lineSeriesIdx++\n  }\n\n  const xAxisLine = { x1: left, y1: top + plotH, x2: left + plotW, y2: top + plotH }\n  const yAxisLine = { x1: left, y1: top, x2: left, y2: top + plotH }\n\n  // In horizontal mode, the \"y-axis\" title describes values (bottom) and \"x-axis\" title describes categories (left)\n  const xAxisObj: PositionedAxis = {\n    ticks: xTicks,\n    line: xAxisLine,\n    ...(hasYTitle ? { title: { text: chart.yAxis.title!, x: left + plotW / 2, y: totalH - XY.padding } } : {}),\n  }\n  const yAxisObj: PositionedAxis = {\n    ticks: yTicks,\n    line: yAxisLine,\n    ...(hasXTitle ? { title: { text: chart.xAxis.title!, x: XY.padding + 4, y: top + plotH / 2, rotate: -90 } } : {}),\n  }\n\n  const titleObj = hasTitle ? { text: chart.title!, x: totalW / 2, y: XY.padding + XY.titleFontSize } : undefined\n\n  // Legend\n  const legendY = XY.padding + (hasTitle ? XY.titleHeight : 0) + XY.legendHeight / 2\n  const legend = hasLegend ? buildLegendItems(chart, totalW / 2, legendY, colorMap) : []\n\n  return { width: totalW, height: totalH, horizontal: true, title: titleObj, xAxis: xAxisObj, yAxis: yAxisObj, plotArea, bars, lines, gridLines, legend }\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\nfunction getDataCount(chart: XYChart): number {\n  if (chart.xAxis.categories) return chart.xAxis.categories.length\n  // For numeric range, use the length of the first series\n  for (const s of chart.series) {\n    if (s.data.length > 0) return s.data.length\n  }\n  return 1\n}\n\nfunction getCategoryLabels(chart: XYChart, count: number): string[] {\n  if (chart.xAxis.categories) return chart.xAxis.categories\n  if (chart.xAxis.range) {\n    const { min, max } = chart.xAxis.range\n    const step = count > 1 ? (max - min) / (count - 1) : 0\n    return Array.from({ length: count }, (_, i) => formatTickValue(min + step * i))\n  }\n  return Array.from({ length: count }, (_, i) => String(i + 1))\n}\n\nfunction buildXTicks(chart: XYChart, xScale: (i: number) => number, axisY: number, _bandWidth: number): AxisTick[] {\n  const count = getDataCount(chart)\n  const labels = getCategoryLabels(chart, count)\n  return labels.map((label, i) => ({\n    label,\n    x: xScale(i), y: axisY,\n    tx: xScale(i), ty: axisY + XY.tickLength,\n    labelX: xScale(i), labelY: axisY + 18,\n    textAnchor: 'middle' as const,\n  }))\n}\n\nfunction layoutBars(\n  chart: XYChart, xScale: (i: number) => number, yScale: (v: number) => number,\n  bandWidth: number, yMin: number, catLabels: string[], colorMap: number[],\n): PositionedBar[] {\n  const barSeries = chart.series.filter(s => s.type === 'bar')\n  const barCount = barSeries.length\n  if (barCount === 0) return []\n\n  const usable = bandWidth * (1 - XY.barPadRatio)\n  const rawBarW = barCount > 1 ? (usable - (barCount - 1) * XY.barGroupGap) / barCount : usable\n  const singleBarW = Math.min(rawBarW, XY.maxBarWidth)\n  const groupW = barCount > 1\n    ? singleBarW * barCount + XY.barGroupGap * (barCount - 1)\n    : singleBarW\n  const bars: PositionedBar[] = []\n\n  let bIdx = 0\n  let seriesArrayIdx = 0\n  for (const s of chart.series) {\n    if (s.type !== 'bar') { seriesArrayIdx++; continue }\n    for (let i = 0; i < s.data.length; i++) {\n      const cx = xScale(i)\n      const groupLeft = cx - groupW / 2\n      const bx = groupLeft + bIdx * (singleBarW + XY.barGroupGap)\n      const valY = yScale(s.data[i]!)\n      const baseY = yScale(Math.max(0, yMin))\n      bars.push({\n        x: bx,\n        y: Math.min(valY, baseY),\n        width: singleBarW,\n        height: Math.abs(baseY - valY),\n        value: s.data[i]!,\n        label: catLabels[i]!,\n        seriesIndex: bIdx,\n        colorIndex: colorMap[seriesArrayIdx]!,\n      })\n    }\n    bIdx++\n    seriesArrayIdx++\n  }\n  return bars\n}\n\nfunction layoutLines(chart: XYChart, xScale: (i: number) => number, yScale: (v: number) => number, catLabels: string[], colorMap: number[]): PositionedLine[] {\n  const lines: PositionedLine[] = []\n  let lineIdx = 0\n  let seriesArrayIdx = 0\n  for (const s of chart.series) {\n    if (s.type !== 'line') { seriesArrayIdx++; continue }\n    const points = s.data.map((v, i) => ({ x: xScale(i), y: yScale(v), value: v, label: catLabels[i]! }))\n    lines.push({ points, seriesIndex: lineIdx, colorIndex: colorMap[seriesArrayIdx]! })\n    lineIdx++\n    seriesArrayIdx++\n  }\n  return lines\n}\n\n/** Generate \"nice\" tick values for a numeric range */\nfunction niceTickValues(min: number, max: number): number[] {\n  const range = max - min\n  if (range <= 0) return [min]\n\n  // Find nice interval\n  const rawInterval = range / 6\n  const magnitude = Math.pow(10, Math.floor(Math.log10(rawInterval)))\n  const residual = rawInterval / magnitude\n  let niceInterval: number\n  if (residual <= 1.5) niceInterval = magnitude\n  else if (residual <= 3) niceInterval = 2 * magnitude\n  else if (residual <= 7) niceInterval = 5 * magnitude\n  else niceInterval = 10 * magnitude\n\n  const start = Math.ceil(min / niceInterval) * niceInterval\n  const ticks: number[] = []\n  for (let v = start; v <= max + niceInterval * 0.001; v += niceInterval) {\n    ticks.push(Math.round(v * 1e10) / 1e10) // avoid floating-point noise\n  }\n  return ticks\n}\n\nfunction formatTickValue(v: number): string {\n  if (Number.isInteger(v)) return String(v)\n  // Limit decimal places\n  return v.toFixed(Math.abs(v) < 10 ? 1 : 0)\n}\n\n/** Build centered legend items for multi-series charts */\nfunction buildLegendItems(chart: XYChart, centerX: number, y: number, colorMap: number[]): LegendItem[] {\n  const items: LegendItem[] = []\n  let barIdx = 0, lineIdx = 0\n  for (let si = 0; si < chart.series.length; si++) {\n    const s = chart.series[si]!\n    const label = s.type === 'bar' ? `Bar ${barIdx + 1}` : `Line ${lineIdx + 1}`\n    items.push({ label, x: 0, y, type: s.type, seriesIndex: s.type === 'bar' ? barIdx : lineIdx, colorIndex: colorMap[si]! })\n    if (s.type === 'bar') barIdx++\n    else lineIdx++\n  }\n\n  // Measure total width, then center\n  const itemWidths = items.map(item => {\n    const textW = estimateTextWidth(item.label, XY.legendFontSize, XY.legendFontWeight)\n    return XY.legendSwatchW + XY.legendGap + textW\n  })\n  const totalWidth = itemWidths.reduce((a, b) => a + b, 0) + (items.length - 1) * XY.legendItemGap\n  let x = centerX - totalWidth / 2\n\n  for (let i = 0; i < items.length; i++) {\n    items[i]!.x = x\n    x += itemWidths[i]! + XY.legendItemGap\n  }\n\n  return items\n}\n"
  },
  {
    "path": "src/xychart/parser.ts",
    "content": "import type { XYChart, XYAxis, XYChartSeries } from './types.ts'\n\n// ============================================================================\n// XY Chart parser\n//\n// Parses Mermaid xychart-beta syntax into a typed XYChart structure.\n//\n// Supported directives:\n//   xychart-beta [horizontal]\n//   title \"Chart Title\"\n//   x-axis [label1, label2, ...]          — categorical\n//   x-axis min --> max                     — numeric range\n//   x-axis \"Axis Title\" [label1, ...]      — with title\n//   x-axis \"Axis Title\" min --> max        — with title\n//   y-axis (same patterns)\n//   bar [val1, val2, ...]\n//   line [val1, val2, ...]\n// ============================================================================\n\n/**\n * Parse a Mermaid xychart-beta diagram from preprocessed lines.\n * Lines should already be trimmed and comment-stripped.\n */\nexport function parseXYChart(lines: string[]): XYChart {\n  const xAxis: XYAxis = {}\n  const yAxis: XYAxis = {}\n  const series: XYChartSeries[] = []\n  let title: string | undefined\n  let horizontal = false\n\n  for (const line of lines) {\n    // Header line — detect horizontal\n    if (/^xychart(-beta)?\\b/i.test(line)) {\n      if (/\\bhorizontal\\b/i.test(line)) horizontal = true\n      continue\n    }\n\n    // Title\n    const titleMatch = line.match(/^title\\s+\"([^\"]+)\"/)\n    if (titleMatch) {\n      title = titleMatch[1]\n      continue\n    }\n\n    // x-axis with categories: x-axis \"Title\" [a, b, c] or x-axis [a, b, c]\n    const xCatMatch = line.match(/^x-axis\\s+(?:\"([^\"]*)\"\\s*)?\\[([^\\]]+)\\]/)\n    if (xCatMatch) {\n      if (xCatMatch[1]) xAxis.title = xCatMatch[1]\n      xAxis.categories = xCatMatch[2]!.split(',').map(s => s.trim())\n      continue\n    }\n\n    // x-axis with range: x-axis \"Title\" min --> max or x-axis min --> max\n    const xRangeMatch = line.match(/^x-axis\\s+(?:\"([^\"]*)\"\\s+)?(-?\\d+(?:\\.\\d+)?)\\s*-->\\s*(-?\\d+(?:\\.\\d+)?)/)\n    if (xRangeMatch) {\n      if (xRangeMatch[1]) xAxis.title = xRangeMatch[1]\n      xAxis.range = { min: parseFloat(xRangeMatch[2]!), max: parseFloat(xRangeMatch[3]!) }\n      continue\n    }\n\n    // y-axis with range: y-axis \"Title\" min --> max or y-axis min --> max\n    const yRangeMatch = line.match(/^y-axis\\s+(?:\"([^\"]*)\"\\s+)?(-?\\d+(?:\\.\\d+)?)\\s*-->\\s*(-?\\d+(?:\\.\\d+)?)/)\n    if (yRangeMatch) {\n      if (yRangeMatch[1]) yAxis.title = yRangeMatch[1]\n      yAxis.range = { min: parseFloat(yRangeMatch[2]!), max: parseFloat(yRangeMatch[3]!) }\n      continue\n    }\n\n    // y-axis with just title (no range)\n    const yTitleOnly = line.match(/^y-axis\\s+\"([^\"]+)\"\\s*$/)\n    if (yTitleOnly) {\n      yAxis.title = yTitleOnly[1]\n      continue\n    }\n\n    // bar [...]\n    const barMatch = line.match(/^bar\\s+\\[([^\\]]+)\\]/)\n    if (barMatch) {\n      series.push({ type: 'bar', data: parseNumericArray(barMatch[1]!) })\n      continue\n    }\n\n    // line [...]\n    const lineMatch = line.match(/^line\\s+\\[([^\\]]+)\\]/)\n    if (lineMatch) {\n      series.push({ type: 'line', data: parseNumericArray(lineMatch[1]!) })\n      continue\n    }\n  }\n\n  // Auto-derive y-axis range from data if not specified\n  if (!yAxis.range && series.length > 0) {\n    const allValues = series.flatMap(s => s.data)\n    let min = Math.min(...allValues)\n    let max = Math.max(...allValues)\n    const span = max - min || 1\n    // Add 10% padding\n    min = min - span * 0.1\n    max = max + span * 0.1\n    // Floor to 0 if all values are positive and min is close to 0\n    if (min > 0 && min < span * 0.5) min = 0\n    yAxis.range = { min, max }\n  }\n\n  // Fallback y-axis range\n  if (!yAxis.range) {\n    yAxis.range = { min: 0, max: 100 }\n  }\n\n  return { title, horizontal, xAxis, yAxis, series }\n}\n\nfunction parseNumericArray(str: string): number[] {\n  return str.split(',').map(s => parseFloat(s.trim()))\n}\n"
  },
  {
    "path": "src/xychart/renderer.ts",
    "content": "import type { PositionedXYChart } from './types.ts'\nimport type { DiagramColors } from '../theme.ts'\nimport { svgOpenTag, buildStyleBlock } from '../theme.ts'\nimport { TEXT_BASELINE_SHIFT, estimateTextWidth } from '../styles.ts'\nimport { getSeriesColor, CHART_ACCENT_FALLBACK } from './colors.ts'\n\n// ============================================================================\n// XY Chart SVG renderer\n//\n// Renders positioned XY charts to SVG strings.\n// All colors use CSS custom properties (var(--_xxx)) from the theme system.\n//\n// Visual style: clean, minimal, modern. Inspired by Apple/Craft chart design.\n//   - No axis lines or tick marks — labels float freely\n//   - Ultra-subtle solid grid lines\n//   - Bars with rounded tops, flat at baseline\n//   - Smooth curved lines, no visible dots (dots appear on hover)\n//\n// Render order (back to front):\n//   1. Grid lines\n//   2. Bars (as paths with rounded tops)\n//   3. Lines (smooth curves)\n//   4. Dots (hidden by default, visible on hover when interactive)\n//   5. Axis labels\n//   6. Axis titles\n//   7. Chart title\n//   8. Legend\n// ============================================================================\n\nconst CHART_FONT = {\n  titleSize: 18,\n  titleWeight: 600,\n  axisTitleSize: 15,\n  axisTitleWeight: 500,\n  labelSize: 14,\n  labelWeight: 400,\n  legendSize: 14,\n  legendWeight: 400,\n  dotRadius: 5,\n  lineWidth: 2.5,\n  barRadius: 8,\n} as const\n\nconst TIP = {\n  fontSize: 15,\n  fontWeight: 500,\n  height: 32,\n  padX: 14,\n  offsetY: 12,\n  rx: 8,\n  minY: 4,\n  pointerSize: 6,\n} as const\n\n/**\n * Render a positioned XY chart as an SVG string.\n */\nexport function renderXYChartSvg(\n  chart: PositionedXYChart,\n  colors: DiagramColors,\n  font: string = 'Inter',\n  transparent: boolean = false,\n  interactive: boolean = false,\n): string {\n  const parts: string[] = []\n\n  // SVG root + base styles\n  // Stamp data-xychart-colors so theme-switching JS knows how many series color vars to update\n  const maxColorIdx = Math.max(0, ...chart.bars.map(b => b.colorIndex), ...chart.lines.map(l => l.colorIndex))\n  const svgTag = svgOpenTag(chart.width, chart.height, colors, transparent)\n    .replace('<svg ', `<svg data-xychart-colors=\"${maxColorIdx}\" `)\n  parts.push(svgTag)\n  parts.push(buildStyleBlock(font, false))\n\n  // Sparse lines (≤12 points) show dots by default\n  const maxLinePoints = Math.max(...chart.lines.map(l => l.points.length), 0)\n  const sparse = maxLinePoints > 0 && maxLinePoints <= 12\n\n  // Chart-specific styles + gradient defs\n  const { style: chartCss, defs: chartDefs } = chartStyles(chart, interactive, sparse, colors.accent, colors.bg)\n  parts.push(chartCss)\n  if (chartDefs) parts.push(chartDefs)\n\n  // 1. Dot grid (dense dots across plot area, aligned to tick spacing)\n  const { plotArea } = chart\n  const xTicks = chart.xAxis.ticks.map(t => t.x)\n  const yVals = chart.horizontal\n    ? chart.yAxis.ticks.map(t => t.y)\n    : chart.gridLines.map(g => g.y1)\n  const xBase = xTicks.length > 1 ? Math.abs(xTicks[1]! - xTicks[0]!) : plotArea.width / 6\n  const yBase = yVals.length > 1 ? Math.abs(yVals[1]! - yVals[0]!) : plotArea.height / 6\n  const xGap = xBase / Math.max(1, Math.round(xBase / 20))\n  const yGap = yBase / Math.max(1, Math.round(yBase / 20))\n  const xAnchor = xTicks[0] ?? plotArea.x\n  const yAnchor = yVals[0] ?? plotArea.y\n  const xStart = xAnchor - Math.ceil((xAnchor - plotArea.x) / xGap) * xGap\n  const yStart = yAnchor - Math.ceil((yAnchor - plotArea.y) / yGap) * yGap\n  for (let y = yStart; y <= plotArea.y + plotArea.height + 0.5; y += yGap) {\n    for (let x = xStart; x <= plotArea.x + plotArea.width + 0.5; x += xGap) {\n      parts.push(`<circle cx=\"${r(x)}\" cy=\"${r(y)}\" r=\"1.5\" class=\"xychart-grid\"/>`)\n    }\n  }\n\n  // 2. Bars — always render bar paths inline (before lines for correct z-order)\n  //    Interactive: also build overlay groups with transparent hit-areas + tooltips (deferred to step 9)\n  const barOverlay: string[] = []\n  for (const bar of chart.bars) {\n    const dataAttrs = ` data-value=\"${bar.value}\"${bar.label ? ` data-label=\"${escapeXml(bar.label)}\"` : ''}`\n    const barPath = chart.horizontal\n      ? roundedRightBarPath(bar.x, bar.y, bar.width, bar.height, CHART_FONT.barRadius)\n      : roundedTopBarPath(bar.x, bar.y, bar.width, bar.height, CHART_FONT.barRadius)\n    parts.push(\n      `<path d=\"${barPath}\" class=\"xychart-bar xychart-color-${bar.colorIndex}\"${dataAttrs}/>`\n    )\n    if (interactive) {\n      const tipText = formatTipValue(bar.value)\n      const tipTitle = bar.label ? `${bar.label}: ${tipText}` : tipText\n      const tip = tooltipAbove(bar.x + bar.width / 2, bar.y, tipText)\n      barOverlay.push(\n        `<g class=\"xychart-bar-group\">` +\n        `<rect x=\"${r(bar.x)}\" y=\"${r(bar.y)}\" width=\"${r(bar.width)}\" height=\"${r(bar.height)}\" fill=\"transparent\"/>` +\n        `<title>${escapeXml(tipTitle)}</title>` +\n        tip +\n        `</g>`\n      )\n    }\n  }\n\n  // 3. Lines — shadow first (wider, low opacity), then crisp line on top\n  for (const line of chart.lines) {\n    if (line.points.length === 0) continue\n    const d = smoothCurvePath(line.points)\n    parts.push(`<path d=\"${d}\" class=\"xychart-line-shadow xychart-color-${line.colorIndex}\" transform=\"translate(0,2)\"/>`)\n    parts.push(`<path d=\"${d}\" class=\"xychart-line xychart-color-${line.colorIndex}\"/>`)\n  }\n\n  // 4. Dots — grouped by x-position; interactive groups deferred to overlay\n  const dotOverlay: string[] = []\n  if (interactive || sparse) {\n    // Build legend label lookup: line seriesIndex → \"Line 1\", \"Line 2\", etc.\n    const lineLegendLabels = new Map<number, string>()\n    for (const item of chart.legend) {\n      if (item.type === 'line') lineLegendLabels.set(item.seriesIndex, item.label)\n    }\n\n    type DotEntry = { x: number; y: number; value: number; label?: string; seriesIndex: number; colorIndex: number }\n    const columns = new Map<string, DotEntry[]>()\n\n    for (const line of chart.lines) {\n      for (const p of line.points) {\n        const key = r(p.x)\n        if (!columns.has(key)) columns.set(key, [])\n        columns.get(key)!.push({ x: p.x, y: p.y, value: p.value, label: p.label, seriesIndex: line.seriesIndex, colorIndex: line.colorIndex })\n      }\n    }\n\n    for (const entries of columns.values()) {\n      const cx = entries[0]!.x\n      const label = entries[0]!.label || ''\n\n      if (interactive && entries.length > 1) {\n        const topY = Math.min(...entries.map(e => e.y))\n        const botY = Math.max(...entries.map(e => e.y))\n        const hitPad = CHART_FONT.dotRadius * 3\n        const hitArea = `<rect x=\"${r(cx - hitPad)}\" y=\"${r(topY - hitPad)}\" width=\"${r(hitPad * 2)}\" height=\"${r(botY - topY + hitPad * 2)}\" fill=\"transparent\" class=\"xychart-hit\"/>`\n        const tipEntries = entries.map(e => ({\n          text: formatTipValue(e.value),\n          legendLabel: lineLegendLabels.get(e.seriesIndex) || `Line ${e.seriesIndex + 1}`,\n        }))\n        const tip = multiTooltipAbove(cx, topY - CHART_FONT.dotRadius, label, tipEntries)\n        const valStrs = tipEntries.map(e => e.text)\n        const titleText = label ? `${label}: ${valStrs.join(' · ')}` : valStrs.join(' · ')\n\n        let group = `<g class=\"xychart-dot-group\">${hitArea}`\n        for (const e of entries) {\n          const dataAttrs = ` data-value=\"${e.value}\"${e.label ? ` data-label=\"${escapeXml(e.label)}\"` : ''}`\n          group += `<circle cx=\"${r(e.x)}\" cy=\"${r(e.y)}\" r=\"${CHART_FONT.dotRadius}\" class=\"xychart-dot xychart-color-${e.colorIndex}\"${dataAttrs}/>`\n        }\n        group += `<title>${escapeXml(titleText)}</title>${tip}</g>`\n        dotOverlay.push(group)\n\n      } else if (interactive) {\n        const e = entries[0]!\n        const dataAttrs = ` data-value=\"${e.value}\"${e.label ? ` data-label=\"${escapeXml(e.label)}\"` : ''}`\n        const tipText = formatTipValue(e.value)\n        const tipTitle = e.label ? `${e.label}: ${tipText}` : tipText\n        const tip = tooltipAbove(cx, e.y - CHART_FONT.dotRadius, tipText)\n        const hitArea = sparse\n          ? `<circle cx=\"${r(cx)}\" cy=\"${r(e.y)}\" r=\"${CHART_FONT.dotRadius * 3}\" fill=\"transparent\" class=\"xychart-hit\"/>`\n          : ''\n        dotOverlay.push(\n          `<g class=\"xychart-dot-group\">${hitArea}` +\n          `<circle cx=\"${r(e.x)}\" cy=\"${r(e.y)}\" r=\"${CHART_FONT.dotRadius}\" class=\"xychart-dot xychart-color-${e.colorIndex}\"${dataAttrs}/>` +\n          `<title>${escapeXml(tipTitle)}</title>${tip}</g>`\n        )\n\n      } else {\n        // Sparse, not interactive: static dots render inline\n        for (const e of entries) {\n          const dataAttrs = ` data-value=\"${e.value}\"${e.label ? ` data-label=\"${escapeXml(e.label)}\"` : ''}`\n          parts.push(\n            `<circle cx=\"${r(e.x)}\" cy=\"${r(e.y)}\" r=\"${CHART_FONT.dotRadius}\" class=\"xychart-dot xychart-color-${e.colorIndex}\"${dataAttrs}/>`\n          )\n        }\n      }\n    }\n  }\n\n  // 5. Axis labels (no axis lines, no tick marks — just floating labels)\n  for (const tick of chart.xAxis.ticks) {\n    parts.push(\n      `<text x=\"${tick.labelX}\" y=\"${tick.labelY}\" text-anchor=\"${tick.textAnchor}\" ` +\n      `font-size=\"${CHART_FONT.labelSize}\" font-weight=\"${CHART_FONT.labelWeight}\" ` +\n      `dy=\"${TEXT_BASELINE_SHIFT}\" class=\"xychart-label\">${escapeXml(tick.label)}</text>`\n    )\n  }\n  for (const tick of chart.yAxis.ticks) {\n    parts.push(\n      `<text x=\"${tick.labelX}\" y=\"${tick.labelY}\" text-anchor=\"${tick.textAnchor}\" ` +\n      `font-size=\"${CHART_FONT.labelSize}\" font-weight=\"${CHART_FONT.labelWeight}\" ` +\n      `dy=\"${TEXT_BASELINE_SHIFT}\" class=\"xychart-label\">${escapeXml(tick.label)}</text>`\n    )\n  }\n\n  // 6. Axis titles\n  if (chart.xAxis.title) {\n    const t = chart.xAxis.title\n    const transform = t.rotate ? ` transform=\"rotate(${t.rotate},${t.x},${t.y})\"` : ''\n    parts.push(\n      `<text x=\"${t.x}\" y=\"${t.y}\" text-anchor=\"middle\"${transform} ` +\n      `font-size=\"${CHART_FONT.axisTitleSize}\" font-weight=\"${CHART_FONT.axisTitleWeight}\" ` +\n      `dy=\"${TEXT_BASELINE_SHIFT}\" class=\"xychart-axis-title\">${escapeXml(t.text)}</text>`\n    )\n  }\n  if (chart.yAxis.title) {\n    const t = chart.yAxis.title\n    const transform = t.rotate ? ` transform=\"rotate(${t.rotate},${t.x},${t.y})\"` : ''\n    parts.push(\n      `<text x=\"${t.x}\" y=\"${t.y}\" text-anchor=\"middle\"${transform} ` +\n      `font-size=\"${CHART_FONT.axisTitleSize}\" font-weight=\"${CHART_FONT.axisTitleWeight}\" ` +\n      `dy=\"${TEXT_BASELINE_SHIFT}\" class=\"xychart-axis-title\">${escapeXml(t.text)}</text>`\n    )\n  }\n\n  // 7. Chart title\n  if (chart.title) {\n    parts.push(\n      `<text x=\"${chart.title.x}\" y=\"${chart.title.y}\" text-anchor=\"middle\" ` +\n      `font-size=\"${CHART_FONT.titleSize}\" font-weight=\"${CHART_FONT.titleWeight}\" ` +\n      `dy=\"${TEXT_BASELINE_SHIFT}\" class=\"xychart-title\">${escapeXml(chart.title.text)}</text>`\n    )\n  }\n\n  // 8. Legend\n  for (const item of chart.legend) {\n    const swatchW = 14, swatchH = 14\n    const gap = 6\n    if (item.type === 'bar') {\n      parts.push(\n        `<rect x=\"${item.x}\" y=\"${item.y - swatchH / 2}\" width=\"${swatchW}\" height=\"${swatchH}\" rx=\"3\" ` +\n        `class=\"xychart-bar xychart-color-${item.colorIndex}\"/>`\n      )\n    } else {\n      const ly = item.y\n      parts.push(\n        `<line x1=\"${item.x}\" y1=\"${ly}\" x2=\"${item.x + swatchW}\" y2=\"${ly}\" ` +\n        `stroke-width=\"${CHART_FONT.lineWidth}\" stroke-linecap=\"round\" class=\"xychart-legend-line xychart-color-${item.colorIndex}\"/>`\n      )\n    }\n    parts.push(\n      `<text x=\"${item.x + swatchW + gap}\" y=\"${item.y}\" text-anchor=\"start\" ` +\n      `font-size=\"${CHART_FONT.legendSize}\" font-weight=\"${CHART_FONT.legendWeight}\" ` +\n      `dy=\"${TEXT_BASELINE_SHIFT}\" class=\"xychart-label\">${escapeXml(item.label)}</text>`\n    )\n  }\n\n  // 9. Interactive overlay — rendered last so tooltips are always on top\n  for (const g of barOverlay) parts.push(g)\n  for (const g of dotOverlay) parts.push(g)\n\n  parts.push('</svg>')\n  return parts.join('\\n')\n}\n\n// ============================================================================\n// Chart-specific CSS styles\n// ============================================================================\n\nfunction chartStyles(chart: PositionedXYChart, interactive: boolean, sparse: boolean, themeAccent?: string, bgColor?: string): { style: string; defs: string } {\n  const accentHex = themeAccent ?? CHART_ACCENT_FALLBACK\n\n  // Collect all unique global color indices from bars + lines\n  const colorIndices = new Set<number>()\n  for (const b of chart.bars) colorIndices.add(b.colorIndex)\n  for (const l of chart.lines) colorIndices.add(l.colorIndex)\n\n  // Define --xychart-color-N CSS custom properties (updatable by theme-switching JS)\n  // Also define --xychart-bar-fill-N via color-mix() so it stays dynamic on theme change\n  const colorVarDefs: string[] = []\n  for (const idx of [...colorIndices].sort((a, b) => a - b)) {\n    const value = idx === 0\n      ? `var(--accent, ${CHART_ACCENT_FALLBACK})`\n      : getSeriesColor(idx, accentHex, bgColor)\n    colorVarDefs.push(`    --xychart-color-${idx}: ${value};`)\n    colorVarDefs.push(`    --xychart-bar-fill-${idx}: color-mix(in srgb, var(--bg) 75%, var(--xychart-color-${idx}) 25%);`)\n  }\n\n  // Generate unified color rules — one per global index, referencing the CSS vars\n  const seriesRules: string[] = []\n  for (const idx of [...colorIndices].sort((a, b) => a - b)) {\n    const color = `var(--xychart-color-${idx})`\n    // Bar-specific: stroke + solid blended fill (no opacity)\n    seriesRules.push(`  .xychart-bar.xychart-color-${idx} { stroke: ${color}; fill: var(--xychart-bar-fill-${idx}); }`)\n    // Line/dot-specific: stroke for paths, fill for circles\n    seriesRules.push(`  path.xychart-color-${idx}, line.xychart-color-${idx} { stroke: ${color}; }`)\n    seriesRules.push(`  circle.xychart-color-${idx} { fill: ${color}; }`)\n  }\n\n  const tipRules = interactive ? `\n  .xychart-tip { opacity: 0; pointer-events: none; }\n  .xychart-tip-bg { fill: var(--_text); filter: drop-shadow(0 1px 3px color-mix(in srgb, var(--fg) 20%, transparent)); }\n  .xychart-tip-text { fill: var(--bg); font-size: ${TIP.fontSize}px; font-weight: ${TIP.fontWeight}; }\n  .xychart-tip-ptr { fill: var(--_text); }\n  .xychart-bar-group:hover .xychart-tip,\n  .xychart-dot-group:hover .xychart-tip { opacity: 1; }` : ''\n\n  const colorVarsBlock = colorVarDefs.length > 0 ? `\\n  svg {\\n${colorVarDefs.join('\\n')}\\n  }` : ''\n\n  const style = `<style>\n  .xychart-grid { fill: var(--_inner-stroke); stroke: none; opacity: 0.65; }\n  .xychart-bar { stroke-width: 1.5; }\n  .xychart-line { fill: none; stroke-width: ${CHART_FONT.lineWidth}; stroke-linecap: round; stroke-linejoin: round; }\n  .xychart-line-shadow { fill: none; stroke-width: 5; stroke-linecap: round; stroke-linejoin: round; opacity: 0.12; }\n  .xychart-dot { stroke: var(--bg); stroke-width: 2; }\n  .xychart-label { fill: var(--_text-muted); }\n  .xychart-axis-title { fill: var(--_text-sec); }\n  .xychart-title { fill: var(--_text); }${colorVarsBlock}\n${seriesRules.join('\\n')}${tipRules}\n</style>`\n\n  return { style, defs: '' }\n}\n\n\n// ============================================================================\n// Bar path with all corners rounded\n// ============================================================================\n\nfunction roundedTopBarPath(x: number, y: number, w: number, h: number, radius: number): string {\n  const rr = Math.min(radius, w / 2, h / 2)\n  if (rr <= 0) {\n    return `M${r(x)},${r(y)} h${r(w)} v${r(h)} h${r(-w)} Z`\n  }\n  return [\n    `M${r(x)},${r(y + rr)}`,                                   // start below top-left\n    `Q${r(x)},${r(y)} ${r(x + rr)},${r(y)}`,                   // top-left\n    `L${r(x + w - rr)},${r(y)}`,                                // top edge\n    `Q${r(x + w)},${r(y)} ${r(x + w)},${r(y + rr)}`,           // top-right\n    `L${r(x + w)},${r(y + h - rr)}`,                            // right edge\n    `Q${r(x + w)},${r(y + h)} ${r(x + w - rr)},${r(y + h)}`,   // bottom-right\n    `L${r(x + rr)},${r(y + h)}`,                                // bottom edge\n    `Q${r(x)},${r(y + h)} ${r(x)},${r(y + h - rr)}`,           // bottom-left\n    'Z',\n  ].join(' ')\n}\n\n// ============================================================================\n// Bar path with all corners rounded (for horizontal charts)\n// ============================================================================\n\nfunction roundedRightBarPath(x: number, y: number, w: number, h: number, radius: number): string {\n  const rr = Math.min(radius, w / 2, h / 2)\n  if (rr <= 0) {\n    return `M${r(x)},${r(y)} h${r(w)} v${r(h)} h${r(-w)} Z`\n  }\n  return [\n    `M${r(x + rr)},${r(y)}`,                                    // start after top-left\n    `L${r(x + w - rr)},${r(y)}`,                                // top edge\n    `Q${r(x + w)},${r(y)} ${r(x + w)},${r(y + rr)}`,           // top-right\n    `L${r(x + w)},${r(y + h - rr)}`,                            // right edge\n    `Q${r(x + w)},${r(y + h)} ${r(x + w - rr)},${r(y + h)}`,   // bottom-right\n    `L${r(x + rr)},${r(y + h)}`,                                // bottom edge\n    `Q${r(x)},${r(y + h)} ${r(x)},${r(y + h - rr)}`,           // bottom-left\n    `L${r(x)},${r(y + rr)}`,                                    // left edge\n    `Q${r(x)},${r(y)} ${r(x + rr)},${r(y)}`,                   // top-left\n    'Z',\n  ].join(' ')\n}\n\n// ============================================================================\n// Smooth line interpolation — Natural cubic spline\n//\n// Computes the mathematically smoothest curve through all data points by\n// minimizing total curvature (integrated second derivative). Treats y as a\n// function of x, so the curve can never go backwards.\n//\n// Algorithm: tridiagonal system for second derivatives (Thomas algorithm),\n// then convert each cubic segment to SVG cubic Bezier commands.\n// ============================================================================\n\nfunction smoothCurvePath(points: Array<{ x: number; y: number }>): string {\n  if (points.length === 0) return ''\n  if (points.length === 1) return `M${r(points[0]!.x)},${r(points[0]!.y)}`\n  if (points.length === 2) {\n    return `M${r(points[0]!.x)},${r(points[0]!.y)} L${r(points[1]!.x)},${r(points[1]!.y)}`\n  }\n\n  const n = points.length\n\n  // 1. Interval widths and secant slopes\n  const h: number[] = []\n  const delta: number[] = []\n  for (let i = 0; i < n - 1; i++) {\n    h.push(points[i + 1]!.x - points[i]!.x)\n    delta.push(h[i]! === 0 ? 0 : (points[i + 1]!.y - points[i]!.y) / h[i]!)\n  }\n\n  // 2. Solve tridiagonal system for second derivatives c[] (natural boundary: c[0] = c[n-1] = 0)\n  const c = new Array<number>(n).fill(0)\n  if (n > 2) {\n    // Forward elimination\n    const cp = new Array<number>(n).fill(0) // modified upper diagonal\n    const dp = new Array<number>(n).fill(0) // modified right-hand side\n    for (let i = 1; i < n - 1; i++) {\n      const diag = 2 * (h[i - 1]! + h[i]!)\n      const rhs = 3 * (delta[i]! - delta[i - 1]!)\n      if (i === 1) {\n        cp[i] = h[i]! / diag\n        dp[i] = rhs / diag\n      } else {\n        const w = diag - h[i - 1]! * cp[i - 1]!\n        cp[i] = h[i]! / w\n        dp[i] = (rhs - h[i - 1]! * dp[i - 1]!) / w\n      }\n    }\n    // Back substitution\n    for (let i = n - 2; i >= 1; i--) {\n      c[i] = dp[i]! - cp[i]! * c[i + 1]!\n    }\n  }\n\n  // 3. Compute first derivatives (slopes) at each knot\n  const slopes = new Array<number>(n).fill(0)\n  for (let i = 0; i < n - 1; i++) {\n    slopes[i] = delta[i]! - h[i]! * (2 * c[i]! + c[i + 1]!) / 3\n  }\n  // Slope at last point: derivative of last segment at its end\n  slopes[n - 1] = delta[n - 2]! + h[n - 2]! * (c[n - 2]!) / 3\n\n  // 4. Convert to cubic Bezier — control points strictly between endpoints in x\n  let path = `M${r(points[0]!.x)},${r(points[0]!.y)}`\n  for (let i = 0; i < n - 1; i++) {\n    const seg = h[i]! / 3\n    const cp1x = points[i]!.x + seg\n    const cp1y = points[i]!.y + slopes[i]! * seg\n    const cp2x = points[i + 1]!.x - seg\n    const cp2y = points[i + 1]!.y - slopes[i + 1]! * seg\n    path += ` C${r(cp1x)},${r(cp1y)} ${r(cp2x)},${r(cp2y)} ${r(points[i + 1]!.x)},${r(points[i + 1]!.y)}`\n  }\n\n  return path\n}\n\n// ============================================================================\n// Tooltip rendering\n// ============================================================================\n\n/**\n * Multi-value tooltip: category label on top, each series value below with legend text label.\n */\nfunction multiTooltipAbove(cx: number, topY: number, label: string, entries: Array<{ text: string; legendLabel: string }>): string {\n  const lineH = 20\n  const padY = 6\n  const labelGap = 10\n  const headingW = estimateTextWidth(label, TIP.fontSize, 600)\n  const maxRowW = Math.max(...entries.map(e => {\n    const legendW = estimateTextWidth(e.legendLabel, TIP.fontSize, TIP.fontWeight)\n    const valW = estimateTextWidth(e.text, TIP.fontSize, TIP.fontWeight)\n    return legendW + labelGap + valW\n  }))\n  const bgW = Math.max(headingW, maxRowW) + TIP.padX * 2\n  const bgH = padY + lineH + entries.length * lineH + padY\n\n  const tipY = Math.max(TIP.minY, topY - TIP.offsetY - bgH - TIP.pointerSize)\n  const bgX = cx - bgW / 2\n\n  const ptrX = cx\n  const ptrY = tipY + bgH\n  const ps = TIP.pointerSize\n  const pointer = `<polygon points=\"${r(ptrX - ps)},${r(ptrY)} ${r(ptrX + ps)},${r(ptrY)} ${r(ptrX)},${r(ptrY + ps)}\" class=\"xychart-tip xychart-tip-ptr\"/>`\n\n  let svg = `<rect x=\"${r(bgX)}\" y=\"${r(tipY)}\" width=\"${r(bgW)}\" height=\"${bgH}\" rx=\"${TIP.rx}\" class=\"xychart-tip xychart-tip-bg\"/>`\n  svg += pointer\n\n  // Category label (bold, centered)\n  let textY = tipY + padY + lineH / 2\n  svg += `<text x=\"${r(cx)}\" y=\"${r(textY)}\" text-anchor=\"middle\" font-weight=\"600\" font-size=\"${TIP.fontSize}\" dy=\"${TEXT_BASELINE_SHIFT}\" class=\"xychart-tip xychart-tip-text\">${escapeXml(label)}</text>`\n\n  // Value lines: legend label left-aligned, value right-aligned\n  const rowLeft = bgX + TIP.padX\n  const rowRight = bgX + bgW - TIP.padX\n  for (const entry of entries) {\n    textY += lineH\n    svg += `<text x=\"${r(rowLeft)}\" y=\"${r(textY)}\" text-anchor=\"start\" font-size=\"${TIP.fontSize}\" font-weight=\"${TIP.fontWeight}\" dy=\"${TEXT_BASELINE_SHIFT}\" class=\"xychart-tip xychart-tip-text\">${escapeXml(entry.legendLabel)}</text>`\n    svg += `<text x=\"${r(rowRight)}\" y=\"${r(textY)}\" text-anchor=\"end\" font-size=\"${TIP.fontSize}\" font-weight=\"${TIP.fontWeight}\" dy=\"${TEXT_BASELINE_SHIFT}\" class=\"xychart-tip xychart-tip-text\">${escapeXml(entry.text)}</text>`\n  }\n\n  return svg\n}\n\nfunction tooltipAbove(cx: number, topY: number, text: string): string {\n  const textW = estimateTextWidth(text, TIP.fontSize, TIP.fontWeight)\n  const bgW = textW + TIP.padX * 2\n  const bgH = TIP.height\n  const tipY = Math.max(TIP.minY, topY - TIP.offsetY - bgH - TIP.pointerSize)\n  const bgX = cx - bgW / 2\n  const textX = cx\n  const textY = tipY + bgH / 2\n\n  const ptrX = cx\n  const ptrY = tipY + bgH\n  const ps = TIP.pointerSize\n  const pointer = `<polygon points=\"${r(ptrX - ps)},${r(ptrY)} ${r(ptrX + ps)},${r(ptrY)} ${r(ptrX)},${r(ptrY + ps)}\" class=\"xychart-tip xychart-tip-ptr\"/>`\n\n  return (\n    `<rect x=\"${r(bgX)}\" y=\"${r(tipY)}\" width=\"${r(bgW)}\" height=\"${bgH}\" rx=\"${TIP.rx}\" class=\"xychart-tip xychart-tip-bg\"/>` +\n    pointer +\n    `<text x=\"${r(textX)}\" y=\"${r(textY)}\" text-anchor=\"middle\" dy=\"${TEXT_BASELINE_SHIFT}\" class=\"xychart-tip xychart-tip-text\">${escapeXml(text)}</text>`\n  )\n}\n\nfunction formatTipValue(v: number): string {\n  if (Number.isInteger(v)) return v.toLocaleString('en-US')\n  return v.toFixed(Math.abs(v) < 10 ? 1 : 0)\n}\n\nfunction r(n: number): string {\n  return String(Math.round(n * 10) / 10)\n}\n\nfunction escapeXml(text: string): string {\n  return text\n    .replace(/&/g, '&amp;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n    .replace(/\"/g, '&quot;')\n}\n\n\n"
  },
  {
    "path": "src/xychart/types.ts",
    "content": "// ============================================================================\n// XY Chart types\n//\n// Models the parsed and positioned representations of a Mermaid xychart-beta\n// diagram. Supports bar charts, line charts, and combinations with categorical\n// or numeric x-axes.\n// ============================================================================\n\n/** Parsed XY chart — logical structure from mermaid text */\nexport interface XYChart {\n  /** Optional chart title */\n  title?: string\n  /** Chart orientation: vertical (default) or horizontal */\n  horizontal: boolean\n  /** X-axis configuration */\n  xAxis: XYAxis\n  /** Y-axis configuration */\n  yAxis: XYAxis\n  /** Data series (bar and/or line) */\n  series: XYChartSeries[]\n}\n\n/** Axis configuration — categorical (labels) or numeric (range) */\nexport interface XYAxis {\n  /** Optional axis title/label */\n  title?: string\n  /** Categorical labels (e.g., [\"jan\", \"feb\", \"mar\"]) — mutually exclusive with range */\n  categories?: string[]\n  /** Numeric range — mutually exclusive with categories */\n  range?: { min: number; max: number }\n}\n\n/** A single data series (bar or line) */\nexport interface XYChartSeries {\n  /** Series type */\n  type: 'bar' | 'line'\n  /** Data values — one per category, or evenly spaced across numeric range */\n  data: number[]\n}\n\n// ============================================================================\n// Positioned XY chart — ready for SVG rendering\n// ============================================================================\n\nexport interface PositionedXYChart {\n  width: number\n  height: number\n  /** Whether this is a horizontal (rotated) chart */\n  horizontal?: boolean\n  /** Title text and position (if present) */\n  title?: PositionedTitle\n  /** Positioned x-axis with tick marks and labels */\n  xAxis: PositionedAxis\n  /** Positioned y-axis with tick marks and labels */\n  yAxis: PositionedAxis\n  /** The plot area bounds (inside axes) */\n  plotArea: PlotArea\n  /** Positioned bar groups */\n  bars: PositionedBar[]\n  /** Positioned line polylines */\n  lines: PositionedLine[]\n  /** Horizontal grid lines for readability */\n  gridLines: GridLine[]\n  /** Legend items (shown when multiple series) */\n  legend: LegendItem[]\n}\n\nexport interface LegendItem {\n  /** Display label */\n  label: string\n  /** Position of the swatch/icon */\n  x: number\n  y: number\n  /** Series type determines swatch shape (rect for bar, line+dot for line) */\n  type: 'bar' | 'line'\n  /** Series index within its type (for layout grouping) */\n  seriesIndex: number\n  /** Global color index across all series (for unified color assignment) */\n  colorIndex: number\n}\n\nexport interface PositionedTitle {\n  text: string\n  x: number\n  y: number\n}\n\nexport interface PositionedAxis {\n  /** Optional axis title text and position */\n  title?: { text: string; x: number; y: number; rotate?: number }\n  /** Tick positions along the axis */\n  ticks: AxisTick[]\n  /** Axis line: start and end coordinates */\n  line: { x1: number; y1: number; x2: number; y2: number }\n}\n\nexport interface AxisTick {\n  /** Label text for this tick */\n  label: string\n  /** Position of the tick mark on the axis */\n  x: number\n  y: number\n  /** End of the tick mark (short perpendicular line) */\n  tx: number\n  ty: number\n  /** Label anchor position */\n  labelX: number\n  labelY: number\n  /** Text anchor for label */\n  textAnchor: 'start' | 'middle' | 'end'\n}\n\nexport interface PlotArea {\n  x: number\n  y: number\n  width: number\n  height: number\n}\n\nexport interface PositionedBar {\n  /** Bar rectangle in SVG coordinates */\n  x: number\n  y: number\n  width: number\n  height: number\n  /** Original data value */\n  value: number\n  /** Category label for this bar (e.g. \"Jan\") */\n  label?: string\n  /** Series index within bar type (for layout grouping) */\n  seriesIndex: number\n  /** Global color index across all series */\n  colorIndex: number\n}\n\nexport interface PositionedLine {\n  /** Polyline points */\n  points: Array<{ x: number; y: number; value: number; label?: string }>\n  /** Series index within line type (for layout grouping) */\n  seriesIndex: number\n  /** Global color index across all series */\n  colorIndex: number\n}\n\nexport interface GridLine {\n  x1: number\n  y1: number\n  x2: number\n  y2: number\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"types\": [\"@types/bun\"],\n    \"lib\": [\"ESNext\"],\n    \"target\": \"ESNext\",\n    \"module\": \"ESNext\",\n    \"moduleDetection\": \"force\",\n    \"allowJs\": true,\n    \"esModuleInterop\": true,\n    \"resolveJsonModule\": true,\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"verbatimModuleSyntax\": true,\n    \"noEmit\": true,\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedIndexedAccess\": true,\n    \"noUnusedLocals\": false,\n    \"noUnusedParameters\": false,\n    \"noPropertyAccessFromIndexSignature\": false,\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"beautiful-mermaid\": [\"./src/index.ts\"],\n      \"beautiful-mermaid/*\": [\"./src/*\"]\n    },\n    \"rootDir\": \"src\",\n    \"outDir\": \"dist\",\n    \"declaration\": true,\n    \"declarationMap\": true\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\"node_modules\", \"dist\"]\n}\n"
  },
  {
    "path": "tsup.config.ts",
    "content": "import { defineConfig } from 'tsup'\n\nexport default defineConfig({\n  entry: ['src/index.ts'],\n  format: ['esm'],\n  dts: true,\n  splitting: false,\n  sourcemap: true,\n  clean: true,\n  target: 'es2022',\n  outDir: 'dist',\n  external: ['elkjs', 'entities'],\n})\n"
  },
  {
    "path": "wrangler.toml",
    "content": "name = \"craft-agents-mermaid\"\ncompatibility_date = \"2024-01-01\"\npages_build_output_dir = \"dist\"\n"
  },
  {
    "path": "xychart-design.md",
    "content": "# XY Chart (xychart-beta) — Phase 2 Design Document\n\n## Overview\n\nThis document specifies the architecture for adding `xychart-beta` / `xychart` support to beautiful-mermaid. The implementation follows the same parse → layout → render pipeline used by all other diagram types (ER, sequence, class, flowchart).\n\nXY charts render bar charts, line charts, or combinations thereof, with categorical or numeric x-axes, numeric y-axes, optional titles, and optional horizontal orientation.\n\n---\n\n## Mermaid Syntax Reference\n\n```\nxychart-beta\n    title \"Sales Revenue\"\n    x-axis [jan, feb, mar, apr, may, jun]\n    y-axis \"Revenue (USD)\" 4000 --> 11000\n    bar [5000, 6000, 7500, 8200, 9800, 10500]\n    line [5000, 6000, 7500, 8200, 9800, 10500]\n```\n\nHorizontal variant:\n```\nxychart-beta horizontal\n    title \"Sales Revenue\"\n    x-axis [jan, feb, mar, apr, may, jun]\n    y-axis \"Revenue (USD)\" 4000 --> 11000\n    bar [5000, 6000, 7500, 8200, 9800, 10500]\n```\n\nNumeric x-axis variant:\n```\nxychart-beta\n    x-axis 0 --> 100\n    y-axis 0 --> 50\n    line [10, 20, 35, 40, 45]\n```\n\n---\n\n## Files to Create\n\n### 1. `src/xychart/types.ts`\n\n```typescript\n// ============================================================================\n// XY Chart types\n//\n// Models the parsed and positioned representations of a Mermaid xychart-beta\n// diagram. Supports bar charts, line charts, and combinations with categorical\n// or numeric x-axes.\n// ============================================================================\n\n/** Parsed XY chart — logical structure from mermaid text */\nexport interface XYChart {\n  /** Optional chart title */\n  title?: string\n  /** Chart orientation: vertical (default) or horizontal */\n  horizontal: boolean\n  /** X-axis configuration */\n  xAxis: XYAxis\n  /** Y-axis configuration */\n  yAxis: XYAxis\n  /** Data series (bar and/or line) */\n  series: XYChartSeries[]\n}\n\n/** Axis configuration — categorical (labels) or numeric (range) */\nexport interface XYAxis {\n  /** Optional axis title/label */\n  title?: string\n  /** Categorical labels (e.g., [\"jan\", \"feb\", \"mar\"]) — mutually exclusive with range */\n  categories?: string[]\n  /** Numeric range — mutually exclusive with categories */\n  range?: { min: number; max: number }\n}\n\n/** A single data series (bar or line) */\nexport interface XYChartSeries {\n  /** Series type */\n  type: 'bar' | 'line'\n  /** Data values — one per category, or evenly spaced across numeric range */\n  data: number[]\n}\n\n// ============================================================================\n// Positioned XY chart — ready for SVG rendering\n// ============================================================================\n\nexport interface PositionedXYChart {\n  width: number\n  height: number\n  /** Title text and position (if present) */\n  title?: PositionedTitle\n  /** Positioned x-axis with tick marks and labels */\n  xAxis: PositionedAxis\n  /** Positioned y-axis with tick marks and labels */\n  yAxis: PositionedAxis\n  /** The plot area bounds (inside axes) */\n  plotArea: PlotArea\n  /** Positioned bar groups */\n  bars: PositionedBar[]\n  /** Positioned line polylines */\n  lines: PositionedLine[]\n  /** Horizontal grid lines for readability */\n  gridLines: GridLine[]\n}\n\nexport interface PositionedTitle {\n  text: string\n  x: number\n  y: number\n}\n\nexport interface PositionedAxis {\n  /** Optional axis title text and position */\n  title?: { text: string; x: number; y: number; rotate?: number }\n  /** Tick positions along the axis */\n  ticks: AxisTick[]\n  /** Axis line: start and end coordinates */\n  line: { x1: number; y1: number; x2: number; y2: number }\n}\n\nexport interface AxisTick {\n  /** Label text for this tick */\n  label: string\n  /** Position of the tick mark on the axis */\n  x: number\n  y: number\n  /** End of the tick mark (short perpendicular line) */\n  tx: number\n  ty: number\n  /** Label anchor position */\n  labelX: number\n  labelY: number\n  /** Text anchor for label (\"middle\", \"end\", \"start\") */\n  textAnchor: 'start' | 'middle' | 'end'\n}\n\nexport interface PlotArea {\n  x: number\n  y: number\n  width: number\n  height: number\n}\n\nexport interface PositionedBar {\n  /** Bar rectangle in SVG coordinates */\n  x: number\n  y: number\n  width: number\n  height: number\n  /** Original data value (for tooltips/labels if needed) */\n  value: number\n  /** Series index (for coloring multiple bar series) */\n  seriesIndex: number\n}\n\nexport interface PositionedLine {\n  /** Polyline points */\n  points: Array<{ x: number; y: number }>\n  /** Series index (for coloring multiple line series) */\n  seriesIndex: number\n}\n\nexport interface GridLine {\n  x1: number\n  y1: number\n  x2: number\n  y2: number\n}\n```\n\n### 2. `src/xychart/parser.ts`\n\n```typescript\nimport type { XYChart, XYAxis, XYChartSeries } from './types.ts'\n\n/**\n * Parse a Mermaid xychart-beta diagram.\n * Expects the first line to be \"xychart-beta\" (optionally followed by \"horizontal\").\n *\n * Supported directives (order-independent after header):\n *   title \"Chart Title\"\n *   x-axis [label1, label2, ...]          — categorical\n *   x-axis min --> max                     — numeric range\n *   x-axis \"Axis Title\" [label1, ...]      — with title\n *   y-axis min --> max                     — numeric range\n *   y-axis \"Axis Title\" min --> max        — with title\n *   bar [val1, val2, ...]\n *   line [val1, val2, ...]\n */\nexport function parseXYChart(lines: string[]): XYChart\n```\n\n**Parsing strategy:**\n\n1. First line: detect `horizontal` flag from `xychart-beta horizontal`\n2. Iterate remaining lines, match each directive:\n   - `title \"...\"` → extract quoted string\n   - `x-axis [...]` → parse as categorical labels (split by comma, trim quotes)\n   - `x-axis <num> --> <num>` → parse as numeric range\n   - `x-axis \"Title\" [...]` or `x-axis \"Title\" <num> --> <num>` → title + data\n   - `y-axis` — same patterns as x-axis\n   - `bar [...]` → parse numeric array, push `{ type: 'bar', data }`\n   - `line [...]` → parse numeric array, push `{ type: 'line', data }`\n3. If no y-axis range is specified, auto-derive from min/max of all series data (with padding)\n\n**Internal helpers:**\n- `parseNumericArray(str: string): number[]` — parse `[1, 2, 3]` to `[1, 2, 3]`\n- `parseCategoryArray(str: string): string[]` — parse `[jan, feb, \"mar apr\"]` with optional quoting\n- `parseRange(str: string): { min: number; max: number } | null`\n\n### 3. `src/xychart/layout.ts`\n\n```typescript\nimport type { XYChart, PositionedXYChart } from './types.ts'\nimport type { RenderOptions } from '../types.ts'\n\n/**\n * Lay out a parsed XY chart by computing pixel coordinates.\n * No dagre needed — direct coordinate-space mapping.\n *\n * Layout is synchronous but kept async for API consistency.\n */\nexport async function layoutXYChart(\n  chart: XYChart,\n  options: RenderOptions = {}\n): Promise<PositionedXYChart>\n```\n\n**Layout algorithm:**\n\n1. **Canvas sizing:**\n   - Default plot area: 600 x 400px (tunable via constants)\n   - Padding: `options.padding ?? 40`\n   - Reserve space for title (if present): ~30px top\n   - Reserve space for axis labels: ~50px bottom (x-axis), ~60px left (y-axis)\n   - Reserve space for axis titles: ~20px each side if present\n\n2. **Scale computation:**\n   - X-scale: Maps data indices (categorical) or numeric range to `plotArea.x .. plotArea.x + plotArea.width`\n   - Y-scale: Maps `yAxis.range.min .. yAxis.range.max` to `plotArea.y + plotArea.height .. plotArea.y` (inverted for SVG)\n   - For categorical x-axis: even spacing with `bandWidth = plotArea.width / categories.length`\n\n3. **Y-axis auto-range** (when not specified):\n   - Collect all data values across all series\n   - `min = Math.min(...allValues)`, `max = Math.max(...allValues)`\n   - Add 10% padding: `min - 0.1 * range`, `max + 0.1 * range`\n   - Round to nice tick values\n\n4. **Tick generation:**\n   - X-axis categorical: one tick per category, centered in band\n   - X-axis numeric: 5-10 ticks at \"nice\" intervals (1, 2, 5, 10, 20, 50, ...)\n   - Y-axis: 5-8 ticks at nice intervals\n\n5. **Bar rectangles:**\n   - `barWidth = bandWidth * 0.6` (60% of band, leaving gaps)\n   - Multiple bar series: subdivide band width equally\n   - Each bar: `{ x, y: scale(value), width: barWidth, height: plotBottom - scale(value) }`\n\n6. **Line polylines:**\n   - For each line series: one point per data value at `(xCenter, scale(value))`\n\n7. **Grid lines:**\n   - Horizontal lines at each y-axis tick, spanning the plot width\n\n8. **Horizontal mode:**\n   - Swap x and y throughout: categories go on y-axis (top-to-bottom), values on x-axis (left-to-right)\n   - Bars become horizontal rectangles\n   - Line polylines have swapped coordinates\n\n**Layout constants:**\n```typescript\nconst XY = {\n  padding: 40,\n  plotWidth: 600,\n  plotHeight: 400,\n  titleFontSize: 16,\n  titleHeight: 30,\n  axisLabelFontSize: 11,\n  axisTitleFontSize: 12,\n  xLabelHeight: 50,\n  yLabelWidth: 60,\n  axisTitlePad: 20,\n  tickLength: 5,\n  barPadRatio: 0.2,     // gap between bars as fraction of band\n  barGroupPadRatio: 0.1, // gap between bars within a group\n  gridDash: '3 3',\n} as const\n```\n\n**Text width estimation:**\nUses `estimateTextWidth()` from `../styles.ts` for:\n- Chart title width (to center it)\n- Y-axis label widths (to determine left margin)\n- X-axis label widths (to check for overlap / rotation)\n\n### 4. `src/xychart/renderer.ts`\n\n```typescript\nimport type { PositionedXYChart } from './types.ts'\nimport type { DiagramColors } from '../theme.ts'\n\n/**\n * Render a positioned XY chart as an SVG string.\n */\nexport function renderXYChartSvg(\n  chart: PositionedXYChart,\n  colors: DiagramColors,\n  font?: string,\n  transparent?: boolean\n): string\n```\n\n**SVG structure and render order:**\n\n1. `svgOpenTag()` — sets CSS variables on the root `<svg>` element\n2. `buildStyleBlock(font, false)` — no mono font needed for charts\n3. Additional `<style>` rules for chart-specific elements:\n   - `.xychart-bar { fill: var(--_accent); }` (or series-indexed colors)\n   - `.xychart-line { stroke: var(--_accent); fill: none; stroke-width: 2; }`\n   - `.xychart-grid { stroke: var(--_inner-stroke); stroke-dasharray: 3 3; }`\n   - `.xychart-axis { stroke: var(--_line); }`\n   - `.xychart-dot { fill: var(--_accent); }` (data points on lines)\n4. Grid lines (behind data, using `var(--_inner-stroke)`)\n5. Bar rectangles (using `var(--_accent)` or series color palette)\n6. Line polylines with data point circles\n7. Axis lines and tick marks (using `var(--_line)`)\n8. Axis labels (using `var(--_text-muted)`)\n9. Axis titles (using `var(--_text-sec)`)\n10. Chart title (using `var(--_text)`, centered, bold)\n\n**Theming integration:**\n\nThe chart uses the same CSS custom property system as all other diagram types. Mapping to chart elements:\n\n| CSS Variable | Chart Element |\n|---|---|\n| `var(--_text)` | Chart title |\n| `var(--_text-sec)` | Axis titles |\n| `var(--_text-muted)` | Axis tick labels |\n| `var(--_line)` | Axis lines, tick marks |\n| `var(--_inner-stroke)` | Grid lines |\n| `var(--_accent)` | Bars (primary), line strokes |\n| `var(--_node-fill)` | Bar fill (alternative — lighter) |\n| `var(--_node-stroke)` | Bar stroke |\n| `var(--bg)` | Background / label backgrounds |\n\n**Multi-series coloring:**\n\nFor charts with multiple series, generate palette colors using `color-mix()`:\n```css\n.xychart-series-0 { --_series: var(--_accent); }\n.xychart-series-1 { --_series: color-mix(in srgb, var(--_accent) 60%, var(--fg)); }\n.xychart-series-2 { --_series: color-mix(in srgb, var(--_accent) 40%, var(--fg)); }\n```\n\n---\n\n## Files to Modify\n\n### 1. `src/index.ts`\n\nAdd xychart detection in `detectDiagramType()` and import the pipeline:\n\n```typescript\n// Add to imports:\nimport { parseXYChart } from './xychart/parser.ts'\nimport { layoutXYChart } from './xychart/layout.ts'\nimport { renderXYChartSvg } from './xychart/renderer.ts'\n\n// Update return type:\nfunction detectDiagramType(text: string): 'flowchart' | 'sequence' | 'class' | 'er' | 'xychart' {\n  const firstLine = text.trim().split(/[\\n;]/)[0]?.trim().toLowerCase() ?? ''\n\n  if (/^xychart-beta\\b/.test(firstLine)) return 'xychart'\n  if (/^xychart\\b/.test(firstLine)) return 'xychart'\n  if (/^sequencediagram\\s*$/.test(firstLine)) return 'sequence'\n  if (/^classdiagram\\s*$/.test(firstLine)) return 'class'\n  if (/^erdiagram\\s*$/.test(firstLine)) return 'er'\n\n  return 'flowchart'\n}\n\n// Add case in renderMermaid switch:\ncase 'xychart': {\n  const chart = parseXYChart(lines)\n  const positioned = await layoutXYChart(chart, options)\n  return renderXYChartSvg(positioned, colors, font, transparent)\n}\n```\n\nNote: `xychart-beta` detection must use `\\b` (word boundary) rather than `\\s*$` because the first line may contain `horizontal` after the keyword.\n\n### 2. `samples-data.ts`\n\nAdd xychart samples in a new `'XY Chart'` category section. Examples should cover:\n- Basic bar chart (categorical x-axis)\n- Basic line chart\n- Combined bar + line\n- Horizontal orientation\n- Numeric x-axis with range\n- Axis titles\n- Chart title\n- Large data sets\n- Single data point\n- Negative values\n- Theme variants\n\n### 3. `src/browser.ts`\n\nNo changes needed — the browser bundle imports from `src/index.ts` which re-exports `renderMermaid`. Since xychart is handled inside `renderMermaid`, the browser bundle will automatically support xychart diagrams.\n\n---\n\n## Key Patterns (from existing pipelines)\n\n### Parse → Layout → Render Pipeline\n\nEvery diagram type follows the same three-stage pipeline:\n\n1. **Parser** (`parser.ts`): Takes `string[]` (preprocessed lines, comments stripped, semicolons split). Returns a typed AST (e.g., `ErDiagram`, `SequenceDiagram`). Pure function, no side effects.\n\n2. **Layout** (`layout.ts`): Takes the parsed AST + `RenderOptions`. Returns a positioned structure with pixel coordinates. Async (for API consistency even when synchronous internally). Uses `estimateTextWidth()` for sizing. ER/class/flowchart use dagre; sequence uses direct coordinate computation.\n\n3. **Renderer** (`renderer.ts`): Takes the positioned structure + `DiagramColors` + font + transparent flag. Returns an SVG string. Uses `svgOpenTag()` and `buildStyleBlock()` from `theme.ts`. All colors via CSS custom properties (`var(--_xxx)`).\n\n### Theming via CSS Custom Properties\n\n- Two required variables: `--bg`, `--fg` — set as inline styles on the `<svg>` tag\n- Five optional enrichment variables: `--line`, `--accent`, `--muted`, `--surface`, `--border`\n- Derived internal variables (`--_text`, `--_line`, `--_node-fill`, etc.) computed in `<style>` using `color-mix()` fallbacks\n- Renderers never hardcode colors — always reference `var(--_xxx)`\n\n### Text Width Estimation\n\n- `estimateTextWidth(text, fontSize, fontWeight)` — proportional fonts (Inter)\n- `estimateMonoTextWidth(text, fontSize)` — monospace fonts\n- Used in layout to size boxes, compute margins, center labels\n- Constants in `styles.ts`: `FONT_SIZES`, `FONT_WEIGHTS`, `STROKE_WIDTHS`, `TEXT_BASELINE_SHIFT`\n\n### SVG Structure Pattern\n\n```\n<svg ...style=\"--bg:...;--fg:...\">\n  <style>\n    @import url(...)\n    text { font-family: ... }\n    svg { --_text: ...; --_line: ...; ... }\n  </style>\n  <defs>...</defs>\n  <!-- background elements (grid, relationship lines) -->\n  <!-- primary elements (bars, boxes, nodes) -->\n  <!-- foreground elements (labels, markers) -->\n</svg>\n```\n\nAll SVG is generated as string concatenation (no DOM). Parts are pushed to an array and joined with `\\n`.\n\n### Integration Checklist\n\n- [ ] `src/xychart/types.ts` — type definitions\n- [ ] `src/xychart/parser.ts` — parse xychart-beta text\n- [ ] `src/xychart/layout.ts` — compute positions\n- [ ] `src/xychart/renderer.ts` — render SVG\n- [ ] `src/index.ts` — add detection + routing\n- [ ] `samples-data.ts` — add xychart samples\n- [ ] Tests — parser unit tests for each syntax variant\n\n---\n\n## Design Decisions\n\n### No dagre dependency\nXY charts use a fixed coordinate-space layout. Unlike ER/class/flowchart diagrams that need graph layout algorithms, charts have a predetermined structure: axes form a fixed frame, data maps linearly to pixel coordinates. Direct computation is simpler, faster, and avoids the dagre dependency.\n\n### Categorical vs numeric x-axis\nThe parser detects which variant is used: `[label1, label2, ...]` → categorical, `min --> max` → numeric range. The layout handles both via a unified scale function that maps data indices or numeric values to pixel positions.\n\n### Series palette via color-mix\nRather than hardcoding a palette, multi-series charts derive colors from the theme's `--accent` using `color-mix()`. This ensures the chart looks correct in any theme without maintaining separate palettes.\n\n### Horizontal mode as coordinate swap\nHorizontal orientation is handled by swapping x/y coordinates during layout rather than having a separate rendering path. The renderer draws the same SVG elements regardless of orientation — only the positions differ.\n"
  },
  {
    "path": "xychart-samples-data.ts",
    "content": "/**\n * XY Chart sample definitions for the beautiful-mermaid visual test suite.\n *\n * Contains ~100 xychart-beta examples covering bar charts, line charts,\n * combined charts, axis configurations, horizontal orientation, titles,\n * large datasets, edge cases, and real-world scenarios.\n */\n\nexport interface Sample {\n  title: string\n  description: string\n  source: string\n  /** Optional category tag for grouping in the Table of Contents */\n  category?: string\n}\n\nexport const xychartSamples: Sample[] = [\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  BASIC BAR CHARTS\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'Simple Bar Chart',\n    category: 'Basic Bar Charts',\n    description: 'A minimal bar chart with three data points.',\n    source: `xychart-beta\n    title \"Simple Bar Chart\"\n    x-axis [A, B, C]\n    bar [10, 20, 30]`,\n  },\n  {\n    title: 'Five-Category Bars',\n    category: 'Basic Bar Charts',\n    description: 'Bar chart with five categories and varied values.',\n    source: `xychart-beta\n    title \"Product Sales\"\n    x-axis [Widgets, Gadgets, Gizmos, Doodads, Thingamajigs]\n    bar [150, 230, 180, 95, 310]`,\n  },\n  {\n    title: 'Descending Values',\n    category: 'Basic Bar Charts',\n    description: 'Bars sorted in descending order to show ranking.',\n    source: `xychart-beta\n    title \"Browser Market Share\"\n    x-axis [Chrome, Safari, Firefox, Edge, Other]\n    bar [65, 19, 8, 5, 3]`,\n  },\n  {\n    title: 'Single Bar',\n    category: 'Basic Bar Charts',\n    description: 'A bar chart with only two data points.',\n    source: `xychart-beta\n    title \"Q1 vs Q2\"\n    x-axis [Q1, Q2]\n    bar [4500, 5200]`,\n  },\n  {\n    title: 'Eight-Category Bars',\n    category: 'Basic Bar Charts',\n    description: 'Bar chart with eight categories and varied heights.',\n    source: `xychart-beta\n    title \"Department Headcount\"\n    x-axis [Eng, Sales, Marketing, Support, HR, Finance, Legal, Ops]\n    bar [45, 32, 18, 25, 8, 12, 6, 15]`,\n  },\n  {\n    title: 'Small Values',\n    category: 'Basic Bar Charts',\n    description: 'Bar chart with small integer values under 10.',\n    source: `xychart-beta\n    title \"Daily Bugs Found\"\n    x-axis [Mon, Tue, Wed, Thu, Fri]\n    bar [3, 7, 2, 5, 1]`,\n  },\n  {\n    title: 'Uniform Values',\n    category: 'Basic Bar Charts',\n    description: 'Bars with nearly identical heights.',\n    source: `xychart-beta\n    title \"Consistent Output\"\n    x-axis [Week 1, Week 2, Week 3, Week 4]\n    bar [100, 102, 99, 101]`,\n  },\n  {\n    title: 'Wide Range',\n    category: 'Basic Bar Charts',\n    description: 'Bars with a wide range of values from small to large.',\n    source: `xychart-beta\n    title \"City Population (thousands)\"\n    x-axis [Town A, Town B, City C, Metro D, Mega E]\n    bar [5, 25, 150, 800, 3500]`,\n  },\n  {\n    title: 'Ten-Category Bars',\n    category: 'Basic Bar Charts',\n    description: 'Bar chart with ten data points showing monthly figures.',\n    source: `xychart-beta\n    title \"Monthly Signups\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct]\n    bar [120, 145, 190, 210, 250, 280, 310, 295, 340, 380]`,\n  },\n  {\n    title: 'Bars With Y-Axis Range',\n    category: 'Basic Bar Charts',\n    description: 'Bar chart with an explicit y-axis range.',\n    source: `xychart-beta\n    title \"Test Scores\"\n    x-axis [Alice, Bob, Carol, Dave, Eve]\n    y-axis \"Score\" 0 --> 100\n    bar [85, 72, 91, 68, 95]`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  BASIC LINE CHARTS\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'Simple Line Chart',\n    category: 'Basic Line Charts',\n    description: 'A line chart showing a gentle upward trend with natural variation.',\n    source: `xychart-beta\n    title \"Simple Trend\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug]\n    line [100, 118, 148, 140, 150, 162, 155, 180]`,\n  },\n  {\n    title: 'Upward Trend',\n    category: 'Basic Line Charts',\n    description: 'Line chart showing a steady upward trend.',\n    source: `xychart-beta\n    title \"Revenue Growth\"\n    x-axis [2019, 2020, 2021, 2022, 2023]\n    line [500, 620, 780, 950, 1200]`,\n  },\n  {\n    title: 'Downward Trend',\n    category: 'Basic Line Charts',\n    description: 'Line chart showing a declining trend.',\n    source: `xychart-beta\n    title \"Declining Defect Rate\"\n    x-axis [Sprint 1, Sprint 2, Sprint 3, Sprint 4, Sprint 5, Sprint 6]\n    line [25, 20, 15, 12, 8, 5]`,\n  },\n  {\n    title: 'Oscillating Values',\n    category: 'Basic Line Charts',\n    description: 'Line chart with values that rise and fall repeatedly.',\n    source: `xychart-beta\n    title \"Daily Temperature Variation\"\n    x-axis [Mon, Tue, Wed, Thu, Fri, Sat, Sun]\n    line [18, 22, 17, 24, 19, 26, 20]`,\n  },\n  {\n    title: 'Large Scale Line',\n    category: 'Basic Line Charts',\n    description: 'Line chart with values in the thousands.',\n    source: `xychart-beta\n    title \"Website Visitors\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun]\n    y-axis \"Visitors\" 0 --> 50000\n    line [12000, 18000, 25000, 31000, 38000, 45000]`,\n  },\n  {\n    title: 'Flat Line',\n    category: 'Basic Line Charts',\n    description: 'A line chart where values remain approximately constant.',\n    source: `xychart-beta\n    title \"Stable Metric\"\n    x-axis [W1, W2, W3, W4, W5, W6]\n    line [50, 51, 49, 50, 52, 50]`,\n  },\n  {\n    title: 'Spike Pattern',\n    category: 'Basic Line Charts',\n    description: 'Line chart with a dramatic spike in the middle.',\n    source: `xychart-beta\n    title \"Traffic Spike\"\n    x-axis [6am, 7am, 8am, 9am, 10am, 11am, 12pm, 1pm, 2pm, 3pm, 4pm, 5pm, 6pm]\n    line [200, 250, 350, 650, 1200, 2800, 4500, 3800, 2800, 1600, 900, 500, 300]`,\n  },\n  {\n    title: 'V-Shape Recovery',\n    category: 'Basic Line Charts',\n    description: 'Line chart showing a sharp decline followed by recovery.',\n    source: `xychart-beta\n    title \"Stock Price Recovery\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct]\n    line [100, 85, 65, 48, 38, 40, 52, 68, 82, 95]`,\n  },\n  {\n    title: 'Step Pattern',\n    category: 'Basic Line Charts',\n    description: 'Line chart with staircase-like jumps between plateaus.',\n    source: `xychart-beta\n    title \"Pricing Tiers\"\n    x-axis [Free, Basic, Pro, Team, Enterprise]\n    line [0, 10, 25, 50, 100]`,\n  },\n  {\n    title: 'Two Lines',\n    category: 'Basic Line Charts',\n    description: 'Two line series plotted on the same chart.',\n    source: `xychart-beta\n    title \"Planned vs Actual\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug]\n    line [100, 145, 190, 240, 280, 320, 360, 400]\n    line [90, 130, 185, 235, 275, 340, 380, 420]`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  COMBINED BAR + LINE\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'Bar and Line Overlay',\n    category: 'Combined Bar + Line',\n    description: 'Bars with a line overlaid showing the same data as a trend.',\n    source: `xychart-beta\n    title \"Monthly Revenue\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]\n    y-axis \"Revenue (USD)\" 0 --> 10000\n    bar [4200, 5000, 5800, 6200, 5500, 7000, 7800, 7200, 8400, 8100, 9000, 9200]\n    line [4200, 5000, 5800, 6200, 5500, 7000, 7800, 7200, 8400, 8100, 9000, 9200]`,\n  },\n  {\n    title: 'Bar with Trend Line',\n    category: 'Combined Bar + Line',\n    description: 'Bars showing actual values with a line showing the moving average trend.',\n    source: `xychart-beta\n    title \"Sales with Trend\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]\n    bar [300, 380, 280, 450, 350, 520, 420, 550, 480, 610, 530, 680]\n    line [300, 330, 320, 353, 352, 395, 390, 420, 418, 460, 458, 498]`,\n  },\n  {\n    title: 'Bar with Target Line',\n    category: 'Combined Bar + Line',\n    description: 'Bars showing actual performance with a flat target line.',\n    source: `xychart-beta\n    title \"Performance vs Target\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]\n    y-axis \"Units\" 0 --> 600\n    bar [320, 380, 410, 350, 380, 520, 290, 440, 480, 510, 450, 530]\n    line [400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400]`,\n  },\n  {\n    title: 'Revenue and Profit',\n    category: 'Combined Bar + Line',\n    description: 'Bars for revenue with a line for profit margin.',\n    source: `xychart-beta\n    title \"Revenue and Profit\"\n    x-axis [23Q1, 23Q2, 23Q3, 23Q4, 24Q1, 24Q2, 24Q3, 24Q4]\n    bar [4200, 4800, 5500, 6200, 6800, 7200, 7600, 8100]\n    line [1000, 1200, 1450, 1700, 1900, 2100, 2350, 2600]`,\n  },\n  {\n    title: 'Dual Dataset Overlay',\n    category: 'Combined Bar + Line',\n    description: 'Two bars and one line series for multi-metric comparison.',\n    source: `xychart-beta\n    title \"Orders, Returns, and Net\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug]\n    bar [200, 230, 260, 300, 280, 310, 340, 350]\n    bar [20, 25, 28, 30, 35, 28, 25, 22]\n    line [180, 205, 232, 270, 245, 282, 315, 328]`,\n  },\n  {\n    title: 'Costs vs Revenue',\n    category: 'Combined Bar + Line',\n    description: 'Bars for costs with a line showing revenue growth.',\n    source: `xychart-beta\n    title \"Costs vs Revenue\"\n    x-axis [2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025]\n    bar [350, 370, 400, 420, 450, 440, 460, 470]\n    line [220, 280, 350, 480, 620, 780, 950, 1100]`,\n  },\n  {\n    title: 'Bar with Two Lines',\n    category: 'Combined Bar + Line',\n    description: 'Bars with two overlaid line series for comparison.',\n    source: `xychart-beta\n    title \"Actual, Forecast, Target\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]\n    bar [100, 110, 115, 125, 130, 140, 135, 145, 150, 155, 158, 165]\n    line [95, 105, 115, 125, 130, 138, 142, 148, 152, 158, 162, 168]\n    line [130, 130, 130, 130, 130, 130, 130, 130, 130, 130, 130, 130]`,\n  },\n  {\n    title: 'Stacked Context',\n    category: 'Combined Bar + Line',\n    description: 'Multiple bar series with a line showing the total.',\n    source: `xychart-beta\n    title \"Channel Performance\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug]\n    bar [100, 108, 118, 130, 140, 148, 155, 165]\n    bar [80, 84, 88, 95, 100, 105, 108, 115]\n    line [180, 192, 206, 225, 240, 253, 263, 280]`,\n  },\n  {\n    title: 'Cumulative Line Over Bars',\n    category: 'Combined Bar + Line',\n    description: 'Monthly bars with a cumulative line showing running total.',\n    source: `xychart-beta\n    title \"Monthly and Cumulative Sales\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]\n    bar [80, 100, 120, 150, 140, 170, 180, 160, 190, 200, 210, 220]\n    line [80, 180, 300, 450, 590, 760, 940, 1100, 1290, 1490, 1700, 1920]`,\n  },\n  {\n    title: 'Conversion Funnel',\n    category: 'Combined Bar + Line',\n    description: 'Bars for stage counts with a line showing conversion rate.',\n    source: `xychart-beta\n    title \"Funnel Analysis\"\n    x-axis [Visitors, Leads, Signups, Activated, Engaged, Paid, Retained]\n    bar [10000, 5500, 3000, 1800, 1200, 800, 500]\n    line [10000, 5500, 3000, 1800, 1200, 800, 500]`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  AXIS CONFIGURATIONS\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'Categorical X-Axis',\n    category: 'Axis Configurations',\n    description: 'Standard categorical x-axis with string labels.',\n    source: `xychart-beta\n    title \"Fruit Preferences\"\n    x-axis [Apple, Banana, Cherry, Date, Elderberry]\n    bar [45, 32, 28, 15, 8]`,\n  },\n  {\n    title: 'Numeric X-Axis Range',\n    category: 'Axis Configurations',\n    description: 'Numeric x-axis using the range syntax.',\n    source: `xychart-beta\n    title \"Distribution Curve\"\n    x-axis 0 --> 100\n    line [4, 7, 13, 21, 31, 43, 58, 71, 84, 91, 95, 91, 84, 71, 58, 43, 31, 21, 13, 7, 4]`,\n  },\n  {\n    title: 'Y-Axis with Label and Range',\n    category: 'Axis Configurations',\n    description: 'Y-axis with a title and explicit min/max range.',\n    source: `xychart-beta\n    title \"Temperature Log\"\n    x-axis [6am, 8am, 10am, 12pm, 2pm, 4pm, 6pm, 8pm]\n    y-axis \"Temp (F)\" 50 --> 100\n    line [55, 60, 70, 80, 85, 80, 72, 62]`,\n  },\n  {\n    title: 'Y-Axis Range Without Label',\n    category: 'Axis Configurations',\n    description: 'Y-axis with range but no title.',\n    source: `xychart-beta\n    title \"Sensor Readings\"\n    x-axis [T1, T2, T3, T4, T5, T6, T7, T8]\n    y-axis 0 --> 500\n    line [120, 180, 270, 360, 380, 340, 260, 190]`,\n  },\n  {\n    title: 'X-Axis with Title',\n    category: 'Axis Configurations',\n    description: 'Categorical x-axis with an axis title.',\n    source: `xychart-beta\n    title \"Quarterly Results\"\n    x-axis \"Quarter\" [Q1, Q2, Q3, Q4]\n    y-axis \"Revenue ($K)\" 0 --> 1000\n    bar [420, 580, 710, 890]`,\n  },\n  {\n    title: 'Both Axes Titled',\n    category: 'Axis Configurations',\n    description: 'Both x-axis and y-axis have descriptive titles.',\n    source: `xychart-beta\n    title \"Experiment Results\"\n    x-axis \"Trial Number\" [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]\n    y-axis \"Measurement (mm)\" 0 --> 50\n    line [12, 15, 19, 24, 22, 26, 30, 28, 32, 35]`,\n  },\n  {\n    title: 'Long Category Labels',\n    category: 'Axis Configurations',\n    description: 'X-axis with long multi-word category labels.',\n    source: `xychart-beta\n    title \"Department Budget\"\n    x-axis [Engineering, Product Management, Customer Success, Human Resources]\n    bar [850, 420, 310, 180]`,\n  },\n  {\n    title: 'Many Short Labels',\n    category: 'Axis Configurations',\n    description: 'X-axis with many single-character labels.',\n    source: `xychart-beta\n    title \"Letter Frequency\"\n    x-axis [A, B, C, D, E, F, G, H, I, J, K, L, M]\n    bar [82, 15, 28, 43, 127, 22, 20, 61, 70, 2, 8, 40, 24]`,\n  },\n  {\n    title: 'Wide Y-Axis Range',\n    category: 'Axis Configurations',\n    description: 'Y-axis spanning a large range from 0 to 100000.',\n    source: `xychart-beta\n    title \"Annual Revenue\"\n    x-axis [2020, 2021, 2022, 2023, 2024]\n    y-axis \"USD\" 0 --> 100000\n    bar [15000, 28000, 45000, 67000, 92000]`,\n  },\n  {\n    title: 'Narrow Y-Axis Range',\n    category: 'Axis Configurations',\n    description: 'Y-axis with a tight range to emphasize small differences.',\n    source: `xychart-beta\n    title \"CPU Temperature\"\n    x-axis [10s, 20s, 30s, 40s, 50s, 60s]\n    y-axis \"Celsius\" 60 --> 80\n    line [65, 68, 72, 75, 73, 70]`,\n  },\n  {\n    title: 'Auto-Range Y-Axis',\n    category: 'Axis Configurations',\n    description: 'No y-axis declaration; auto-ranged from data.',\n    source: `xychart-beta\n    title \"Auto Range\"\n    x-axis [A, B, C, D, E]\n    bar [42, 87, 63, 29, 75]`,\n  },\n  {\n    title: 'Year Labels',\n    category: 'Axis Configurations',\n    description: 'X-axis with year labels as categories.',\n    source: `xychart-beta\n    title \"Company Growth\"\n    x-axis [2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025]\n    line [10, 15, 12, 22, 35, 48, 62, 80]`,\n  },\n  {\n    title: 'Percentage Y-Axis',\n    category: 'Axis Configurations',\n    description: 'Y-axis configured as percentage from 0 to 100.',\n    source: `xychart-beta\n    title \"Completion Rate\"\n    x-axis [W1, W2, W3, W4, W5, W6, W7, W8]\n    y-axis \"Percent\" 0 --> 100\n    line [5, 12, 28, 50, 72, 85, 93, 97]`,\n  },\n  {\n    title: 'Numeric X-Axis with Bars',\n    category: 'Axis Configurations',\n    description: 'Numeric x-axis range combined with bar data.',\n    source: `xychart-beta\n    title \"Histogram\"\n    x-axis 0 --> 50\n    bar [5, 12, 25, 38, 30, 18, 8]`,\n  },\n  {\n    title: 'Mixed Short and Long Labels',\n    category: 'Axis Configurations',\n    description: 'X-axis with a mix of short and longer labels.',\n    source: `xychart-beta\n    title \"Regional Sales\"\n    x-axis [US, EU, Asia Pacific, LATAM, MEA, ANZ]\n    bar [450, 380, 520, 180, 95, 60]`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  HORIZONTAL ORIENTATION\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'Horizontal Bar Chart',\n    category: 'Horizontal Orientation',\n    description: 'Simple horizontal bar chart.',\n    source: `xychart-beta horizontal\n    title \"Language Popularity\"\n    x-axis [Python, JavaScript, Java, Go, Rust]\n    bar [30, 25, 20, 12, 8]`,\n  },\n  {\n    title: 'Horizontal with Y-Axis',\n    category: 'Horizontal Orientation',\n    description: 'Horizontal bars with explicit y-axis range.',\n    source: `xychart-beta horizontal\n    title \"Sprint Velocity\"\n    x-axis [Sprint 1, Sprint 2, Sprint 3, Sprint 4, Sprint 5]\n    y-axis \"Story Points\" 0 --> 100\n    bar [45, 52, 68, 72, 80]`,\n  },\n  {\n    title: 'Horizontal Line Chart',\n    category: 'Horizontal Orientation',\n    description: 'Line chart in horizontal orientation.',\n    source: `xychart-beta horizontal\n    title \"Response Time Trend\"\n    x-axis [v1.0, v1.1, v1.2, v1.3, v1.4, v1.5, v1.6, v1.7]\n    line [450, 410, 370, 330, 290, 260, 235, 210]`,\n  },\n  {\n    title: 'Horizontal Combined',\n    category: 'Horizontal Orientation',\n    description: 'Horizontal chart with both bars and a line.',\n    source: `xychart-beta horizontal\n    title \"Budget vs Actual\"\n    x-axis [Eng, Sales, Marketing, Product, Ops, HR, Finance, Legal]\n    bar [500, 350, 250, 200, 150, 120, 100, 80]\n    line [480, 380, 230, 180, 160, 110, 95, 75]`,\n  },\n  {\n    title: 'Horizontal Ranking',\n    category: 'Horizontal Orientation',\n    description: 'Horizontal bars showing a ranked list.',\n    source: `xychart-beta horizontal\n    title \"Top Features Requested\"\n    x-axis [Dark Mode, API Access, Mobile App, SSO, Webhooks, CSV Export]\n    bar [245, 198, 176, 152, 134, 112]`,\n  },\n  {\n    title: 'Horizontal Small Values',\n    category: 'Horizontal Orientation',\n    description: 'Horizontal bars with small single-digit values.',\n    source: `xychart-beta horizontal\n    title \"Team Satisfaction Survey\"\n    x-axis [Culture, Compensation, Growth, Balance, Tools]\n    y-axis \"Rating\" 0 --> 5\n    bar [4, 3, 4, 5, 3]`,\n  },\n  {\n    title: 'Horizontal Two Bars',\n    category: 'Horizontal Orientation',\n    description: 'Horizontal chart with two bar series.',\n    source: `xychart-beta horizontal\n    title \"This Year vs Last Year\"\n    x-axis [Q1, Q2, Q3, Q4]\n    bar [200, 250, 300, 280]\n    bar [180, 220, 270, 310]`,\n  },\n  {\n    title: 'Horizontal Wide Range',\n    category: 'Horizontal Orientation',\n    description: 'Horizontal bars with a wide value range.',\n    source: `xychart-beta horizontal\n    title \"GitHub Stars\"\n    x-axis [React, Vue, Angular, Svelte, Solid]\n    bar [220000, 210000, 95000, 78000, 32000]`,\n  },\n  {\n    title: 'Horizontal with Two Lines',\n    category: 'Horizontal Orientation',\n    description: 'Horizontal chart with two line series.',\n    source: `xychart-beta horizontal\n    title \"Planned vs Actual Delivery\"\n    x-axis [F1, F2, F3, F4, F5, F6, F7, F8]\n    line [10, 12, 15, 17, 20, 22, 24, 25]\n    line [12, 13, 14, 18, 22, 21, 23, 24]`,\n  },\n  {\n    title: 'Horizontal Long Labels',\n    category: 'Horizontal Orientation',\n    description: 'Horizontal orientation with long category labels.',\n    source: `xychart-beta horizontal\n    title \"Error Categories\"\n    x-axis [Authentication Failure, Database Timeout, Rate Limit Exceeded, Invalid Input]\n    bar [342, 128, 89, 456]`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  TITLES & FORMATTING\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'No Title',\n    category: 'Titles & Formatting',\n    description: 'Chart without a title declaration.',\n    source: `xychart-beta\n    x-axis [A, B, C, D]\n    bar [10, 20, 30, 40]`,\n  },\n  {\n    title: 'Short Title',\n    category: 'Titles & Formatting',\n    description: 'Chart with a very short one-word title.',\n    source: `xychart-beta\n    title \"Sales\"\n    x-axis [Jan, Feb, Mar]\n    bar [100, 200, 150]`,\n  },\n  {\n    title: 'Long Title',\n    category: 'Titles & Formatting',\n    description: 'Chart with a long descriptive title.',\n    source: `xychart-beta\n    title \"Quarterly Revenue Comparison Across All Regional Offices 2024\"\n    x-axis [Q1, Q2, Q3, Q4]\n    bar [1200, 1500, 1800, 2100]`,\n  },\n  {\n    title: 'Title with Numbers',\n    category: 'Titles & Formatting',\n    description: 'Title containing numeric values.',\n    source: `xychart-beta\n    title \"FY2024 Q3 Results\"\n    x-axis [Product A, Product B, Product C]\n    bar [340, 520, 180]`,\n  },\n  {\n    title: 'Title with Special Characters',\n    category: 'Titles & Formatting',\n    description: 'Title containing parentheses and symbols.',\n    source: `xychart-beta\n    title \"Growth Rate (%)\"\n    x-axis [2020, 2021, 2022, 2023]\n    line [5, 12, 8, 15]`,\n  },\n  {\n    title: 'Title with Ampersand',\n    category: 'Titles & Formatting',\n    description: 'Title using the ampersand character.',\n    source: `xychart-beta\n    title \"R&D Investment\"\n    x-axis [2021, 2022, 2023, 2024]\n    bar [200, 280, 350, 420]`,\n  },\n  {\n    title: 'Title with Hyphen',\n    category: 'Titles & Formatting',\n    description: 'Title containing hyphens.',\n    source: `xychart-beta\n    title \"Year-Over-Year Comparison\"\n    x-axis [Jan, Feb, Mar, Apr, May]\n    bar [100, 120, 90, 140, 130]\n    line [110, 115, 100, 125, 135]`,\n  },\n  {\n    title: 'Minimal Chart',\n    category: 'Titles & Formatting',\n    description: 'The most minimal possible xychart with just axis and data.',\n    source: `xychart-beta\n    x-axis [A, B]\n    bar [1, 2]`,\n  },\n  {\n    title: 'Full Specification',\n    category: 'Titles & Formatting',\n    description: 'Chart with every optional element specified.',\n    source: `xychart-beta\n    title \"Complete Chart\"\n    x-axis \"Category\" [Alpha, Beta, Gamma, Delta]\n    y-axis \"Value\" 0 --> 500\n    bar [120, 340, 250, 410]\n    line [120, 340, 250, 410]`,\n  },\n  {\n    title: 'Title with Colon',\n    category: 'Titles & Formatting',\n    description: 'Title containing a colon separator.',\n    source: `xychart-beta\n    title \"Metrics: Daily Active Users\"\n    x-axis [Mon, Tue, Wed, Thu, Fri]\n    line [1500, 1800, 2200, 2000, 1700]`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  LARGE DATASETS\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: '12-Month Dataset',\n    category: 'Large Datasets',\n    description: 'Full year of monthly data points.',\n    source: `xychart-beta\n    title \"Monthly Active Users (2024)\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]\n    line [12000, 13500, 15200, 16800, 18500, 20100, 19800, 21500, 23000, 24200, 25800, 28000]`,\n  },\n  {\n    title: '26-Point Alphabet',\n    category: 'Large Datasets',\n    description: 'Bar chart with 26 data points, one per letter.',\n    source: `xychart-beta\n    title \"Letter Distribution\"\n    x-axis [A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z]\n    bar [82, 15, 28, 43, 127, 22, 20, 61, 70, 2, 8, 40, 24, 67, 75, 19, 1, 60, 63, 91, 28, 10, 24, 2, 20, 1]`,\n  },\n  {\n    title: 'Dense Weekly Data',\n    category: 'Large Datasets',\n    description: 'Line chart with data for many weeks.',\n    source: `xychart-beta\n    title \"Weekly Downloads\"\n    x-axis [W1, W2, W3, W4, W5, W6, W7, W8, W9, W10, W11, W12, W13, W14, W15, W16, W17, W18, W19, W20]\n    line [500, 520, 480, 550, 600, 580, 620, 650, 700, 680, 720, 750, 800, 780, 820, 850, 900, 880, 920, 950]`,\n  },\n  {\n    title: 'Multiple Series Large',\n    category: 'Large Datasets',\n    description: 'Three data series across 12 months.',\n    source: `xychart-beta\n    title \"Product Lines Revenue\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]\n    bar [500, 520, 540, 580, 600, 620, 650, 640, 680, 700, 720, 750]\n    bar [300, 320, 310, 350, 370, 380, 400, 390, 420, 440, 450, 470]\n    line [800, 840, 850, 930, 970, 1000, 1050, 1030, 1100, 1140, 1170, 1220]`,\n  },\n  {\n    title: 'Two Bars Twelve Months',\n    category: 'Large Datasets',\n    description: 'Two bar series over 12 months for year comparison.',\n    source: `xychart-beta\n    title \"2023 vs 2024 Monthly Sales\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]\n    bar [180, 200, 220, 240, 260, 250, 270, 290, 310, 330, 350, 380]\n    bar [210, 230, 250, 270, 300, 290, 310, 330, 360, 380, 400, 430]`,\n  },\n  {\n    title: 'High Frequency Data',\n    category: 'Large Datasets',\n    description: 'Hourly data points across a full day.',\n    source: `xychart-beta\n    title \"Hourly Server Load\"\n    x-axis [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]\n    line [15, 12, 10, 8, 7, 9, 18, 45, 72, 85, 88, 90, 82, 78, 80, 85, 88, 75, 60, 45, 35, 28, 22, 18]`,\n  },\n  {\n    title: 'Large Values Dataset',\n    category: 'Large Datasets',\n    description: 'Dataset with values in the millions.',\n    source: `xychart-beta\n    title \"Cloud Spending by Month\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]\n    y-axis \"USD\" 0 --> 500000\n    bar [120000, 135000, 148000, 162000, 178000, 195000, 210000, 228000, 245000, 268000, 290000, 315000]`,\n  },\n  {\n    title: 'Three Lines Large',\n    category: 'Large Datasets',\n    description: 'Three overlapping line series over 10 data points.',\n    source: `xychart-beta\n    title \"Multi-Region Latency\"\n    x-axis [T1, T2, T3, T4, T5, T6, T7, T8, T9, T10]\n    line [45, 48, 42, 50, 55, 52, 47, 44, 49, 46]\n    line [120, 115, 125, 118, 122, 130, 128, 135, 132, 127]\n    line [200, 210, 195, 205, 215, 220, 208, 212, 218, 225]`,\n  },\n  {\n    title: 'Quarterly Four Years',\n    category: 'Large Datasets',\n    description: 'Quarterly data spanning four years.',\n    source: `xychart-beta\n    title \"Quarterly Earnings (2021-2024)\"\n    x-axis [21Q1, 21Q2, 21Q3, 21Q4, 22Q1, 22Q2, 22Q3, 22Q4, 23Q1, 23Q2, 23Q3, 23Q4, 24Q1, 24Q2, 24Q3, 24Q4]\n    bar [120, 140, 135, 160, 155, 175, 170, 190, 185, 210, 205, 230, 225, 250, 245, 270]`,\n  },\n  {\n    title: 'Dense Bars and Line',\n    category: 'Large Datasets',\n    description: 'Dense bar chart with 15 categories and an overlay line.',\n    source: `xychart-beta\n    title \"Daily Active Users (First 15 Days)\"\n    x-axis [D1, D2, D3, D4, D5, D6, D7, D8, D9, D10, D11, D12, D13, D14, D15]\n    bar [500, 520, 510, 530, 540, 480, 450, 520, 550, 540, 560, 570, 510, 490, 530]\n    line [500, 510, 510, 520, 530, 520, 510, 515, 525, 530, 535, 540, 535, 530, 530]`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  EDGE CASES\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'Single Data Point',\n    category: 'Edge Cases',\n    description: 'Chart with only one data point.',\n    source: `xychart-beta\n    title \"Single Value\"\n    x-axis [Only]\n    bar [42]`,\n  },\n  {\n    title: 'All Zeros',\n    category: 'Edge Cases',\n    description: 'Bar chart where every value is zero.',\n    source: `xychart-beta\n    title \"No Activity\"\n    x-axis [Mon, Tue, Wed, Thu, Fri]\n    bar [0, 0, 0, 0, 0]`,\n  },\n  {\n    title: 'Very Large Numbers',\n    category: 'Edge Cases',\n    description: 'Chart with values in the millions.',\n    source: `xychart-beta\n    title \"National GDP (millions)\"\n    x-axis [Country A, Country B, Country C]\n    bar [5200000, 3800000, 2900000]`,\n  },\n  {\n    title: 'Very Small Numbers',\n    category: 'Edge Cases',\n    description: 'Chart with very small decimal-like integer values.',\n    source: `xychart-beta\n    title \"Trace Amounts\"\n    x-axis [Sample A, Sample B, Sample C, Sample D]\n    bar [1, 2, 1, 3]`,\n  },\n  {\n    title: 'Two Data Points',\n    category: 'Edge Cases',\n    description: 'Minimal chart with exactly two data points.',\n    source: `xychart-beta\n    title \"Before and After\"\n    x-axis [Before, After]\n    bar [25, 75]`,\n  },\n  {\n    title: 'Single Value Repeated',\n    category: 'Edge Cases',\n    description: 'All bars have the identical value.',\n    source: `xychart-beta\n    title \"Equal Distribution\"\n    x-axis [A, B, C, D, E]\n    bar [50, 50, 50, 50, 50]`,\n  },\n  {\n    title: 'Extreme Outlier',\n    category: 'Edge Cases',\n    description: 'One value is dramatically larger than the rest.',\n    source: `xychart-beta\n    title \"Revenue by Product\"\n    x-axis [Niche A, Niche B, Flagship, Niche C, Niche D]\n    bar [50, 30, 5000, 40, 20]`,\n  },\n  {\n    title: 'Descending to Zero',\n    category: 'Edge Cases',\n    description: 'Values that decrease to zero.',\n    source: `xychart-beta\n    title \"Declining Interest\"\n    x-axis [W1, W2, W3, W4, W5, W6, W7, W8]\n    line [100, 78, 55, 35, 20, 10, 4, 0]`,\n  },\n  {\n    title: 'Ascending from Zero',\n    category: 'Edge Cases',\n    description: 'Values that start at zero and increase.',\n    source: `xychart-beta\n    title \"Ramp Up\"\n    x-axis [Day 1, Day 2, Day 3, Day 4, Day 5]\n    bar [0, 10, 50, 150, 400]`,\n  },\n  {\n    title: 'Alternating High Low',\n    category: 'Edge Cases',\n    description: 'Values alternating between high and low.',\n    source: `xychart-beta\n    title \"Alternating Pattern\"\n    x-axis [A, B, C, D, E, F, G, H]\n    bar [100, 10, 100, 10, 100, 10, 100, 10]`,\n  },\n\n  // ══════════════════════════════════════════════════════════════════════════\n  //  REAL-WORLD SCENARIOS\n  // ══════════════════════════════════════════════════════════════════════════\n\n  {\n    title: 'Monthly Revenue Report',\n    category: 'Real-World Scenarios',\n    description: 'Typical monthly revenue bar chart for a SaaS company.',\n    source: `xychart-beta\n    title \"Monthly Revenue (2024)\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]\n    y-axis \"Revenue ($K)\" 0 --> 500\n    bar [180, 195, 210, 225, 250, 268, 285, 275, 300, 320, 340, 380]`,\n  },\n  {\n    title: 'Cumulative Registered Users',\n    category: 'Real-World Scenarios',\n    description: 'Cumulative user growth over months showing accelerating adoption.',\n    source: `xychart-beta\n    title \"Cumulative Registered Users\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]\n    y-axis \"Users\" 0 --> 50000\n    line [2500, 5200, 8800, 13100, 18000, 23500, 29200, 34800, 39500, 43200, 46500, 50000]`,\n  },\n  {\n    title: 'Temperature Over the Year',\n    category: 'Real-World Scenarios',\n    description: 'Average monthly temperature showing seasonal variation.',\n    source: `xychart-beta\n    title \"Average Monthly Temperature\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]\n    y-axis \"Temp (F)\" 20 --> 100\n    line [32, 35, 45, 58, 68, 78, 85, 83, 74, 60, 48, 36]`,\n  },\n  {\n    title: 'Stock Price Trend',\n    category: 'Real-World Scenarios',\n    description: 'Weekly stock price movement over a quarter.',\n    source: `xychart-beta\n    title \"ACME Corp Stock Price\"\n    x-axis [W1, W2, W3, W4, W5, W6, W7, W8, W9, W10, W11, W12, W13]\n    y-axis \"Price ($)\" 80 --> 160\n    line [100, 105, 98, 112, 108, 120, 115, 125, 135, 128, 140, 148, 155]`,\n  },\n  {\n    title: 'Survey Results',\n    category: 'Real-World Scenarios',\n    description: 'Customer satisfaction survey scores by category.',\n    source: `xychart-beta\n    title \"Customer Satisfaction Survey\"\n    x-axis [Ease of Use, Performance, Support, Pricing, Features, Reliability]\n    y-axis \"Score\" 0 --> 5\n    bar [4, 3, 5, 3, 4, 4]`,\n  },\n  {\n    title: 'API Response Time',\n    category: 'Real-World Scenarios',\n    description: 'P95 API response times across endpoints.',\n    source: `xychart-beta\n    title \"P95 Response Time by Endpoint\"\n    x-axis [/users, /orders, /products, /search, /auth, /reports]\n    y-axis \"ms\" 0 --> 1000\n    bar [120, 250, 180, 450, 80, 850]`,\n  },\n  {\n    title: 'Website Analytics',\n    category: 'Real-World Scenarios',\n    description: 'Daily page views and unique visitors for a week.',\n    source: `xychart-beta\n    title \"Website Traffic\"\n    x-axis [Mon, Tue, Wed, Thu, Fri, Sat, Sun]\n    bar [8500, 9200, 9800, 9500, 8800, 5200, 4100]\n    line [3200, 3500, 3800, 3600, 3400, 2100, 1800]`,\n  },\n  {\n    title: 'Sprint Burndown',\n    category: 'Real-World Scenarios',\n    description: 'Remaining story points over a two-week sprint.',\n    source: `xychart-beta\n    title \"Sprint Burndown\"\n    x-axis [D1, D2, D3, D4, D5, D6, D7, D8, D9, D10]\n    y-axis \"Story Points\" 0 --> 80\n    line [72, 65, 58, 50, 45, 38, 30, 22, 12, 0]\n    line [72, 65, 58, 50, 43, 36, 29, 22, 14, 0]`,\n  },\n  {\n    title: 'Marketing Funnel',\n    category: 'Real-World Scenarios',\n    description: 'Marketing conversion funnel from impressions to purchases.',\n    source: `xychart-beta\n    title \"Marketing Funnel\"\n    x-axis [Impressions, Clicks, Signups, Trials, Purchases]\n    bar [50000, 5000, 1200, 400, 150]`,\n  },\n  {\n    title: 'Server Error Rates',\n    category: 'Real-World Scenarios',\n    description: 'HTTP error rates by hour during an incident.',\n    source: `xychart-beta\n    title \"Error Rate During Incident\"\n    x-axis [10am, 11am, 12pm, 1pm, 2pm, 3pm, 4pm, 5pm]\n    y-axis \"Errors/min\" 0 --> 500\n    bar [5, 12, 45, 380, 420, 250, 35, 8]\n    line [5, 12, 45, 380, 420, 250, 35, 8]`,\n  },\n  {\n    title: 'Employee Growth',\n    category: 'Real-World Scenarios',\n    description: 'Headcount growth at a startup over years.',\n    source: `xychart-beta\n    title \"Company Headcount\"\n    x-axis [2018, 2019, 2020, 2021, 2022, 2023, 2024]\n    bar [8, 15, 25, 45, 80, 120, 185]`,\n  },\n  {\n    title: 'Monthly Churn Rate',\n    category: 'Real-World Scenarios',\n    description: 'Customer churn rate percentage over months.',\n    source: `xychart-beta\n    title \"Monthly Churn Rate\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]\n    y-axis \"Churn %\" 0 --> 10\n    line [5, 4, 5, 4, 3, 3, 4, 3, 3, 2, 2, 2]`,\n  },\n  {\n    title: 'A/B Test Results',\n    category: 'Real-World Scenarios',\n    description: 'Conversion rates for control vs variant across segments.',\n    source: `xychart-beta\n    title \"A/B Test Conversion Rates\"\n    x-axis [Mobile, Desktop, Tablet, New Users, Returning]\n    bar [3, 5, 4, 2, 6]\n    bar [4, 7, 5, 3, 8]`,\n  },\n  {\n    title: 'Cloud Cost Breakdown',\n    category: 'Real-World Scenarios',\n    description: 'Monthly cloud infrastructure costs by service.',\n    source: `xychart-beta\n    title \"Monthly Cloud Costs\"\n    x-axis [Compute, Storage, Database, Network, CDN, Monitoring, Other]\n    y-axis \"USD\" 0 --> 20000\n    bar [15000, 8000, 6500, 3200, 2800, 1500, 900]`,\n  },\n  {\n    title: 'Release Frequency',\n    category: 'Real-World Scenarios',\n    description: 'Number of production deployments per month.',\n    source: `xychart-beta\n    title \"Production Deployments per Month\"\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]\n    bar [12, 15, 18, 22, 20, 25, 28, 24, 30, 32, 35, 38]\n    line [12, 15, 18, 22, 20, 25, 28, 24, 30, 32, 35, 38]`,\n  },\n]\n"
  },
  {
    "path": "xychart-test.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n  <meta name=\"theme-color\" id=\"theme-color-meta\" content=\"#f9f9fa\" />\n  <title>XY Chart Test — Beautiful Mermaid</title>\n  <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n  <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\n  <link href=\"https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap\" rel=\"stylesheet\" />\n  <script src=\"https://cdn.jsdelivr.net/npm/chart.js\"></script>\n  <style>\n    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n\n    body {\n      --t-bg: #FFFFFF;\n      --t-fg: #27272A;\n      --t-accent: #3b82f6;\n      --foreground-rgb: 39, 39, 42;\n      --accent-rgb: 59, 130, 246;\n      --shadow-border-opacity: 0.08;\n      --shadow-blur-opacity: 0.06;\n      --theme-bar-bg: #f9f9fa;\n\n      font-family: 'Geist', system-ui, -apple-system, sans-serif;\n      background: color-mix(in srgb, var(--t-fg) 4%, var(--t-bg));\n      color: var(--t-fg);\n      line-height: 1.6;\n      margin: 0;\n      transition: background 0.2s, color 0.2s;\n    }\n    .content-wrapper {\n      max-width: 1440px;\n      margin: 0 auto;\n      padding: 2rem;\n      padding-top: 0;\n    }\n    @media (min-width: 1000px) {\n      .content-wrapper { padding: 3rem; padding-top: 0; }\n    }\n\n    body::before, body::after {\n      content: '';\n      position: fixed;\n      left: 0; right: 0;\n      height: 64px;\n      pointer-events: none;\n      z-index: 1000;\n      will-change: transform;\n    }\n    body::before {\n      top: 0;\n      background: linear-gradient(to bottom, var(--theme-bar-bg) 0%, transparent 100%);\n    }\n    body::after {\n      bottom: 0;\n      background: linear-gradient(to top, var(--theme-bar-bg) 0%, transparent 100%);\n    }\n\n    /* Theme bar */\n    .theme-bar {\n      position: sticky; top: 0; z-index: 1001;\n      background: transparent;\n      padding: 0.5rem 2rem;\n      display: flex; align-items: center; gap: 0.75rem;\n      overflow: visible;\n    }\n    .theme-label {\n      font-size: 0.7rem; font-weight: 600;\n      text-transform: uppercase; letter-spacing: 0.06em;\n      color: color-mix(in srgb, var(--t-fg) 35%, var(--t-bg));\n      white-space: nowrap;\n    }\n    .theme-pills {\n      display: flex; gap: 0.3rem; overflow: visible;\n      padding: 4px; margin: -4px; margin-left: auto;\n      position: relative; z-index: 2;\n    }\n    .theme-pills-inline { display: flex; gap: 0.3rem; }\n    @media (max-width: 1024px) {\n      .theme-pills-inline { display: none; }\n    }\n    .theme-pill {\n      display: flex; align-items: center; height: 30px;\n      gap: 8px; padding: 0 14px 0 12px; border: none; border-radius: 8px;\n      background: color-mix(in srgb, var(--t-bg) 97%, var(--t-fg));\n      color: color-mix(in srgb, var(--t-fg) 80%, var(--t-bg));\n      font-size: 12px; font-weight: 500; font-family: inherit;\n      cursor: pointer; white-space: nowrap;\n      transition: color 0.15s, background 0.15s, box-shadow 0.2s, transform 0.1s;\n    }\n    .theme-pill:hover {\n      color: var(--t-fg);\n      background: color-mix(in srgb, var(--t-bg) 92%, var(--t-fg));\n    }\n    .theme-pill.active {\n      color: var(--t-fg); background: var(--t-bg); font-weight: 600;\n    }\n    .theme-pill:active { transform: translateY(0.5px); }\n    .theme-swatch {\n      display: inline-block; width: 14px; height: 14px;\n      border-radius: 50%; flex-shrink: 0;\n    }\n\n    .theme-more-wrapper { position: relative; }\n    .theme-more-dropdown {\n      display: none; position: absolute; top: calc(100% + 6px); right: 0;\n      background: var(--t-bg); border-radius: 12px; padding: 6px;\n      flex-direction: column; gap: 2px; min-width: 160px; z-index: 1002;\n    }\n    .theme-more-dropdown.open { display: flex; }\n    .theme-more-dropdown .theme-pill {\n      width: 100%; justify-content: flex-start;\n      background: transparent; box-shadow: none;\n    }\n    .theme-more-dropdown .theme-pill:hover {\n      background: color-mix(in srgb, var(--t-bg) 92%, var(--t-fg));\n    }\n    .theme-more-dropdown .theme-pill.active,\n    .theme-more-dropdown .theme-pill.shadow-tinted {\n      background: var(--t-bg);\n      box-shadow:\n        rgba(0,0,0,0) 0px 0px 0px 0px, rgba(0,0,0,0) 0px 0px 0px 0px,\n        rgba(var(--foreground-rgb), 0.06) 0px 0px 0px 1px,\n        rgba(0,0,0,var(--shadow-blur-opacity)) 0px 1px 1px -0.5px,\n        rgba(0,0,0,var(--shadow-blur-opacity)) 0px 3px 3px -1.5px;\n    }\n\n    /* Contents button */\n    .contents-btn {\n      position: absolute; left: 50%; transform: translateX(-50%);\n      display: flex; align-items: center; height: 30px; gap: 6px;\n      padding: 0 12px; border: none; border-radius: 8px;\n      background: color-mix(in srgb, var(--t-bg) 97%, var(--t-fg));\n      color: color-mix(in srgb, var(--t-fg) 80%, var(--t-bg));\n      font-size: 12px; font-weight: 500; font-family: inherit;\n      cursor: pointer; white-space: nowrap;\n      transition: color 0.15s, background 0.15s, box-shadow 0.2s, transform 0.1s;\n    }\n    .contents-btn:hover {\n      color: var(--t-fg);\n      background: color-mix(in srgb, var(--t-bg) 92%, var(--t-fg));\n    }\n    .contents-btn.active { color: var(--t-fg); background: var(--t-bg); }\n    .contents-btn:active { transform: translateX(-50%) translateY(0.5px); }\n    .contents-btn svg { width: 14px; height: 14px; flex-shrink: 0; }\n\n    /* Shadow utilities */\n    .shadow-minimal {\n      box-shadow:\n        rgba(0,0,0,0) 0px 0px 0px 0px, rgba(0,0,0,0) 0px 0px 0px 0px,\n        rgba(var(--foreground-rgb), 0.06) 0px 0px 0px 1px,\n        rgba(0,0,0,var(--shadow-blur-opacity)) 0px 1px 1px -0.5px,\n        rgba(0,0,0,var(--shadow-blur-opacity)) 0px 3px 3px -1.5px;\n    }\n    .shadow-modal-small {\n      box-shadow:\n        rgba(0,0,0,0) 0px 0px 0px 0px, rgba(0,0,0,0) 0px 0px 0px 0px,\n        rgba(var(--foreground-rgb), 0.06) 0px 0px 0px 1px,\n        rgba(0,0,0,calc(var(--shadow-blur-opacity)*0.67)) 0px 1px 1px -0.5px,\n        rgba(0,0,0,calc(var(--shadow-blur-opacity)*0.67)) 0px 3px 3px 0px,\n        rgba(0,0,0,calc(var(--shadow-blur-opacity)*0.33)) 0px 6px 6px 0px,\n        rgba(0,0,0,calc(var(--shadow-blur-opacity)*0.33)) 0px 12px 12px 0px,\n        rgba(0,0,0,calc(var(--shadow-blur-opacity)*0.33)) 0px 24px 24px 0px;\n    }\n    .shadow-tinted {\n      --shadow-color: 0, 0, 0;\n      box-shadow:\n        rgba(var(--shadow-color),0) 0px 0px 0px 0px, rgba(var(--shadow-color),0) 0px 0px 0px 0px,\n        rgba(var(--shadow-color),calc(var(--shadow-border-opacity)*1.5)) 0px 0px 0px 1px,\n        rgba(var(--shadow-color),var(--shadow-border-opacity)) 0px 1px 1px -0.5px,\n        rgba(var(--shadow-color),var(--shadow-blur-opacity)) 0px 3px 3px -1.5px,\n        rgba(var(--shadow-color),calc(var(--shadow-blur-opacity)*0.67)) 0px 6px 6px -3px;\n    }\n\n    /* Mega menu */\n    .mega-menu {\n      display: none; position: absolute; top: calc(100% + 6px);\n      left: 50%; transform: translateX(-50%);\n      max-width: 1180px; width: max-content;\n      background: var(--t-bg); border-radius: 12px;\n      padding: 1.5rem 2rem; max-height: 70vh;\n      overflow-y: auto; z-index: 998;\n    }\n    .mega-menu.open { display: block; }\n    .toc-grid { columns: 3; column-gap: 2rem; }\n    .toc-category {\n      display: inline-block; width: 100%;\n      margin: 0; padding-bottom: 1rem;\n    }\n    .toc-category h3 {\n      font-size: 0.85rem; font-weight: 600;\n      margin: 0 0 0.5rem 0; color: var(--t-fg);\n      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n    }\n    .toc-category ol {\n      padding: 0; margin: 0; list-style: none; font-size: 0.8rem;\n    }\n    .toc-category li {\n      margin-bottom: 0.15rem;\n      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n    }\n    .toc-category a { color: var(--t-fg); text-decoration: none; }\n    .toc-category a:hover { text-decoration: underline; }\n    .toc-num { color: color-mix(in srgb, var(--t-fg) 30%, var(--t-bg)); }\n\n    /* Sample card */\n    .sample {\n      background: var(--t-bg);\n      margin-bottom: 2rem;\n      overflow: hidden;\n    }\n    .sample-header {\n      padding: 1.25rem 1.5rem;\n      max-width: 48rem;\n      border-bottom: 1px solid color-mix(in srgb, var(--t-fg) 5%, var(--t-bg));\n    }\n    .sample-header h2 {\n      font-size: 1.5rem; font-weight: 500; color: var(--t-fg);\n    }\n    .description {\n      color: color-mix(in srgb, var(--t-fg) 50%, var(--t-bg));\n      font-size: 1rem; font-weight: 400; margin-top: 0.1rem;\n    }\n    .description code {\n      font-family: 'JetBrains Mono', 'Fira Code', monospace;\n      font-size: 0.875em;\n      color: color-mix(in srgb, var(--t-fg) 85%, var(--t-bg));\n      background: color-mix(in srgb, var(--t-fg) 6%, var(--t-bg));\n      padding: 0.15rem 0.4rem; border-radius: 3px;\n    }\n\n    .sample-content {\n      display: grid;\n      grid-template-columns: minmax(180px, 0.8fr) minmax(280px, 1fr) minmax(280px, 1fr);\n      min-height: 350px;\n    }\n    @media (max-width: 900px) {\n      .sample-content { grid-template-columns: 1fr; }\n      .chart-panel { border-left: none !important; border-top: 1px solid color-mix(in srgb, var(--t-fg) 5%, var(--t-bg)) !important; }\n      .svg-panel { border-left: none !important; border-top: 1px solid color-mix(in srgb, var(--t-fg) 5%, var(--t-bg)) !important; }\n    }\n\n    /* Source panel */\n    .source-panel {\n      padding: 0.75rem 1.5rem;\n      border-right: 1px solid color-mix(in srgb, var(--t-fg) 5%, var(--t-bg));\n      min-width: 0; overflow-y: auto;\n      background: color-mix(in srgb, var(--t-fg) 1.5%, var(--t-bg));\n    }\n    .source-panel .shiki {\n      background: transparent !important;\n      padding: 0.5rem 0; font-size: 0.8rem; line-height: 1.5;\n      overflow-x: auto; white-space: pre-wrap; word-break: break-word; margin: 0;\n    }\n    .source-panel .shiki code {\n      background: transparent;\n      font-family: 'JetBrains Mono', 'Fira Code', monospace;\n    }\n    .source-panel .shiki,\n    .source-panel .shiki span[style*=\"#24292e\"],\n    .source-panel .shiki span[style*=\"#24292E\"] {\n      color: color-mix(in srgb, var(--t-fg) 70%, var(--t-bg)) !important;\n    }\n    .source-panel .shiki span[style*=\"#D73A49\"],\n    .source-panel .shiki span[style*=\"#d73a49\"] {\n      color: color-mix(in srgb, var(--t-fg) 90%, var(--t-bg)) !important;\n      font-weight: 500;\n    }\n    .source-panel .shiki span[style*=\"#6F42C1\"],\n    .source-panel .shiki span[style*=\"#6f42c1\"] {\n      color: color-mix(in srgb, var(--t-fg) 65%, var(--t-bg)) !important;\n    }\n    .source-panel .shiki span[style*=\"#E36209\"],\n    .source-panel .shiki span[style*=\"#e36209\"] {\n      color: color-mix(in srgb, var(--t-fg) 75%, var(--t-bg)) !important;\n    }\n    .source-panel .shiki span[style*=\"#032F62\"],\n    .source-panel .shiki span[style*=\"#032f62\"] {\n      color: color-mix(in srgb, var(--t-fg) 50%, var(--t-bg)) !important;\n    }\n\n    /* Chart panel */\n    .chart-panel {\n      padding: 1.25rem 1.5rem;\n      display: flex; align-items: center; justify-content: center;\n      min-width: 0;\n      border-right: 1px solid color-mix(in srgb, var(--t-fg) 5%, var(--t-bg));\n    }\n    .chart-panel canvas {\n      max-width: 100%;\n      max-height: 320px;\n    }\n\n    /* SVG panel */\n    .svg-panel {\n      padding: 1.25rem 1.5rem;\n      display: flex; align-items: center; justify-content: center;\n      min-width: 0;\n      background: color-mix(in srgb, var(--t-fg) 1.5%, var(--t-bg));\n    }\n    .svg-panel svg {\n      max-width: 100%;\n      max-height: 420px;\n      height: auto;\n    }\n    .svg-loading {\n      font-size: 0.85rem; font-weight: 400;\n      color: color-mix(in srgb, var(--t-fg) 25%, var(--t-bg));\n    }\n\n    /* Section title */\n    .section-title {\n      font-size: 1.875rem; font-weight: 800; line-height: 1.2;\n      margin: 0; padding: 2.5rem 0 1.5rem; color: var(--t-fg);\n    }\n\n    /* Hero header */\n    .hero-header {\n      max-width: 1440px; margin: 0 auto;\n      padding: 6rem 2rem 2rem; text-align: left;\n    }\n    @media (min-width: 1000px) {\n      .hero-header { padding: 6rem 3rem 2rem; }\n    }\n    .hero-title {\n      font-size: 2.25rem; font-weight: 800; line-height: 1.2;\n      margin: 0 0 0.25rem; color: var(--t-fg);\n    }\n    .hero-tagline {\n      font-size: 1rem; font-weight: 500;\n      color: color-mix(in srgb, var(--t-fg) 50%, var(--t-bg));\n      margin: 0 0 1rem;\n    }\n    .hero-description {\n      font-size: 0.95rem; line-height: 1.6;\n      color: color-mix(in srgb, var(--t-fg) 70%, var(--t-bg));\n      margin: 0 0 1.5rem; max-width: 680px;\n    }\n    .hero-meta { margin-top: 1.25rem; }\n    .hero-meta .meta {\n      font-size: 0.85rem;\n      color: color-mix(in srgb, var(--t-fg) 40%, var(--t-bg));\n      margin: 0.15rem 0;\n    }\n\n    /* Footer */\n    .site-footer {\n      position: relative; z-index: 10;\n      padding: 1.5rem 2rem 2rem;\n      display: flex; align-items: center; justify-content: space-between;\n      max-width: 1440px; width: 100%; margin: 0 auto;\n      font-size: 12px;\n      color: color-mix(in srgb, var(--t-fg) 50%, var(--t-bg));\n    }\n    @media (min-width: 1000px) {\n      .site-footer { padding: 1.5rem 3rem 2rem; }\n    }\n  </style>\n</head>\n<body>\n  <div id=\"safari-theme-color\" style=\"position:fixed;top:0;left:0;right:0;height:1px;background:var(--theme-bar-bg);z-index:9999;pointer-events:none;\"></div>\n\n  <div class=\"theme-bar\" id=\"theme-bar\">\n    <div style=\"font-size:12px;font-weight:600;color:color-mix(in srgb, var(--t-fg) 80%, var(--t-bg));white-space:nowrap;\">XY Chart Test</div>\n    <button class=\"contents-btn shadow-minimal\" id=\"contents-btn\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"><line x1=\"3\" y1=\"4\" x2=\"13\" y2=\"4\"/><line x1=\"3\" y1=\"8\" x2=\"13\" y2=\"8\"/><line x1=\"3\" y1=\"12\" x2=\"10\" y2=\"12\"/></svg>Contents</button>\n    <div class=\"theme-pills\" id=\"theme-pills\">\n      \n    <div class=\"theme-pills-inline\">\n      <button class=\"theme-pill shadow-minimal active\" data-theme=\"\"><span class=\"theme-swatch\" style=\"background:#FFFFFF;box-shadow:inset 0 0 0 1px rgba(0,0,0,0.1)\"></span>Default</button>\n      <button class=\"theme-pill shadow-minimal\" data-theme=\"dracula\"><span class=\"theme-swatch\" style=\"background:#282a36;box-shadow:inset 0 0 0 1px rgba(255,255,255,0.15)\"></span>Dracula</button>\n      <button class=\"theme-pill shadow-minimal\" data-theme=\"solarized-light\"><span class=\"theme-swatch\" style=\"background:#fdf6e3;box-shadow:inset 0 0 0 1px rgba(0,0,0,0.1)\"></span>Solarized</button>\n    </div>\n    <div class=\"theme-more-wrapper\">\n      <button class=\"theme-pill shadow-minimal\" id=\"theme-more-btn\">15 Themes</button>\n      <div class=\"theme-more-dropdown shadow-modal-small\" id=\"theme-more-dropdown\">\n        <button class=\"theme-pill shadow-minimal active\" data-theme=\"\"><span class=\"theme-swatch\" style=\"background:#FFFFFF;box-shadow:inset 0 0 0 1px rgba(0,0,0,0.1)\"></span>Default</button>\n        <button class=\"theme-pill shadow-minimal\" data-theme=\"zinc-dark\"><span class=\"theme-swatch\" style=\"background:#18181B;box-shadow:inset 0 0 0 1px rgba(255,255,255,0.15)\"></span>Zinc Dark</button>\n        <button class=\"theme-pill shadow-minimal\" data-theme=\"tokyo-night\"><span class=\"theme-swatch\" style=\"background:#1a1b26;box-shadow:inset 0 0 0 1px rgba(255,255,255,0.15)\"></span>Tokyo Night</button>\n        <button class=\"theme-pill shadow-minimal\" data-theme=\"tokyo-night-storm\"><span class=\"theme-swatch\" style=\"background:#24283b;box-shadow:inset 0 0 0 1px rgba(255,255,255,0.15)\"></span>Tokyo Storm</button>\n        <button class=\"theme-pill shadow-minimal\" data-theme=\"tokyo-night-light\"><span class=\"theme-swatch\" style=\"background:#d5d6db;box-shadow:inset 0 0 0 1px rgba(0,0,0,0.1)\"></span>Tokyo Light</button>\n        <button class=\"theme-pill shadow-minimal\" data-theme=\"catppuccin-mocha\"><span class=\"theme-swatch\" style=\"background:#1e1e2e;box-shadow:inset 0 0 0 1px rgba(255,255,255,0.15)\"></span>Catppuccin</button>\n        <button class=\"theme-pill shadow-minimal\" data-theme=\"catppuccin-latte\"><span class=\"theme-swatch\" style=\"background:#eff1f5;box-shadow:inset 0 0 0 1px rgba(0,0,0,0.1)\"></span>Latte</button>\n        <button class=\"theme-pill shadow-minimal\" data-theme=\"nord\"><span class=\"theme-swatch\" style=\"background:#2e3440;box-shadow:inset 0 0 0 1px rgba(255,255,255,0.15)\"></span>Nord</button>\n        <button class=\"theme-pill shadow-minimal\" data-theme=\"nord-light\"><span class=\"theme-swatch\" style=\"background:#eceff4;box-shadow:inset 0 0 0 1px rgba(0,0,0,0.1)\"></span>Nord Light</button>\n        <button class=\"theme-pill shadow-minimal\" data-theme=\"dracula\"><span class=\"theme-swatch\" style=\"background:#282a36;box-shadow:inset 0 0 0 1px rgba(255,255,255,0.15)\"></span>Dracula</button>\n        <button class=\"theme-pill shadow-minimal\" data-theme=\"github-light\"><span class=\"theme-swatch\" style=\"background:#ffffff;box-shadow:inset 0 0 0 1px rgba(0,0,0,0.1)\"></span>GitHub</button>\n        <button class=\"theme-pill shadow-minimal\" data-theme=\"github-dark\"><span class=\"theme-swatch\" style=\"background:#0d1117;box-shadow:inset 0 0 0 1px rgba(255,255,255,0.15)\"></span>GitHub Dark</button>\n        <button class=\"theme-pill shadow-minimal\" data-theme=\"solarized-light\"><span class=\"theme-swatch\" style=\"background:#fdf6e3;box-shadow:inset 0 0 0 1px rgba(0,0,0,0.1)\"></span>Solarized</button>\n        <button class=\"theme-pill shadow-minimal\" data-theme=\"solarized-dark\"><span class=\"theme-swatch\" style=\"background:#002b36;box-shadow:inset 0 0 0 1px rgba(255,255,255,0.15)\"></span>Solar Dark</button>\n        <button class=\"theme-pill shadow-minimal\" data-theme=\"one-dark\"><span class=\"theme-swatch\" style=\"background:#282c34;box-shadow:inset 0 0 0 1px rgba(255,255,255,0.15)\"></span>One Dark</button>\n      </div>\n    </div>\n    </div>\n    <div class=\"mega-menu shadow-modal-small\" id=\"mega-menu\">\n      <div class=\"toc-grid\">\n        \n        <div class=\"toc-category\">\n          <h3>Basic Bar Charts (10)</h3>\n          <ol start=\"1\">\n            <li><a href=\"#sample-0\"><span class=\"toc-num\">1.</span> Simple Bar Chart</a></li>\n            <li><a href=\"#sample-1\"><span class=\"toc-num\">2.</span> Five-Category Bars</a></li>\n            <li><a href=\"#sample-2\"><span class=\"toc-num\">3.</span> Descending Values</a></li>\n            <li><a href=\"#sample-3\"><span class=\"toc-num\">4.</span> Single Bar</a></li>\n            <li><a href=\"#sample-4\"><span class=\"toc-num\">5.</span> Eight-Category Bars</a></li>\n            <li><a href=\"#sample-5\"><span class=\"toc-num\">6.</span> Small Values</a></li>\n            <li><a href=\"#sample-6\"><span class=\"toc-num\">7.</span> Uniform Values</a></li>\n            <li><a href=\"#sample-7\"><span class=\"toc-num\">8.</span> Wide Range</a></li>\n            <li><a href=\"#sample-8\"><span class=\"toc-num\">9.</span> Ten-Category Bars</a></li>\n            <li><a href=\"#sample-9\"><span class=\"toc-num\">10.</span> Bars With Y-Axis Range</a></li>\n          </ol>\n        </div>\n\n        <div class=\"toc-category\">\n          <h3>Basic Line Charts (10)</h3>\n          <ol start=\"11\">\n            <li><a href=\"#sample-10\"><span class=\"toc-num\">11.</span> Simple Line Chart</a></li>\n            <li><a href=\"#sample-11\"><span class=\"toc-num\">12.</span> Upward Trend</a></li>\n            <li><a href=\"#sample-12\"><span class=\"toc-num\">13.</span> Downward Trend</a></li>\n            <li><a href=\"#sample-13\"><span class=\"toc-num\">14.</span> Oscillating Values</a></li>\n            <li><a href=\"#sample-14\"><span class=\"toc-num\">15.</span> Large Scale Line</a></li>\n            <li><a href=\"#sample-15\"><span class=\"toc-num\">16.</span> Flat Line</a></li>\n            <li><a href=\"#sample-16\"><span class=\"toc-num\">17.</span> Spike Pattern</a></li>\n            <li><a href=\"#sample-17\"><span class=\"toc-num\">18.</span> V-Shape Recovery</a></li>\n            <li><a href=\"#sample-18\"><span class=\"toc-num\">19.</span> Step Pattern</a></li>\n            <li><a href=\"#sample-19\"><span class=\"toc-num\">20.</span> Two Lines</a></li>\n          </ol>\n        </div>\n\n        <div class=\"toc-category\">\n          <h3>Combined Bar + Line (10)</h3>\n          <ol start=\"21\">\n            <li><a href=\"#sample-20\"><span class=\"toc-num\">21.</span> Bar and Line Overlay</a></li>\n            <li><a href=\"#sample-21\"><span class=\"toc-num\">22.</span> Bar with Trend Line</a></li>\n            <li><a href=\"#sample-22\"><span class=\"toc-num\">23.</span> Bar with Target Line</a></li>\n            <li><a href=\"#sample-23\"><span class=\"toc-num\">24.</span> Revenue and Profit</a></li>\n            <li><a href=\"#sample-24\"><span class=\"toc-num\">25.</span> Dual Dataset Overlay</a></li>\n            <li><a href=\"#sample-25\"><span class=\"toc-num\">26.</span> Costs vs Revenue</a></li>\n            <li><a href=\"#sample-26\"><span class=\"toc-num\">27.</span> Bar with Two Lines</a></li>\n            <li><a href=\"#sample-27\"><span class=\"toc-num\">28.</span> Stacked Context</a></li>\n            <li><a href=\"#sample-28\"><span class=\"toc-num\">29.</span> Cumulative Line Over Bars</a></li>\n            <li><a href=\"#sample-29\"><span class=\"toc-num\">30.</span> Conversion Funnel</a></li>\n          </ol>\n        </div>\n\n        <div class=\"toc-category\">\n          <h3>Axis Configurations (15)</h3>\n          <ol start=\"31\">\n            <li><a href=\"#sample-30\"><span class=\"toc-num\">31.</span> Categorical X-Axis</a></li>\n            <li><a href=\"#sample-31\"><span class=\"toc-num\">32.</span> Numeric X-Axis Range</a></li>\n            <li><a href=\"#sample-32\"><span class=\"toc-num\">33.</span> Y-Axis with Label and Range</a></li>\n            <li><a href=\"#sample-33\"><span class=\"toc-num\">34.</span> Y-Axis Range Without Label</a></li>\n            <li><a href=\"#sample-34\"><span class=\"toc-num\">35.</span> X-Axis with Title</a></li>\n            <li><a href=\"#sample-35\"><span class=\"toc-num\">36.</span> Both Axes Titled</a></li>\n            <li><a href=\"#sample-36\"><span class=\"toc-num\">37.</span> Long Category Labels</a></li>\n            <li><a href=\"#sample-37\"><span class=\"toc-num\">38.</span> Many Short Labels</a></li>\n            <li><a href=\"#sample-38\"><span class=\"toc-num\">39.</span> Wide Y-Axis Range</a></li>\n            <li><a href=\"#sample-39\"><span class=\"toc-num\">40.</span> Narrow Y-Axis Range</a></li>\n            <li><a href=\"#sample-40\"><span class=\"toc-num\">41.</span> Auto-Range Y-Axis</a></li>\n            <li><a href=\"#sample-41\"><span class=\"toc-num\">42.</span> Year Labels</a></li>\n            <li><a href=\"#sample-42\"><span class=\"toc-num\">43.</span> Percentage Y-Axis</a></li>\n            <li><a href=\"#sample-43\"><span class=\"toc-num\">44.</span> Numeric X-Axis with Bars</a></li>\n            <li><a href=\"#sample-44\"><span class=\"toc-num\">45.</span> Mixed Short and Long Labels</a></li>\n          </ol>\n        </div>\n\n        <div class=\"toc-category\">\n          <h3>Horizontal Orientation (10)</h3>\n          <ol start=\"46\">\n            <li><a href=\"#sample-45\"><span class=\"toc-num\">46.</span> Horizontal Bar Chart</a></li>\n            <li><a href=\"#sample-46\"><span class=\"toc-num\">47.</span> Horizontal with Y-Axis</a></li>\n            <li><a href=\"#sample-47\"><span class=\"toc-num\">48.</span> Horizontal Line Chart</a></li>\n            <li><a href=\"#sample-48\"><span class=\"toc-num\">49.</span> Horizontal Combined</a></li>\n            <li><a href=\"#sample-49\"><span class=\"toc-num\">50.</span> Horizontal Ranking</a></li>\n            <li><a href=\"#sample-50\"><span class=\"toc-num\">51.</span> Horizontal Small Values</a></li>\n            <li><a href=\"#sample-51\"><span class=\"toc-num\">52.</span> Horizontal Two Bars</a></li>\n            <li><a href=\"#sample-52\"><span class=\"toc-num\">53.</span> Horizontal Wide Range</a></li>\n            <li><a href=\"#sample-53\"><span class=\"toc-num\">54.</span> Horizontal with Two Lines</a></li>\n            <li><a href=\"#sample-54\"><span class=\"toc-num\">55.</span> Horizontal Long Labels</a></li>\n          </ol>\n        </div>\n\n        <div class=\"toc-category\">\n          <h3>Titles &amp; Formatting (10)</h3>\n          <ol start=\"56\">\n            <li><a href=\"#sample-55\"><span class=\"toc-num\">56.</span> No Title</a></li>\n            <li><a href=\"#sample-56\"><span class=\"toc-num\">57.</span> Short Title</a></li>\n            <li><a href=\"#sample-57\"><span class=\"toc-num\">58.</span> Long Title</a></li>\n            <li><a href=\"#sample-58\"><span class=\"toc-num\">59.</span> Title with Numbers</a></li>\n            <li><a href=\"#sample-59\"><span class=\"toc-num\">60.</span> Title with Special Characters</a></li>\n            <li><a href=\"#sample-60\"><span class=\"toc-num\">61.</span> Title with Ampersand</a></li>\n            <li><a href=\"#sample-61\"><span class=\"toc-num\">62.</span> Title with Hyphen</a></li>\n            <li><a href=\"#sample-62\"><span class=\"toc-num\">63.</span> Minimal Chart</a></li>\n            <li><a href=\"#sample-63\"><span class=\"toc-num\">64.</span> Full Specification</a></li>\n            <li><a href=\"#sample-64\"><span class=\"toc-num\">65.</span> Title with Colon</a></li>\n          </ol>\n        </div>\n\n        <div class=\"toc-category\">\n          <h3>Large Datasets (10)</h3>\n          <ol start=\"66\">\n            <li><a href=\"#sample-65\"><span class=\"toc-num\">66.</span> 12-Month Dataset</a></li>\n            <li><a href=\"#sample-66\"><span class=\"toc-num\">67.</span> 26-Point Alphabet</a></li>\n            <li><a href=\"#sample-67\"><span class=\"toc-num\">68.</span> Dense Weekly Data</a></li>\n            <li><a href=\"#sample-68\"><span class=\"toc-num\">69.</span> Multiple Series Large</a></li>\n            <li><a href=\"#sample-69\"><span class=\"toc-num\">70.</span> Two Bars Twelve Months</a></li>\n            <li><a href=\"#sample-70\"><span class=\"toc-num\">71.</span> High Frequency Data</a></li>\n            <li><a href=\"#sample-71\"><span class=\"toc-num\">72.</span> Large Values Dataset</a></li>\n            <li><a href=\"#sample-72\"><span class=\"toc-num\">73.</span> Three Lines Large</a></li>\n            <li><a href=\"#sample-73\"><span class=\"toc-num\">74.</span> Quarterly Four Years</a></li>\n            <li><a href=\"#sample-74\"><span class=\"toc-num\">75.</span> Dense Bars and Line</a></li>\n          </ol>\n        </div>\n\n        <div class=\"toc-category\">\n          <h3>Edge Cases (10)</h3>\n          <ol start=\"76\">\n            <li><a href=\"#sample-75\"><span class=\"toc-num\">76.</span> Single Data Point</a></li>\n            <li><a href=\"#sample-76\"><span class=\"toc-num\">77.</span> All Zeros</a></li>\n            <li><a href=\"#sample-77\"><span class=\"toc-num\">78.</span> Very Large Numbers</a></li>\n            <li><a href=\"#sample-78\"><span class=\"toc-num\">79.</span> Very Small Numbers</a></li>\n            <li><a href=\"#sample-79\"><span class=\"toc-num\">80.</span> Two Data Points</a></li>\n            <li><a href=\"#sample-80\"><span class=\"toc-num\">81.</span> Single Value Repeated</a></li>\n            <li><a href=\"#sample-81\"><span class=\"toc-num\">82.</span> Extreme Outlier</a></li>\n            <li><a href=\"#sample-82\"><span class=\"toc-num\">83.</span> Descending to Zero</a></li>\n            <li><a href=\"#sample-83\"><span class=\"toc-num\">84.</span> Ascending from Zero</a></li>\n            <li><a href=\"#sample-84\"><span class=\"toc-num\">85.</span> Alternating High Low</a></li>\n          </ol>\n        </div>\n\n        <div class=\"toc-category\">\n          <h3>Real-World Scenarios (15)</h3>\n          <ol start=\"86\">\n            <li><a href=\"#sample-85\"><span class=\"toc-num\">86.</span> Monthly Revenue Report</a></li>\n            <li><a href=\"#sample-86\"><span class=\"toc-num\">87.</span> Cumulative Registered Users</a></li>\n            <li><a href=\"#sample-87\"><span class=\"toc-num\">88.</span> Temperature Over the Year</a></li>\n            <li><a href=\"#sample-88\"><span class=\"toc-num\">89.</span> Stock Price Trend</a></li>\n            <li><a href=\"#sample-89\"><span class=\"toc-num\">90.</span> Survey Results</a></li>\n            <li><a href=\"#sample-90\"><span class=\"toc-num\">91.</span> API Response Time</a></li>\n            <li><a href=\"#sample-91\"><span class=\"toc-num\">92.</span> Website Analytics</a></li>\n            <li><a href=\"#sample-92\"><span class=\"toc-num\">93.</span> Sprint Burndown</a></li>\n            <li><a href=\"#sample-93\"><span class=\"toc-num\">94.</span> Marketing Funnel</a></li>\n            <li><a href=\"#sample-94\"><span class=\"toc-num\">95.</span> Server Error Rates</a></li>\n            <li><a href=\"#sample-95\"><span class=\"toc-num\">96.</span> Employee Growth</a></li>\n            <li><a href=\"#sample-96\"><span class=\"toc-num\">97.</span> Monthly Churn Rate</a></li>\n            <li><a href=\"#sample-97\"><span class=\"toc-num\">98.</span> A/B Test Results</a></li>\n            <li><a href=\"#sample-98\"><span class=\"toc-num\">99.</span> Cloud Cost Breakdown</a></li>\n            <li><a href=\"#sample-99\"><span class=\"toc-num\">100.</span> Release Frequency</a></li>\n          </ol>\n        </div>\n      </div>\n    </div>\n  </div>\n\n  <header class=\"hero-header\">\n    <h1 class=\"hero-title\">XY Chart Test</h1>\n    <p class=\"hero-tagline\">xychart-beta rendering comparison: Chart.js vs beautiful-mermaid</p>\n    <p class=\"hero-description\">\n      Column 1 shows the Mermaid source, Column 2 renders a Chart.js reference,\n      and Column 3 shows the beautiful-mermaid SVG rendering with theme support.\n    </p>\n    <div class=\"hero-meta\">\n      <p class=\"meta\">100 xychart-beta examples across 9 categories</p>\n      <p class=\"meta\">Chart.js reference + beautiful-mermaid SVG comparison</p>\n    </div>\n  </header>\n\n  <div class=\"content-wrapper\">\n\n  <h2 class=\"section-title\" id=\"cat-basic-bar-charts\">Basic Bar Charts</h2>\n\n    <section class=\"sample\" id=\"sample-0\">\n      <div class=\"sample-header\">\n        <h2>Simple Bar Chart</h2>\n        <p class=\"description\">A minimal bar chart with three data points.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Simple Bar Chart\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">A</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> B</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> C</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">10</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 20</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 30</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-0\">\n          <canvas id=\"chart-0\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-0\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-1\">\n      <div class=\"sample-header\">\n        <h2>Five-Category Bars</h2>\n        <p class=\"description\">Bar chart with five categories and varied values.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Product Sales\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Widgets</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Gadgets</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Gizmos</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Doodads</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Thingamajigs</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">150</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 230</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 180</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 95</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 310</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-1\">\n          <canvas id=\"chart-1\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-1\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-2\">\n      <div class=\"sample-header\">\n        <h2>Descending Values</h2>\n        <p class=\"description\">Bars sorted in descending order to show ranking.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Browser Market Share\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Chrome</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Safari</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Firefox</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Edge</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Other</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">65</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 19</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 8</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 5</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 3</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-2\">\n          <canvas id=\"chart-2\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-2\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-3\">\n      <div class=\"sample-header\">\n        <h2>Single Bar</h2>\n        <p class=\"description\">A bar chart with only two data points.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Q1 vs Q2\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Q1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Q2</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">4500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 5200</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-3\">\n          <canvas id=\"chart-3\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-3\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-4\">\n      <div class=\"sample-header\">\n        <h2>Eight-Category Bars</h2>\n        <p class=\"description\">Bar chart with eight categories and varied heights.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Department Headcount\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Eng</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sales</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Marketing</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Support</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> HR</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Finance</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Legal</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Ops</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">45</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 32</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 18</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 25</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 8</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 12</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 6</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 15</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-4\">\n          <canvas id=\"chart-4\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-4\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-5\">\n      <div class=\"sample-header\">\n        <h2>Small Values</h2>\n        <p class=\"description\">Bar chart with small integer values under 10.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Daily Bugs Found\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Mon</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Tue</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Wed</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Thu</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Fri</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 7</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 5</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 1</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-5\">\n          <canvas id=\"chart-5\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-5\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-6\">\n      <div class=\"sample-header\">\n        <h2>Uniform Values</h2>\n        <p class=\"description\">Bars with nearly identical heights.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Consistent Output\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Week 1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Week 2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Week 3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Week 4</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">100</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 102</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 99</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 101</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-6\">\n          <canvas id=\"chart-6\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-6\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-7\">\n      <div class=\"sample-header\">\n        <h2>Wide Range</h2>\n        <p class=\"description\">Bars with a wide range of values from small to large.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"City Population (thousands)\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Town A</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Town B</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> City C</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Metro D</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Mega E</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">5</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 25</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 150</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 800</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 3500</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-7\">\n          <canvas id=\"chart-7\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-7\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-8\">\n      <div class=\"sample-header\">\n        <h2>Ten-Category Bars</h2>\n        <p class=\"description\">Bar chart with ten data points showing monthly figures.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Monthly Signups\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Jan</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Feb</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Mar</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Apr</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> May</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jun</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jul</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Aug</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sep</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Oct</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">120</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 145</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 190</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 210</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 250</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 280</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 310</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 295</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 340</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 380</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-8\">\n          <canvas id=\"chart-8\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-8\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-9\">\n      <div class=\"sample-header\">\n        <h2>Bars With Y-Axis Range</h2>\n        <p class=\"description\">Bar chart with an explicit y-axis range.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Test Scores\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Alice</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Bob</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Carol</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Dave</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Eve</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    y-axis</span><span style=\"color:#032F62\"> \"Score\"</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 100</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">85</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 72</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 91</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 68</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 95</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-9\">\n          <canvas id=\"chart-9\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-9\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n  <h2 class=\"section-title\" id=\"cat-basic-line-charts\">Basic Line Charts</h2>\n\n    <section class=\"sample\" id=\"sample-10\">\n      <div class=\"sample-header\">\n        <h2>Simple Line Chart</h2>\n        <p class=\"description\">A minimal line chart with four data points.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Simple Trend\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Q1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Q2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Q3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Q4</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">100</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 150</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 130</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 180</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-10\">\n          <canvas id=\"chart-10\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-10\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-11\">\n      <div class=\"sample-header\">\n        <h2>Upward Trend</h2>\n        <p class=\"description\">Line chart showing a steady upward trend.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Revenue Growth\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">2019</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2020</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2021</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2022</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2023</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 620</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 780</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 950</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 1200</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-11\">\n          <canvas id=\"chart-11\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-11\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-12\">\n      <div class=\"sample-header\">\n        <h2>Downward Trend</h2>\n        <p class=\"description\">Line chart showing a declining trend.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Declining Defect Rate\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Sprint 1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sprint 2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sprint 3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sprint 4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sprint 5</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sprint 6</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">25</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 20</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 15</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 12</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 8</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 5</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-12\">\n          <canvas id=\"chart-12\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-12\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-13\">\n      <div class=\"sample-header\">\n        <h2>Oscillating Values</h2>\n        <p class=\"description\">Line chart with values that rise and fall repeatedly.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Daily Temperature Variation\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Mon</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Tue</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Wed</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Thu</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Fri</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sat</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sun</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">18</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 22</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 17</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 24</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 19</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 26</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 20</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-13\">\n          <canvas id=\"chart-13\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-13\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-14\">\n      <div class=\"sample-header\">\n        <h2>Large Scale Line</h2>\n        <p class=\"description\">Line chart with values in the thousands.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Website Visitors\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Jan</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Feb</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Mar</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Apr</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> May</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jun</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    y-axis</span><span style=\"color:#032F62\"> \"Visitors\"</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 50000</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">12000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 18000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 25000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 31000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 38000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 45000</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-14\">\n          <canvas id=\"chart-14\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-14\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-15\">\n      <div class=\"sample-header\">\n        <h2>Flat Line</h2>\n        <p class=\"description\">A line chart where values remain approximately constant.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Stable Metric\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">W1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W5</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W6</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">50</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 51</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 49</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 50</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 52</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 50</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-15\">\n          <canvas id=\"chart-15\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-15\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-16\">\n      <div class=\"sample-header\">\n        <h2>Spike Pattern</h2>\n        <p class=\"description\">Line chart with a dramatic spike in the middle.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Traffic Spike\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">6</span><span style=\"color:#032F62\">am</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 8</span><span style=\"color:#032F62\">am</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 10</span><span style=\"color:#032F62\">am</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 12</span><span style=\"color:#032F62\">pm</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2</span><span style=\"color:#032F62\">pm</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 4</span><span style=\"color:#032F62\">pm</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 6</span><span style=\"color:#032F62\">pm</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 350</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 1200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 4500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2800</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 900</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 300</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-16\">\n          <canvas id=\"chart-16\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-16\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-17\">\n      <div class=\"sample-header\">\n        <h2>V-Shape Recovery</h2>\n        <p class=\"description\">Line chart showing a sharp decline followed by recovery.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Stock Price Recovery\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Jan</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Feb</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Mar</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Apr</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> May</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jun</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">100</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 72</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 45</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 38</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 65</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 95</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-17\">\n          <canvas id=\"chart-17\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-17\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-18\">\n      <div class=\"sample-header\">\n        <h2>Step Pattern</h2>\n        <p class=\"description\">Line chart with staircase-like jumps between plateaus.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Pricing Tiers\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Free</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Basic</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Pro</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Team</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Enterprise</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">0</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 10</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 25</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 50</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 100</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-18\">\n          <canvas id=\"chart-18\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-18\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-19\">\n      <div class=\"sample-header\">\n        <h2>Two Lines</h2>\n        <p class=\"description\">Two line series plotted on the same chart.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Planned vs Actual\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Q1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Q2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Q3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Q4</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">100</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 300</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 400</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">90</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 210</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 280</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 420</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-19\">\n          <canvas id=\"chart-19\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-19\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n  <h2 class=\"section-title\" id=\"cat-combined-bar-line\">Combined Bar + Line</h2>\n\n    <section class=\"sample\" id=\"sample-20\">\n      <div class=\"sample-header\">\n        <h2>Bar and Line Overlay</h2>\n        <p class=\"description\">Bars with a line overlaid showing the same data as a trend.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Monthly Revenue\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Jan</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Feb</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Mar</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Apr</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> May</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jun</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    y-axis</span><span style=\"color:#24292E\"> \"Revenue </span><span style=\"color:#032F62\">(USD</span><span style=\"color:#24292E\">)\" </span><span style=\"color:#005CC5\">0</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 10000</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">5000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 6200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 7800</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 4500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 9200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 8100</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">5000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 6200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 7800</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 4500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 9200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 8100</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-20\">\n          <canvas id=\"chart-20\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-20\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-21\">\n      <div class=\"sample-header\">\n        <h2>Bar with Trend Line</h2>\n        <p class=\"description\">Bars showing actual values with a line showing the moving average trend.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Sales with Trend\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Jan</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Feb</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Mar</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Apr</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> May</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jun</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">300</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 450</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 280</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 520</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 390</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 610</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">300</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 375</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 343</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 388</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 388</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 425</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-21\">\n          <canvas id=\"chart-21\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-21\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-22\">\n      <div class=\"sample-header\">\n        <h2>Bar with Target Line</h2>\n        <p class=\"description\">Bars showing actual performance with a flat target line.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Performance vs Target\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Jan</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Feb</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Mar</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Apr</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> May</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jun</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    y-axis</span><span style=\"color:#032F62\"> \"Units\"</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 600</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">320</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 410</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 380</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 520</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 290</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 480</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">400</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 400</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 400</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 400</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 400</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 400</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-22\">\n          <canvas id=\"chart-22\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-22\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-23\">\n      <div class=\"sample-header\">\n        <h2>Revenue and Profit</h2>\n        <p class=\"description\">Bars for revenue with a line for profit margin.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Revenue and Profit\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Q1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Q2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Q3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Q4</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">5000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 6500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 7200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 8100</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">1200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 1800</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2100</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2600</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-23\">\n          <canvas id=\"chart-23\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-23\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-24\">\n      <div class=\"sample-header\">\n        <h2>Dual Dataset Overlay</h2>\n        <p class=\"description\">Two bars and one line series for multi-metric comparison.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Orders, Returns, and Net\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Jan</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Feb</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Mar</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Apr</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> May</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 250</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 300</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 280</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 350</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">20</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 30</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 25</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 35</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 28</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">180</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 220</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 275</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 245</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 322</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-24\">\n          <canvas id=\"chart-24\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-24\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-25\">\n      <div class=\"sample-header\">\n        <h2>Costs vs Revenue</h2>\n        <p class=\"description\">Bars for costs with a line showing revenue growth.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Costs vs Revenue\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">2020</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2021</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2022</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2023</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2024</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">400</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 420</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 450</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 440</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 460</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">350</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 480</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 620</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 780</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 950</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-25\">\n          <canvas id=\"chart-25\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-25\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-26\">\n      <div class=\"sample-header\">\n        <h2>Bar with Two Lines</h2>\n        <p class=\"description\">Bars with two overlaid line series for comparison.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Actual, Forecast, Target\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Jan</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Feb</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Mar</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Apr</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> May</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jun</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">100</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 120</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 115</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 140</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 135</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 160</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">95</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 110</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 120</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 130</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 140</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 150</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">120</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 120</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 120</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 120</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 120</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 120</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-26\">\n          <canvas id=\"chart-26\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-26\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-27\">\n      <div class=\"sample-header\">\n        <h2>Stacked Context</h2>\n        <p class=\"description\">Multiple bar series with a line showing the total.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Channel Performance\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Jan</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Feb</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Mar</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Apr</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">100</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 120</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 140</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 160</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">80</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 90</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 100</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 110</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">180</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 210</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 240</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 270</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-27\">\n          <canvas id=\"chart-27\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-27\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-28\">\n      <div class=\"sample-header\">\n        <h2>Cumulative Line Over Bars</h2>\n        <p class=\"description\">Monthly bars with a cumulative line showing running total.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Monthly and Cumulative Sales\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Jan</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Feb</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Mar</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Apr</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> May</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jun</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">100</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 150</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 120</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 180</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 170</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">100</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 250</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 370</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 550</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 750</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 920</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-28\">\n          <canvas id=\"chart-28\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-28\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-29\">\n      <div class=\"sample-header\">\n        <h2>Conversion Funnel</h2>\n        <p class=\"description\">Bars for stage counts with a line showing conversion rate.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Funnel Analysis\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Visitors</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Signups</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Activated</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Paid</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Retained</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">10000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 3000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 1500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 800</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 500</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">10000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 3000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 1500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 800</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 500</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-29\">\n          <canvas id=\"chart-29\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-29\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n  <h2 class=\"section-title\" id=\"cat-axis-configurations\">Axis Configurations</h2>\n\n    <section class=\"sample\" id=\"sample-30\">\n      <div class=\"sample-header\">\n        <h2>Categorical X-Axis</h2>\n        <p class=\"description\">Standard categorical x-axis with string labels.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Fruit Preferences\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Apple</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Banana</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Cherry</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Date</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Elderberry</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">45</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 32</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 28</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 15</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 8</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-30\">\n          <canvas id=\"chart-30\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-30\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-31\">\n      <div class=\"sample-header\">\n        <h2>Numeric X-Axis Range</h2>\n        <p class=\"description\">Numeric x-axis using the range syntax.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Distribution Curve\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 100</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">5</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 15</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 35</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 60</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 80</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 95</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 80</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 60</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 35</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 15</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 5</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-31\">\n          <canvas id=\"chart-31\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-31\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-32\">\n      <div class=\"sample-header\">\n        <h2>Y-Axis with Label and Range</h2>\n        <p class=\"description\">Y-axis with a title and explicit min/max range.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Temperature Log\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">6</span><span style=\"color:#032F62\">am</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 9</span><span style=\"color:#032F62\">am</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 12</span><span style=\"color:#032F62\">pm</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 3</span><span style=\"color:#032F62\">pm</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 6</span><span style=\"color:#032F62\">pm</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 9</span><span style=\"color:#032F62\">pm</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    y-axis</span><span style=\"color:#24292E\"> \"Temp </span><span style=\"color:#032F62\">(F</span><span style=\"color:#24292E\">)\" </span><span style=\"color:#005CC5\">50</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 100</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">58</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 65</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 78</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 85</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 76</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 62</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-32\">\n          <canvas id=\"chart-32\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-32\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-33\">\n      <div class=\"sample-header\">\n        <h2>Y-Axis Range Without Label</h2>\n        <p class=\"description\">Y-axis with range but no title.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Sensor Readings\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">T1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> T2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> T3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> T4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> T5</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    y-axis</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 500</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">120</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 250</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 380</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 310</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 190</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-33\">\n          <canvas id=\"chart-33\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-33\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-34\">\n      <div class=\"sample-header\">\n        <h2>X-Axis with Title</h2>\n        <p class=\"description\">Categorical x-axis with an axis title.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Quarterly Results\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#032F62\"> \"Quarter\"</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Q1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Q2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Q3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Q4</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    y-axis</span><span style=\"color:#24292E\"> \"Revenue </span><span style=\"color:#032F62\">($K</span><span style=\"color:#24292E\">)\" </span><span style=\"color:#005CC5\">0</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 1000</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">420</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 580</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 710</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 890</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-34\">\n          <canvas id=\"chart-34\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-34\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-35\">\n      <div class=\"sample-header\">\n        <h2>Both Axes Titled</h2>\n        <p class=\"description\">Both x-axis and y-axis have descriptive titles.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Experiment Results\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#032F62\"> \"Trial Number\"</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 5</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 6</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    y-axis</span><span style=\"color:#24292E\"> \"Measurement </span><span style=\"color:#032F62\">(mm</span><span style=\"color:#24292E\">)\" </span><span style=\"color:#005CC5\">0</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 50</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">12</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 18</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 25</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 22</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 31</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 28</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-35\">\n          <canvas id=\"chart-35\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-35\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-36\">\n      <div class=\"sample-header\">\n        <h2>Long Category Labels</h2>\n        <p class=\"description\">X-axis with long multi-word category labels.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Department Budget\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Engineering</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Product Management</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Customer Success</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Human Resources</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">850</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 420</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 310</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 180</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-36\">\n          <canvas id=\"chart-36\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-36\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-37\">\n      <div class=\"sample-header\">\n        <h2>Many Short Labels</h2>\n        <p class=\"description\">X-axis with many single-character labels.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Letter Frequency\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">A</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> B</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> C</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> E</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> F</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> G</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> H</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> I</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> J</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> K</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> L</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> M</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">82</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 15</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 28</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 43</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 127</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 22</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 20</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 61</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 70</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 8</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 40</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 24</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-37\">\n          <canvas id=\"chart-37\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-37\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-38\">\n      <div class=\"sample-header\">\n        <h2>Wide Y-Axis Range</h2>\n        <p class=\"description\">Y-axis spanning a large range from 0 to 100000.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Annual Revenue\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">2020</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2021</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2022</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2023</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2024</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    y-axis</span><span style=\"color:#032F62\"> \"USD\"</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 100000</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">15000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 28000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 45000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 67000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 92000</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-38\">\n          <canvas id=\"chart-38\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-38\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-39\">\n      <div class=\"sample-header\">\n        <h2>Narrow Y-Axis Range</h2>\n        <p class=\"description\">Y-axis with a tight range to emphasize small differences.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"CPU Temperature\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">10</span><span style=\"color:#032F62\">s</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 20</span><span style=\"color:#032F62\">s</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 30</span><span style=\"color:#032F62\">s</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 40</span><span style=\"color:#032F62\">s</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 50</span><span style=\"color:#032F62\">s</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 60</span><span style=\"color:#032F62\">s</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    y-axis</span><span style=\"color:#032F62\"> \"Celsius\"</span><span style=\"color:#005CC5\"> 60</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 80</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">65</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 68</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 72</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 75</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 73</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 70</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-39\">\n          <canvas id=\"chart-39\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-39\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-40\">\n      <div class=\"sample-header\">\n        <h2>Auto-Range Y-Axis</h2>\n        <p class=\"description\">No y-axis declaration; auto-ranged from data.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Auto Range\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">A</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> B</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> C</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> E</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">42</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 87</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 63</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 29</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 75</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-40\">\n          <canvas id=\"chart-40\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-40\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-41\">\n      <div class=\"sample-header\">\n        <h2>Year Labels</h2>\n        <p class=\"description\">X-axis with year labels as categories.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Company Growth\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">2018</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2019</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2020</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2021</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2022</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2023</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2024</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2025</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">10</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 15</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 12</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 22</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 35</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 48</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 62</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 80</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-41\">\n          <canvas id=\"chart-41\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-41\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-42\">\n      <div class=\"sample-header\">\n        <h2>Percentage Y-Axis</h2>\n        <p class=\"description\">Y-axis configured as percentage from 0 to 100.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Completion Rate\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Week 1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Week 2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Week 3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Week 4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Week 5</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    y-axis</span><span style=\"color:#032F62\"> \"Percent\"</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 100</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">15</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 35</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 58</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 78</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 95</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-42\">\n          <canvas id=\"chart-42\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-42\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-43\">\n      <div class=\"sample-header\">\n        <h2>Numeric X-Axis with Bars</h2>\n        <p class=\"description\">Numeric x-axis range combined with bar data.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Histogram\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 50</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">5</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 12</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 25</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 38</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 30</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 18</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 8</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-43\">\n          <canvas id=\"chart-43\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-43\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-44\">\n      <div class=\"sample-header\">\n        <h2>Mixed Short and Long Labels</h2>\n        <p class=\"description\">X-axis with a mix of short and longer labels.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Regional Sales\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">US</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> EU</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Asia Pacific</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> LATAM</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> MEA</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> ANZ</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">450</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 380</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 520</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 180</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 95</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 60</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-44\">\n          <canvas id=\"chart-44\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-44\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n  <h2 class=\"section-title\" id=\"cat-horizontal-orientation\">Horizontal Orientation</h2>\n\n    <section class=\"sample\" id=\"sample-45\">\n      <div class=\"sample-header\">\n        <h2>Horizontal Bar Chart</h2>\n        <p class=\"description\">Simple horizontal bar chart.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta horizontal</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Language Popularity\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Python</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> JavaScript</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Java</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Go</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Rust</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">30</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 25</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 20</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 12</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 8</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-45\">\n          <canvas id=\"chart-45\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-45\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-46\">\n      <div class=\"sample-header\">\n        <h2>Horizontal with Y-Axis</h2>\n        <p class=\"description\">Horizontal bars with explicit y-axis range.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta horizontal</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Sprint Velocity\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Sprint 1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sprint 2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sprint 3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sprint 4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sprint 5</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    y-axis</span><span style=\"color:#032F62\"> \"Story Points\"</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 100</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">45</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 52</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 68</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 72</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 80</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-46\">\n          <canvas id=\"chart-46\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-46\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-47\">\n      <div class=\"sample-header\">\n        <h2>Horizontal Line Chart</h2>\n        <p class=\"description\">Line chart in horizontal orientation.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta horizontal</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Response Time Trend\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">v1.0</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> v1.1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> v1.2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> v1.3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> v1.4</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">450</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 380</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 320</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 280</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 210</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-47\">\n          <canvas id=\"chart-47\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-47\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-48\">\n      <div class=\"sample-header\">\n        <h2>Horizontal Combined</h2>\n        <p class=\"description\">Horizontal chart with both bars and a line.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta horizontal</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Budget vs Actual\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Eng</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sales</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Marketing</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Ops</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> HR</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 350</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 150</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 100</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">480</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 380</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 180</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 160</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 95</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-48\">\n          <canvas id=\"chart-48\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-48\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-49\">\n      <div class=\"sample-header\">\n        <h2>Horizontal Ranking</h2>\n        <p class=\"description\">Horizontal bars showing a ranked list.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta horizontal</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Top Features Requested\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Dark Mode</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> API Access</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Mobile App</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> SSO</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Webhooks</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> CSV Export</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">245</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 198</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 176</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 152</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 134</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 112</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-49\">\n          <canvas id=\"chart-49\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-49\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-50\">\n      <div class=\"sample-header\">\n        <h2>Horizontal Small Values</h2>\n        <p class=\"description\">Horizontal bars with small single-digit values.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta horizontal</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Team Satisfaction Survey\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Culture</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Compensation</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Growth</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Balance</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Tools</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    y-axis</span><span style=\"color:#032F62\"> \"Rating\"</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 5</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 5</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 3</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-50\">\n          <canvas id=\"chart-50\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-50\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-51\">\n      <div class=\"sample-header\">\n        <h2>Horizontal Two Bars</h2>\n        <p class=\"description\">Horizontal chart with two bar series.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta horizontal</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"This Year vs Last Year\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Q1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Q2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Q3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Q4</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 250</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 300</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 280</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">180</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 220</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 270</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 310</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-51\">\n          <canvas id=\"chart-51\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-51\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-52\">\n      <div class=\"sample-header\">\n        <h2>Horizontal Wide Range</h2>\n        <p class=\"description\">Horizontal bars with a wide value range.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta horizontal</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"GitHub Stars\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">React</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Vue</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Angular</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Svelte</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Solid</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">220000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 210000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 95000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 78000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 32000</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-52\">\n          <canvas id=\"chart-52\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-52\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-53\">\n      <div class=\"sample-header\">\n        <h2>Horizontal with Two Lines</h2>\n        <p class=\"description\">Horizontal chart with two line series.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta horizontal</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Planned vs Actual Delivery\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Feature A</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Feature B</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Feature C</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Feature D</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">10</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 15</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 20</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 25</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">12</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 14</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 22</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 23</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-53\">\n          <canvas id=\"chart-53\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-53\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-54\">\n      <div class=\"sample-header\">\n        <h2>Horizontal Long Labels</h2>\n        <p class=\"description\">Horizontal orientation with long category labels.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta horizontal</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Error Categories\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Authentication Failure</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Database Timeout</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Rate Limit Exceeded</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Invalid Input</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">342</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 128</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 89</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 456</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-54\">\n          <canvas id=\"chart-54\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-54\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n  <h2 class=\"section-title\" id=\"cat-titles-formatting\">Titles &amp; Formatting</h2>\n\n    <section class=\"sample\" id=\"sample-55\">\n      <div class=\"sample-header\">\n        <h2>No Title</h2>\n        <p class=\"description\">Chart without a title declaration.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">A</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> B</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> C</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">10</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 20</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 30</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 40</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-55\">\n          <canvas id=\"chart-55\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-55\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-56\">\n      <div class=\"sample-header\">\n        <h2>Short Title</h2>\n        <p class=\"description\">Chart with a very short one-word title.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Sales\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Jan</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Feb</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Mar</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">100</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 150</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-56\">\n          <canvas id=\"chart-56\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-56\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-57\">\n      <div class=\"sample-header\">\n        <h2>Long Title</h2>\n        <p class=\"description\">Chart with a long descriptive title.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Quarterly Revenue Comparison Across All Regional Offices 2024\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Q1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Q2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Q3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Q4</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">1200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 1500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 1800</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2100</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-57\">\n          <canvas id=\"chart-57\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-57\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-58\">\n      <div class=\"sample-header\">\n        <h2>Title with Numbers</h2>\n        <p class=\"description\">Title containing numeric values.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"FY2024 Q3 Results\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Product A</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Product B</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Product C</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">340</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 520</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 180</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-58\">\n          <canvas id=\"chart-58\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-58\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-59\">\n      <div class=\"sample-header\">\n        <h2>Title with Special Characters</h2>\n        <p class=\"description\">Title containing parentheses and symbols.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Growth Rate (%)\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">2020</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2021</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2022</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2023</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">5</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 12</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 8</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 15</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-59\">\n          <canvas id=\"chart-59\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-59\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-60\">\n      <div class=\"sample-header\">\n        <h2>Title with Ampersand</h2>\n        <p class=\"description\">Title using the ampersand character.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"R&#x26;D Investment\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">2021</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2022</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2023</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2024</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 280</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 350</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 420</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-60\">\n          <canvas id=\"chart-60\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-60\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-61\">\n      <div class=\"sample-header\">\n        <h2>Title with Hyphen</h2>\n        <p class=\"description\">Title containing hyphens.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Year-Over-Year Comparison\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Jan</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Feb</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Mar</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Apr</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> May</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">100</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 120</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 90</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 140</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 130</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">110</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 115</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 100</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 125</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 135</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-61\">\n          <canvas id=\"chart-61\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-61\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-62\">\n      <div class=\"sample-header\">\n        <h2>Minimal Chart</h2>\n        <p class=\"description\">The most minimal possible xychart with just axis and data.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">A</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> B</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-62\">\n          <canvas id=\"chart-62\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-62\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-63\">\n      <div class=\"sample-header\">\n        <h2>Full Specification</h2>\n        <p class=\"description\">Chart with every optional element specified.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Complete Chart\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#032F62\"> \"Category\"</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Alpha</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Beta</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Gamma</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Delta</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    y-axis</span><span style=\"color:#032F62\"> \"Value\"</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 500</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">120</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 340</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 250</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 410</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">120</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 340</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 250</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 410</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-63\">\n          <canvas id=\"chart-63\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-63\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-64\">\n      <div class=\"sample-header\">\n        <h2>Title with Colon</h2>\n        <p class=\"description\">Title containing a colon separator.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Metrics: Daily Active Users\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Mon</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Tue</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Wed</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Thu</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Fri</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">1500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 1800</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 1700</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-64\">\n          <canvas id=\"chart-64\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-64\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n  <h2 class=\"section-title\" id=\"cat-large-datasets\">Large Datasets</h2>\n\n    <section class=\"sample\" id=\"sample-65\">\n      <div class=\"sample-header\">\n        <h2>12-Month Dataset</h2>\n        <p class=\"description\">Full year of monthly data points.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Monthly Active Users (2024)\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Jan</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Feb</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Mar</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Apr</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> May</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jun</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jul</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Aug</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sep</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Oct</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Nov</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Dec</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">12000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 13500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 15200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 16800</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 18500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 20100</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 19800</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 21500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 23000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 24200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 25800</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 28000</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-65\">\n          <canvas id=\"chart-65\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-65\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-66\">\n      <div class=\"sample-header\">\n        <h2>26-Point Alphabet</h2>\n        <p class=\"description\">Bar chart with 26 data points, one per letter.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Letter Distribution\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">A</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> B</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> C</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> E</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> F</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> G</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> H</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> I</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> J</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> K</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> L</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> M</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> N</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> O</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> P</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Q</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> R</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> S</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> T</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> U</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> V</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> X</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Y</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Z</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">82</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 15</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 28</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 43</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 127</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 22</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 20</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 61</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 70</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 8</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 40</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 24</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 67</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 75</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 19</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 60</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 63</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 91</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 28</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 10</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 24</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 20</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 1</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-66\">\n          <canvas id=\"chart-66\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-66\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-67\">\n      <div class=\"sample-header\">\n        <h2>Dense Weekly Data</h2>\n        <p class=\"description\">Line chart with data for many weeks.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Weekly Downloads\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">W1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W5</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W6</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W7</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W8</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W9</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W10</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W11</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W12</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W13</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W14</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W15</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W16</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W17</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W18</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W19</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W20</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 520</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 480</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 550</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 600</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 580</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 620</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 650</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 700</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 680</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 720</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 750</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 800</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 780</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 820</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 850</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 900</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 880</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 920</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 950</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-67\">\n          <canvas id=\"chart-67\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-67\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-68\">\n      <div class=\"sample-header\">\n        <h2>Multiple Series Large</h2>\n        <p class=\"description\">Three data series across 12 months.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Product Lines Revenue\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Jan</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Feb</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Mar</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Apr</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> May</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jun</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jul</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Aug</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sep</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Oct</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Nov</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Dec</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 520</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 540</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 580</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 600</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 620</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 650</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 640</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 680</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 700</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 720</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 750</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">300</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 320</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 310</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 350</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 370</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 380</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 400</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 390</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 420</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 440</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 450</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 470</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">800</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 840</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 850</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 930</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 970</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 1000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 1050</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 1030</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 1100</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 1140</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 1170</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 1220</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-68\">\n          <canvas id=\"chart-68\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-68\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-69\">\n      <div class=\"sample-header\">\n        <h2>Two Bars Twelve Months</h2>\n        <p class=\"description\">Two bar series over 12 months for year comparison.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"2023 vs 2024 Monthly Sales\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Jan</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Feb</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Mar</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Apr</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> May</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jun</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jul</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Aug</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sep</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Oct</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Nov</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Dec</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">180</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 220</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 240</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 260</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 250</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 270</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 290</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 310</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 330</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 350</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 380</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">210</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 230</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 250</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 270</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 300</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 290</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 310</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 330</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 360</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 380</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 400</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 430</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-69\">\n          <canvas id=\"chart-69\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-69\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-70\">\n      <div class=\"sample-header\">\n        <h2>High Frequency Data</h2>\n        <p class=\"description\">Hourly data points across a full day.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Hourly Server Load\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">0</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 5</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 6</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 7</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 8</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 9</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 10</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 11</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 12</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 13</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 14</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 15</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 16</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 17</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 18</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 19</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 20</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 21</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 22</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 23</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">15</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 12</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 10</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 8</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 7</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 9</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 18</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 45</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 72</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 85</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 88</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 90</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 82</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 78</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 80</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 85</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 88</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 75</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 60</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 45</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 35</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 28</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 22</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 18</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-70\">\n          <canvas id=\"chart-70\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-70\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-71\">\n      <div class=\"sample-header\">\n        <h2>Large Values Dataset</h2>\n        <p class=\"description\">Dataset with values in the millions.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Cloud Spending by Month\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Jan</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Feb</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Mar</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Apr</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> May</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jun</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jul</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Aug</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sep</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Oct</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Nov</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Dec</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    y-axis</span><span style=\"color:#032F62\"> \"USD\"</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 500000</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">120000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 135000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 148000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 162000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 178000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 195000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 210000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 228000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 245000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 268000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 290000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 315000</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-71\">\n          <canvas id=\"chart-71\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-71\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-72\">\n      <div class=\"sample-header\">\n        <h2>Three Lines Large</h2>\n        <p class=\"description\">Three overlapping line series over 10 data points.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Multi-Region Latency\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">T1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> T2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> T3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> T4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> T5</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> T6</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> T7</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> T8</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> T9</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> T10</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">45</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 48</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 42</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 50</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 55</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 52</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 47</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 44</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 49</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 46</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">120</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 115</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 125</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 118</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 122</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 130</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 128</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 135</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 132</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 127</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 210</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 195</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 205</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 215</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 220</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 208</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 212</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 218</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 225</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-72\">\n          <canvas id=\"chart-72\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-72\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-73\">\n      <div class=\"sample-header\">\n        <h2>Quarterly Four Years</h2>\n        <p class=\"description\">Quarterly data spanning four years.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Quarterly Earnings (2021-2024)\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">21</span><span style=\"color:#032F62\">Q1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 21</span><span style=\"color:#032F62\">Q2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 21</span><span style=\"color:#032F62\">Q3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 21</span><span style=\"color:#032F62\">Q4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 22</span><span style=\"color:#032F62\">Q1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 22</span><span style=\"color:#032F62\">Q2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 22</span><span style=\"color:#032F62\">Q3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 22</span><span style=\"color:#032F62\">Q4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 23</span><span style=\"color:#032F62\">Q1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 23</span><span style=\"color:#032F62\">Q2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 23</span><span style=\"color:#032F62\">Q3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 23</span><span style=\"color:#032F62\">Q4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 24</span><span style=\"color:#032F62\">Q1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 24</span><span style=\"color:#032F62\">Q2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 24</span><span style=\"color:#032F62\">Q3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 24</span><span style=\"color:#032F62\">Q4</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">120</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 140</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 135</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 160</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 155</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 175</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 170</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 190</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 185</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 210</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 205</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 230</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 225</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 250</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 245</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 270</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-73\">\n          <canvas id=\"chart-73\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-73\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-74\">\n      <div class=\"sample-header\">\n        <h2>Dense Bars and Line</h2>\n        <p class=\"description\">Dense bar chart with 15 categories and an overlay line.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Daily Active Users (First 15 Days)\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">D1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D5</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D6</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D7</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D8</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D9</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D10</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D11</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D12</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D13</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D14</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D15</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 520</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 510</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 530</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 540</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 480</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 450</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 520</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 550</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 540</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 560</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 570</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 510</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 490</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 530</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 510</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 510</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 520</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 530</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 520</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 510</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 515</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 525</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 530</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 535</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 540</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 535</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 530</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 530</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-74\">\n          <canvas id=\"chart-74\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-74\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n  <h2 class=\"section-title\" id=\"cat-edge-cases\">Edge Cases</h2>\n\n    <section class=\"sample\" id=\"sample-75\">\n      <div class=\"sample-header\">\n        <h2>Single Data Point</h2>\n        <p class=\"description\">Chart with only one data point.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Single Value\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Only</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">42</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-75\">\n          <canvas id=\"chart-75\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-75\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-76\">\n      <div class=\"sample-header\">\n        <h2>All Zeros</h2>\n        <p class=\"description\">Bar chart where every value is zero.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"No Activity\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Mon</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Tue</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Wed</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Thu</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Fri</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">0</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-76\">\n          <canvas id=\"chart-76\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-76\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-77\">\n      <div class=\"sample-header\">\n        <h2>Very Large Numbers</h2>\n        <p class=\"description\">Chart with values in the millions.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"National GDP (millions)\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Country A</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Country B</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Country C</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">5200000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 3800000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2900000</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-77\">\n          <canvas id=\"chart-77\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-77\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-78\">\n      <div class=\"sample-header\">\n        <h2>Very Small Numbers</h2>\n        <p class=\"description\">Chart with very small decimal-like integer values.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Trace Amounts\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Sample A</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sample B</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sample C</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sample D</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 3</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-78\">\n          <canvas id=\"chart-78\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-78\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-79\">\n      <div class=\"sample-header\">\n        <h2>Two Data Points</h2>\n        <p class=\"description\">Minimal chart with exactly two data points.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Before and After\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Before</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> After</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">25</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 75</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-79\">\n          <canvas id=\"chart-79\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-79\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-80\">\n      <div class=\"sample-header\">\n        <h2>Single Value Repeated</h2>\n        <p class=\"description\">All bars have the identical value.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Equal Distribution\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">A</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> B</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> C</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> E</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">50</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 50</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 50</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 50</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 50</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-80\">\n          <canvas id=\"chart-80\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-80\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-81\">\n      <div class=\"sample-header\">\n        <h2>Extreme Outlier</h2>\n        <p class=\"description\">One value is dramatically larger than the rest.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Revenue by Product\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Niche A</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Niche B</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Flagship</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Niche C</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Niche D</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">50</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 30</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 5000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 40</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 20</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-81\">\n          <canvas id=\"chart-81\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-81\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-82\">\n      <div class=\"sample-header\">\n        <h2>Descending to Zero</h2>\n        <p class=\"description\">Values that decrease to zero.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Declining Interest\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Week 1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Week 2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Week 3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Week 4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Week 5</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">100</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 60</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 25</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 8</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-82\">\n          <canvas id=\"chart-82\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-82\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-83\">\n      <div class=\"sample-header\">\n        <h2>Ascending from Zero</h2>\n        <p class=\"description\">Values that start at zero and increase.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Ramp Up\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Day 1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Day 2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Day 3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Day 4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Day 5</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">0</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 10</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 50</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 150</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 400</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-83\">\n          <canvas id=\"chart-83\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-83\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-84\">\n      <div class=\"sample-header\">\n        <h2>Alternating High Low</h2>\n        <p class=\"description\">Values alternating between high and low.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Alternating Pattern\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">A</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> B</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> C</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> E</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> F</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> G</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> H</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">100</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 10</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 100</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 10</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 100</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 10</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 100</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 10</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-84\">\n          <canvas id=\"chart-84\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-84\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n  <h2 class=\"section-title\" id=\"cat-real-world-scenarios\">Real-World Scenarios</h2>\n\n    <section class=\"sample\" id=\"sample-85\">\n      <div class=\"sample-header\">\n        <h2>Monthly Revenue Report</h2>\n        <p class=\"description\">Typical monthly revenue bar chart for a SaaS company.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Monthly Revenue (2024)\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Jan</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Feb</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Mar</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Apr</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> May</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jun</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jul</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Aug</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sep</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Oct</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Nov</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Dec</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    y-axis</span><span style=\"color:#24292E\"> \"Revenue </span><span style=\"color:#032F62\">($K</span><span style=\"color:#24292E\">)\" </span><span style=\"color:#005CC5\">0</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 500</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">180</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 195</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 210</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 225</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 250</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 268</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 285</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 275</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 300</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 320</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 340</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 380</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-85\">\n          <canvas id=\"chart-85\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-85\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-86\">\n      <div class=\"sample-header\">\n        <h2>Cumulative Registered Users</h2>\n        <p class=\"description\">Cumulative user growth over months showing accelerating adoption.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Cumulative Registered Users\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Jan</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Feb</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Mar</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Apr</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> May</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jun</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jul</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Aug</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sep</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Oct</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Nov</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Dec</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    y-axis</span><span style=\"color:#032F62\"> \"Users\"</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 50000</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">2500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 5200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 8800</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 13100</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 18000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 23500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 29200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 34800</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 39500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 43200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 46500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 50000</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-86\">\n          <canvas id=\"chart-86\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-86\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-87\">\n      <div class=\"sample-header\">\n        <h2>Temperature Over the Year</h2>\n        <p class=\"description\">Average monthly temperature showing seasonal variation.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Average Monthly Temperature\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Jan</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Feb</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Mar</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Apr</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> May</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jun</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jul</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Aug</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sep</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Oct</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Nov</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Dec</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    y-axis</span><span style=\"color:#24292E\"> \"Temp </span><span style=\"color:#032F62\">(F</span><span style=\"color:#24292E\">)\" </span><span style=\"color:#005CC5\">20</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 100</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">32</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 35</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 45</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 58</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 68</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 78</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 85</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 83</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 74</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 60</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 48</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 36</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-87\">\n          <canvas id=\"chart-87\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-87\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-88\">\n      <div class=\"sample-header\">\n        <h2>Stock Price Trend</h2>\n        <p class=\"description\">Weekly stock price movement over a quarter.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"ACME Corp Stock Price\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">W1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W5</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W6</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W7</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W8</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W9</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W10</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W11</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W12</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> W13</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    y-axis</span><span style=\"color:#24292E\"> \"Price </span><span style=\"color:#032F62\">($</span><span style=\"color:#24292E\">)\" </span><span style=\"color:#005CC5\">80</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 160</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">100</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 105</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 98</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 112</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 108</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 120</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 115</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 125</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 135</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 128</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 140</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 148</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 155</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-88\">\n          <canvas id=\"chart-88\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-88\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-89\">\n      <div class=\"sample-header\">\n        <h2>Survey Results</h2>\n        <p class=\"description\">Customer satisfaction survey scores by category.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Customer Satisfaction Survey\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Ease of Use</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Performance</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Support</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Pricing</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Features</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Reliability</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    y-axis</span><span style=\"color:#032F62\"> \"Score\"</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 5</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 5</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 4</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-89\">\n          <canvas id=\"chart-89\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-89\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-90\">\n      <div class=\"sample-header\">\n        <h2>API Response Time</h2>\n        <p class=\"description\">P95 API response times across endpoints.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"P95 Response Time by Endpoint\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">/users</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> /orders</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> /products</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> /search</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> /auth</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> /reports</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    y-axis</span><span style=\"color:#032F62\"> \"ms\"</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 1000</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">120</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 250</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 180</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 450</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 80</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 850</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-90\">\n          <canvas id=\"chart-90\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-90\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-91\">\n      <div class=\"sample-header\">\n        <h2>Website Analytics</h2>\n        <p class=\"description\">Daily page views and unique visitors for a week.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Website Traffic\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Mon</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Tue</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Wed</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Thu</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Fri</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sat</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sun</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">8500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 9200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 9800</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 9500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 8800</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 5200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 4100</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">3200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 3500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 3800</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 3600</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 3400</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2100</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 1800</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-91\">\n          <canvas id=\"chart-91\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-91\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-92\">\n      <div class=\"sample-header\">\n        <h2>Sprint Burndown</h2>\n        <p class=\"description\">Remaining story points over a two-week sprint.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Sprint Burndown\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">D1</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D5</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D6</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D7</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D8</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D9</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> D10</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    y-axis</span><span style=\"color:#032F62\"> \"Story Points\"</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 80</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">72</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 65</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 58</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 50</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 45</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 38</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 30</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 22</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 12</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">72</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 65</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 58</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 50</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 43</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 36</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 29</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 22</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 14</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-92\">\n          <canvas id=\"chart-92\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-92\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-93\">\n      <div class=\"sample-header\">\n        <h2>Marketing Funnel</h2>\n        <p class=\"description\">Marketing conversion funnel from impressions to purchases.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Marketing Funnel\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Impressions</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Clicks</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Signups</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Trials</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Purchases</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">50000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 5000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 1200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 400</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 150</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-93\">\n          <canvas id=\"chart-93\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-93\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-94\">\n      <div class=\"sample-header\">\n        <h2>Server Error Rates</h2>\n        <p class=\"description\">HTTP error rates by hour during an incident.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Error Rate During Incident\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">10</span><span style=\"color:#032F62\">am</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 11</span><span style=\"color:#032F62\">am</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 12</span><span style=\"color:#032F62\">pm</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 1</span><span style=\"color:#032F62\">pm</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2</span><span style=\"color:#032F62\">pm</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 3</span><span style=\"color:#032F62\">pm</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 4</span><span style=\"color:#032F62\">pm</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 5</span><span style=\"color:#032F62\">pm</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    y-axis</span><span style=\"color:#032F62\"> \"Errors/min\"</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 500</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">5</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 12</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 45</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 380</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 420</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 250</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 35</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 8</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">5</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 12</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 45</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 380</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 420</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 250</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 35</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 8</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-94\">\n          <canvas id=\"chart-94\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-94\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-95\">\n      <div class=\"sample-header\">\n        <h2>Employee Growth</h2>\n        <p class=\"description\">Headcount growth at a startup over years.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Company Headcount\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">2018</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2019</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2020</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2021</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2022</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2023</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2024</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">8</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 15</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 25</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 45</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 80</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 120</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 185</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-95\">\n          <canvas id=\"chart-95\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-95\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-96\">\n      <div class=\"sample-header\">\n        <h2>Monthly Churn Rate</h2>\n        <p class=\"description\">Customer churn rate percentage over months.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Monthly Churn Rate\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Jan</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Feb</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Mar</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Apr</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> May</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jun</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jul</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Aug</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sep</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Oct</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Nov</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Dec</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    y-axis</span><span style=\"color:#032F62\"> \"Churn %\"</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 10</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">5</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 5</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-96\">\n          <canvas id=\"chart-96\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-96\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-97\">\n      <div class=\"sample-header\">\n        <h2>A/B Test Results</h2>\n        <p class=\"description\">Conversion rates for control vs variant across segments.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"A/B Test Conversion Rates\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Mobile</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Desktop</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Tablet</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> New Users</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Returning</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 5</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 6</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">4</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 7</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 5</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 3</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 8</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-97\">\n          <canvas id=\"chart-97\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-97\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-98\">\n      <div class=\"sample-header\">\n        <h2>Cloud Cost Breakdown</h2>\n        <p class=\"description\">Monthly cloud infrastructure costs by service.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Monthly Cloud Costs\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Compute</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Storage</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Database</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Network</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> CDN</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Monitoring</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Other</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    y-axis</span><span style=\"color:#032F62\"> \"USD\"</span><span style=\"color:#005CC5\"> 0</span><span style=\"color:#D73A49\"> --></span><span style=\"color:#005CC5\"> 20000</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">15000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 8000</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 6500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 3200</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 2800</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 1500</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 900</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-98\">\n          <canvas id=\"chart-98\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-98\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"sample\" id=\"sample-99\">\n      <div class=\"sample-header\">\n        <h2>Release Frequency</h2>\n        <p class=\"description\">Number of production deployments per month.</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          <pre class=\"shiki github-light\" style=\"background-color:#fff;color:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49\">xychart-beta</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    title</span><span style=\"color:#032F62\"> \"Production Deployments per Month\"</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    x-axis</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#032F62\">Jan</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Feb</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Mar</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Apr</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> May</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jun</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Jul</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Aug</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Sep</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Oct</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Nov</span><span style=\"color:#D73A49\">,</span><span style=\"color:#032F62\"> Dec</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    bar</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">12</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 15</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 18</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 22</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 20</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 25</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 28</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 24</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 30</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 32</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 35</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 38</span><span style=\"color:#D73A49\">]</span></span>\n<span class=\"line\"><span style=\"color:#D73A49\">    line</span><span style=\"color:#D73A49\"> [</span><span style=\"color:#005CC5\">12</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 15</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 18</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 22</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 20</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 25</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 28</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 24</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 30</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 32</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 35</span><span style=\"color:#D73A49\">,</span><span style=\"color:#005CC5\"> 38</span><span style=\"color:#D73A49\">]</span></span></code></pre>\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-99\">\n          <canvas id=\"chart-99\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-99\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>\n  </div>\n\n  <footer class=\"site-footer\">\n    <span>&copy; 2026 Luki Labs. MIT License.</span>\n  </footer>\n\n  <script>\n  // ============================================================================\n  // Theme system\n  // ============================================================================\n  var THEMES = {\"zinc-dark\":{\"bg\":\"#18181B\",\"fg\":\"#FAFAFA\"},\"tokyo-night\":{\"bg\":\"#1a1b26\",\"fg\":\"#a9b1d6\",\"line\":\"#3d59a1\",\"accent\":\"#7aa2f7\",\"muted\":\"#565f89\"},\"tokyo-night-storm\":{\"bg\":\"#24283b\",\"fg\":\"#a9b1d6\",\"line\":\"#3d59a1\",\"accent\":\"#7aa2f7\",\"muted\":\"#565f89\"},\"tokyo-night-light\":{\"bg\":\"#d5d6db\",\"fg\":\"#343b58\",\"line\":\"#34548a\",\"accent\":\"#34548a\",\"muted\":\"#9699a3\"},\"catppuccin-mocha\":{\"bg\":\"#1e1e2e\",\"fg\":\"#cdd6f4\",\"line\":\"#585b70\",\"accent\":\"#cba6f7\",\"muted\":\"#6c7086\"},\"catppuccin-latte\":{\"bg\":\"#eff1f5\",\"fg\":\"#4c4f69\",\"line\":\"#9ca0b0\",\"accent\":\"#8839ef\",\"muted\":\"#9ca0b0\"},\"nord\":{\"bg\":\"#2e3440\",\"fg\":\"#d8dee9\",\"line\":\"#4c566a\",\"accent\":\"#88c0d0\",\"muted\":\"#616e88\"},\"nord-light\":{\"bg\":\"#eceff4\",\"fg\":\"#2e3440\",\"line\":\"#aab1c0\",\"accent\":\"#5e81ac\",\"muted\":\"#7b88a1\"},\"dracula\":{\"bg\":\"#282a36\",\"fg\":\"#f8f8f2\",\"line\":\"#6272a4\",\"accent\":\"#bd93f9\",\"muted\":\"#6272a4\"},\"github-light\":{\"bg\":\"#ffffff\",\"fg\":\"#1f2328\",\"line\":\"#d1d9e0\",\"accent\":\"#0969da\",\"muted\":\"#59636e\"},\"github-dark\":{\"bg\":\"#0d1117\",\"fg\":\"#e6edf3\",\"line\":\"#3d444d\",\"accent\":\"#4493f8\",\"muted\":\"#9198a1\"},\"solarized-light\":{\"bg\":\"#fdf6e3\",\"fg\":\"#657b83\",\"line\":\"#93a1a1\",\"accent\":\"#268bd2\",\"muted\":\"#93a1a1\"},\"solarized-dark\":{\"bg\":\"#002b36\",\"fg\":\"#839496\",\"line\":\"#586e75\",\"accent\":\"#268bd2\",\"muted\":\"#586e75\"},\"one-dark\":{\"bg\":\"#282c34\",\"fg\":\"#abb2bf\",\"line\":\"#4b5263\",\"accent\":\"#c678dd\",\"muted\":\"#5c6370\"}};\n  var chartInstances = [];\n\n  function hexToRgb(hex) {\n    if (!hex || typeof hex !== 'string') return null;\n    var v = hex.trim();\n    if (v[0] === '#') v = v.slice(1);\n    if (v.length === 3) v = v[0]+v[0]+v[1]+v[1]+v[2]+v[2];\n    if (v.length !== 6) return null;\n    var n = parseInt(v, 16);\n    if (Number.isNaN(n)) return null;\n    return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };\n  }\n\n  function setShadowVars(theme) {\n    var body = document.body;\n    var fg = theme ? theme.fg : '#27272A';\n    var bg = theme ? theme.bg : '#FFFFFF';\n    var fgRgb = hexToRgb(fg) || { r: 39, g: 39, b: 42 };\n    var bgRgb = hexToRgb(bg) || { r: 255, g: 255, b: 255 };\n    var brightness = (bgRgb.r * 299 + bgRgb.g * 587 + bgRgb.b * 114) / 1000;\n    var darkMode = brightness < 140;\n    body.style.setProperty('--foreground-rgb', fgRgb.r + ', ' + fgRgb.g + ', ' + fgRgb.b);\n    body.style.setProperty('--shadow-border-opacity', darkMode ? '0.15' : '0.08');\n    body.style.setProperty('--shadow-blur-opacity', darkMode ? '0.12' : '0.06');\n  }\n\n  function updateThemeColor(fg, bg) {\n    var fgRgb = hexToRgb(fg) || { r: 39, g: 39, b: 42 };\n    var bgRgb = hexToRgb(bg) || { r: 255, g: 255, b: 255 };\n    var r = Math.round(bgRgb.r * 0.96 + fgRgb.r * 0.04);\n    var g = Math.round(bgRgb.g * 0.96 + fgRgb.g * 0.04);\n    var b = Math.round(bgRgb.b * 0.96 + fgRgb.b * 0.04);\n    var hex = '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);\n    document.getElementById('theme-color-meta').setAttribute('content', hex);\n    document.body.style.setProperty('--theme-bar-bg', hex);\n    var safariDiv = document.getElementById('safari-theme-color');\n    safariDiv.style.background = hex;\n    safariDiv.style.display = 'none';\n    void safariDiv.offsetHeight;\n    safariDiv.style.display = '';\n  }\n\n  function getChartColors(theme) {\n    var fg = theme ? theme.fg : '#27272A';\n    var bg = theme ? theme.bg : '#FFFFFF';\n    var fgRgb = hexToRgb(fg) || { r: 39, g: 39, b: 42 };\n    var bgRgb = hexToRgb(bg) || { r: 255, g: 255, b: 255 };\n    // Grid lines: 12% fg mixed into bg\n    var gridR = Math.round(bgRgb.r * 0.88 + fgRgb.r * 0.12);\n    var gridG = Math.round(bgRgb.g * 0.88 + fgRgb.g * 0.12);\n    var gridB = Math.round(bgRgb.b * 0.88 + fgRgb.b * 0.12);\n    // Tick text: 60% fg mixed into bg\n    var tickR = Math.round(bgRgb.r * 0.4 + fgRgb.r * 0.6);\n    var tickG = Math.round(bgRgb.g * 0.4 + fgRgb.g * 0.6);\n    var tickB = Math.round(bgRgb.b * 0.4 + fgRgb.b * 0.6);\n    return {\n      fg: fg,\n      bg: bg,\n      grid: 'rgb(' + gridR + ',' + gridG + ',' + gridB + ')',\n      tick: 'rgb(' + tickR + ',' + tickG + ',' + tickB + ')',\n      accent: theme && theme.accent ? theme.accent : '#3b82f6',\n    };\n  }\n\n  var BAR_PALETTE = [\n    'rgba(59,130,246,0.7)',   // blue\n    'rgba(16,185,129,0.7)',   // emerald\n    'rgba(245,158,11,0.7)',   // amber\n    'rgba(239,68,68,0.7)',    // red\n    'rgba(139,92,246,0.7)',   // violet\n    'rgba(236,72,153,0.7)',   // pink\n    'rgba(6,182,212,0.7)',    // cyan\n    'rgba(132,204,22,0.7)',   // lime\n  ];\n\n  var LINE_PALETTE = [\n    'rgba(239,68,68,0.9)',    // red\n    'rgba(16,185,129,0.9)',   // emerald\n    'rgba(245,158,11,0.9)',   // amber\n    'rgba(139,92,246,0.9)',   // violet\n    'rgba(6,182,212,0.9)',    // cyan\n    'rgba(236,72,153,0.9)',   // pink\n    'rgba(59,130,246,0.9)',   // blue\n    'rgba(132,204,22,0.9)',   // lime\n  ];\n\n  // ============================================================================\n  // XYChart parser\n  // ============================================================================\n  function parseXYChart(source) {\n    var lines = source.split('\\n').map(function(l) { return l.trim(); }).filter(Boolean);\n    var result = {\n      title: '',\n      horizontal: false,\n      xLabels: null,\n      xRange: null,\n      xTitle: '',\n      yRange: null,\n      yTitle: '',\n      bars: [],\n      lines: [],\n    };\n\n    // Check for horizontal\n    if (lines.length > 0 && lines[0].indexOf('horizontal') !== -1) {\n      result.horizontal = true;\n    }\n\n    for (var i = 0; i < lines.length; i++) {\n      var line = lines[i];\n\n      // Title\n      var titleMatch = line.match(/^title\\s+\"([^\"]+)\"/);\n      if (titleMatch) {\n        result.title = titleMatch[1];\n        continue;\n      }\n\n      // x-axis with labels: x-axis \"Title\" [a, b, c] or x-axis [a, b, c]\n      var xLabelMatch = line.match(/^x-axis\\s+(?:\"([^\"]*)\"\\s*)?\\[([^\\]]+)\\]/);\n      if (xLabelMatch) {\n        if (xLabelMatch[1]) result.xTitle = xLabelMatch[1];\n        result.xLabels = xLabelMatch[2].split(',').map(function(s) { return s.trim(); });\n        continue;\n      }\n\n      // x-axis with range: x-axis \"Title\" min --> max or x-axis min --> max\n      var xRangeMatch = line.match(/^x-axis\\s+(?:\"([^\"]*)\"\\s+)?(\\d+(?:\\.\\d+)?)\\s*-->\\s*(\\d+(?:\\.\\d+)?)/);\n      if (xRangeMatch) {\n        if (xRangeMatch[1]) result.xTitle = xRangeMatch[1];\n        result.xRange = [parseFloat(xRangeMatch[2]), parseFloat(xRangeMatch[3])];\n        continue;\n      }\n\n      // y-axis: y-axis \"Title\" min --> max or y-axis min --> max\n      var yRangeMatch = line.match(/^y-axis\\s+(?:\"([^\"]*)\"\\s+)?(\\d+(?:\\.\\d+)?)\\s*-->\\s*(\\d+(?:\\.\\d+)?)/);\n      if (yRangeMatch) {\n        if (yRangeMatch[1]) result.yTitle = yRangeMatch[1];\n        result.yRange = [parseFloat(yRangeMatch[2]), parseFloat(yRangeMatch[3])];\n        continue;\n      }\n\n      // y-axis with just title (no range)\n      var yTitleOnly = line.match(/^y-axis\\s+\"([^\"]+)\"$/);\n      if (yTitleOnly) {\n        result.yTitle = yTitleOnly[1];\n        continue;\n      }\n\n      // bar [...]\n      var barMatch = line.match(/^bar\\s+\\[([^\\]]+)\\]/);\n      if (barMatch) {\n        result.bars.push(barMatch[1].split(',').map(function(s) { return parseFloat(s.trim()); }));\n        continue;\n      }\n\n      // line [...]\n      var lineMatch = line.match(/^line\\s+\\[([^\\]]+)\\]/);\n      if (lineMatch) {\n        result.lines.push(lineMatch[1].split(',').map(function(s) { return parseFloat(s.trim()); }));\n        continue;\n      }\n    }\n\n    return result;\n  }\n\n  function buildChartConfig(parsed, colors) {\n    var datasets = [];\n    var labels = parsed.xLabels;\n\n    // If numeric range and no labels, generate labels from range\n    if (!labels && parsed.xRange) {\n      var dataLen = 0;\n      if (parsed.bars.length > 0) dataLen = parsed.bars[0].length;\n      else if (parsed.lines.length > 0) dataLen = parsed.lines[0].length;\n      if (dataLen > 0) {\n        labels = [];\n        var step = (parsed.xRange[1] - parsed.xRange[0]) / (dataLen - 1 || 1);\n        for (var k = 0; k < dataLen; k++) {\n          labels.push(String(Math.round((parsed.xRange[0] + step * k) * 100) / 100));\n        }\n      }\n    }\n\n    // If still no labels, generate numbered labels\n    if (!labels) {\n      var maxLen = 0;\n      parsed.bars.forEach(function(b) { if (b.length > maxLen) maxLen = b.length; });\n      parsed.lines.forEach(function(l) { if (l.length > maxLen) maxLen = l.length; });\n      labels = [];\n      for (var k = 0; k < maxLen; k++) labels.push(String(k + 1));\n    }\n\n    // Add bar datasets\n    for (var b = 0; b < parsed.bars.length; b++) {\n      datasets.push({\n        type: 'bar',\n        label: 'Bar ' + (b + 1),\n        data: parsed.bars[b],\n        backgroundColor: BAR_PALETTE[b % BAR_PALETTE.length],\n        borderColor: BAR_PALETTE[b % BAR_PALETTE.length].replace('0.7', '1'),\n        borderWidth: 1,\n        order: 2,\n      });\n    }\n\n    // Add line datasets\n    for (var l = 0; l < parsed.lines.length; l++) {\n      datasets.push({\n        type: 'line',\n        label: 'Line ' + (l + 1),\n        data: parsed.lines[l],\n        borderColor: LINE_PALETTE[l % LINE_PALETTE.length],\n        backgroundColor: LINE_PALETTE[l % LINE_PALETTE.length].replace('0.9', '0.1'),\n        borderWidth: 2,\n        pointRadius: 3,\n        pointHoverRadius: 5,\n        tension: 0.1,\n        fill: false,\n        order: 1,\n      });\n    }\n\n    var scales = {\n      x: {\n        title: { display: !!parsed.xTitle, text: parsed.xTitle, color: colors.tick },\n        ticks: { color: colors.tick },\n        grid: { color: colors.grid },\n      },\n      y: {\n        title: { display: !!parsed.yTitle, text: parsed.yTitle, color: colors.tick },\n        ticks: { color: colors.tick },\n        grid: { color: colors.grid },\n      },\n    };\n\n    if (parsed.yRange) {\n      scales.y.min = parsed.yRange[0];\n      scales.y.max = parsed.yRange[1];\n    }\n\n    var config = {\n      type: datasets.length === 1 ? datasets[0].type : 'bar',\n      data: { labels: labels, datasets: datasets },\n      options: {\n        responsive: true,\n        maintainAspectRatio: true,\n        indexAxis: parsed.horizontal ? 'y' : 'x',\n        plugins: {\n          title: {\n            display: !!parsed.title,\n            text: parsed.title,\n            color: colors.fg,\n            font: { size: 14, weight: '600', family: \"'Geist', system-ui, sans-serif\" },\n          },\n          legend: { display: datasets.length > 1, labels: { color: colors.tick } },\n        },\n        scales: scales,\n      },\n    };\n\n    return config;\n  }\n\n  function updateChartColors(chart, colors) {\n    var opts = chart.options;\n    if (opts.plugins.title) opts.plugins.title.color = colors.fg;\n    if (opts.plugins.legend && opts.plugins.legend.labels) opts.plugins.legend.labels.color = colors.tick;\n    if (opts.scales.x) {\n      if (opts.scales.x.title) opts.scales.x.title.color = colors.tick;\n      opts.scales.x.ticks.color = colors.tick;\n      opts.scales.x.grid.color = colors.grid;\n    }\n    if (opts.scales.y) {\n      if (opts.scales.y.title) opts.scales.y.title.color = colors.tick;\n      opts.scales.y.ticks.color = colors.tick;\n      opts.scales.y.grid.color = colors.grid;\n    }\n    chart.update('none');\n  }\n\n  // ============================================================================\n  // Theme application\n  // ============================================================================\n  function applyTheme(themeKey) {\n    var theme = themeKey ? THEMES[themeKey] : null;\n    var body = document.body;\n\n    if (theme) {\n      body.style.setProperty('--t-bg', theme.bg);\n      body.style.setProperty('--t-fg', theme.fg);\n      body.style.setProperty('--t-accent', theme.accent || '#3b82f6');\n    } else {\n      body.style.setProperty('--t-bg', '#FFFFFF');\n      body.style.setProperty('--t-fg', '#27272A');\n      body.style.setProperty('--t-accent', '#3b82f6');\n    }\n    setShadowVars(theme);\n    updateThemeColor(theme ? theme.fg : '#27272A', theme ? theme.bg : '#FFFFFF');\n\n    // Update chart panel backgrounds\n    for (var j = 0; j < 100; j++) {\n      var panel = document.getElementById('chart-panel-' + j);\n      if (panel) panel.style.background = theme ? theme.bg : '';\n    }\n\n    // Update all Chart.js instances\n    var colors = getChartColors(theme);\n    for (var j = 0; j < chartInstances.length; j++) {\n      updateChartColors(chartInstances[j], colors);\n    }\n\n    // Re-render all SVGs with new theme\n    renderAllSvgs(themeKey);\n\n    // Update active pill\n    var pills = document.querySelectorAll('.theme-pill');\n    for (var j = 0; j < pills.length; j++) {\n      var isActive = pills[j].getAttribute('data-theme') === themeKey;\n      pills[j].classList.toggle('active', isActive);\n      pills[j].classList.toggle('shadow-tinted', isActive);\n    }\n\n    if (themeKey) localStorage.setItem('xychart-theme', themeKey);\n    else localStorage.removeItem('xychart-theme');\n  }\n\n  // ============================================================================\n  // Event handlers\n  // ============================================================================\n\n  // Theme pills\n  document.getElementById('theme-pills').addEventListener('click', function(e) {\n    var pill = e.target.closest('.theme-pill');\n    if (!pill || pill.id === 'theme-more-btn') return;\n    applyTheme(pill.getAttribute('data-theme') || '');\n    var dd = document.getElementById('theme-more-dropdown');\n    if (dd && dd.classList.contains('open')) dd.classList.remove('open');\n  });\n\n  // More themes dropdown\n  var moreBtn = document.getElementById('theme-more-btn');\n  var moreDropdown = document.getElementById('theme-more-dropdown');\n  if (moreBtn && moreDropdown) {\n    moreBtn.addEventListener('click', function(e) {\n      e.stopPropagation();\n      moreDropdown.classList.toggle('open');\n    });\n    document.addEventListener('click', function(e) {\n      if (!moreDropdown.classList.contains('open')) return;\n      if (!e.target.closest('.theme-more-wrapper')) moreDropdown.classList.remove('open');\n    });\n    document.addEventListener('keydown', function(e) {\n      if (e.key === 'Escape' && moreDropdown.classList.contains('open')) moreDropdown.classList.remove('open');\n    });\n  }\n\n  // Contents mega-menu\n  var contentsBtn = document.getElementById('contents-btn');\n  var megaMenu = document.getElementById('mega-menu');\n  contentsBtn.addEventListener('click', function(e) {\n    e.stopPropagation();\n    var isOpen = megaMenu.classList.toggle('open');\n    contentsBtn.classList.toggle('active', isOpen);\n    contentsBtn.classList.toggle('shadow-tinted', isOpen);\n  });\n  megaMenu.addEventListener('click', function(e) {\n    var link = e.target.closest('a');\n    if (!link) return;\n    e.preventDefault();\n    megaMenu.classList.remove('open');\n    contentsBtn.classList.remove('active');\n    contentsBtn.classList.remove('shadow-tinted');\n    var target = document.querySelector(link.getAttribute('href'));\n    if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });\n  });\n  document.addEventListener('click', function(e) {\n    if (!megaMenu.classList.contains('open')) return;\n    if (!e.target.closest('.mega-menu') && !e.target.closest('.contents-btn')) {\n      megaMenu.classList.remove('open');\n      contentsBtn.classList.remove('active');\n      contentsBtn.classList.remove('shadow-tinted');\n    }\n  });\n  document.addEventListener('keydown', function(e) {\n    if (e.key === 'Escape' && megaMenu.classList.contains('open')) {\n      megaMenu.classList.remove('open');\n      contentsBtn.classList.remove('active');\n      contentsBtn.classList.remove('shadow-tinted');\n    }\n  });\n\n  // ============================================================================\n  // Restore saved theme\n  // ============================================================================\n  var savedTheme = localStorage.getItem('xychart-theme');\n  if (savedTheme && THEMES[savedTheme]) {\n    document.body.style.setProperty('--t-bg', THEMES[savedTheme].bg);\n    document.body.style.setProperty('--t-fg', THEMES[savedTheme].fg);\n    document.body.style.setProperty('--t-accent', THEMES[savedTheme].accent || '#3b82f6');\n    setShadowVars(THEMES[savedTheme]);\n    updateThemeColor(THEMES[savedTheme].fg, THEMES[savedTheme].bg);\n    var pills = document.querySelectorAll('.theme-pill');\n    for (var j = 0; j < pills.length; j++) {\n      var isActive = pills[j].getAttribute('data-theme') === savedTheme;\n      pills[j].classList.toggle('active', isActive);\n      pills[j].classList.toggle('shadow-tinted', isActive);\n    }\n  } else {\n    setShadowVars(null);\n  }\n\n  // ============================================================================\n  // Render all charts\n  // ============================================================================\n  var sources = [\"xychart-beta\\n    title \\\"Simple Bar Chart\\\"\\n    x-axis [A, B, C]\\n    bar [10, 20, 30]\",\"xychart-beta\\n    title \\\"Product Sales\\\"\\n    x-axis [Widgets, Gadgets, Gizmos, Doodads, Thingamajigs]\\n    bar [150, 230, 180, 95, 310]\",\"xychart-beta\\n    title \\\"Browser Market Share\\\"\\n    x-axis [Chrome, Safari, Firefox, Edge, Other]\\n    bar [65, 19, 8, 5, 3]\",\"xychart-beta\\n    title \\\"Q1 vs Q2\\\"\\n    x-axis [Q1, Q2]\\n    bar [4500, 5200]\",\"xychart-beta\\n    title \\\"Department Headcount\\\"\\n    x-axis [Eng, Sales, Marketing, Support, HR, Finance, Legal, Ops]\\n    bar [45, 32, 18, 25, 8, 12, 6, 15]\",\"xychart-beta\\n    title \\\"Daily Bugs Found\\\"\\n    x-axis [Mon, Tue, Wed, Thu, Fri]\\n    bar [3, 7, 2, 5, 1]\",\"xychart-beta\\n    title \\\"Consistent Output\\\"\\n    x-axis [Week 1, Week 2, Week 3, Week 4]\\n    bar [100, 102, 99, 101]\",\"xychart-beta\\n    title \\\"City Population (thousands)\\\"\\n    x-axis [Town A, Town B, City C, Metro D, Mega E]\\n    bar [5, 25, 150, 800, 3500]\",\"xychart-beta\\n    title \\\"Monthly Signups\\\"\\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct]\\n    bar [120, 145, 190, 210, 250, 280, 310, 295, 340, 380]\",\"xychart-beta\\n    title \\\"Test Scores\\\"\\n    x-axis [Alice, Bob, Carol, Dave, Eve]\\n    y-axis \\\"Score\\\" 0 --> 100\\n    bar [85, 72, 91, 68, 95]\",\"xychart-beta\\n    title \\\"Simple Trend\\\"\\n    x-axis [Q1, Q2, Q3, Q4]\\n    line [100, 150, 130, 180]\",\"xychart-beta\\n    title \\\"Revenue Growth\\\"\\n    x-axis [2019, 2020, 2021, 2022, 2023]\\n    line [500, 620, 780, 950, 1200]\",\"xychart-beta\\n    title \\\"Declining Defect Rate\\\"\\n    x-axis [Sprint 1, Sprint 2, Sprint 3, Sprint 4, Sprint 5, Sprint 6]\\n    line [25, 20, 15, 12, 8, 5]\",\"xychart-beta\\n    title \\\"Daily Temperature Variation\\\"\\n    x-axis [Mon, Tue, Wed, Thu, Fri, Sat, Sun]\\n    line [18, 22, 17, 24, 19, 26, 20]\",\"xychart-beta\\n    title \\\"Website Visitors\\\"\\n    x-axis [Jan, Feb, Mar, Apr, May, Jun]\\n    y-axis \\\"Visitors\\\" 0 --> 50000\\n    line [12000, 18000, 25000, 31000, 38000, 45000]\",\"xychart-beta\\n    title \\\"Stable Metric\\\"\\n    x-axis [W1, W2, W3, W4, W5, W6]\\n    line [50, 51, 49, 50, 52, 50]\",\"xychart-beta\\n    title \\\"Traffic Spike\\\"\\n    x-axis [6am, 8am, 10am, 12pm, 2pm, 4pm, 6pm]\\n    line [200, 350, 1200, 4500, 2800, 900, 300]\",\"xychart-beta\\n    title \\\"Stock Price Recovery\\\"\\n    x-axis [Jan, Feb, Mar, Apr, May, Jun]\\n    line [100, 72, 45, 38, 65, 95]\",\"xychart-beta\\n    title \\\"Pricing Tiers\\\"\\n    x-axis [Free, Basic, Pro, Team, Enterprise]\\n    line [0, 10, 25, 50, 100]\",\"xychart-beta\\n    title \\\"Planned vs Actual\\\"\\n    x-axis [Q1, Q2, Q3, Q4]\\n    line [100, 200, 300, 400]\\n    line [90, 210, 280, 420]\",\"xychart-beta\\n    title \\\"Monthly Revenue\\\"\\n    x-axis [Jan, Feb, Mar, Apr, May, Jun]\\n    y-axis \\\"Revenue (USD)\\\" 0 --> 10000\\n    bar [5000, 6200, 7800, 4500, 9200, 8100]\\n    line [5000, 6200, 7800, 4500, 9200, 8100]\",\"xychart-beta\\n    title \\\"Sales with Trend\\\"\\n    x-axis [Jan, Feb, Mar, Apr, May, Jun]\\n    bar [300, 450, 280, 520, 390, 610]\\n    line [300, 375, 343, 388, 388, 425]\",\"xychart-beta\\n    title \\\"Performance vs Target\\\"\\n    x-axis [Jan, Feb, Mar, Apr, May, Jun]\\n    y-axis \\\"Units\\\" 0 --> 600\\n    bar [320, 410, 380, 520, 290, 480]\\n    line [400, 400, 400, 400, 400, 400]\",\"xychart-beta\\n    title \\\"Revenue and Profit\\\"\\n    x-axis [Q1, Q2, Q3, Q4]\\n    bar [5000, 6500, 7200, 8100]\\n    line [1200, 1800, 2100, 2600]\",\"xychart-beta\\n    title \\\"Orders, Returns, and Net\\\"\\n    x-axis [Jan, Feb, Mar, Apr, May]\\n    bar [200, 250, 300, 280, 350]\\n    bar [20, 30, 25, 35, 28]\\n    line [180, 220, 275, 245, 322]\",\"xychart-beta\\n    title \\\"Costs vs Revenue\\\"\\n    x-axis [2020, 2021, 2022, 2023, 2024]\\n    bar [400, 420, 450, 440, 460]\\n    line [350, 480, 620, 780, 950]\",\"xychart-beta\\n    title \\\"Actual, Forecast, Target\\\"\\n    x-axis [Jan, Feb, Mar, Apr, May, Jun]\\n    bar [100, 120, 115, 140, 135, 160]\\n    line [95, 110, 120, 130, 140, 150]\\n    line [120, 120, 120, 120, 120, 120]\",\"xychart-beta\\n    title \\\"Channel Performance\\\"\\n    x-axis [Jan, Feb, Mar, Apr]\\n    bar [100, 120, 140, 160]\\n    bar [80, 90, 100, 110]\\n    line [180, 210, 240, 270]\",\"xychart-beta\\n    title \\\"Monthly and Cumulative Sales\\\"\\n    x-axis [Jan, Feb, Mar, Apr, May, Jun]\\n    bar [100, 150, 120, 180, 200, 170]\\n    line [100, 250, 370, 550, 750, 920]\",\"xychart-beta\\n    title \\\"Funnel Analysis\\\"\\n    x-axis [Visitors, Signups, Activated, Paid, Retained]\\n    bar [10000, 3000, 1500, 800, 500]\\n    line [10000, 3000, 1500, 800, 500]\",\"xychart-beta\\n    title \\\"Fruit Preferences\\\"\\n    x-axis [Apple, Banana, Cherry, Date, Elderberry]\\n    bar [45, 32, 28, 15, 8]\",\"xychart-beta\\n    title \\\"Distribution Curve\\\"\\n    x-axis 0 --> 100\\n    line [5, 15, 35, 60, 80, 95, 80, 60, 35, 15, 5]\",\"xychart-beta\\n    title \\\"Temperature Log\\\"\\n    x-axis [6am, 9am, 12pm, 3pm, 6pm, 9pm]\\n    y-axis \\\"Temp (F)\\\" 50 --> 100\\n    line [58, 65, 78, 85, 76, 62]\",\"xychart-beta\\n    title \\\"Sensor Readings\\\"\\n    x-axis [T1, T2, T3, T4, T5]\\n    y-axis 0 --> 500\\n    line [120, 250, 380, 310, 190]\",\"xychart-beta\\n    title \\\"Quarterly Results\\\"\\n    x-axis \\\"Quarter\\\" [Q1, Q2, Q3, Q4]\\n    y-axis \\\"Revenue ($K)\\\" 0 --> 1000\\n    bar [420, 580, 710, 890]\",\"xychart-beta\\n    title \\\"Experiment Results\\\"\\n    x-axis \\\"Trial Number\\\" [1, 2, 3, 4, 5, 6]\\n    y-axis \\\"Measurement (mm)\\\" 0 --> 50\\n    line [12, 18, 25, 22, 31, 28]\",\"xychart-beta\\n    title \\\"Department Budget\\\"\\n    x-axis [Engineering, Product Management, Customer Success, Human Resources]\\n    bar [850, 420, 310, 180]\",\"xychart-beta\\n    title \\\"Letter Frequency\\\"\\n    x-axis [A, B, C, D, E, F, G, H, I, J, K, L, M]\\n    bar [82, 15, 28, 43, 127, 22, 20, 61, 70, 2, 8, 40, 24]\",\"xychart-beta\\n    title \\\"Annual Revenue\\\"\\n    x-axis [2020, 2021, 2022, 2023, 2024]\\n    y-axis \\\"USD\\\" 0 --> 100000\\n    bar [15000, 28000, 45000, 67000, 92000]\",\"xychart-beta\\n    title \\\"CPU Temperature\\\"\\n    x-axis [10s, 20s, 30s, 40s, 50s, 60s]\\n    y-axis \\\"Celsius\\\" 60 --> 80\\n    line [65, 68, 72, 75, 73, 70]\",\"xychart-beta\\n    title \\\"Auto Range\\\"\\n    x-axis [A, B, C, D, E]\\n    bar [42, 87, 63, 29, 75]\",\"xychart-beta\\n    title \\\"Company Growth\\\"\\n    x-axis [2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025]\\n    line [10, 15, 12, 22, 35, 48, 62, 80]\",\"xychart-beta\\n    title \\\"Completion Rate\\\"\\n    x-axis [Week 1, Week 2, Week 3, Week 4, Week 5]\\n    y-axis \\\"Percent\\\" 0 --> 100\\n    line [15, 35, 58, 78, 95]\",\"xychart-beta\\n    title \\\"Histogram\\\"\\n    x-axis 0 --> 50\\n    bar [5, 12, 25, 38, 30, 18, 8]\",\"xychart-beta\\n    title \\\"Regional Sales\\\"\\n    x-axis [US, EU, Asia Pacific, LATAM, MEA, ANZ]\\n    bar [450, 380, 520, 180, 95, 60]\",\"xychart-beta horizontal\\n    title \\\"Language Popularity\\\"\\n    x-axis [Python, JavaScript, Java, Go, Rust]\\n    bar [30, 25, 20, 12, 8]\",\"xychart-beta horizontal\\n    title \\\"Sprint Velocity\\\"\\n    x-axis [Sprint 1, Sprint 2, Sprint 3, Sprint 4, Sprint 5]\\n    y-axis \\\"Story Points\\\" 0 --> 100\\n    bar [45, 52, 68, 72, 80]\",\"xychart-beta horizontal\\n    title \\\"Response Time Trend\\\"\\n    x-axis [v1.0, v1.1, v1.2, v1.3, v1.4]\\n    line [450, 380, 320, 280, 210]\",\"xychart-beta horizontal\\n    title \\\"Budget vs Actual\\\"\\n    x-axis [Eng, Sales, Marketing, Ops, HR]\\n    bar [500, 350, 200, 150, 100]\\n    line [480, 380, 180, 160, 95]\",\"xychart-beta horizontal\\n    title \\\"Top Features Requested\\\"\\n    x-axis [Dark Mode, API Access, Mobile App, SSO, Webhooks, CSV Export]\\n    bar [245, 198, 176, 152, 134, 112]\",\"xychart-beta horizontal\\n    title \\\"Team Satisfaction Survey\\\"\\n    x-axis [Culture, Compensation, Growth, Balance, Tools]\\n    y-axis \\\"Rating\\\" 0 --> 5\\n    bar [4, 3, 4, 5, 3]\",\"xychart-beta horizontal\\n    title \\\"This Year vs Last Year\\\"\\n    x-axis [Q1, Q2, Q3, Q4]\\n    bar [200, 250, 300, 280]\\n    bar [180, 220, 270, 310]\",\"xychart-beta horizontal\\n    title \\\"GitHub Stars\\\"\\n    x-axis [React, Vue, Angular, Svelte, Solid]\\n    bar [220000, 210000, 95000, 78000, 32000]\",\"xychart-beta horizontal\\n    title \\\"Planned vs Actual Delivery\\\"\\n    x-axis [Feature A, Feature B, Feature C, Feature D]\\n    line [10, 15, 20, 25]\\n    line [12, 14, 22, 23]\",\"xychart-beta horizontal\\n    title \\\"Error Categories\\\"\\n    x-axis [Authentication Failure, Database Timeout, Rate Limit Exceeded, Invalid Input]\\n    bar [342, 128, 89, 456]\",\"xychart-beta\\n    x-axis [A, B, C, D]\\n    bar [10, 20, 30, 40]\",\"xychart-beta\\n    title \\\"Sales\\\"\\n    x-axis [Jan, Feb, Mar]\\n    bar [100, 200, 150]\",\"xychart-beta\\n    title \\\"Quarterly Revenue Comparison Across All Regional Offices 2024\\\"\\n    x-axis [Q1, Q2, Q3, Q4]\\n    bar [1200, 1500, 1800, 2100]\",\"xychart-beta\\n    title \\\"FY2024 Q3 Results\\\"\\n    x-axis [Product A, Product B, Product C]\\n    bar [340, 520, 180]\",\"xychart-beta\\n    title \\\"Growth Rate (%)\\\"\\n    x-axis [2020, 2021, 2022, 2023]\\n    line [5, 12, 8, 15]\",\"xychart-beta\\n    title \\\"R&D Investment\\\"\\n    x-axis [2021, 2022, 2023, 2024]\\n    bar [200, 280, 350, 420]\",\"xychart-beta\\n    title \\\"Year-Over-Year Comparison\\\"\\n    x-axis [Jan, Feb, Mar, Apr, May]\\n    bar [100, 120, 90, 140, 130]\\n    line [110, 115, 100, 125, 135]\",\"xychart-beta\\n    x-axis [A, B]\\n    bar [1, 2]\",\"xychart-beta\\n    title \\\"Complete Chart\\\"\\n    x-axis \\\"Category\\\" [Alpha, Beta, Gamma, Delta]\\n    y-axis \\\"Value\\\" 0 --> 500\\n    bar [120, 340, 250, 410]\\n    line [120, 340, 250, 410]\",\"xychart-beta\\n    title \\\"Metrics: Daily Active Users\\\"\\n    x-axis [Mon, Tue, Wed, Thu, Fri]\\n    line [1500, 1800, 2200, 2000, 1700]\",\"xychart-beta\\n    title \\\"Monthly Active Users (2024)\\\"\\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]\\n    line [12000, 13500, 15200, 16800, 18500, 20100, 19800, 21500, 23000, 24200, 25800, 28000]\",\"xychart-beta\\n    title \\\"Letter Distribution\\\"\\n    x-axis [A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z]\\n    bar [82, 15, 28, 43, 127, 22, 20, 61, 70, 2, 8, 40, 24, 67, 75, 19, 1, 60, 63, 91, 28, 10, 24, 2, 20, 1]\",\"xychart-beta\\n    title \\\"Weekly Downloads\\\"\\n    x-axis [W1, W2, W3, W4, W5, W6, W7, W8, W9, W10, W11, W12, W13, W14, W15, W16, W17, W18, W19, W20]\\n    line [500, 520, 480, 550, 600, 580, 620, 650, 700, 680, 720, 750, 800, 780, 820, 850, 900, 880, 920, 950]\",\"xychart-beta\\n    title \\\"Product Lines Revenue\\\"\\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]\\n    bar [500, 520, 540, 580, 600, 620, 650, 640, 680, 700, 720, 750]\\n    bar [300, 320, 310, 350, 370, 380, 400, 390, 420, 440, 450, 470]\\n    line [800, 840, 850, 930, 970, 1000, 1050, 1030, 1100, 1140, 1170, 1220]\",\"xychart-beta\\n    title \\\"2023 vs 2024 Monthly Sales\\\"\\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]\\n    bar [180, 200, 220, 240, 260, 250, 270, 290, 310, 330, 350, 380]\\n    bar [210, 230, 250, 270, 300, 290, 310, 330, 360, 380, 400, 430]\",\"xychart-beta\\n    title \\\"Hourly Server Load\\\"\\n    x-axis [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]\\n    line [15, 12, 10, 8, 7, 9, 18, 45, 72, 85, 88, 90, 82, 78, 80, 85, 88, 75, 60, 45, 35, 28, 22, 18]\",\"xychart-beta\\n    title \\\"Cloud Spending by Month\\\"\\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]\\n    y-axis \\\"USD\\\" 0 --> 500000\\n    bar [120000, 135000, 148000, 162000, 178000, 195000, 210000, 228000, 245000, 268000, 290000, 315000]\",\"xychart-beta\\n    title \\\"Multi-Region Latency\\\"\\n    x-axis [T1, T2, T3, T4, T5, T6, T7, T8, T9, T10]\\n    line [45, 48, 42, 50, 55, 52, 47, 44, 49, 46]\\n    line [120, 115, 125, 118, 122, 130, 128, 135, 132, 127]\\n    line [200, 210, 195, 205, 215, 220, 208, 212, 218, 225]\",\"xychart-beta\\n    title \\\"Quarterly Earnings (2021-2024)\\\"\\n    x-axis [21Q1, 21Q2, 21Q3, 21Q4, 22Q1, 22Q2, 22Q3, 22Q4, 23Q1, 23Q2, 23Q3, 23Q4, 24Q1, 24Q2, 24Q3, 24Q4]\\n    bar [120, 140, 135, 160, 155, 175, 170, 190, 185, 210, 205, 230, 225, 250, 245, 270]\",\"xychart-beta\\n    title \\\"Daily Active Users (First 15 Days)\\\"\\n    x-axis [D1, D2, D3, D4, D5, D6, D7, D8, D9, D10, D11, D12, D13, D14, D15]\\n    bar [500, 520, 510, 530, 540, 480, 450, 520, 550, 540, 560, 570, 510, 490, 530]\\n    line [500, 510, 510, 520, 530, 520, 510, 515, 525, 530, 535, 540, 535, 530, 530]\",\"xychart-beta\\n    title \\\"Single Value\\\"\\n    x-axis [Only]\\n    bar [42]\",\"xychart-beta\\n    title \\\"No Activity\\\"\\n    x-axis [Mon, Tue, Wed, Thu, Fri]\\n    bar [0, 0, 0, 0, 0]\",\"xychart-beta\\n    title \\\"National GDP (millions)\\\"\\n    x-axis [Country A, Country B, Country C]\\n    bar [5200000, 3800000, 2900000]\",\"xychart-beta\\n    title \\\"Trace Amounts\\\"\\n    x-axis [Sample A, Sample B, Sample C, Sample D]\\n    bar [1, 2, 1, 3]\",\"xychart-beta\\n    title \\\"Before and After\\\"\\n    x-axis [Before, After]\\n    bar [25, 75]\",\"xychart-beta\\n    title \\\"Equal Distribution\\\"\\n    x-axis [A, B, C, D, E]\\n    bar [50, 50, 50, 50, 50]\",\"xychart-beta\\n    title \\\"Revenue by Product\\\"\\n    x-axis [Niche A, Niche B, Flagship, Niche C, Niche D]\\n    bar [50, 30, 5000, 40, 20]\",\"xychart-beta\\n    title \\\"Declining Interest\\\"\\n    x-axis [Week 1, Week 2, Week 3, Week 4, Week 5]\\n    line [100, 60, 25, 8, 0]\",\"xychart-beta\\n    title \\\"Ramp Up\\\"\\n    x-axis [Day 1, Day 2, Day 3, Day 4, Day 5]\\n    bar [0, 10, 50, 150, 400]\",\"xychart-beta\\n    title \\\"Alternating Pattern\\\"\\n    x-axis [A, B, C, D, E, F, G, H]\\n    bar [100, 10, 100, 10, 100, 10, 100, 10]\",\"xychart-beta\\n    title \\\"Monthly Revenue (2024)\\\"\\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]\\n    y-axis \\\"Revenue ($K)\\\" 0 --> 500\\n    bar [180, 195, 210, 225, 250, 268, 285, 275, 300, 320, 340, 380]\",\"xychart-beta\\n    title \\\"Cumulative Registered Users\\\"\\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]\\n    y-axis \\\"Users\\\" 0 --> 50000\\n    line [2500, 5200, 8800, 13100, 18000, 23500, 29200, 34800, 39500, 43200, 46500, 50000]\",\"xychart-beta\\n    title \\\"Average Monthly Temperature\\\"\\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]\\n    y-axis \\\"Temp (F)\\\" 20 --> 100\\n    line [32, 35, 45, 58, 68, 78, 85, 83, 74, 60, 48, 36]\",\"xychart-beta\\n    title \\\"ACME Corp Stock Price\\\"\\n    x-axis [W1, W2, W3, W4, W5, W6, W7, W8, W9, W10, W11, W12, W13]\\n    y-axis \\\"Price ($)\\\" 80 --> 160\\n    line [100, 105, 98, 112, 108, 120, 115, 125, 135, 128, 140, 148, 155]\",\"xychart-beta\\n    title \\\"Customer Satisfaction Survey\\\"\\n    x-axis [Ease of Use, Performance, Support, Pricing, Features, Reliability]\\n    y-axis \\\"Score\\\" 0 --> 5\\n    bar [4, 3, 5, 3, 4, 4]\",\"xychart-beta\\n    title \\\"P95 Response Time by Endpoint\\\"\\n    x-axis [/users, /orders, /products, /search, /auth, /reports]\\n    y-axis \\\"ms\\\" 0 --> 1000\\n    bar [120, 250, 180, 450, 80, 850]\",\"xychart-beta\\n    title \\\"Website Traffic\\\"\\n    x-axis [Mon, Tue, Wed, Thu, Fri, Sat, Sun]\\n    bar [8500, 9200, 9800, 9500, 8800, 5200, 4100]\\n    line [3200, 3500, 3800, 3600, 3400, 2100, 1800]\",\"xychart-beta\\n    title \\\"Sprint Burndown\\\"\\n    x-axis [D1, D2, D3, D4, D5, D6, D7, D8, D9, D10]\\n    y-axis \\\"Story Points\\\" 0 --> 80\\n    line [72, 65, 58, 50, 45, 38, 30, 22, 12, 0]\\n    line [72, 65, 58, 50, 43, 36, 29, 22, 14, 0]\",\"xychart-beta\\n    title \\\"Marketing Funnel\\\"\\n    x-axis [Impressions, Clicks, Signups, Trials, Purchases]\\n    bar [50000, 5000, 1200, 400, 150]\",\"xychart-beta\\n    title \\\"Error Rate During Incident\\\"\\n    x-axis [10am, 11am, 12pm, 1pm, 2pm, 3pm, 4pm, 5pm]\\n    y-axis \\\"Errors/min\\\" 0 --> 500\\n    bar [5, 12, 45, 380, 420, 250, 35, 8]\\n    line [5, 12, 45, 380, 420, 250, 35, 8]\",\"xychart-beta\\n    title \\\"Company Headcount\\\"\\n    x-axis [2018, 2019, 2020, 2021, 2022, 2023, 2024]\\n    bar [8, 15, 25, 45, 80, 120, 185]\",\"xychart-beta\\n    title \\\"Monthly Churn Rate\\\"\\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]\\n    y-axis \\\"Churn %\\\" 0 --> 10\\n    line [5, 4, 5, 4, 3, 3, 4, 3, 3, 2, 2, 2]\",\"xychart-beta\\n    title \\\"A/B Test Conversion Rates\\\"\\n    x-axis [Mobile, Desktop, Tablet, New Users, Returning]\\n    bar [3, 5, 4, 2, 6]\\n    bar [4, 7, 5, 3, 8]\",\"xychart-beta\\n    title \\\"Monthly Cloud Costs\\\"\\n    x-axis [Compute, Storage, Database, Network, CDN, Monitoring, Other]\\n    y-axis \\\"USD\\\" 0 --> 20000\\n    bar [15000, 8000, 6500, 3200, 2800, 1500, 900]\",\"xychart-beta\\n    title \\\"Production Deployments per Month\\\"\\n    x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]\\n    bar [12, 15, 18, 22, 20, 25, 28, 24, 30, 32, 35, 38]\\n    line [12, 15, 18, 22, 20, 25, 28, 24, 30, 32, 35, 38]\"];\n  var activeTheme = savedTheme && THEMES[savedTheme] ? THEMES[savedTheme] : null;\n  var colors = getChartColors(activeTheme);\n\n  Chart.defaults.font.family = \"'Geist', system-ui, sans-serif\";\n\n  for (var i = 0; i < sources.length; i++) {\n    var canvas = document.getElementById('chart-' + i);\n    if (!canvas) continue;\n    var parsed = parseXYChart(sources[i]);\n    var config = buildChartConfig(parsed, colors);\n    try {\n      var chart = new Chart(canvas, config);\n      chartInstances.push(chart);\n    } catch (err) {\n      console.error('Chart ' + i + ' failed:', err);\n    }\n\n    // Set panel bg if theme is active\n    if (activeTheme) {\n      var panel = document.getElementById('chart-panel-' + i);\n      if (panel) panel.style.background = activeTheme.bg;\n    }\n  }\n\n  // ============================================================================\n  // Render beautiful-mermaid SVGs\n  // ============================================================================\n  function renderAllSvgs(themeKey) {\n    var theme = themeKey ? THEMES[themeKey] : null;\n    var opts = theme ? { bg: theme.bg, fg: theme.fg, interactive: true } : { interactive: true };\n    if (theme) {\n      if (theme.line) opts.line = theme.line;\n      if (theme.accent) opts.accent = theme.accent;\n      if (theme.muted) opts.muted = theme.muted;\n      if (theme.surface) opts.surface = theme.surface;\n      if (theme.border) opts.border = theme.border;\n    }\n    for (var i = 0; i < sources.length; i++) {\n      (function(idx) {\n        window.__mermaid.renderMermaid(sources[idx], opts).then(function(svg) {\n          var panel = document.getElementById('svg-panel-' + idx);\n          if (panel) panel.innerHTML = svg;\n        }).catch(function(err) {\n          console.error('SVG ' + idx + ' failed:', err);\n          var panel = document.getElementById('svg-panel-' + idx);\n          if (panel) panel.innerHTML = '<div class=\"svg-loading\">Error: ' + err.message + '</div>';\n        });\n      })(i);\n    }\n  }\n\n  // Initial SVG render — wait for the module bundle to set window.__mermaid\n  window.__renderAllSvgs = renderAllSvgs;\n  window.__initThemeKey = savedTheme || '';\n  </script>\n  <script type=\"module\">\nvar a7=Object.create;var{getPrototypeOf:r7,defineProperty:c6,getOwnPropertyNames:t7}=Object;var e7=Object.prototype.hasOwnProperty;var H6=($,J,K)=>{K=$!=null?a7(r7($)):{};let Q=J||!$||!$.__esModule?c6(K,\"default\",{value:$,enumerable:!0}):K;for(let Z of t7($))if(!e7.call(Q,Z))c6(Q,Z,{get:()=>$[Z],enumerable:!0});return Q};var $9=($,J)=>()=>(J||$((J={exports:{}}).exports,J),J.exports);var R6=(($)=>typeof require<\"u\"?require:typeof Proxy<\"u\"?new Proxy($,{get:(J,K)=>(typeof require<\"u\"?require:J)[K]}):$)(function($){if(typeof require<\"u\")return require.apply(this,arguments);throw Error('Dynamic require of \"'+$+'\" is not supported')});var G6=$9((M7,T6)=>{(function($){if(typeof M7===\"object\"&&typeof T6<\"u\")T6.exports=$();else if(typeof define===\"function\"&&define.amd)define([],$);else{var J;if(typeof window<\"u\")J=window;else if(typeof global<\"u\")J=global;else if(typeof self<\"u\")J=self;else J=this;J.dagre=$()}})(function(){var $,J,K;return function(){function Q(Z,V,B){function U(G,q){if(!V[G]){if(!Z[G]){var z=R6;if(!q&&z)return z(G,!0);if(j)return j(G,!0);var _=Error(\"Cannot find module '\"+G+\"'\");throw _.code=\"MODULE_NOT_FOUND\",_}var W=V[G]={exports:{}};Z[G][0].call(W.exports,function(F){var H=Z[G][1][F];return U(H||F)},W,W.exports,Q,Z,V,B)}return V[G].exports}for(var j=R6,P=0;P<B.length;P++)U(B[P]);return U}return Q}()({1:[function(Q,Z,V){Z.exports={graphlib:Q(\"@dagrejs/graphlib\"),layout:Q(\"./lib/layout\"),debug:Q(\"./lib/debug\"),util:{time:Q(\"./lib/util\").time,notime:Q(\"./lib/util\").notime},version:Q(\"./lib/version\")}},{\"./lib/debug\":6,\"./lib/layout\":8,\"./lib/util\":27,\"./lib/version\":28,\"@dagrejs/graphlib\":29}],2:[function(Q,Z,V){let B=Q(\"./greedy-fas\"),U=Q(\"./util\").uniqueId;Z.exports={run:j,undo:G};function j(q){(q.graph().acyclicer===\"greedy\"?B(q,_(q)):P(q)).forEach((W)=>{let F=q.edge(W);q.removeEdge(W),F.forwardName=W.name,F.reversed=!0,q.setEdge(W.w,W.v,F,U(\"rev\"))});function _(W){return(F)=>{return W.edge(F).weight}}}function P(q){let z=[],_={},W={};function F(H){if(Object.hasOwn(W,H))return;W[H]=!0,_[H]=!0,q.outEdges(H).forEach((M)=>{if(Object.hasOwn(_,M.w))z.push(M);else F(M.w)}),delete _[H]}return q.nodes().forEach(F),z}function G(q){q.edges().forEach((z)=>{let _=q.edge(z);if(_.reversed){q.removeEdge(z);let W=_.forwardName;delete _.reversed,delete _.forwardName,q.setEdge(z.w,z.v,_,W)}})}},{\"./greedy-fas\":7,\"./util\":27}],3:[function(Q,Z,V){let B=Q(\"./util\");Z.exports=U;function U(P){function G(q){let z=P.children(q),_=P.node(q);if(z.length)z.forEach(G);if(Object.hasOwn(_,\"minRank\")){_.borderLeft=[],_.borderRight=[];for(let W=_.minRank,F=_.maxRank+1;W<F;++W)j(P,\"borderLeft\",\"_bl\",q,_,W),j(P,\"borderRight\",\"_br\",q,_,W)}}P.children().forEach(G)}function j(P,G,q,z,_,W){let F={width:0,height:0,rank:W,borderType:G},H=_[G][W-1],M=B.addDummyNode(P,\"border\",F,q);if(_[G][W]=M,P.setParent(M,z),H)P.setEdge(H,M,{weight:1})}},{\"./util\":27}],4:[function(Q,Z,V){Z.exports={adjust:B,undo:U};function B(W){let F=W.graph().rankdir.toLowerCase();if(F===\"lr\"||F===\"rl\")j(W)}function U(W){let F=W.graph().rankdir.toLowerCase();if(F===\"bt\"||F===\"rl\")G(W);if(F===\"lr\"||F===\"rl\")z(W),j(W)}function j(W){W.nodes().forEach((F)=>P(W.node(F))),W.edges().forEach((F)=>P(W.edge(F)))}function P(W){let F=W.width;W.width=W.height,W.height=F}function G(W){W.nodes().forEach((F)=>q(W.node(F))),W.edges().forEach((F)=>{let H=W.edge(F);if(H.points.forEach(q),Object.hasOwn(H,\"y\"))q(H)})}function q(W){W.y=-W.y}function z(W){W.nodes().forEach((F)=>_(W.node(F))),W.edges().forEach((F)=>{let H=W.edge(F);if(H.points.forEach(_),Object.hasOwn(H,\"x\"))_(H)})}function _(W){let F=W.x;W.x=W.y,W.y=F}},{}],5:[function(Q,Z,V){class B{constructor(){let P={};P._next=P._prev=P,this._sentinel=P}dequeue(){let P=this._sentinel,G=P._prev;if(G!==P)return U(G),G}enqueue(P){let G=this._sentinel;if(P._prev&&P._next)U(P);P._next=G._next,G._next._prev=P,G._next=P,P._prev=G}toString(){let P=[],G=this._sentinel,q=G._prev;while(q!==G)P.push(JSON.stringify(q,j)),q=q._prev;return\"[\"+P.join(\", \")+\"]\"}}function U(P){P._prev._next=P._next,P._next._prev=P._prev,delete P._next,delete P._prev}function j(P,G){if(P!==\"_next\"&&P!==\"_prev\")return G}Z.exports=B},{}],6:[function(Q,Z,V){let B=Q(\"./util\"),U=Q(\"@dagrejs/graphlib\").Graph;Z.exports={debugOrdering:j};function j(P){let G=B.buildLayerMatrix(P),q=new U({compound:!0,multigraph:!0}).setGraph({});return P.nodes().forEach((z)=>{q.setNode(z,{label:z}),q.setParent(z,\"layer\"+P.node(z).rank)}),P.edges().forEach((z)=>q.setEdge(z.v,z.w,{},z.name)),G.forEach((z,_)=>{let W=\"layer\"+_;q.setNode(W,{rank:\"same\"}),z.reduce((F,H)=>{return q.setEdge(F,H,{style:\"invis\"}),H})}),q}},{\"./util\":27,\"@dagrejs/graphlib\":29}],7:[function(Q,Z,V){let B=Q(\"@dagrejs/graphlib\").Graph,U=Q(\"./data/list\");Z.exports=P;let j=()=>1;function P(F,H){if(F.nodeCount()<=1)return[];let M=z(F,H||j);return G(M.graph,M.buckets,M.zeroIdx).flatMap((Y)=>F.outEdges(Y.v,Y.w))}function G(F,H,M){let R=[],Y=H[H.length-1],L=H[0],b;while(F.nodeCount()){while(b=L.dequeue())q(F,H,M,b);while(b=Y.dequeue())q(F,H,M,b);if(F.nodeCount()){for(let O=H.length-2;O>0;--O)if(b=H[O].dequeue(),b){R=R.concat(q(F,H,M,b,!0));break}}}return R}function q(F,H,M,R,Y){let L=Y?[]:void 0;return F.inEdges(R.v).forEach((b)=>{let O=F.edge(b),A=F.node(b.v);if(Y)L.push({v:b.v,w:b.w});A.out-=O,_(H,M,A)}),F.outEdges(R.v).forEach((b)=>{let O=F.edge(b),A=b.w,D=F.node(A);D.in-=O,_(H,M,D)}),F.removeNode(R.v),L}function z(F,H){let M=new B,R=0,Y=0;F.nodes().forEach((O)=>{M.setNode(O,{v:O,in:0,out:0})}),F.edges().forEach((O)=>{let A=M.edge(O.v,O.w)||0,D=H(O),T=A+D;M.setEdge(O.v,O.w,T),Y=Math.max(Y,M.node(O.v).out+=D),R=Math.max(R,M.node(O.w).in+=D)});let L=W(Y+R+3).map(()=>new U),b=R+1;return M.nodes().forEach((O)=>{_(L,b,M.node(O))}),{graph:M,buckets:L,zeroIdx:b}}function _(F,H,M){if(!M.out)F[0].enqueue(M);else if(!M.in)F[F.length-1].enqueue(M);else F[M.out-M.in+H].enqueue(M)}function W(F){let H=[];for(let M=0;M<F;M++)H.push(M);return H}},{\"./data/list\":5,\"@dagrejs/graphlib\":29}],8:[function(Q,Z,V){let B=Q(\"./acyclic\"),U=Q(\"./normalize\"),j=Q(\"./rank\"),P=Q(\"./util\").normalizeRanks,G=Q(\"./parent-dummy-chains\"),q=Q(\"./util\").removeEmptyRanks,z=Q(\"./nesting-graph\"),_=Q(\"./add-border-segments\"),W=Q(\"./coordinate-system\"),F=Q(\"./order\"),H=Q(\"./position\"),M=Q(\"./util\"),R=Q(\"@dagrejs/graphlib\").Graph;Z.exports=Y;function Y(E,k){let x=k&&k.debugTiming?M.time:M.notime;x(\"layout\",()=>{let n=x(\"  buildLayoutGraph\",()=>X(E));x(\"  runLayout\",()=>L(n,x,k)),x(\"  updateInputGraph\",()=>b(E,n))})}function L(E,k,x){k(\"    makeSpaceForEdgeLabels\",()=>N(E)),k(\"    removeSelfEdges\",()=>p(E)),k(\"    acyclic\",()=>B.run(E)),k(\"    nestingGraph.run\",()=>z.run(E)),k(\"    rank\",()=>j(M.asNonCompoundGraph(E))),k(\"    injectEdgeLabelProxies\",()=>S(E)),k(\"    removeEmptyRanks\",()=>q(E)),k(\"    nestingGraph.cleanup\",()=>z.cleanup(E)),k(\"    normalizeRanks\",()=>P(E)),k(\"    assignRankMinMax\",()=>w(E)),k(\"    removeEdgeLabelProxies\",()=>I(E)),k(\"    normalize.run\",()=>U.run(E)),k(\"    parentDummyChains\",()=>G(E)),k(\"    addBorderSegments\",()=>_(E)),k(\"    order\",()=>F(E,x)),k(\"    insertSelfEdges\",()=>h(E)),k(\"    adjustCoordinateSystem\",()=>W.adjust(E)),k(\"    position\",()=>H(E)),k(\"    positionSelfEdges\",()=>m(E)),k(\"    removeBorderNodes\",()=>l(E)),k(\"    normalize.undo\",()=>U.undo(E)),k(\"    fixupEdgeLabelCoords\",()=>C(E)),k(\"    undoCoordinateSystem\",()=>W.undo(E)),k(\"    translateGraph\",()=>d(E)),k(\"    assignNodeIntersects\",()=>s(E)),k(\"    reversePoints\",()=>c(E)),k(\"    acyclic.undo\",()=>B.undo(E))}function b(E,k){E.nodes().forEach((x)=>{let n=E.node(x),t=k.node(x);if(n){if(n.x=t.x,n.y=t.y,n.rank=t.rank,k.children(x).length)n.width=t.width,n.height=t.height}}),E.edges().forEach((x)=>{let n=E.edge(x),t=k.edge(x);if(n.points=t.points,Object.hasOwn(t,\"x\"))n.x=t.x,n.y=t.y}),E.graph().width=k.graph().width,E.graph().height=k.graph().height}let O=[\"nodesep\",\"edgesep\",\"ranksep\",\"marginx\",\"marginy\"],A={ranksep:50,edgesep:20,nodesep:50,rankdir:\"tb\"},D=[\"acyclicer\",\"ranker\",\"rankdir\",\"align\"],T=[\"width\",\"height\",\"rank\"],v={width:0,height:0},u=[\"minlen\",\"weight\",\"width\",\"height\",\"labeloffset\"],y={minlen:1,weight:1,width:0,height:0,labeloffset:10,labelpos:\"r\"},f=[\"labelpos\"];function X(E){let k=new R({multigraph:!0,compound:!0}),x=o(E.graph());return k.setGraph(Object.assign({},A,g(x,O),M.pick(x,D))),E.nodes().forEach((n)=>{let t=o(E.node(n)),J4=g(t,T);Object.keys(v).forEach((e)=>{if(J4[e]===void 0)J4[e]=v[e]}),k.setNode(n,J4),k.setParent(n,E.parent(n))}),E.edges().forEach((n)=>{let t=o(E.edge(n));k.setEdge(n,Object.assign({},y,g(t,u),M.pick(t,f)))}),k}function N(E){let k=E.graph();k.ranksep/=2,E.edges().forEach((x)=>{let n=E.edge(x);if(n.minlen*=2,n.labelpos.toLowerCase()!==\"c\")if(k.rankdir===\"TB\"||k.rankdir===\"BT\")n.width+=n.labeloffset;else n.height+=n.labeloffset})}function S(E){E.edges().forEach((k)=>{let x=E.edge(k);if(x.width&&x.height){let n=E.node(k.v),J4={rank:(E.node(k.w).rank-n.rank)/2+n.rank,e:k};M.addDummyNode(E,\"edge-proxy\",J4,\"_ep\")}})}function w(E){let k=0;E.nodes().forEach((x)=>{let n=E.node(x);if(n.borderTop)n.minRank=E.node(n.borderTop).rank,n.maxRank=E.node(n.borderBottom).rank,k=Math.max(k,n.maxRank)}),E.graph().maxRank=k}function I(E){E.nodes().forEach((k)=>{let x=E.node(k);if(x.dummy===\"edge-proxy\")E.edge(x.e).labelRank=x.rank,E.removeNode(k)})}function d(E){let k=Number.POSITIVE_INFINITY,x=0,n=Number.POSITIVE_INFINITY,t=0,J4=E.graph(),e=J4.marginx||0,G4=J4.marginy||0;function q4(M4){let{x:W4,y:n4,width:m6,height:h6}=M4;k=Math.min(k,W4-m6/2),x=Math.max(x,W4+m6/2),n=Math.min(n,n4-h6/2),t=Math.max(t,n4+h6/2)}E.nodes().forEach((M4)=>q4(E.node(M4))),E.edges().forEach((M4)=>{let W4=E.edge(M4);if(Object.hasOwn(W4,\"x\"))q4(W4)}),k-=e,n-=G4,E.nodes().forEach((M4)=>{let W4=E.node(M4);W4.x-=k,W4.y-=n}),E.edges().forEach((M4)=>{let W4=E.edge(M4);if(W4.points.forEach((n4)=>{n4.x-=k,n4.y-=n}),Object.hasOwn(W4,\"x\"))W4.x-=k;if(Object.hasOwn(W4,\"y\"))W4.y-=n}),J4.width=x-k+e,J4.height=t-n+G4}function s(E){E.edges().forEach((k)=>{let x=E.edge(k),n=E.node(k.v),t=E.node(k.w),J4,e;if(!x.points)x.points=[],J4=t,e=n;else J4=x.points[0],e=x.points[x.points.length-1];x.points.unshift(M.intersectRect(n,J4)),x.points.push(M.intersectRect(t,e))})}function C(E){E.edges().forEach((k)=>{let x=E.edge(k);if(Object.hasOwn(x,\"x\")){if(x.labelpos===\"l\"||x.labelpos===\"r\")x.width-=x.labeloffset;switch(x.labelpos){case\"l\":x.x-=x.width/2+x.labeloffset;break;case\"r\":x.x+=x.width/2+x.labeloffset;break}}})}function c(E){E.edges().forEach((k)=>{let x=E.edge(k);if(x.reversed)x.points.reverse()})}function l(E){E.nodes().forEach((k)=>{if(E.children(k).length){let x=E.node(k),n=E.node(x.borderTop),t=E.node(x.borderBottom),J4=E.node(x.borderLeft[x.borderLeft.length-1]),e=E.node(x.borderRight[x.borderRight.length-1]);x.width=Math.abs(e.x-J4.x),x.height=Math.abs(t.y-n.y),x.x=J4.x+x.width/2,x.y=n.y+x.height/2}}),E.nodes().forEach((k)=>{if(E.node(k).dummy===\"border\")E.removeNode(k)})}function p(E){E.edges().forEach((k)=>{if(k.v===k.w){var x=E.node(k.v);if(!x.selfEdges)x.selfEdges=[];x.selfEdges.push({e:k,label:E.edge(k)}),E.removeEdge(k)}})}function h(E){var k=M.buildLayerMatrix(E);k.forEach((x)=>{var n=0;x.forEach((t,J4)=>{var e=E.node(t);e.order=J4+n,(e.selfEdges||[]).forEach((G4)=>{M.addDummyNode(E,\"selfedge\",{width:G4.label.width,height:G4.label.height,rank:e.rank,order:J4+ ++n,e:G4.e,label:G4.label},\"_se\")}),delete e.selfEdges})})}function m(E){E.nodes().forEach((k)=>{var x=E.node(k);if(x.dummy===\"selfedge\"){var n=E.node(x.e.v),t=n.x+n.width/2,J4=n.y,e=x.x-t,G4=n.height/2;E.setEdge(x.e,x.label),E.removeNode(k),x.label.points=[{x:t+2*e/3,y:J4-G4},{x:t+5*e/6,y:J4-G4},{x:t+e,y:J4},{x:t+5*e/6,y:J4+G4},{x:t+2*e/3,y:J4+G4}],x.label.x=x.x,x.label.y=x.y}})}function g(E,k){return M.mapValues(M.pick(E,k),Number)}function o(E){var k={};if(E)Object.entries(E).forEach(([x,n])=>{if(typeof x===\"string\")x=x.toLowerCase();k[x]=n});return k}},{\"./acyclic\":2,\"./add-border-segments\":3,\"./coordinate-system\":4,\"./nesting-graph\":9,\"./normalize\":10,\"./order\":15,\"./parent-dummy-chains\":20,\"./position\":22,\"./rank\":24,\"./util\":27,\"@dagrejs/graphlib\":29}],9:[function(Q,Z,V){let B=Q(\"./util\");Z.exports={run:U,cleanup:q};function U(z){let _=B.addDummyNode(z,\"root\",{},\"_root\"),W=P(z),F=Object.values(W),H=B.applyWithChunking(Math.max,F)-1,M=2*H+1;z.graph().nestingRoot=_,z.edges().forEach((Y)=>z.edge(Y).minlen*=M);let R=G(z)+1;z.children().forEach((Y)=>j(z,_,M,R,H,W,Y)),z.graph().nodeRankFactor=M}function j(z,_,W,F,H,M,R){let Y=z.children(R);if(!Y.length){if(R!==_)z.setEdge(_,R,{weight:0,minlen:W});return}let L=B.addBorderNode(z,\"_bt\"),b=B.addBorderNode(z,\"_bb\"),O=z.node(R);if(z.setParent(L,R),O.borderTop=L,z.setParent(b,R),O.borderBottom=b,Y.forEach((A)=>{j(z,_,W,F,H,M,A);let D=z.node(A),T=D.borderTop?D.borderTop:A,v=D.borderBottom?D.borderBottom:A,u=D.borderTop?F:2*F,y=T!==v?1:H-M[R]+1;z.setEdge(L,T,{weight:u,minlen:y,nestingEdge:!0}),z.setEdge(v,b,{weight:u,minlen:y,nestingEdge:!0})}),!z.parent(R))z.setEdge(_,L,{weight:0,minlen:H+M[R]})}function P(z){var _={};function W(F,H){var M=z.children(F);if(M&&M.length)M.forEach((R)=>W(R,H+1));_[F]=H}return z.children().forEach((F)=>W(F,1)),_}function G(z){return z.edges().reduce((_,W)=>_+z.edge(W).weight,0)}function q(z){var _=z.graph();z.removeNode(_.nestingRoot),delete _.nestingRoot,z.edges().forEach((W)=>{var F=z.edge(W);if(F.nestingEdge)z.removeEdge(W)})}},{\"./util\":27}],10:[function(Q,Z,V){let B=Q(\"./util\");Z.exports={run:U,undo:P};function U(G){G.graph().dummyChains=[],G.edges().forEach((q)=>j(G,q))}function j(G,q){let z=q.v,_=G.node(z).rank,W=q.w,F=G.node(W).rank,H=q.name,M=G.edge(q),R=M.labelRank;if(F===_+1)return;G.removeEdge(q);let Y,L,b;for(b=0,++_;_<F;++b,++_){if(M.points=[],L={width:0,height:0,edgeLabel:M,edgeObj:q,rank:_},Y=B.addDummyNode(G,\"edge\",L,\"_d\"),_===R)L.width=M.width,L.height=M.height,L.dummy=\"edge-label\",L.labelpos=M.labelpos;if(G.setEdge(z,Y,{weight:M.weight},H),b===0)G.graph().dummyChains.push(Y);z=Y}G.setEdge(z,W,{weight:M.weight},H)}function P(G){G.graph().dummyChains.forEach((q)=>{let z=G.node(q),_=z.edgeLabel,W;G.setEdge(z.edgeObj,_);while(z.dummy){if(W=G.successors(q)[0],G.removeNode(q),_.points.push({x:z.x,y:z.y}),z.dummy===\"edge-label\")_.x=z.x,_.y=z.y,_.width=z.width,_.height=z.height;q=W,z=G.node(q)}})}},{\"./util\":27}],11:[function(Q,Z,V){Z.exports=B;function B(U,j,P){let G={},q;P.forEach((z)=>{let _=U.parent(z),W,F;while(_){if(W=U.parent(_),W)F=G[W],G[W]=_;else F=q,q=_;if(F&&F!==_){j.setEdge(F,_);return}_=W}})}},{}],12:[function(Q,Z,V){Z.exports=B;function B(U,j=[]){return j.map((P)=>{let G=U.inEdges(P);if(!G.length)return{v:P};else{let q=G.reduce((z,_)=>{let W=U.edge(_),F=U.node(_.v);return{sum:z.sum+W.weight*F.order,weight:z.weight+W.weight}},{sum:0,weight:0});return{v:P,barycenter:q.sum/q.weight,weight:q.weight}}})}},{}],13:[function(Q,Z,V){let B=Q(\"@dagrejs/graphlib\").Graph,U=Q(\"../util\");Z.exports=j;function j(G,q,z,_){if(!_)_=G.nodes();let W=P(G),F=new B({compound:!0}).setGraph({root:W}).setDefaultNodeLabel((H)=>G.node(H));return _.forEach((H)=>{let M=G.node(H),R=G.parent(H);if(M.rank===q||M.minRank<=q&&q<=M.maxRank){if(F.setNode(H),F.setParent(H,R||W),G[z](H).forEach((Y)=>{let L=Y.v===H?Y.w:Y.v,b=F.edge(L,H),O=b!==void 0?b.weight:0;F.setEdge(L,H,{weight:G.edge(Y).weight+O})}),Object.hasOwn(M,\"minRank\"))F.setNode(H,{borderLeft:M.borderLeft[q],borderRight:M.borderRight[q]})}}),F}function P(G){var q;while(G.hasNode(q=U.uniqueId(\"_root\")));return q}},{\"../util\":27,\"@dagrejs/graphlib\":29}],14:[function(Q,Z,V){let B=Q(\"../util\").zipObject;Z.exports=U;function U(P,G){let q=0;for(let z=1;z<G.length;++z)q+=j(P,G[z-1],G[z]);return q}function j(P,G,q){let z=B(q,q.map((R,Y)=>Y)),_=G.flatMap((R)=>{return P.outEdges(R).map((Y)=>{return{pos:z[Y.w],weight:P.edge(Y).weight}}).sort((Y,L)=>Y.pos-L.pos)}),W=1;while(W<q.length)W<<=1;let F=2*W-1;W-=1;let H=Array(F).fill(0),M=0;return _.forEach((R)=>{let Y=R.pos+W;H[Y]+=R.weight;let L=0;while(Y>0){if(Y%2)L+=H[Y+1];Y=Y-1>>1,H[Y]+=R.weight}M+=R.weight*L}),M}},{\"../util\":27}],15:[function(Q,Z,V){let B=Q(\"./init-order\"),U=Q(\"./cross-count\"),j=Q(\"./sort-subgraph\"),P=Q(\"./build-layer-graph\"),G=Q(\"./add-subgraph-constraints\"),q=Q(\"@dagrejs/graphlib\").Graph,z=Q(\"../util\");Z.exports=_;function _(M,R){if(R&&typeof R.customOrder===\"function\"){R.customOrder(M,_);return}let Y=z.maxRank(M),L=W(M,z.range(1,Y+1),\"inEdges\"),b=W(M,z.range(Y-1,-1,-1),\"outEdges\"),O=B(M);if(H(M,O),R&&R.disableOptimalOrderHeuristic)return;let A=Number.POSITIVE_INFINITY,D;for(let T=0,v=0;v<4;++T,++v){F(T%2?L:b,T%4>=2),O=z.buildLayerMatrix(M);let u=U(M,O);if(u<A)v=0,D=Object.assign({},O),A=u}H(M,D)}function W(M,R,Y){let L=new Map,b=(O,A)=>{if(!L.has(O))L.set(O,[]);L.get(O).push(A)};for(let O of M.nodes()){let A=M.node(O);if(typeof A.rank===\"number\")b(A.rank,O);if(typeof A.minRank===\"number\"&&typeof A.maxRank===\"number\"){for(let D=A.minRank;D<=A.maxRank;D++)if(D!==A.rank)b(D,O)}}return R.map(function(O){return P(M,O,Y,L.get(O)||[])})}function F(M,R){let Y=new q;M.forEach(function(L){let b=L.graph().root,O=j(L,b,Y,R);O.vs.forEach((A,D)=>L.node(A).order=D),G(L,Y,O.vs)})}function H(M,R){Object.values(R).forEach((Y)=>Y.forEach((L,b)=>M.node(L).order=b))}},{\"../util\":27,\"./add-subgraph-constraints\":11,\"./build-layer-graph\":13,\"./cross-count\":14,\"./init-order\":16,\"./sort-subgraph\":18,\"@dagrejs/graphlib\":29}],16:[function(Q,Z,V){let B=Q(\"../util\");Z.exports=U;function U(j){let P={},G=j.nodes().filter((H)=>!j.children(H).length),q=G.map((H)=>j.node(H).rank),z=B.applyWithChunking(Math.max,q),_=B.range(z+1).map(()=>[]);function W(H){if(P[H])return;P[H]=!0;let M=j.node(H);_[M.rank].push(H),j.successors(H).forEach(W)}return G.sort((H,M)=>j.node(H).rank-j.node(M).rank).forEach(W),_}},{\"../util\":27}],17:[function(Q,Z,V){let B=Q(\"../util\");Z.exports=U;function U(G,q){let z={};G.forEach((W,F)=>{let H=z[W.v]={indegree:0,in:[],out:[],vs:[W.v],i:F};if(W.barycenter!==void 0)H.barycenter=W.barycenter,H.weight=W.weight}),q.edges().forEach((W)=>{let F=z[W.v],H=z[W.w];if(F!==void 0&&H!==void 0)H.indegree++,F.out.push(z[W.w])});let _=Object.values(z).filter((W)=>!W.indegree);return j(_)}function j(G){let q=[];function z(W){return(F)=>{if(F.merged)return;if(F.barycenter===void 0||W.barycenter===void 0||F.barycenter>=W.barycenter)P(W,F)}}function _(W){return(F)=>{if(F.in.push(W),--F.indegree===0)G.push(F)}}while(G.length){let W=G.pop();q.push(W),W.in.reverse().forEach(z(W)),W.out.forEach(_(W))}return q.filter((W)=>!W.merged).map((W)=>{return B.pick(W,[\"vs\",\"i\",\"barycenter\",\"weight\"])})}function P(G,q){let z=0,_=0;if(G.weight)z+=G.barycenter*G.weight,_+=G.weight;if(q.weight)z+=q.barycenter*q.weight,_+=q.weight;G.vs=q.vs.concat(G.vs),G.barycenter=z/_,G.weight=_,G.i=Math.min(q.i,G.i),q.merged=!0}},{\"../util\":27}],18:[function(Q,Z,V){let B=Q(\"./barycenter\"),U=Q(\"./resolve-conflicts\"),j=Q(\"./sort\");Z.exports=P;function P(z,_,W,F){let H=z.children(_),M=z.node(_),R=M?M.borderLeft:void 0,Y=M?M.borderRight:void 0,L={};if(R)H=H.filter((D)=>D!==R&&D!==Y);let b=B(z,H);b.forEach((D)=>{if(z.children(D.v).length){let T=P(z,D.v,W,F);if(L[D.v]=T,Object.hasOwn(T,\"barycenter\"))q(D,T)}});let O=U(b,W);G(O,L);let A=j(O,F);if(R){if(A.vs=[R,A.vs,Y].flat(!0),z.predecessors(R).length){let D=z.node(z.predecessors(R)[0]),T=z.node(z.predecessors(Y)[0]);if(!Object.hasOwn(A,\"barycenter\"))A.barycenter=0,A.weight=0;A.barycenter=(A.barycenter*A.weight+D.order+T.order)/(A.weight+2),A.weight+=2}}return A}function G(z,_){z.forEach((W)=>{W.vs=W.vs.flatMap((F)=>{if(_[F])return _[F].vs;return F})})}function q(z,_){if(z.barycenter!==void 0)z.barycenter=(z.barycenter*z.weight+_.barycenter*_.weight)/(z.weight+_.weight),z.weight+=_.weight;else z.barycenter=_.barycenter,z.weight=_.weight}},{\"./barycenter\":12,\"./resolve-conflicts\":17,\"./sort\":19}],19:[function(Q,Z,V){let B=Q(\"../util\");Z.exports=U;function U(G,q){let z=B.partition(G,(L)=>{return Object.hasOwn(L,\"barycenter\")}),_=z.lhs,W=z.rhs.sort((L,b)=>b.i-L.i),F=[],H=0,M=0,R=0;_.sort(P(!!q)),R=j(F,W,R),_.forEach((L)=>{R+=L.vs.length,F.push(L.vs),H+=L.barycenter*L.weight,M+=L.weight,R=j(F,W,R)});let Y={vs:F.flat(!0)};if(M)Y.barycenter=H/M,Y.weight=M;return Y}function j(G,q,z){let _;while(q.length&&(_=q[q.length-1]).i<=z)q.pop(),G.push(_.vs),z++;return z}function P(G){return(q,z)=>{if(q.barycenter<z.barycenter)return-1;else if(q.barycenter>z.barycenter)return 1;return!G?q.i-z.i:z.i-q.i}}},{\"../util\":27}],20:[function(Q,Z,V){Z.exports=B;function B(P){let G=j(P);P.graph().dummyChains.forEach((q)=>{let z=P.node(q),_=z.edgeObj,W=U(P,G,_.v,_.w),F=W.path,H=W.lca,M=0,R=F[M],Y=!0;while(q!==_.w){if(z=P.node(q),Y){while((R=F[M])!==H&&P.node(R).maxRank<z.rank)M++;if(R===H)Y=!1}if(!Y){while(M<F.length-1&&P.node(R=F[M+1]).minRank<=z.rank)M++;R=F[M]}P.setParent(q,R),q=P.successors(q)[0]}})}function U(P,G,q,z){let _=[],W=[],F=Math.min(G[q].low,G[z].low),H=Math.max(G[q].lim,G[z].lim),M,R;M=q;do M=P.parent(M),_.push(M);while(M&&(G[M].low>F||H>G[M].lim));R=M,M=z;while((M=P.parent(M))!==R)W.push(M);return{path:_.concat(W.reverse()),lca:R}}function j(P){let G={},q=0;function z(_){let W=q;P.children(_).forEach(z),G[_]={low:W,lim:q++}}return P.children().forEach(z),G}},{}],21:[function(Q,Z,V){let B=Q(\"@dagrejs/graphlib\").Graph,U=Q(\"../util\");Z.exports={positionX:Y,findType1Conflicts:j,findType2Conflicts:P,addConflict:q,hasConflict:z,verticalAlignment:_,horizontalCompaction:W,alignCoordinates:M,findSmallestWidthAlignment:H,balance:R};function j(O,A){let D={};function T(v,u){let y=0,f=0,X=v.length,N=u[u.length-1];return u.forEach((S,w)=>{let I=G(O,S),d=I?O.node(I).order:X;if(I||S===N)u.slice(f,w+1).forEach((s)=>{O.predecessors(s).forEach((C)=>{let c=O.node(C),l=c.order;if((l<y||d<l)&&!(c.dummy&&O.node(s).dummy))q(D,C,s)})}),f=w+1,y=d}),u}return A.length&&A.reduce(T),D}function P(O,A){let D={};function T(u,y,f,X,N){let S;U.range(y,f).forEach((w)=>{if(S=u[w],O.node(S).dummy)O.predecessors(S).forEach((I)=>{let d=O.node(I);if(d.dummy&&(d.order<X||d.order>N))q(D,I,S)})})}function v(u,y){let f=-1,X,N=0;return y.forEach((S,w)=>{if(O.node(S).dummy===\"border\"){let I=O.predecessors(S);if(I.length)X=O.node(I[0]).order,T(y,N,w,f,X),N=w,f=X}T(y,N,y.length,X,u.length)}),y}return A.length&&A.reduce(v),D}function G(O,A){if(O.node(A).dummy)return O.predecessors(A).find((D)=>O.node(D).dummy)}function q(O,A,D){if(A>D){let v=A;A=D,D=v}let T=O[A];if(!T)O[A]=T={};T[D]=!0}function z(O,A,D){if(A>D){let T=A;A=D,D=T}return!!O[A]&&Object.hasOwn(O[A],D)}function _(O,A,D,T){let v={},u={},y={};return A.forEach((f)=>{f.forEach((X,N)=>{v[X]=X,u[X]=X,y[X]=N})}),A.forEach((f)=>{let X=-1;f.forEach((N)=>{let S=T(N);if(S.length){S=S.sort((I,d)=>y[I]-y[d]);let w=(S.length-1)/2;for(let I=Math.floor(w),d=Math.ceil(w);I<=d;++I){let s=S[I];if(u[N]===N&&X<y[s]&&!z(D,N,s))u[s]=N,u[N]=v[N]=v[s],X=y[s]}}})}),{root:v,align:u}}function W(O,A,D,T,v){let u={},y=F(O,A,D,v),f=v?\"borderLeft\":\"borderRight\";function X(w,I){let d=y.nodes(),s=d.pop(),C={};while(s){if(C[s])w(s);else C[s]=!0,d.push(s),d=d.concat(I(s));s=d.pop()}}function N(w){u[w]=y.inEdges(w).reduce((I,d)=>{return Math.max(I,u[d.v]+y.edge(d))},0)}function S(w){let I=y.outEdges(w).reduce((s,C)=>{return Math.min(s,u[C.w]-y.edge(C))},Number.POSITIVE_INFINITY),d=O.node(w);if(I!==Number.POSITIVE_INFINITY&&d.borderType!==f)u[w]=Math.max(u[w],I)}return X(N,y.predecessors.bind(y)),X(S,y.successors.bind(y)),Object.keys(T).forEach((w)=>u[w]=u[D[w]]),u}function F(O,A,D,T){let v=new B,u=O.graph(),y=L(u.nodesep,u.edgesep,T);return A.forEach((f)=>{let X;f.forEach((N)=>{let S=D[N];if(v.setNode(S),X){var w=D[X],I=v.edge(w,S);v.setEdge(w,S,Math.max(y(O,N,X),I||0))}X=N})}),v}function H(O,A){return Object.values(A).reduce((D,T)=>{let{NEGATIVE_INFINITY:v,POSITIVE_INFINITY:u}=Number;Object.entries(T).forEach(([f,X])=>{let N=b(O,f)/2;v=Math.max(X+N,v),u=Math.min(X-N,u)});let y=v-u;if(y<D[0])D=[y,T];return D},[Number.POSITIVE_INFINITY,null])[1]}function M(O,A){let D=Object.values(A),T=U.applyWithChunking(Math.min,D),v=U.applyWithChunking(Math.max,D);[\"u\",\"d\"].forEach((u)=>{[\"l\",\"r\"].forEach((y)=>{let f=u+y,X=O[f];if(X===A)return;let N=Object.values(X),S=T-U.applyWithChunking(Math.min,N);if(y!==\"l\")S=v-U.applyWithChunking(Math.max,N);if(S)O[f]=U.mapValues(X,(w)=>w+S)})})}function R(O,A){return U.mapValues(O.ul,(D,T)=>{if(A)return O[A.toLowerCase()][T];else{let v=Object.values(O).map((u)=>u[T]).sort((u,y)=>u-y);return(v[1]+v[2])/2}})}function Y(O){let A=U.buildLayerMatrix(O),D=Object.assign(j(O,A),P(O,A)),T={},v;[\"u\",\"d\"].forEach((y)=>{v=y===\"u\"?A:Object.values(A).reverse(),[\"l\",\"r\"].forEach((f)=>{if(f===\"r\")v=v.map((w)=>{return Object.values(w).reverse()});let X=(y===\"u\"?O.predecessors:O.successors).bind(O),N=_(O,v,D,X),S=W(O,v,N.root,N.align,f===\"r\");if(f===\"r\")S=U.mapValues(S,(w)=>-w);T[y+f]=S})});let u=H(O,T);return M(T,u),R(T,O.graph().align)}function L(O,A,D){return(T,v,u)=>{let y=T.node(v),f=T.node(u),X=0,N;if(X+=y.width/2,Object.hasOwn(y,\"labelpos\"))switch(y.labelpos.toLowerCase()){case\"l\":N=-y.width/2;break;case\"r\":N=y.width/2;break}if(N)X+=D?N:-N;if(N=0,X+=(y.dummy?A:O)/2,X+=(f.dummy?A:O)/2,X+=f.width/2,Object.hasOwn(f,\"labelpos\"))switch(f.labelpos.toLowerCase()){case\"l\":N=f.width/2;break;case\"r\":N=-f.width/2;break}if(N)X+=D?N:-N;return N=0,X}}function b(O,A){return O.node(A).width}},{\"../util\":27,\"@dagrejs/graphlib\":29}],22:[function(Q,Z,V){let B=Q(\"../util\"),U=Q(\"./bk\").positionX;Z.exports=j;function j(G){G=B.asNonCompoundGraph(G),P(G),Object.entries(U(G)).forEach(([q,z])=>G.node(q).x=z)}function P(G){let q=B.buildLayerMatrix(G),z=G.graph().ranksep,_=0;q.forEach((W)=>{let F=W.reduce((H,M)=>{let R=G.node(M).height;if(H>R)return H;else return R},0);W.forEach((H)=>G.node(H).y=_+F/2),_+=F+z})}},{\"../util\":27,\"./bk\":21}],23:[function(Q,Z,V){var B=Q(\"@dagrejs/graphlib\").Graph,U=Q(\"./util\").slack;Z.exports=j;function j(z){var _=new B({directed:!1}),W=z.nodes()[0],F=z.nodeCount();_.setNode(W,{});var H,M;while(P(_,z)<F)H=G(_,z),M=_.hasNode(H.v)?U(z,H):-U(z,H),q(_,z,M);return _}function P(z,_){function W(F){_.nodeEdges(F).forEach((H)=>{var M=H.v,R=F===M?H.w:M;if(!z.hasNode(R)&&!U(_,H))z.setNode(R,{}),z.setEdge(F,R,{}),W(R)})}return z.nodes().forEach(W),z.nodeCount()}function G(z,_){return _.edges().reduce((F,H)=>{let M=Number.POSITIVE_INFINITY;if(z.hasNode(H.v)!==z.hasNode(H.w))M=U(_,H);if(M<F[0])return[M,H];return F},[Number.POSITIVE_INFINITY,null])[1]}function q(z,_,W){z.nodes().forEach((F)=>_.node(F).rank+=W)}},{\"./util\":26,\"@dagrejs/graphlib\":29}],24:[function(Q,Z,V){var B=Q(\"./util\"),U=B.longestPath,j=Q(\"./feasible-tree\"),P=Q(\"./network-simplex\");Z.exports=G;function G(W){var F=W.graph().ranker;if(F instanceof Function)return F(W);switch(W.graph().ranker){case\"network-simplex\":_(W);break;case\"tight-tree\":z(W);break;case\"longest-path\":q(W);break;case\"none\":break;default:_(W)}}var q=U;function z(W){U(W),j(W)}function _(W){P(W)}},{\"./feasible-tree\":23,\"./network-simplex\":25,\"./util\":26}],25:[function(Q,Z,V){var B=Q(\"./feasible-tree\"),U=Q(\"./util\").slack,j=Q(\"./util\").longestPath,P=Q(\"@dagrejs/graphlib\").alg.preorder,G=Q(\"@dagrejs/graphlib\").alg.postorder,q=Q(\"../util\").simplify;Z.exports=z,z.initLowLimValues=H,z.initCutValues=_,z.calcCutValue=F,z.leaveEdge=R,z.enterEdge=Y,z.exchangeEdges=L;function z(D){D=q(D),j(D);var T=B(D);H(T),_(T,D);var v,u;while(v=R(T))u=Y(T,D,v),L(T,D,v,u)}function _(D,T){var v=G(D,D.nodes());v=v.slice(0,v.length-1),v.forEach((u)=>W(D,T,u))}function W(D,T,v){var u=D.node(v),y=u.parent;D.edge(v,y).cutvalue=F(D,T,v)}function F(D,T,v){var u=D.node(v),y=u.parent,f=!0,X=T.edge(v,y),N=0;if(!X)f=!1,X=T.edge(y,v);return N=X.weight,T.nodeEdges(v).forEach((S)=>{var w=S.v===v,I=w?S.w:S.v;if(I!==y){var d=w===f,s=T.edge(S).weight;if(N+=d?s:-s,O(D,v,I)){var C=D.edge(v,I).cutvalue;N+=d?-C:C}}}),N}function H(D,T){if(arguments.length<2)T=D.nodes()[0];M(D,{},1,T)}function M(D,T,v,u,y){var f=v,X=D.node(u);if(T[u]=!0,D.neighbors(u).forEach((N)=>{if(!Object.hasOwn(T,N))v=M(D,T,v,N,u)}),X.low=f,X.lim=v++,y)X.parent=y;else delete X.parent;return v}function R(D){return D.edges().find((T)=>D.edge(T).cutvalue<0)}function Y(D,T,v){var{v:u,w:y}=v;if(!T.hasEdge(u,y))u=v.w,y=v.v;var f=D.node(u),X=D.node(y),N=f,S=!1;if(f.lim>X.lim)N=X,S=!0;var w=T.edges().filter((I)=>{return S===A(D,D.node(I.v),N)&&S!==A(D,D.node(I.w),N)});return w.reduce((I,d)=>{if(U(T,d)<U(T,I))return d;return I})}function L(D,T,v,u){var{v:y,w:f}=v;D.removeEdge(y,f),D.setEdge(u.v,u.w,{}),H(D),_(D,T),b(D,T)}function b(D,T){var v=D.nodes().find((y)=>!T.node(y).parent),u=P(D,v);u=u.slice(1),u.forEach((y)=>{var f=D.node(y).parent,X=T.edge(y,f),N=!1;if(!X)X=T.edge(f,y),N=!0;T.node(y).rank=T.node(f).rank+(N?X.minlen:-X.minlen)})}function O(D,T,v){return D.hasEdge(T,v)}function A(D,T,v){return v.low<=T.lim&&T.lim<=v.lim}},{\"../util\":27,\"./feasible-tree\":23,\"./util\":26,\"@dagrejs/graphlib\":29}],26:[function(Q,Z,V){let{applyWithChunking:B}=Q(\"../util\");Z.exports={longestPath:U,slack:j};function U(P){var G={};function q(z){var _=P.node(z);if(Object.hasOwn(G,z))return _.rank;G[z]=!0;let W=P.outEdges(z).map((H)=>{if(H==null)return Number.POSITIVE_INFINITY;return q(H.w)-P.edge(H).minlen});var F=B(Math.min,W);if(F===Number.POSITIVE_INFINITY)F=0;return _.rank=F}P.sources().forEach(q)}function j(P,G){return P.node(G.w).rank-P.node(G.v).rank-P.edge(G).minlen}},{\"../util\":27}],27:[function(Q,Z,V){let B=Q(\"@dagrejs/graphlib\").Graph;Z.exports={addBorderNode:H,addDummyNode:U,applyWithChunking:Y,asNonCompoundGraph:P,buildLayerMatrix:_,intersectRect:z,mapValues:y,maxRank:L,normalizeRanks:W,notime:A,partition:b,pick:u,predecessorWeights:q,range:v,removeEmptyRanks:F,simplify:j,successorWeights:G,time:O,uniqueId:T,zipObject:f};function U(X,N,S,w){var I=w;while(X.hasNode(I))I=T(w);return S.dummy=N,X.setNode(I,S),I}function j(X){let N=new B().setGraph(X.graph());return X.nodes().forEach((S)=>N.setNode(S,X.node(S))),X.edges().forEach((S)=>{let w=N.edge(S.v,S.w)||{weight:0,minlen:1},I=X.edge(S);N.setEdge(S.v,S.w,{weight:w.weight+I.weight,minlen:Math.max(w.minlen,I.minlen)})}),N}function P(X){let N=new B({multigraph:X.isMultigraph()}).setGraph(X.graph());return X.nodes().forEach((S)=>{if(!X.children(S).length)N.setNode(S,X.node(S))}),X.edges().forEach((S)=>{N.setEdge(S,X.edge(S))}),N}function G(X){let N=X.nodes().map((S)=>{let w={};return X.outEdges(S).forEach((I)=>{w[I.w]=(w[I.w]||0)+X.edge(I).weight}),w});return f(X.nodes(),N)}function q(X){let N=X.nodes().map((S)=>{let w={};return X.inEdges(S).forEach((I)=>{w[I.v]=(w[I.v]||0)+X.edge(I).weight}),w});return f(X.nodes(),N)}function z(X,N){let{x:S,y:w}=X,I=N.x-S,d=N.y-w,s=X.width/2,C=X.height/2;if(!I&&!d)throw Error(\"Not possible to find intersection inside of the rectangle\");let c,l;if(Math.abs(d)*s>Math.abs(I)*C){if(d<0)C=-C;c=C*I/d,l=C}else{if(I<0)s=-s;c=s,l=s*d/I}return{x:S+c,y:w+l}}function _(X){let N=v(L(X)+1).map(()=>[]);return X.nodes().forEach((S)=>{let w=X.node(S),I=w.rank;if(I!==void 0)N[I][w.order]=S}),N}function W(X){let N=X.nodes().map((w)=>{let I=X.node(w).rank;if(I===void 0)return Number.MAX_VALUE;return I}),S=Y(Math.min,N);X.nodes().forEach((w)=>{let I=X.node(w);if(Object.hasOwn(I,\"rank\"))I.rank-=S})}function F(X){let N=X.nodes().map((s)=>X.node(s).rank),S=Y(Math.min,N),w=[];X.nodes().forEach((s)=>{let C=X.node(s).rank-S;if(!w[C])w[C]=[];w[C].push(s)});let I=0,d=X.graph().nodeRankFactor;Array.from(w).forEach((s,C)=>{if(s===void 0&&C%d!==0)--I;else if(s!==void 0&&I)s.forEach((c)=>X.node(c).rank+=I)})}function H(X,N,S,w){let I={width:0,height:0};if(arguments.length>=4)I.rank=S,I.order=w;return U(X,\"border\",I,N)}function M(X,N=R){let S=[];for(let w=0;w<X.length;w+=N){let I=X.slice(w,w+N);S.push(I)}return S}let R=65535;function Y(X,N){if(N.length>R){let S=M(N);return X.apply(null,S.map((w)=>X.apply(null,w)))}else return X.apply(null,N)}function L(X){let S=X.nodes().map((w)=>{let I=X.node(w).rank;if(I===void 0)return Number.MIN_VALUE;return I});return Y(Math.max,S)}function b(X,N){let S={lhs:[],rhs:[]};return X.forEach((w)=>{if(N(w))S.lhs.push(w);else S.rhs.push(w)}),S}function O(X,N){let S=Date.now();try{return N()}finally{console.log(X+\" time: \"+(Date.now()-S)+\"ms\")}}function A(X,N){return N()}let D=0;function T(X){var N=++D;return X+(\"\"+N)}function v(X,N,S=1){if(N==null)N=X,X=0;let w=(d)=>d<N;if(S<0)w=(d)=>N<d;let I=[];for(let d=X;w(d);d+=S)I.push(d);return I}function u(X,N){let S={};for(let w of N)if(X[w]!==void 0)S[w]=X[w];return S}function y(X,N){let S=N;if(typeof N===\"string\")S=(w)=>w[N];return Object.entries(X).reduce((w,[I,d])=>{return w[I]=S(d,I),w},{})}function f(X,N){return X.reduce((S,w,I)=>{return S[w]=N[I],S},{})}},{\"@dagrejs/graphlib\":29}],28:[function(Q,Z,V){Z.exports=\"1.1.8\"},{}],29:[function(Q,Z,V){var B=Q(\"./lib\");Z.exports={Graph:B.Graph,json:Q(\"./lib/json\"),alg:Q(\"./lib/alg\"),version:B.version}},{\"./lib\":45,\"./lib/alg\":36,\"./lib/json\":46}],30:[function(Q,Z,V){Z.exports=B;function B(U){var j={},P=[],G;function q(z){if(Object.hasOwn(j,z))return;j[z]=!0,G.push(z),U.successors(z).forEach(q),U.predecessors(z).forEach(q)}return U.nodes().forEach(function(z){if(G=[],q(z),G.length)P.push(G)}),P}},{}],31:[function(Q,Z,V){Z.exports=B;function B(G,q,z){if(!Array.isArray(q))q=[q];var _=G.isDirected()?(M)=>G.successors(M):(M)=>G.neighbors(M),W=z===\"post\"?U:j,F=[],H={};return q.forEach((M)=>{if(!G.hasNode(M))throw Error(\"Graph does not have node: \"+M);W(M,_,H,F)}),F}function U(G,q,z,_){var W=[[G,!1]];while(W.length>0){var F=W.pop();if(F[1])_.push(F[0]);else if(!Object.hasOwn(z,F[0]))z[F[0]]=!0,W.push([F[0],!0]),P(q(F[0]),(H)=>W.push([H,!1]))}}function j(G,q,z,_){var W=[G];while(W.length>0){var F=W.pop();if(!Object.hasOwn(z,F))z[F]=!0,_.push(F),P(q(F),(H)=>W.push(H))}}function P(G,q){var z=G.length;while(z--)q(G[z],z,G);return G}},{}],32:[function(Q,Z,V){var B=Q(\"./dijkstra\");Z.exports=U;function U(j,P,G){return j.nodes().reduce(function(q,z){return q[z]=B(j,z,P,G),q},{})}},{\"./dijkstra\":33}],33:[function(Q,Z,V){var B=Q(\"../data/priority-queue\");Z.exports=j;var U=()=>1;function j(G,q,z,_){return P(G,String(q),z||U,_||function(W){return G.outEdges(W)})}function P(G,q,z,_){var W={},F=new B,H,M,R=function(Y){var L=Y.v!==H?Y.v:Y.w,b=W[L],O=z(Y),A=M.distance+O;if(O<0)throw Error(\"dijkstra does not allow negative edge weights. Bad edge: \"+Y+\" Weight: \"+O);if(A<b.distance)b.distance=A,b.predecessor=H,F.decrease(L,A)};G.nodes().forEach(function(Y){var L=Y===q?0:Number.POSITIVE_INFINITY;W[Y]={distance:L},F.add(Y,L)});while(F.size()>0){if(H=F.removeMin(),M=W[H],M.distance===Number.POSITIVE_INFINITY)break;_(H).forEach(R)}return W}},{\"../data/priority-queue\":43}],34:[function(Q,Z,V){var B=Q(\"./tarjan\");Z.exports=U;function U(j){return B(j).filter(function(P){return P.length>1||P.length===1&&j.hasEdge(P[0],P[0])})}},{\"./tarjan\":41}],35:[function(Q,Z,V){Z.exports=U;var B=()=>1;function U(P,G,q){return j(P,G||B,q||function(z){return P.outEdges(z)})}function j(P,G,q){var z={},_=P.nodes();return _.forEach(function(W){z[W]={},z[W][W]={distance:0},_.forEach(function(F){if(W!==F)z[W][F]={distance:Number.POSITIVE_INFINITY}}),q(W).forEach(function(F){var H=F.v===W?F.w:F.v,M=G(F);z[W][H]={distance:M,predecessor:W}})}),_.forEach(function(W){var F=z[W];_.forEach(function(H){var M=z[H];_.forEach(function(R){var Y=M[W],L=F[R],b=M[R],O=Y.distance+L.distance;if(O<b.distance)b.distance=O,b.predecessor=L.predecessor})})}),z}},{}],36:[function(Q,Z,V){Z.exports={components:Q(\"./components\"),dijkstra:Q(\"./dijkstra\"),dijkstraAll:Q(\"./dijkstra-all\"),findCycles:Q(\"./find-cycles\"),floydWarshall:Q(\"./floyd-warshall\"),isAcyclic:Q(\"./is-acyclic\"),postorder:Q(\"./postorder\"),preorder:Q(\"./preorder\"),prim:Q(\"./prim\"),tarjan:Q(\"./tarjan\"),topsort:Q(\"./topsort\")}},{\"./components\":30,\"./dijkstra\":33,\"./dijkstra-all\":32,\"./find-cycles\":34,\"./floyd-warshall\":35,\"./is-acyclic\":37,\"./postorder\":38,\"./preorder\":39,\"./prim\":40,\"./tarjan\":41,\"./topsort\":42}],37:[function(Q,Z,V){var B=Q(\"./topsort\");Z.exports=U;function U(j){try{B(j)}catch(P){if(P instanceof B.CycleException)return!1;throw P}return!0}},{\"./topsort\":42}],38:[function(Q,Z,V){var B=Q(\"./dfs\");Z.exports=U;function U(j,P){return B(j,P,\"post\")}},{\"./dfs\":31}],39:[function(Q,Z,V){var B=Q(\"./dfs\");Z.exports=U;function U(j,P){return B(j,P,\"pre\")}},{\"./dfs\":31}],40:[function(Q,Z,V){var B=Q(\"../graph\"),U=Q(\"../data/priority-queue\");Z.exports=j;function j(P,G){var q=new B,z={},_=new U,W;function F(M){var R=M.v===W?M.w:M.v,Y=_.priority(R);if(Y!==void 0){var L=G(M);if(L<Y)z[R]=W,_.decrease(R,L)}}if(P.nodeCount()===0)return q;P.nodes().forEach(function(M){_.add(M,Number.POSITIVE_INFINITY),q.setNode(M)}),_.decrease(P.nodes()[0],0);var H=!1;while(_.size()>0){if(W=_.removeMin(),Object.hasOwn(z,W))q.setEdge(W,z[W]);else if(H)throw Error(\"Input graph is not connected: \"+P);else H=!0;P.nodeEdges(W).forEach(F)}return q}},{\"../data/priority-queue\":43,\"../graph\":44}],41:[function(Q,Z,V){Z.exports=B;function B(U){var j=0,P=[],G={},q=[];function z(_){var W=G[_]={onStack:!0,lowlink:j,index:j++};if(P.push(_),U.successors(_).forEach(function(M){if(!Object.hasOwn(G,M))z(M),W.lowlink=Math.min(W.lowlink,G[M].lowlink);else if(G[M].onStack)W.lowlink=Math.min(W.lowlink,G[M].index)}),W.lowlink===W.index){var F=[],H;do H=P.pop(),G[H].onStack=!1,F.push(H);while(_!==H);q.push(F)}}return U.nodes().forEach(function(_){if(!Object.hasOwn(G,_))z(_)}),q}},{}],42:[function(Q,Z,V){function B(j){var P={},G={},q=[];function z(_){if(Object.hasOwn(G,_))throw new U;if(!Object.hasOwn(P,_))G[_]=!0,P[_]=!0,j.predecessors(_).forEach(z),delete G[_],q.push(_)}if(j.sinks().forEach(z),Object.keys(P).length!==j.nodeCount())throw new U;return q}class U extends Error{constructor(){super(...arguments)}}Z.exports=B,B.CycleException=U},{}],43:[function(Q,Z,V){class B{_arr=[];_keyIndices={};size(){return this._arr.length}keys(){return this._arr.map(function(U){return U.key})}has(U){return Object.hasOwn(this._keyIndices,U)}priority(U){var j=this._keyIndices[U];if(j!==void 0)return this._arr[j].priority}min(){if(this.size()===0)throw Error(\"Queue underflow\");return this._arr[0].key}add(U,j){var P=this._keyIndices;if(U=String(U),!Object.hasOwn(P,U)){var G=this._arr,q=G.length;return P[U]=q,G.push({key:U,priority:j}),this._decrease(q),!0}return!1}removeMin(){this._swap(0,this._arr.length-1);var U=this._arr.pop();return delete this._keyIndices[U.key],this._heapify(0),U.key}decrease(U,j){var P=this._keyIndices[U];if(j>this._arr[P].priority)throw Error(\"New priority is greater than current priority. Key: \"+U+\" Old: \"+this._arr[P].priority+\" New: \"+j);this._arr[P].priority=j,this._decrease(P)}_heapify(U){var j=this._arr,P=2*U,G=P+1,q=U;if(P<j.length){if(q=j[P].priority<j[q].priority?P:q,G<j.length)q=j[G].priority<j[q].priority?G:q;if(q!==U)this._swap(U,q),this._heapify(q)}}_decrease(U){var j=this._arr,P=j[U].priority,G;while(U!==0){if(G=U>>1,j[G].priority<P)break;this._swap(U,G),U=G}}_swap(U,j){var P=this._arr,G=this._keyIndices,q=P[U],z=P[j];P[U]=z,P[j]=q,G[z.key]=U,G[q.key]=j}}Z.exports=B},{}],44:[function(Q,Z,V){var B=\"\\x00\",U=\"\\x00\",j=\"\\x01\";class P{_isDirected=!0;_isMultigraph=!1;_isCompound=!1;_label;_defaultNodeLabelFn=()=>{return};_defaultEdgeLabelFn=()=>{return};_nodes={};_in={};_preds={};_out={};_sucs={};_edgeObjs={};_edgeLabels={};_nodeCount=0;_edgeCount=0;_parent;_children;constructor(F){if(F)this._isDirected=Object.hasOwn(F,\"directed\")?F.directed:!0,this._isMultigraph=Object.hasOwn(F,\"multigraph\")?F.multigraph:!1,this._isCompound=Object.hasOwn(F,\"compound\")?F.compound:!1;if(this._isCompound)this._parent={},this._children={},this._children[U]={}}isDirected(){return this._isDirected}isMultigraph(){return this._isMultigraph}isCompound(){return this._isCompound}setGraph(F){return this._label=F,this}graph(){return this._label}setDefaultNodeLabel(F){if(this._defaultNodeLabelFn=F,typeof F!==\"function\")this._defaultNodeLabelFn=()=>F;return this}nodeCount(){return this._nodeCount}nodes(){return Object.keys(this._nodes)}sources(){var F=this;return this.nodes().filter((H)=>Object.keys(F._in[H]).length===0)}sinks(){var F=this;return this.nodes().filter((H)=>Object.keys(F._out[H]).length===0)}setNodes(F,H){var M=arguments,R=this;return F.forEach(function(Y){if(M.length>1)R.setNode(Y,H);else R.setNode(Y)}),this}setNode(F,H){if(Object.hasOwn(this._nodes,F)){if(arguments.length>1)this._nodes[F]=H;return this}if(this._nodes[F]=arguments.length>1?H:this._defaultNodeLabelFn(F),this._isCompound)this._parent[F]=U,this._children[F]={},this._children[U][F]=!0;return this._in[F]={},this._preds[F]={},this._out[F]={},this._sucs[F]={},++this._nodeCount,this}node(F){return this._nodes[F]}hasNode(F){return Object.hasOwn(this._nodes,F)}removeNode(F){var H=this;if(Object.hasOwn(this._nodes,F)){var M=(R)=>H.removeEdge(H._edgeObjs[R]);if(delete this._nodes[F],this._isCompound)this._removeFromParentsChildList(F),delete this._parent[F],this.children(F).forEach(function(R){H.setParent(R)}),delete this._children[F];Object.keys(this._in[F]).forEach(M),delete this._in[F],delete this._preds[F],Object.keys(this._out[F]).forEach(M),delete this._out[F],delete this._sucs[F],--this._nodeCount}return this}setParent(F,H){if(!this._isCompound)throw Error(\"Cannot set parent in a non-compound graph\");if(H===void 0)H=U;else{H+=\"\";for(var M=H;M!==void 0;M=this.parent(M))if(M===F)throw Error(\"Setting \"+H+\" as parent of \"+F+\" would create a cycle\");this.setNode(H)}return this.setNode(F),this._removeFromParentsChildList(F),this._parent[F]=H,this._children[H][F]=!0,this}_removeFromParentsChildList(F){delete this._children[this._parent[F]][F]}parent(F){if(this._isCompound){var H=this._parent[F];if(H!==U)return H}}children(F=U){if(this._isCompound){var H=this._children[F];if(H)return Object.keys(H)}else if(F===U)return this.nodes();else if(this.hasNode(F))return[]}predecessors(F){var H=this._preds[F];if(H)return Object.keys(H)}successors(F){var H=this._sucs[F];if(H)return Object.keys(H)}neighbors(F){var H=this.predecessors(F);if(H){let R=new Set(H);for(var M of this.successors(F))R.add(M);return Array.from(R.values())}}isLeaf(F){var H;if(this.isDirected())H=this.successors(F);else H=this.neighbors(F);return H.length===0}filterNodes(F){var H=new this.constructor({directed:this._isDirected,multigraph:this._isMultigraph,compound:this._isCompound});H.setGraph(this.graph());var M=this;Object.entries(this._nodes).forEach(function([L,b]){if(F(L))H.setNode(L,b)}),Object.values(this._edgeObjs).forEach(function(L){if(H.hasNode(L.v)&&H.hasNode(L.w))H.setEdge(L,M.edge(L))});var R={};function Y(L){var b=M.parent(L);if(b===void 0||H.hasNode(b))return R[L]=b,b;else if(b in R)return R[b];else return Y(b)}if(this._isCompound)H.nodes().forEach((L)=>H.setParent(L,Y(L)));return H}setDefaultEdgeLabel(F){if(this._defaultEdgeLabelFn=F,typeof F!==\"function\")this._defaultEdgeLabelFn=()=>F;return this}edgeCount(){return this._edgeCount}edges(){return Object.values(this._edgeObjs)}setPath(F,H){var M=this,R=arguments;return F.reduce(function(Y,L){if(R.length>1)M.setEdge(Y,L,H);else M.setEdge(Y,L);return L}),this}setEdge(){var F,H,M,R,Y=!1,L=arguments[0];if(typeof L===\"object\"&&L!==null&&\"v\"in L){if(F=L.v,H=L.w,M=L.name,arguments.length===2)R=arguments[1],Y=!0}else if(F=L,H=arguments[1],M=arguments[3],arguments.length>2)R=arguments[2],Y=!0;if(F=\"\"+F,H=\"\"+H,M!==void 0)M=\"\"+M;var b=z(this._isDirected,F,H,M);if(Object.hasOwn(this._edgeLabels,b)){if(Y)this._edgeLabels[b]=R;return this}if(M!==void 0&&!this._isMultigraph)throw Error(\"Cannot set a named edge when isMultigraph = false\");this.setNode(F),this.setNode(H),this._edgeLabels[b]=Y?R:this._defaultEdgeLabelFn(F,H,M);var O=_(this._isDirected,F,H,M);return F=O.v,H=O.w,Object.freeze(O),this._edgeObjs[b]=O,G(this._preds[H],F),G(this._sucs[F],H),this._in[H][b]=O,this._out[F][b]=O,this._edgeCount++,this}edge(F,H,M){var R=arguments.length===1?W(this._isDirected,arguments[0]):z(this._isDirected,F,H,M);return this._edgeLabels[R]}edgeAsObj(){let F=this.edge(...arguments);if(typeof F!==\"object\")return{label:F};return F}hasEdge(F,H,M){var R=arguments.length===1?W(this._isDirected,arguments[0]):z(this._isDirected,F,H,M);return Object.hasOwn(this._edgeLabels,R)}removeEdge(F,H,M){var R=arguments.length===1?W(this._isDirected,arguments[0]):z(this._isDirected,F,H,M),Y=this._edgeObjs[R];if(Y)F=Y.v,H=Y.w,delete this._edgeLabels[R],delete this._edgeObjs[R],q(this._preds[H],F),q(this._sucs[F],H),delete this._in[H][R],delete this._out[F][R],this._edgeCount--;return this}inEdges(F,H){var M=this._in[F];if(M){var R=Object.values(M);if(!H)return R;return R.filter((Y)=>Y.v===H)}}outEdges(F,H){var M=this._out[F];if(M){var R=Object.values(M);if(!H)return R;return R.filter((Y)=>Y.w===H)}}nodeEdges(F,H){var M=this.inEdges(F,H);if(M)return M.concat(this.outEdges(F,H))}}function G(F,H){if(F[H])F[H]++;else F[H]=1}function q(F,H){if(!--F[H])delete F[H]}function z(F,H,M,R){var Y=\"\"+H,L=\"\"+M;if(!F&&Y>L){var b=Y;Y=L,L=b}return Y+j+L+j+(R===void 0?B:R)}function _(F,H,M,R){var Y=\"\"+H,L=\"\"+M;if(!F&&Y>L){var b=Y;Y=L,L=b}var O={v:Y,w:L};if(R)O.name=R;return O}function W(F,H){return z(F,H.v,H.w,H.name)}Z.exports=P},{}],45:[function(Q,Z,V){Z.exports={Graph:Q(\"./graph\"),version:Q(\"./version\")}},{\"./graph\":44,\"./version\":47}],46:[function(Q,Z,V){var B=Q(\"./graph\");Z.exports={write:U,read:G};function U(q){var z={options:{directed:q.isDirected(),multigraph:q.isMultigraph(),compound:q.isCompound()},nodes:j(q),edges:P(q)};if(q.graph()!==void 0)z.value=structuredClone(q.graph());return z}function j(q){return q.nodes().map(function(z){var _=q.node(z),W=q.parent(z),F={v:z};if(_!==void 0)F.value=_;if(W!==void 0)F.parent=W;return F})}function P(q){return q.edges().map(function(z){var _=q.edge(z),W={v:z.v,w:z.w};if(z.name!==void 0)W.name=z.name;if(_!==void 0)W.value=_;return W})}function G(q){var z=new B(q.options).setGraph(q.value);return q.nodes.forEach(function(_){if(z.setNode(_.v,_.value),_.parent)z.setParent(_.v,_.parent)}),q.edges.forEach(function(_){z.setEdge({v:_.v,w:_.w,name:_.name},_.value)}),z}},{\"./graph\":44}],47:[function(Q,Z,V){Z.exports=\"2.2.4\"},{}]},{},[1])(1)})});var c4={bg:\"#FFFFFF\",fg:\"#27272A\"},A4={text:100,textSec:60,textMuted:40,textFaint:25,line:30,arrow:50,nodeFill:3,nodeStroke:20,groupHeader:5,innerStroke:12,keyBadge:10},O6={\"zinc-dark\":{bg:\"#18181B\",fg:\"#FAFAFA\"},\"tokyo-night\":{bg:\"#1a1b26\",fg:\"#a9b1d6\",line:\"#3d59a1\",accent:\"#7aa2f7\",muted:\"#565f89\"},\"tokyo-night-storm\":{bg:\"#24283b\",fg:\"#a9b1d6\",line:\"#3d59a1\",accent:\"#7aa2f7\",muted:\"#565f89\"},\"tokyo-night-light\":{bg:\"#d5d6db\",fg:\"#343b58\",line:\"#34548a\",accent:\"#34548a\",muted:\"#9699a3\"},\"catppuccin-mocha\":{bg:\"#1e1e2e\",fg:\"#cdd6f4\",line:\"#585b70\",accent:\"#cba6f7\",muted:\"#6c7086\"},\"catppuccin-latte\":{bg:\"#eff1f5\",fg:\"#4c4f69\",line:\"#9ca0b0\",accent:\"#8839ef\",muted:\"#9ca0b0\"},nord:{bg:\"#2e3440\",fg:\"#d8dee9\",line:\"#4c566a\",accent:\"#88c0d0\",muted:\"#616e88\"},\"nord-light\":{bg:\"#eceff4\",fg:\"#2e3440\",line:\"#aab1c0\",accent:\"#5e81ac\",muted:\"#7b88a1\"},dracula:{bg:\"#282a36\",fg:\"#f8f8f2\",line:\"#6272a4\",accent:\"#bd93f9\",muted:\"#6272a4\"},\"github-light\":{bg:\"#ffffff\",fg:\"#1f2328\",line:\"#d1d9e0\",accent:\"#0969da\",muted:\"#59636e\"},\"github-dark\":{bg:\"#0d1117\",fg:\"#e6edf3\",line:\"#3d444d\",accent:\"#4493f8\",muted:\"#9198a1\"},\"solarized-light\":{bg:\"#fdf6e3\",fg:\"#657b83\",line:\"#93a1a1\",accent:\"#268bd2\",muted:\"#93a1a1\"},\"solarized-dark\":{bg:\"#002b36\",fg:\"#839496\",line:\"#586e75\",accent:\"#268bd2\",muted:\"#586e75\"},\"one-dark\":{bg:\"#282c34\",fg:\"#abb2bf\",line:\"#4b5263\",accent:\"#c678dd\",muted:\"#5c6370\"}};function A6($){let J=$.colors??{},K=$.type===\"dark\",Q=(Z)=>$.tokenColors?.find((V)=>Array.isArray(V.scope)?V.scope.includes(Z):V.scope===Z)?.settings?.foreground;return{bg:J[\"editor.background\"]??(K?\"#1e1e1e\":\"#ffffff\"),fg:J[\"editor.foreground\"]??(K?\"#d4d4d4\":\"#333333\"),line:J[\"editorLineNumber.foreground\"]??void 0,accent:J.focusBorder??Q(\"keyword\")??void 0,muted:Q(\"comment\")??J[\"editorLineNumber.foreground\"]??void 0,surface:J[\"editor.selectionBackground\"]??void 0,border:J[\"editorWidget.border\"]??void 0}}function D4($,J){let K=[`@import url('https://fonts.googleapis.com/css2?family=${encodeURIComponent($)}:wght@400;500;600;700&amp;display=swap');`,...J?[\"@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&amp;display=swap');\"]:[]],Q=`\n    /* Derived from --bg and --fg (overridable via --line, --accent, etc.) */\n    --_text:          var(--fg);\n    --_text-sec:      var(--muted, color-mix(in srgb, var(--fg) ${A4.textSec}%, var(--bg)));\n    --_text-muted:    var(--muted, color-mix(in srgb, var(--fg) ${A4.textMuted}%, var(--bg)));\n    --_text-faint:    color-mix(in srgb, var(--fg) ${A4.textFaint}%, var(--bg));\n    --_line:          var(--line, color-mix(in srgb, var(--fg) ${A4.line}%, var(--bg)));\n    --_arrow:         var(--accent, color-mix(in srgb, var(--fg) ${A4.arrow}%, var(--bg)));\n    --_node-fill:     var(--surface, color-mix(in srgb, var(--fg) ${A4.nodeFill}%, var(--bg)));\n    --_node-stroke:   var(--border, color-mix(in srgb, var(--fg) ${A4.nodeStroke}%, var(--bg)));\n    --_group-fill:    var(--bg);\n    --_group-hdr:     color-mix(in srgb, var(--fg) ${A4.groupHeader}%, var(--bg));\n    --_inner-stroke:  color-mix(in srgb, var(--fg) ${A4.innerStroke}%, var(--bg));\n    --_key-badge:     color-mix(in srgb, var(--fg) ${A4.keyBadge}%, var(--bg));`;return[\"<style>\",`  ${K.join(`\n  `)}`,`  text { font-family: '${$}', system-ui, sans-serif; }`,...J?[\"  .mono { font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace; }\"]:[],`  svg {${Q}`,\"  }\",\"</style>\"].join(`\n`)}function Y4($,J,K,Q){let Z=[`--bg:${K.bg}`,`--fg:${K.fg}`,K.line?`--line:${K.line}`:\"\",K.accent?`--accent:${K.accent}`:\"\",K.muted?`--muted:${K.muted}`:\"\",K.surface?`--surface:${K.surface}`:\"\",K.border?`--border:${K.border}`:\"\"].filter(Boolean).join(\";\");return`<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 ${$} ${J}\" width=\"${$}\" height=\"${J}\" style=\"${Z}${Q?\"\":\";background:var(--bg)\"}\">`}function s4($){let J=$.split(/[\\n;]/).map((Q)=>Q.trim()).filter((Q)=>Q.length>0&&!Q.startsWith(\"%%\"));if(J.length===0)throw Error(\"Empty mermaid diagram\");let K=J[0];if(/^stateDiagram(-v2)?\\s*$/i.test(K))return Q9(J);return J9(J)}function J9($){let J=$[0].match(/^(?:graph|flowchart)\\s+(TD|TB|LR|BT|RL)\\s*$/i);if(!J)throw Error(`Invalid mermaid header: \"${$[0]}\". Expected \"graph TD\", \"flowchart LR\", \"stateDiagram-v2\", etc.`);let Q={direction:J[1].toUpperCase(),nodes:new Map,edges:[],subgraphs:[],classDefs:new Map,classAssignments:new Map,nodeStyles:new Map},Z=[];for(let V=1;V<$.length;V++){let B=$[V],U=B.match(/^classDef\\s+(\\w+)\\s+(.+)$/);if(U){let z=U[1],_=U[2],W=l6(_);Q.classDefs.set(z,W);continue}let j=B.match(/^class\\s+([\\w,-]+)\\s+(\\w+)$/);if(j){let z=j[1].split(\",\").map((W)=>W.trim()),_=j[2];for(let W of z)Q.classAssignments.set(W,_);continue}let P=B.match(/^style\\s+([\\w,-]+)\\s+(.+)$/);if(P){let z=P[1].split(\",\").map((W)=>W.trim()),_=l6(P[2]);for(let W of z)Q.nodeStyles.set(W,{...Q.nodeStyles.get(W),..._});continue}let G=B.match(/^direction\\s+(TD|TB|LR|BT|RL)\\s*$/i);if(G&&Z.length>0){Z[Z.length-1].direction=G[1].toUpperCase();continue}let q=B.match(/^subgraph\\s+(.+)$/);if(q){let z=q[1].trim(),_=z.match(/^([\\w-]+)\\s*\\[(.+)\\]$/),W,F;if(_)W=_[1],F=_[2];else F=z,W=z.replace(/\\s+/g,\"_\").replace(/[^\\w]/g,\"\");let H={id:W,label:F,nodeIds:[],children:[]};Z.push(H);continue}if(B===\"end\"){let z=Z.pop();if(z)if(Z.length>0)Z[Z.length-1].children.push(z);else Q.subgraphs.push(z);continue}z9(B,Q,Z)}return Q}function Q9($){let J={direction:\"TD\",nodes:new Map,edges:[],subgraphs:[],classDefs:new Map,classAssignments:new Map,nodeStyles:new Map},K=[],Q=0,Z=0;for(let V=1;V<$.length;V++){let B=$[V],U=B.match(/^direction\\s+(TD|TB|LR|BT|RL)\\s*$/i);if(U){if(K.length>0)K[K.length-1].direction=U[1].toUpperCase();else J.direction=U[1].toUpperCase();continue}let j=B.match(/^state\\s+(?:\"([^\"]+)\"\\s+as\\s+)?(\\w+)\\s*\\{$/);if(j){let z=j[1]??j[2],W={id:j[2],label:z,nodeIds:[],children:[]};K.push(W);continue}if(B===\"}\"){let z=K.pop();if(z)if(K.length>0)K[K.length-1].children.push(z);else J.subgraphs.push(z);continue}let P=B.match(/^state\\s+\"([^\"]+)\"\\s+as\\s+(\\w+)\\s*$/);if(P){let z=P[1],_=P[2];o4(J,K,{id:_,label:z,shape:\"rounded\"});continue}let G=B.match(/^(\\[\\*\\]|[\\w-]+)\\s*(-->)\\s*(\\[\\*\\]|[\\w-]+)(?:\\s*:\\s*(.+))?$/);if(G){let z=G[1],_=G[3],W=G[4]?.trim()||void 0;if(z===\"[*]\")Q++,z=`_start${Q>1?Q:\"\"}`,o4(J,K,{id:z,label:\"\",shape:\"state-start\"});else p6(J,K,z);if(_===\"[*]\")Z++,_=`_end${Z>1?Z:\"\"}`,o4(J,K,{id:_,label:\"\",shape:\"state-end\"});else p6(J,K,_);J.edges.push({source:z,target:_,label:W,style:\"solid\",hasArrowStart:!1,hasArrowEnd:!0});continue}let q=B.match(/^([\\w-]+)\\s*:\\s*(.+)$/);if(q){let z=q[1],_=q[2].trim();o4(J,K,{id:z,label:_,shape:\"rounded\"});continue}}return J}function o4($,J,K){if(!$.nodes.has(K.id))$.nodes.set(K.id,K);if(J.length>0){let Z=J[J.length-1];if(!Z.nodeIds.includes(K.id))Z.nodeIds.push(K.id)}}function p6($,J,K){if(!$.nodes.has(K))o4($,J,{id:K,label:K,shape:\"rounded\"});else if(J.length>0){let Q=J[J.length-1];if(!Q.nodeIds.includes(K))Q.nodeIds.push(K)}}function l6($){let J={};for(let K of $.split(\",\")){let Q=K.indexOf(\":\");if(Q>0){let Z=K.slice(0,Q).trim(),V=K.slice(Q+1).trim();if(Z&&V)J[Z]=V}}return J}var K9=/^(<)?(-->|-.->|==>|---|-\\.-|===)(?:\\|([^|]*)\\|)?/,Z9=[{regex:/^([\\w-]+)\\(\\(\\((.+?)\\)\\)\\)/,shape:\"doublecircle\"},{regex:/^([\\w-]+)\\(\\[(.+?)\\]\\)/,shape:\"stadium\"},{regex:/^([\\w-]+)\\(\\((.+?)\\)\\)/,shape:\"circle\"},{regex:/^([\\w-]+)\\[\\[(.+?)\\]\\]/,shape:\"subroutine\"},{regex:/^([\\w-]+)\\[\\((.+?)\\)\\]/,shape:\"cylinder\"},{regex:/^([\\w-]+)\\[\\/(.+?)\\\\\\]/,shape:\"trapezoid\"},{regex:/^([\\w-]+)\\[\\\\(.+?)\\/\\]/,shape:\"trapezoid-alt\"},{regex:/^([\\w-]+)>(.+?)\\]/,shape:\"asymmetric\"},{regex:/^([\\w-]+)\\{\\{(.+?)\\}\\}/,shape:\"hexagon\"},{regex:/^([\\w-]+)\\[(.+?)\\]/,shape:\"rectangle\"},{regex:/^([\\w-]+)\\((.+?)\\)/,shape:\"rounded\"},{regex:/^([\\w-]+)\\{(.+?)\\}/,shape:\"diamond\"}],U9=/^([\\w-]+)/,V9=/^:::([\\w][\\w-]*)/;function z9($,J,K){let Q=$.trim(),Z=d6(Q,J,K);if(!Z||Z.ids.length===0)return;Q=Z.remaining.trim();let V=Z.ids;while(Q.length>0){let B=Q.match(K9);if(!B)break;let U=Boolean(B[1]),j=B[2],P=B[3]?.trim()||void 0;Q=Q.slice(B[0].length).trim();let G=j9(j),q=j.endsWith(\">\"),z=d6(Q,J,K);if(!z||z.ids.length===0)break;Q=z.remaining.trim();for(let _ of V)for(let W of z.ids)J.edges.push({source:_,target:W,label:P,style:G,hasArrowStart:U,hasArrowEnd:q});V=z.ids}}function d6($,J,K){let Q=g6($,J,K);if(!Q)return null;let Z=[Q.id],V=Q.remaining.trim();while(V.startsWith(\"&\")){V=V.slice(1).trim();let B=g6(V,J,K);if(!B)break;Z.push(B.id),V=B.remaining.trim()}return{ids:Z,remaining:V}}function g6($,J,K){let Q=null,Z=$;for(let{regex:B,shape:U}of Z9){let j=$.match(B);if(j){Q=j[1];let P=j[2];n6(J,K,{id:Q,label:P,shape:U}),Z=$.slice(j[0].length);break}}if(Q===null){let B=$.match(U9);if(B){if(Q=B[1],!J.nodes.has(Q))n6(J,K,{id:Q,label:Q,shape:\"rectangle\"});else o6(K,Q);Z=$.slice(B[0].length)}}if(Q===null)return null;let V=Z.match(V9);if(V)J.classAssignments.set(Q,V[1]),Z=Z.slice(V[0].length);return{id:Q,remaining:Z}}function n6($,J,K){if(!$.nodes.has(K.id))$.nodes.set(K.id,K);o6(J,K.id)}function o6($,J){if($.length>0){let K=$[$.length-1];if(!K.nodeIds.includes(J))K.nodeIds.push(J)}}function j9($){if($===\"-.->\")return\"dotted\";if($===\"-.-\")return\"dotted\";if($===\"==>\")return\"thick\";if($===\"===\")return\"thick\";return\"solid\"}var j4={x:1,y:0},K4={x:1,y:2},B4={x:0,y:1},Z4={x:2,y:1},X4={x:2,y:0},R4={x:0,y:0},L4={x:2,y:2},I4={x:0,y:2},K6={x:1,y:1};function D6($,J){return $.x===J.x&&$.y===J.y}function s6($,J){return $.x===J.x&&$.y===J.y}function i4($,J){return{x:$.x+J.x,y:$.y+J.y}}function C4($){return`${$.x},${$.y}`}var i6={name:\"\",styles:{}};function F4($,J){let K=[];for(let Q=0;Q<=$;Q++){let Z=[];for(let V=0;V<=J;V++)Z.push(\" \");K.push(Z)}return K}function x4($){let[J,K]=a4($);return F4(J,K)}function a4($){return[$.length-1,($[0]?.length??1)-1]}function w4($,J,K){let[Q,Z]=a4($),V=Math.max(J,Q),B=Math.max(K,Z),U=F4(V,B);for(let j=0;j<U.length;j++)for(let P=0;P<U[0].length;P++)if(j<$.length&&P<$[0].length)U[j][P]=$[j][P];return $.length=0,$.push(...U),$}var B9=new Set([\"─\",\"│\",\"┌\",\"┐\",\"└\",\"┘\",\"├\",\"┤\",\"┬\",\"┴\",\"┼\",\"╴\",\"╵\",\"╶\",\"╷\"]);function a6($){return B9.has($)}var G9={\"─\":{\"│\":\"┼\",\"┌\":\"┬\",\"┐\":\"┬\",\"└\":\"┴\",\"┘\":\"┴\",\"├\":\"┼\",\"┤\":\"┼\",\"┬\":\"┬\",\"┴\":\"┴\"},\"│\":{\"─\":\"┼\",\"┌\":\"├\",\"┐\":\"┤\",\"└\":\"├\",\"┘\":\"┤\",\"├\":\"├\",\"┤\":\"┤\",\"┬\":\"┼\",\"┴\":\"┼\"},\"┌\":{\"─\":\"┬\",\"│\":\"├\",\"┐\":\"┬\",\"└\":\"├\",\"┘\":\"┼\",\"├\":\"├\",\"┤\":\"┼\",\"┬\":\"┬\",\"┴\":\"┼\"},\"┐\":{\"─\":\"┬\",\"│\":\"┤\",\"┌\":\"┬\",\"└\":\"┼\",\"┘\":\"┤\",\"├\":\"┼\",\"┤\":\"┤\",\"┬\":\"┬\",\"┴\":\"┼\"},\"└\":{\"─\":\"┴\",\"│\":\"├\",\"┌\":\"├\",\"┐\":\"┼\",\"┘\":\"┴\",\"├\":\"├\",\"┤\":\"┼\",\"┬\":\"┼\",\"┴\":\"┴\"},\"┘\":{\"─\":\"┴\",\"│\":\"┤\",\"┌\":\"┼\",\"┐\":\"┤\",\"└\":\"┴\",\"├\":\"┼\",\"┤\":\"┤\",\"┬\":\"┼\",\"┴\":\"┴\"},\"├\":{\"─\":\"┼\",\"│\":\"├\",\"┌\":\"├\",\"┐\":\"┼\",\"└\":\"├\",\"┘\":\"┼\",\"┤\":\"┼\",\"┬\":\"┼\",\"┴\":\"┼\"},\"┤\":{\"─\":\"┼\",\"│\":\"┤\",\"┌\":\"┼\",\"┐\":\"┤\",\"└\":\"┼\",\"┘\":\"┤\",\"├\":\"┼\",\"┬\":\"┼\",\"┴\":\"┼\"},\"┬\":{\"─\":\"┬\",\"│\":\"┼\",\"┌\":\"┬\",\"┐\":\"┬\",\"└\":\"┼\",\"┘\":\"┼\",\"├\":\"┼\",\"┤\":\"┼\",\"┴\":\"┼\"},\"┴\":{\"─\":\"┴\",\"│\":\"┼\",\"┌\":\"┼\",\"┐\":\"┼\",\"└\":\"┴\",\"┘\":\"┴\",\"├\":\"┼\",\"┤\":\"┼\",\"┬\":\"┼\"}};function P9($,J){return G9[$]?.[J]??$}function N4($,J,K,...Q){let[Z,V]=a4($);for(let U of Q){let[j,P]=a4(U);Z=Math.max(Z,j+J.x),V=Math.max(V,P+J.y)}let B=F4(Z,V);for(let U=0;U<=Z;U++)for(let j=0;j<=V;j++)if(U<$.length&&j<$[0].length)B[U][j]=$[U][j];for(let U of Q)for(let j=0;j<U.length;j++)for(let P=0;P<U[0].length;P++){let G=U[j][P];if(G!==\" \"){let q=j+J.x,z=P+J.y,_=B[q][z];if(!K&&a6(G)&&a6(_))B[q][z]=P9(_,G);else B[q][z]=G}}return B}function S4($){let[J,K]=a4($),Q=[];for(let Z=0;Z<=K;Z++){let V=\"\";for(let B=0;B<=J;B++)V+=$[B][Z];Q.push(V)}return Q.join(`\n`)}var F9={\"▲\":\"▼\",\"▼\":\"▲\",\"◤\":\"◣\",\"◣\":\"◤\",\"◥\":\"◢\",\"◢\":\"◥\",\"^\":\"v\",v:\"^\",\"┌\":\"└\",\"└\":\"┌\",\"┐\":\"┘\",\"┘\":\"┐\",\"┬\":\"┴\",\"┴\":\"┬\",\"╵\":\"╷\",\"╷\":\"╵\"};function r6($){for(let J of $)J.reverse();for(let J of $)for(let K=0;K<J.length;K++){let Q=F9[J[K]];if(Q)J[K]=Q}return $}function t6($,J,K){w4($,J.x+K.length,J.y);for(let Q=0;Q<K.length;Q++)$[J.x+Q][J.y]=K[Q]}function e6($,J,K){let Q=0,Z=0;for(let V of J.values())Q+=V;for(let V of K.values())Z+=V;w4($,Q-1,Z-1)}function $7($,J){let K=new Map,Q=0;for(let[U,j]of $.nodes){let P={name:U,displayLabel:j.label,index:Q,gridCoord:null,drawingCoord:null,drawing:null,drawn:!1,styleClassName:\"\",styleClass:i6};K.set(U,P),Q++}let Z=[...K.values()],V=[];for(let U of $.edges){let j=K.get(U.source),P=K.get(U.target);if(!j||!P)continue;V.push({from:j,to:P,text:U.label??\"\",path:[],labelLine:[],startDir:{x:0,y:0},endDir:{x:0,y:0}})}let B=[];for(let U of $.subgraphs)J7(U,null,K,B);_9($.subgraphs,B,K,$);for(let[U,j]of $.classAssignments){let P=K.get(U),G=$.classDefs.get(j);if(P&&G)P.styleClassName=j,P.styleClass={name:j,styles:G}}return{nodes:Z,edges:V,canvas:F4(0,0),grid:new Map,columnWidth:new Map,rowHeight:new Map,subgraphs:B,config:J,offsetX:0,offsetY:0}}function J7($,J,K,Q){let Z={name:$.label,nodes:[],parent:J,children:[],minX:0,minY:0,maxX:0,maxY:0};for(let V of $.nodeIds){let B=K.get(V);if(B)Z.nodes.push(B)}Q.push(Z);for(let V of $.children){let B=J7(V,Z,K,Q);Z.children.push(B);for(let U of B.nodes)if(!Z.nodes.includes(U))Z.nodes.push(U)}return Z}function _9($,J,K,Q){let Z=new Map;W9($,J,Z);let V=new Map;function B(U){let j=Z.get(U);if(!j)return;for(let P of U.children)B(P);for(let P of U.nodeIds)if(!V.has(P))V.set(P,j)}for(let U of $)B(U);for(let U of J)U.nodes=U.nodes.filter((j)=>{let P;for(let[q,z]of K)if(z===j){P=q;break}if(!P)return!1;let G=V.get(P);if(!G)return!0;return q9(U,G)})}function q9($,J){let K=J;while(K!==null){if(K===$)return!0;K=K.parent}return!1}function W9($,J,K){let Q=[];function Z(V){for(let B of V)Q.push(B),Z(B.children)}Z($);for(let V=0;V<Q.length&&V<J.length;V++)K.set(Q[V],J[V])}class Q7{items=[];get length(){return this.items.length}push($){this.items.push($),this.bubbleUp(this.items.length-1)}pop(){if(this.items.length===0)return;let $=this.items[0],J=this.items.pop();if(this.items.length>0)this.items[0]=J,this.sinkDown(0);return $}bubbleUp($){while($>0){let J=$-1>>1;if(this.items[$].priority<this.items[J].priority)[this.items[$],this.items[J]]=[this.items[J],this.items[$]],$=J;else break}}sinkDown($){let J=this.items.length;while(!0){let K=$,Q=2*$+1,Z=2*$+2;if(Q<J&&this.items[Q].priority<this.items[K].priority)K=Q;if(Z<J&&this.items[Z].priority<this.items[K].priority)K=Z;if(K!==$)[this.items[$],this.items[K]]=[this.items[K],this.items[$]],$=K;else break}}}function M9($,J){let K=Math.abs($.x-J.x),Q=Math.abs($.y-J.y);if(K===0||Q===0)return K+Q;return K+Q+1}var H9=[{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];function R9($,J){if(J.x<0||J.y<0)return!1;return!$.has(C4(J))}function Y6($,J,K){let Q=new Q7;Q.push({coord:J,priority:0});let Z=new Map;Z.set(C4(J),0);let V=new Map;V.set(C4(J),null);while(Q.length>0){let B=Q.pop().coord;if(D6(B,K)){let j=[],P=B;while(P!==null)j.unshift(P),P=V.get(C4(P))??null;return j}let U=Z.get(C4(B));for(let j of H9){let P={x:B.x+j.x,y:B.y+j.y};if(!R9($,P)&&!D6(P,K))continue;let G=U+1,q=C4(P),z=Z.get(q);if(z===void 0||G<z){Z.set(q,G);let _=G+M9(P,K);Q.push({coord:P,priority:_}),V.set(q,B)}}}return null}function X6($){if($.length<=2)return $;let J=new Set,K=$[0],Q=$[1];for(let Z=2;Z<$.length;Z++){let V=$[Z],B=Q.x-K.x,U=Q.y-K.y,j=V.x-Q.x,P=V.y-Q.y;if(B===j&&U===P)J.add(Z-1);K=Q,Q=V}return $.filter((Z,V)=>!J.has(V))}function Z6($){if($===j4)return K4;if($===K4)return j4;if($===B4)return Z4;if($===Z4)return B4;if($===X4)return I4;if($===R4)return L4;if($===L4)return R4;if($===I4)return X4;return K6}function i($,J){return $.x===J.x&&$.y===J.y}function f4($,J){if($.x===J.x)return $.y<J.y?K4:j4;else if($.y===J.y)return $.x<J.x?Z4:B4;else if($.x<J.x)return $.y<J.y?L4:X4;else return $.y<J.y?I4:R4}function O9($){if($===\"LR\")return[Z4,K4,K4,Z4];return[K4,Z4,Z4,K4]}function A9($,J){if($.from===$.to)return O9(J);let K=f4($.from.gridCoord,$.to.gridCoord),Q,Z,V,B,U=J===\"LR\"?i(K,B4)||i(K,R4)||i(K,I4):i(K,j4)||i(K,R4)||i(K,X4);if(i(K,L4))if(J===\"LR\")Q=K4,Z=B4,V=Z4,B=j4;else Q=Z4,Z=j4,V=K4,B=B4;else if(i(K,X4))if(J===\"LR\")Q=j4,Z=B4,V=Z4,B=K4;else Q=Z4,Z=K4,V=j4,B=B4;else if(i(K,I4))if(J===\"LR\")Q=K4,Z=K4,V=B4,B=j4;else Q=B4,Z=j4,V=K4,B=Z4;else if(i(K,R4))if(J===\"LR\")Q=K4,Z=K4,V=B4,B=K4;else Q=Z4,Z=Z4,V=j4,B=Z4;else if(U)if(J===\"LR\"&&i(K,B4))Q=K4,Z=K4,V=B4,B=Z4;else if(J===\"TD\"&&i(K,j4))Q=Z4,Z=Z4,V=j4,B=K4;else Q=K,Z=Z6(K),V=K,B=Z6(K);else Q=K,Z=Z6(K),V=K,B=Z6(K);return[Q,Z,V,B]}function K7($,J){let[K,Q,Z,V]=A9(J,$.config.graphDirection),B=i4(J.from.gridCoord,K),U=i4(J.to.gridCoord,Q),j=Y6($.grid,B,U);if(j===null){J.startDir=Z,J.endDir=V,J.path=[];return}j=X6(j);let P=i4(J.from.gridCoord,Z),G=i4(J.to.gridCoord,V),q=Y6($.grid,P,G);if(q===null){J.startDir=K,J.endDir=Q,J.path=j;return}if(q=X6(q),j.length<=q.length)J.startDir=K,J.endDir=Q,J.path=j;else J.startDir=Z,J.endDir=V,J.path=q}function Z7($,J){if(J.text.length===0)return;let K=J.text.length,Q=J.path[0],Z=[Q,J.path[1]],V=0;for(let G=1;G<J.path.length;G++){let q=J.path[G],z=[Q,q],_=D9($,z);if(_>=K){Z=z;break}else if(_>V)V=_,Z=z;Q=q}let B=Math.min(Z[0].x,Z[1].x),U=Math.max(Z[0].x,Z[1].x),j=B+Math.floor((U-B)/2),P=$.columnWidth.get(j)??0;$.columnWidth.set(j,Math.max(P,K+2)),J.labelLine=[Z[0],Z[1]]}function D9($,J){let K=0,Q=Math.min(J[0].x,J[1].x),Z=Math.max(J[0].x,J[1].x);for(let V=Q;V<=Z;V++)K+=$.columnWidth.get(V)??0;return K}function U7($,J){let K=$.gridCoord,Q=J.config.useAscii,Z=0;for(let z=0;z<2;z++)Z+=J.columnWidth.get(K.x+z)??0;let V=0;for(let z=0;z<2;z++)V+=J.rowHeight.get(K.y+z)??0;let B={x:0,y:0},U={x:Z,y:V},j=F4(Math.max(B.x,U.x),Math.max(B.y,U.y));if(!Q){for(let z=B.x+1;z<U.x;z++)j[z][B.y]=\"─\";for(let z=B.x+1;z<U.x;z++)j[z][U.y]=\"─\";for(let z=B.y+1;z<U.y;z++)j[B.x][z]=\"│\";for(let z=B.y+1;z<U.y;z++)j[U.x][z]=\"│\";j[B.x][B.y]=\"┌\",j[U.x][B.y]=\"┐\",j[B.x][U.y]=\"└\",j[U.x][U.y]=\"┘\"}else{for(let z=B.x+1;z<U.x;z++)j[z][B.y]=\"-\";for(let z=B.x+1;z<U.x;z++)j[z][U.y]=\"-\";for(let z=B.y+1;z<U.y;z++)j[B.x][z]=\"|\";for(let z=B.y+1;z<U.y;z++)j[U.x][z]=\"|\";j[B.x][B.y]=\"+\",j[U.x][B.y]=\"+\",j[B.x][U.y]=\"+\",j[U.x][U.y]=\"+\"}let P=$.displayLabel,G=B.y+Math.floor(V/2),q=B.x+Math.floor(Z/2)-Math.ceil(P.length/2)+1;for(let z=0;z<P.length;z++)j[q+z][G]=P[z];return j}function U6($,J,K=1){let Q=0;for(let Y of $)for(let L of Y)Q=Math.max(Q,L.length);let V=Q+2*K+2,B=0;for(let Y of $)B+=Math.max(Y.length,1);let U=$.length-1,j=B+U+2,P=J?\"-\":\"─\",G=J?\"|\":\"│\",q=J?\"+\":\"┌\",z=J?\"+\":\"┐\",_=J?\"+\":\"└\",W=J?\"+\":\"┘\",F=J?\"+\":\"├\",H=J?\"+\":\"┤\",M=F4(V-1,j-1);M[0][0]=q;for(let Y=1;Y<V-1;Y++)M[Y][0]=P;M[V-1][0]=z,M[0][j-1]=_;for(let Y=1;Y<V-1;Y++)M[Y][j-1]=P;M[V-1][j-1]=W;for(let Y=1;Y<j-1;Y++)M[0][Y]=G,M[V-1][Y]=G;let R=1;for(let Y=0;Y<$.length;Y++){let L=$[Y],b=L.length>0?L:[\"\"];for(let O of b){let A=1+K;for(let D=0;D<O.length;D++)M[A+D][R]=O[D];R++}if(Y<$.length-1){M[0][R]=F;for(let O=1;O<V-1;O++)M[O][R]=P;M[V-1][R]=H,R++}}return M}function Y9($,J,K,Q,Z,V){let B=f4(J,K),U=[],j=V?\"-\":\"─\",P=V?\"|\":\"│\",G=V?\"\\\\\":\"╲\",q=V?\"/\":\"╱\";if(i(B,j4))for(let z=J.y-Q;z>=K.y-Z;z--)U.push({x:J.x,y:z}),$[J.x][z]=P;else if(i(B,K4))for(let z=J.y+Q;z<=K.y+Z;z++)U.push({x:J.x,y:z}),$[J.x][z]=P;else if(i(B,B4))for(let z=J.x-Q;z>=K.x-Z;z--)U.push({x:z,y:J.y}),$[z][J.y]=j;else if(i(B,Z4))for(let z=J.x+Q;z<=K.x+Z;z++)U.push({x:z,y:J.y}),$[z][J.y]=j;else if(i(B,R4))for(let z=J.x,_=J.y-Q;z>=K.x-Z&&_>=K.y-Z;z--,_--)U.push({x:z,y:_}),$[z][_]=G;else if(i(B,X4))for(let z=J.x,_=J.y-Q;z<=K.x+Z&&_>=K.y-Z;z++,_--)U.push({x:z,y:_}),$[z][_]=q;else if(i(B,I4))for(let z=J.x,_=J.y+Q;z>=K.x-Z&&_<=K.y+Z;z--,_++)U.push({x:z,y:_}),$[z][_]=q;else if(i(B,L4))for(let z=J.x,_=J.y+Q;z<=K.x+Z&&_<=K.y+Z;z++,_++)U.push({x:z,y:_}),$[z][_]=G;return U}function X9($,J){if(J.path.length===0){let P=x4($.canvas);return[P,P,P,P,P]}let K=E9($,J),[Q,Z,V]=I9($,J.path),B=C9($,J.path,Z[0]),U=L9($,Z[Z.length-1],V[V.length-1]),j=N9($,J.path);return[Q,B,U,j,K]}function I9($,J){let K=x4($.canvas),Q=J[0],Z=[],V=[];for(let B=1;B<J.length;B++){let U=J[B],j=p4($,Q),P=p4($,U);if(s6(j,P)){Q=U;continue}let G=f4(Q,U),q=Y9(K,j,P,1,-1,$.config.useAscii);if(q.length===0)q.push(j);Z.push(q),V.push(G),Q=U}return[K,Z,V]}function C9($,J,K){let Q=x4($.canvas);if($.config.useAscii)return Q;let Z=K[0],V=f4(J[0],J[1]);if(i(V,j4))Q[Z.x][Z.y+1]=\"┴\";else if(i(V,K4))Q[Z.x][Z.y-1]=\"┬\";else if(i(V,B4))Q[Z.x+1][Z.y]=\"┤\";else if(i(V,Z4))Q[Z.x-1][Z.y]=\"├\";return Q}function L9($,J,K){let Q=x4($.canvas);if(J.length===0)return Q;let Z=J[0],V=J[J.length-1],B=f4(Z,V);if(J.length===1||i(B,K6))B=K;let U;if(!$.config.useAscii)if(i(B,j4))U=\"▲\";else if(i(B,K4))U=\"▼\";else if(i(B,B4))U=\"◄\";else if(i(B,Z4))U=\"►\";else if(i(B,X4))U=\"◥\";else if(i(B,R4))U=\"◤\";else if(i(B,L4))U=\"◢\";else if(i(B,I4))U=\"◣\";else if(i(K,j4))U=\"▲\";else if(i(K,K4))U=\"▼\";else if(i(K,B4))U=\"◄\";else if(i(K,Z4))U=\"►\";else if(i(K,X4))U=\"◥\";else if(i(K,R4))U=\"◤\";else if(i(K,L4))U=\"◢\";else if(i(K,I4))U=\"◣\";else U=\"●\";else if(i(B,j4))U=\"^\";else if(i(B,K4))U=\"v\";else if(i(B,B4))U=\"<\";else if(i(B,Z4))U=\">\";else if(i(K,j4))U=\"^\";else if(i(K,K4))U=\"v\";else if(i(K,B4))U=\"<\";else if(i(K,Z4))U=\">\";else U=\"*\";return Q[V.x][V.y]=U,Q}function N9($,J){let K=x4($.canvas);for(let Q=1;Q<J.length-1;Q++){let Z=J[Q],V=p4($,Z),B=f4(J[Q-1],Z),U=f4(Z,J[Q+1]),j;if(!$.config.useAscii)if(i(B,Z4)&&i(U,K4)||i(B,j4)&&i(U,B4))j=\"┐\";else if(i(B,Z4)&&i(U,j4)||i(B,K4)&&i(U,B4))j=\"┘\";else if(i(B,B4)&&i(U,K4)||i(B,j4)&&i(U,Z4))j=\"┌\";else if(i(B,B4)&&i(U,j4)||i(B,K4)&&i(U,Z4))j=\"└\";else j=\"+\";else j=\"+\";K[V.x][V.y]=j}return K}function E9($,J){let K=x4($.canvas);if(J.text.length===0)return K;let Q=z7($,J.labelLine);return T9(K,Q,J.text),K}function T9($,J,K){if(J.length<2)return;let Q=Math.min(J[0].x,J[1].x),Z=Math.max(J[0].x,J[1].x),V=Math.min(J[0].y,J[1].y),B=Math.max(J[0].y,J[1].y),U=Q+Math.floor((Z-Q)/2),j=V+Math.floor((B-V)/2),P=U-Math.floor(K.length/2);t6($,{x:P,y:j},K)}function k9($,J){let K=$.maxX-$.minX,Q=$.maxY-$.minY;if(K<=0||Q<=0)return F4(0,0);let Z={x:0,y:0},V={x:K,y:Q},B=F4(K,Q);if(!J.config.useAscii){for(let U=Z.x+1;U<V.x;U++)B[U][Z.y]=\"─\";for(let U=Z.x+1;U<V.x;U++)B[U][V.y]=\"─\";for(let U=Z.y+1;U<V.y;U++)B[Z.x][U]=\"│\";for(let U=Z.y+1;U<V.y;U++)B[V.x][U]=\"│\";B[Z.x][Z.y]=\"┌\",B[V.x][Z.y]=\"┐\",B[Z.x][V.y]=\"└\",B[V.x][V.y]=\"┘\"}else{for(let U=Z.x+1;U<V.x;U++)B[U][Z.y]=\"-\";for(let U=Z.x+1;U<V.x;U++)B[U][V.y]=\"-\";for(let U=Z.y+1;U<V.y;U++)B[Z.x][U]=\"|\";for(let U=Z.y+1;U<V.y;U++)B[V.x][U]=\"|\";B[Z.x][Z.y]=\"+\",B[V.x][Z.y]=\"+\",B[Z.x][V.y]=\"+\",B[V.x][V.y]=\"+\"}return B}function w9($,J){let K=$.maxX-$.minX,Q=$.maxY-$.minY;if(K<=0||Q<=0)return[F4(0,0),{x:0,y:0}];let Z=F4(K,Q),V=1,B=Math.floor(K/2)-Math.floor($.name.length/2);if(B<1)B=1;for(let U=0;U<$.name.length;U++)if(B+U<K)Z[B+U][V]=$.name[U];return[Z,{x:$.minX,y:$.minY}]}function S9($){function J(Q){return Q.parent===null?0:1+J(Q.parent)}let K=[...$];return K.sort((Q,Z)=>J(Q)-J(Z)),K}function V7($){let J=$.config.useAscii,K=S9($.subgraphs);for(let P of K){let G=k9(P,$),q={x:P.minX,y:P.minY};$.canvas=N4($.canvas,q,J,G)}for(let P of $.nodes)if(!P.drawn&&P.drawingCoord&&P.drawing)$.canvas=N4($.canvas,P.drawingCoord,J,P.drawing),P.drawn=!0;let Q=[],Z=[],V=[],B=[],U=[];for(let P of $.edges){let[G,q,z,_,W]=X9($,P);Q.push(G),Z.push(_),V.push(z),B.push(q),U.push(W)}let j={x:0,y:0};$.canvas=N4($.canvas,j,J,...Q),$.canvas=N4($.canvas,j,J,...Z),$.canvas=N4($.canvas,j,J,...V),$.canvas=N4($.canvas,j,J,...B),$.canvas=N4($.canvas,j,J,...U);for(let P of $.subgraphs){if(P.nodes.length===0)continue;let[G,q]=w9(P,$);$.canvas=N4($.canvas,q,J,G)}return $.canvas}function p4($,J,K){let Q=K?{x:J.x+K.x,y:J.y+K.y}:J,Z=0;for(let j=0;j<Q.x;j++)Z+=$.columnWidth.get(j)??0;let V=0;for(let j=0;j<Q.y;j++)V+=$.rowHeight.get(j)??0;let B=$.columnWidth.get(Q.x)??0,U=$.rowHeight.get(Q.y)??0;return{x:Z+Math.floor(B/2)+$.offsetX,y:V+Math.floor(U/2)+$.offsetY}}function z7($,J){return J.map((K)=>p4($,K))}function r4($,J,K){if($.grid.has(C4(K)))if($.config.graphDirection===\"LR\")return r4($,J,{x:K.x,y:K.y+4});else return r4($,J,{x:K.x+4,y:K.y});for(let Q=0;Q<3;Q++)for(let Z=0;Z<3;Z++){let V={x:K.x+Q,y:K.y+Z};$.grid.set(C4(V),J)}return J.gridCoord=K,K}function f9($,J){let K=J.gridCoord,Q=$.config.boxBorderPadding,Z=[1,2*Q+J.displayLabel.length,1],V=[1,1+2*Q,1];for(let B=0;B<Z.length;B++){let U=K.x+B,j=$.columnWidth.get(U)??0;$.columnWidth.set(U,Math.max(j,Z[B]))}for(let B=0;B<V.length;B++){let U=K.y+B,j=$.rowHeight.get(U)??0;$.rowHeight.set(U,Math.max(j,V[B]))}if(K.x>0){let B=$.columnWidth.get(K.x-1)??0;$.columnWidth.set(K.x-1,Math.max(B,$.config.paddingX))}if(K.y>0){let B=$.config.paddingY;if(y9($,J))B+=4;let U=$.rowHeight.get(K.y-1)??0;$.rowHeight.set(K.y-1,Math.max(U,B))}}function b9($,J){for(let K of J){if(!$.columnWidth.has(K.x))$.columnWidth.set(K.x,Math.floor($.config.paddingX/2));if(!$.rowHeight.has(K.y))$.rowHeight.set(K.y,Math.floor($.config.paddingY/2))}}function I6($,J){return $.subgraphs.some((K)=>K.nodes.includes(J))}function C6($,J){for(let K of $.subgraphs)if(K.nodes.includes(J))return K;return null}function y9($,J){let K=C6($,J);if(!K)return!1;let Q=!1;for(let Z of $.edges)if(Z.to===J){if(C6($,Z.from)!==K){Q=!0;break}}if(!Q)return!1;for(let Z of K.nodes){if(Z===J||!Z.gridCoord)continue;let V=!1;for(let B of $.edges)if(B.to===Z){if(C6($,B.from)!==K){V=!0;break}}if(V&&Z.gridCoord.y<J.gridCoord.y)return!1}return!0}function j7($,J){if(J.nodes.length===0)return;let K=1e6,Q=1e6,Z=-1e6,V=-1e6;for(let j of J.children)if(j7($,j),j.nodes.length>0)K=Math.min(K,j.minX),Q=Math.min(Q,j.minY),Z=Math.max(Z,j.maxX),V=Math.max(V,j.maxY);for(let j of J.nodes){if(!j.drawingCoord||!j.drawing)continue;let P=j.drawingCoord.x,G=j.drawingCoord.y,q=P+j.drawing.length-1,z=G+j.drawing[0].length-1;K=Math.min(K,P),Q=Math.min(Q,G),Z=Math.max(Z,q),V=Math.max(V,z)}let B=2,U=2;J.minX=K-B,J.minY=Q-B-U,J.maxX=Z+B,J.maxY=V+B}function x9($){let K=$.subgraphs.filter((Q)=>Q.parent===null&&Q.nodes.length>0);for(let Q=0;Q<K.length;Q++)for(let Z=Q+1;Z<K.length;Z++){let V=K[Q],B=K[Z];if(V.minX<B.maxX&&V.maxX>B.minX){if(V.maxY>=B.minY-1&&V.minY<B.minY)B.minY=V.maxY+1+1;else if(B.maxY>=V.minY-1&&B.minY<V.minY)V.minY=B.maxY+1+1}if(V.minY<B.maxY&&V.maxY>B.minY){if(V.maxX>=B.minX-1&&V.minX<B.minX)B.minX=V.maxX+1+1;else if(B.maxX>=V.minX-1&&B.minX<V.minX)V.minX=B.maxX+1+1}}}function v9($){for(let J of $.subgraphs)j7($,J);x9($)}function u9($){if($.subgraphs.length===0)return;let J=0,K=0;for(let V of $.subgraphs)J=Math.min(J,V.minX),K=Math.min(K,V.minY);let Q=-J,Z=-K;if(Q===0&&Z===0)return;$.offsetX=Q,$.offsetY=Z;for(let V of $.subgraphs)V.minX+=Q,V.minY+=Z,V.maxX+=Q,V.maxY+=Z;for(let V of $.nodes)if(V.drawingCoord)V.drawingCoord.x+=Q,V.drawingCoord.y+=Z}function B7($){let J=$.config.graphDirection,K=Array(100).fill(0),Q=new Set,Z=[];for(let G of $.nodes){if(!Q.has(G.name))Z.push(G);Q.add(G.name);for(let q of L6($,G))Q.add(q.name)}let V=!1,B=!1;for(let G of Z)if(I6($,G)){if(L6($,G).length>0)B=!0}else V=!0;let U=J===\"LR\"&&V&&B,j,P=[];if(U)j=Z.filter((G)=>!I6($,G)),P=Z.filter((G)=>I6($,G));else j=Z;for(let G of j){let q=J===\"LR\"?{x:0,y:K[0]}:{x:K[0],y:0};r4($,$.nodes[G.index],q),K[0]=K[0]+4}if(U&&P.length>0)for(let q of P){let z=J===\"LR\"?{x:4,y:K[4]}:{x:K[4],y:4};r4($,$.nodes[q.index],z),K[4]=K[4]+4}for(let G of $.nodes){let q=G.gridCoord,z=J===\"LR\"?q.x+4:q.y+4,_=K[z];for(let W of L6($,G)){if(W.gridCoord!==null)continue;let F=J===\"LR\"?{x:z,y:_}:{x:_,y:z};r4($,$.nodes[W.index],F),K[z]=_+4,_=K[z]}}for(let G of $.nodes)f9($,G);for(let G of $.edges)K7($,G),b9($,G.path),Z7($,G);for(let G of $.nodes)G.drawingCoord=p4($,G.gridCoord),G.drawing=U7(G,$);e6($.canvas,$.columnWidth,$.rowHeight),v9($),u9($)}function m9($,J){return $.edges.filter((K)=>K.from.name===J.name)}function L6($,J){return m9($,J).map((K)=>K.to)}function V6($){let J={actors:[],messages:[],blocks:[],notes:[]},K=new Set,Q=[];for(let Z=1;Z<$.length;Z++){let V=$[Z],B=V.match(/^(participant|actor)\\s+(\\S+?)(?:\\s+as\\s+(.+))?$/);if(B){let z=B[1],_=B[2],W=B[3]?.trim()??_;if(!K.has(_))K.add(_),J.actors.push({id:_,label:W,type:z});continue}let U=V.match(/^Note\\s+(left of|right of|over)\\s+([^:]+):\\s*(.+)$/i);if(U){let z=U[1].toLowerCase(),_=U[2].trim(),W=U[3].trim(),F=_.split(\",\").map((M)=>M.trim());for(let M of F)t4(J,K,M);let H=\"over\";if(z===\"left of\")H=\"left\";else if(z===\"right of\")H=\"right\";J.notes.push({actorIds:F,text:W,position:H,afterIndex:J.messages.length-1});continue}let j=V.match(/^(loop|alt|opt|par|critical|break|rect)\\s*(.*)$/);if(j){let z=j[1],_=j[2]?.trim()??\"\";Q.push({type:z,label:_,startIndex:J.messages.length,dividers:[]});continue}let P=V.match(/^(else|and)\\s*(.*)$/);if(P&&Q.length>0){let z=P[2]?.trim()??\"\";Q[Q.length-1].dividers.push({index:J.messages.length,label:z});continue}if(V===\"end\"&&Q.length>0){let z=Q.pop();J.blocks.push({type:z.type,label:z.label,startIndex:z.startIndex,endIndex:Math.max(J.messages.length-1,z.startIndex),dividers:z.dividers});continue}let G=V.match(/^(\\S+?)\\s*(--?>?>|--?[)x]|--?>>|--?>)\\s*([+-]?)(\\S+?)\\s*:\\s*(.+)$/);if(G){let z=G[1],_=G[2],W=G[3],F=G[4],H=G[5].trim();t4(J,K,z),t4(J,K,F);let M=_.startsWith(\"--\")?\"dashed\":\"solid\",R=_.includes(\">>\")||_.includes(\"x\")?\"filled\":\"open\",Y={from:z,to:F,label:H,lineStyle:M,arrowHead:R};if(W===\"+\")Y.activate=!0;if(W===\"-\")Y.deactivate=!0;J.messages.push(Y);continue}let q=V.match(/^(\\S+?)\\s*(->>|-->>|-\\)|--\\)|-x|--x|->|-->)\\s*([+-]?)(\\S+?)\\s*:\\s*(.+)$/);if(q){let z=q[1],_=q[2],W=q[3],F=q[4],H=q[5].trim();t4(J,K,z),t4(J,K,F);let M=_.startsWith(\"--\")?\"dashed\":\"solid\",R=_.includes(\">>\")||_.includes(\"x\")?\"filled\":\"open\",Y={from:z,to:F,label:H,lineStyle:M,arrowHead:R};if(W===\"+\")Y.activate=!0;if(W===\"-\")Y.deactivate=!0;J.messages.push(Y);continue}}return J}function t4($,J,K){if(!J.has(K))J.add(K),$.actors.push({id:K,label:K,type:\"participant\"})}function G7($,J){let K=$.split(`\n`).map((C)=>C.trim()).filter((C)=>C.length>0&&!C.startsWith(\"%%\")),Q=V6(K);if(Q.actors.length===0)return\"\";let Z=J.useAscii,V=Z?\"-\":\"─\",B=Z?\"|\":\"│\",U=Z?\"+\":\"┌\",j=Z?\"+\":\"┐\",P=Z?\"+\":\"└\",G=Z?\"+\":\"┘\",q=Z?\"+\":\"┬\",z=Z?\"+\":\"┴\",_=Z?\"+\":\"├\",W=Z?\"+\":\"┤\",F=new Map;Q.actors.forEach((C,c)=>F.set(C.id,c));let H=1,R=Q.actors.map((C)=>C.label.length+2*H+2).map((C)=>Math.ceil(C/2)),Y=3,L=Array(Math.max(Q.actors.length-1,0)).fill(0);for(let C of Q.messages){let c=F.get(C.from),l=F.get(C.to);if(c===l)continue;let p=Math.min(c,l),h=Math.max(c,l),m=C.label.length+4,g=h-p,o=Math.ceil(m/g);for(let E=p;E<h;E++)L[E]=Math.max(L[E],o)}let b=[R[0]];for(let C=1;C<Q.actors.length;C++){let c=Math.max(R[C-1]+R[C]+2,L[C-1]+2,10);b[C]=b[C-1]+c}let O=[],A=[],D=new Map,T=new Map,v=new Map,u=[],y=Y;for(let C=0;C<Q.messages.length;C++){for(let p=0;p<Q.blocks.length;p++)if(Q.blocks[p].startIndex===C)y+=2,D.set(p,y-1);for(let p=0;p<Q.blocks.length;p++)for(let h=0;h<Q.blocks[p].dividers.length;h++)if(Q.blocks[p].dividers[h].index===C)y+=1,v.set(`${p}:${h}`,y),y+=1;y+=1;let c=Q.messages[C];if(c.from===c.to)A[C]=y+1,O[C]=y,y+=3;else A[C]=y,O[C]=y+1,y+=2;for(let p=0;p<Q.notes.length;p++)if(Q.notes[p].afterIndex===C){y+=1;let h=Q.notes[p],m=h.text.split(\"\\\\n\"),g=Math.max(...m.map((x)=>x.length))+4,o=m.length+2,E=F.get(h.actorIds[0])??0,k;if(h.position===\"left\")k=b[E]-g-1;else if(h.position===\"right\")k=b[E]+2;else if(h.actorIds.length>=2){let x=F.get(h.actorIds[1])??E;k=Math.floor((b[E]+b[x])/2)-Math.floor(g/2)}else k=b[E]-Math.floor(g/2);k=Math.max(0,k),u.push({x:k,y,width:g,height:o,lines:m}),y+=o}for(let p=0;p<Q.blocks.length;p++)if(Q.blocks[p].endIndex===C)y+=1,T.set(p,y),y+=1}y+=1;let f=y,X=f+Y,N=b[b.length-1]??0,S=R[R.length-1]??0,w=N+S+2;for(let C=0;C<Q.messages.length;C++){let c=Q.messages[C];if(c.from===c.to){let l=F.get(c.from),p=b[l]+6+2+c.label.length;w=Math.max(w,p+1)}}for(let C of u)w=Math.max(w,C.x+C.width+1);let I=F4(w,X-1);function d(C,c,l){let p=l.length+2*H+2,h=C-Math.floor(p/2);I[h][c]=U;for(let g=1;g<p-1;g++)I[h+g][c]=V;I[h+p-1][c]=j,I[h][c+1]=B,I[h+p-1][c+1]=B;let m=h+1+H;for(let g=0;g<l.length;g++)I[m+g][c+1]=l[g];I[h][c+2]=P;for(let g=1;g<p-1;g++)I[h+g][c+2]=V;I[h+p-1][c+2]=G}for(let C=0;C<Q.actors.length;C++){let c=b[C];for(let l=Y;l<=f;l++)I[c][l]=B}for(let C=0;C<Q.actors.length;C++){let c=Q.actors[C];if(d(b[C],0,c.label),d(b[C],f,c.label),!Z)I[b[C]][Y-1]=q,I[b[C]][f]=z}for(let C=0;C<Q.messages.length;C++){let c=Q.messages[C],l=F.get(c.from),p=F.get(c.to),h=b[l],m=b[p],g=l===p,o=c.lineStyle===\"dashed\",E=c.arrowHead===\"filled\",k=o?Z?\".\":\"╌\":V;if(g){let x=O[C],n=Math.max(4,4);I[h][x]=_;for(let e=h+1;e<h+n;e++)I[e][x]=k;I[h+n][x]=Z?\"+\":\"┐\",I[h+n][x+1]=B;let t=h+n+2;for(let e=0;e<c.label.length;e++)if(t+e<w)I[t+e][x+1]=c.label[e];let J4=E?Z?\"<\":\"◀\":Z?\"<\":\"◁\";I[h][x+2]=J4;for(let e=h+1;e<h+n;e++)I[e][x+2]=k;I[h+n][x+2]=Z?\"+\":\"┘\"}else{let x=A[C],n=O[C],t=h<m,e=Math.floor((h+m)/2)-Math.floor(c.label.length/2);for(let G4=0;G4<c.label.length;G4++){let q4=e+G4;if(q4>=0&&q4<w)I[q4][x]=c.label[G4]}if(t){for(let q4=h+1;q4<m;q4++)I[q4][n]=k;let G4=E?Z?\">\":\"▶\":Z?\">\":\"▷\";I[m][n]=G4}else{for(let q4=m+1;q4<h;q4++)I[q4][n]=k;let G4=E?Z?\"<\":\"◀\":Z?\"<\":\"◁\";I[m][n]=G4}}}for(let C=0;C<Q.blocks.length;C++){let c=Q.blocks[C],l=D.get(C),p=T.get(C);if(l===void 0||p===void 0)continue;let h=w,m=0;for(let k=c.startIndex;k<=c.endIndex;k++){if(k>=Q.messages.length)break;let x=Q.messages[k],n=F.get(x.from)??0,t=F.get(x.to)??0;h=Math.min(h,b[Math.min(n,t)]),m=Math.max(m,b[Math.max(n,t)])}let g=Math.max(0,h-4),o=Math.min(w-1,m+4);I[g][l]=U;for(let k=g+1;k<o;k++)I[k][l]=V;I[o][l]=j;let E=c.label?`${c.type} [${c.label}]`:c.type;for(let k=0;k<E.length&&g+1+k<o;k++)I[g+1+k][l]=E[k];I[g][p]=P;for(let k=g+1;k<o;k++)I[k][p]=V;I[o][p]=G;for(let k=l+1;k<p;k++)I[g][k]=B,I[o][k]=B;for(let k=0;k<c.dividers.length;k++){let x=v.get(`${C}:${k}`);if(x===void 0)continue;let n=s();I[g][x]=_;for(let J4=g+1;J4<o;J4++)I[J4][x]=n;I[o][x]=W;let t=c.dividers[k].label;if(t){let J4=`[${t}]`;for(let e=0;e<J4.length&&g+1+e<o;e++)I[g+1+e][x]=J4[e]}}}for(let C of u){w4(I,C.x+C.width,C.y+C.height),I[C.x][C.y]=U;for(let l=1;l<C.width-1;l++)I[C.x+l][C.y]=V;I[C.x+C.width-1][C.y]=j;for(let l=0;l<C.lines.length;l++){let p=C.y+1+l;I[C.x][p]=B,I[C.x+C.width-1][p]=B;for(let h=0;h<C.lines[l].length;h++)I[C.x+2+h][p]=C.lines[l][h]}let c=C.y+C.height-1;I[C.x][c]=P;for(let l=1;l<C.width-1;l++)I[C.x+l][c]=V;I[C.x+C.width-1][c]=G}return S4(I);function s(){return Z?\"-\":\"╌\"}}function z6($){let J={classes:[],relationships:[],namespaces:[]},K=new Map,Q=null,Z=null,V=0;for(let B=1;B<$.length;B++){let U=$[B];if(Z&&V>0){if(U===\"}\"){if(V--,V===0)Z=null;continue}let W=U.match(/^<<(\\w+)>>$/);if(W){Z.annotation=W[1];continue}let F=P7(U);if(F)if(F.isMethod)Z.methods.push(F.member);else Z.attributes.push(F.member);continue}let j=U.match(/^namespace\\s+(\\S+)\\s*\\{$/);if(j){Q={name:j[1],classIds:[]};continue}if(U===\"}\"&&Q){J.namespaces.push(Q),Q=null;continue}let P=U.match(/^class\\s+(\\S+?)(?:\\s*~(\\w+)~)?\\s*\\{$/);if(P){let W=P[1],F=P[2],H=l4(K,W);if(F)H.label=`${W}<${F}>`;if(Z=H,V=1,Q)Q.classIds.push(W);continue}let G=U.match(/^class\\s+(\\S+?)(?:\\s*~(\\w+)~)?\\s*$/);if(G){let W=G[1],F=G[2],H=l4(K,W);if(F)H.label=`${W}<${F}>`;if(Q)Q.classIds.push(W);continue}let q=U.match(/^class\\s+(\\S+?)\\s*\\{\\s*<<(\\w+)>>\\s*\\}$/);if(q){let W=l4(K,q[1]);W.annotation=q[2];continue}let z=U.match(/^(\\S+?)\\s*:\\s*(.+)$/);if(z){let W=z[2];if(!W.match(/<\\|--|--|\\*--|o--|-->|\\.\\.>|\\.\\.\\|>/)){let F=l4(K,z[1]),H=P7(W);if(H)if(H.isMethod)F.methods.push(H.member);else F.attributes.push(H.member);continue}}let _=h9(U);if(_){l4(K,_.from),l4(K,_.to),J.relationships.push(_);continue}}return J.classes=[...K.values()],J}function l4($,J){let K=$.get(J);if(!K)K={id:J,label:J,attributes:[],methods:[]},$.set(J,K);return K}function P7($){let J=$.trim().replace(/;$/,\"\");if(!J)return null;let K=\"\",Q=J;if(/^[+\\-#~]/.test(Q))K=Q[0],Q=Q.slice(1).trim();let Z=Q.match(/^(.+?)\\(([^)]*)\\)(?:\\s*(.+))?$/);if(Z){let G=Z[1].trim(),q=Z[3]?.trim(),z=G.endsWith(\"$\")||Q.includes(\"$\"),_=G.endsWith(\"*\")||Q.includes(\"*\");return{member:{visibility:K,name:G.replace(/[$*]$/,\"\"),type:q||void 0,isStatic:z,isAbstract:_},isMethod:!0}}let V=Q.split(/\\s+/),B,U;if(V.length>=2)U=V[0],B=V.slice(1).join(\" \");else B=V[0]??Q;let j=B.endsWith(\"$\"),P=B.endsWith(\"*\");return{member:{visibility:K,name:B.replace(/[$*]$/,\"\"),type:U||void 0,isStatic:j,isAbstract:P},isMethod:!1}}function h9($){let J=$.match(/^(\\S+?)\\s+(?:\"([^\"]*?)\"\\s+)?(<\\|--|<\\|\\.\\.|\\*--|o--|-->|--\\*|--o|--|>\\s*|\\.\\.>|\\.\\.\\|>|--)\\s+(?:\"([^\"]*?)\"\\s+)?(\\S+?)(?:\\s*:\\s*(.+))?$/);if(!J)return null;let K=J[1],Q=J[2]||void 0,Z=J[3].trim(),V=J[4]||void 0,B=J[5],U=J[6]?.trim()||void 0,j=c9(Z);if(!j)return null;return{from:K,to:B,type:j.type,markerAt:j.markerAt,label:U,fromCardinality:Q,toCardinality:V}}function c9($){switch($){case\"<|--\":return{type:\"inheritance\",markerAt:\"from\"};case\"<|..\":return{type:\"realization\",markerAt:\"from\"};case\"*--\":return{type:\"composition\",markerAt:\"from\"};case\"--*\":return{type:\"composition\",markerAt:\"to\"};case\"o--\":return{type:\"aggregation\",markerAt:\"from\"};case\"--o\":return{type:\"aggregation\",markerAt:\"to\"};case\"-->\":return{type:\"association\",markerAt:\"to\"};case\"..>\":return{type:\"dependency\",markerAt:\"to\"};case\"..|>\":return{type:\"realization\",markerAt:\"to\"};case\"--\":return{type:\"association\",markerAt:\"to\"};default:return null}}function F7($){let J=$.visibility||\"\",K=$.type?`: ${$.type}`:\"\";return`${J}${$.name}${K}`}function p9($){let J=[];if($.annotation)J.push(`<<${$.annotation}>>`);J.push($.label);let K=$.attributes.map(F7),Q=$.methods.map(F7);if(K.length===0&&Q.length===0)return[J];if(Q.length===0)return[J,K];return[J,K,Q]}function l9($,J){return{type:$,markerAt:J,dashed:$===\"dependency\"||$===\"realization\"}}function d4($,J,K){switch($){case\"inheritance\":case\"realization\":if(K===\"down\")return J?\"^\":\"△\";else if(K===\"up\")return J?\"v\":\"▽\";else if(K===\"left\")return J?\">\":\"◁\";else return J?\"<\":\"▷\";case\"composition\":return J?\"*\":\"◆\";case\"aggregation\":return J?\"o\":\"◇\";case\"association\":case\"dependency\":if(K===\"down\")return J?\"v\":\"▼\";else if(K===\"up\")return J?\"^\":\"▲\";else if(K===\"left\")return J?\"<\":\"◀\";else return J?\">\":\"▶\"}}function _7($,J){let K=$.split(`\n`).map((f)=>f.trim()).filter((f)=>f.length>0&&!f.startsWith(\"%%\")),Q=z6(K);if(Q.classes.length===0)return\"\";let Z=J.useAscii,V=4,B=3,U=new Map,j=new Map,P=new Map;for(let f of Q.classes){let X=p9(f);U.set(f.id,X);let N=0;for(let d of X)for(let s of d)N=Math.max(N,s.length);let S=N+4,w=0;for(let d of X)w+=Math.max(d.length,1);let I=w+(X.length-1)+2;j.set(f.id,S),P.set(f.id,I)}let G=new Map;for(let f of Q.classes)G.set(f.id,f);let q=new Map,z=new Map;for(let f of Q.relationships){let X=f.type===\"inheritance\"||f.type===\"realization\",N=X&&f.markerAt===\"to\"?f.to:f.from,S=X&&f.markerAt===\"to\"?f.from:f.to;if(!q.has(S))q.set(S,new Set);if(q.get(S).add(N),!z.has(N))z.set(N,new Set);z.get(N).add(S)}let _=new Map,F=Q.classes.filter((f)=>!q.has(f.id)||q.get(f.id).size===0).map((f)=>f.id);for(let f of F)_.set(f,0);let H=Q.classes.length-1,M=0;while(M<F.length){let f=F[M++],X=z.get(f);if(!X)continue;for(let N of X){let S=(_.get(f)??0)+1;if(S>H)continue;if(!_.has(N)||_.get(N)<S)_.set(N,S),F.push(N)}}for(let f of Q.classes)if(!_.has(f.id))_.set(f.id,0);let R=Math.max(...[..._.values()],0),Y=Array.from({length:R+1},()=>[]);for(let f of Q.classes)Y[_.get(f.id)].push(f.id);let L=new Map,b=0;for(let f=0;f<=R;f++){let X=Y[f];if(X.length===0)continue;let N=0,S=0;for(let w of X){let I=G.get(w),d=j.get(w),s=P.get(w);L.set(w,{cls:I,sections:U.get(w),x:N,y:b,width:d,height:s}),N+=d+V,S=Math.max(S,s)}b+=S+B}let O=0,A=0;for(let f of L.values())O=Math.max(O,f.x+f.width),A=Math.max(A,f.y+f.height);O+=4,A+=2;let D=F4(O-1,A-1);for(let f of L.values()){let X=U6(f.sections,Z);for(let N=0;N<X.length;N++)for(let S=0;S<X[0].length;S++){let w=X[N][S];if(w!==\" \"){let I=f.x+N,d=f.y+S;if(I<O&&d<A)D[I][d]=w}}}let T=Z?\"-\":\"─\",v=Z?\"|\":\"│\",u=Z?\".\":\"╌\",y=Z?\":\":\"┊\";for(let f of Q.relationships){let X=L.get(f.from),N=L.get(f.to);if(!X||!N)continue;let S=l9(f.type,f.markerAt),w=S.dashed?u:T,I=S.dashed?y:v,d=X.x+Math.floor(X.width/2),s=X.y+X.height-1,C=N.x+Math.floor(N.width/2),c=N.y;if(s<c){let l=s+Math.floor((c-s)/2);for(let p=s+1;p<=l;p++)if(p<A)D[d][p]=I;if(d!==C){let p=Math.min(d,C),h=Math.max(d,C);for(let m=p;m<=h;m++)if(m<O&&l<A)D[m][l]=w;if(!Z&&l<A)if(d<C)D[d][l]=\"└\",D[C][l]=\"┐\";else D[d][l]=\"┘\",D[C][l]=\"┌\"}for(let p=l+1;p<c;p++)if(p<A)D[C][p]=I;if(S.markerAt===\"to\"){let p=d4(S.type,Z,\"down\"),h=c-1;if(h>=0&&h<A)for(let m=0;m<p.length;m++){let g=C-Math.floor(p.length/2)+m;if(g>=0&&g<O)D[g][h]=p[m]}}if(S.markerAt===\"from\"){let p=d4(S.type,Z,\"down\"),h=s+1;if(h<A)for(let m=0;m<p.length;m++){let g=d-Math.floor(p.length/2)+m;if(g>=0&&g<O)D[g][h]=p[m]}}}else if(N.y+N.height-1<X.y){let l=X.y,p=N.y+N.height-1,h=p+Math.floor((l-p)/2);for(let m=l-1;m>=h;m--)if(m>=0&&m<A)D[d][m]=I;if(d!==C){let m=Math.min(d,C),g=Math.max(d,C);for(let o=m;o<=g;o++)if(o<O&&h>=0&&h<A)D[o][h]=w;if(!Z&&h>=0&&h<A)if(d<C)D[d][h]=\"┌\",D[C][h]=\"┘\";else D[d][h]=\"┐\",D[C][h]=\"└\"}for(let m=h-1;m>p;m--)if(m>=0&&m<A)D[C][m]=I;if(S.markerAt===\"from\"){let m=d4(S.type,Z,\"up\"),g=l-1;if(g>=0&&g<A)for(let o=0;o<m.length;o++){let E=d-Math.floor(m.length/2)+o;if(E>=0&&E<O)D[E][g]=m[o]}}if(S.markerAt===\"to\"){let g=S.type===\"inheritance\"||S.type===\"realization\"?\"down\":\"up\",o=d4(S.type,Z,g),E=p+1;if(E<A)for(let k=0;k<o.length;k++){let x=C-Math.floor(o.length/2)+k;if(x>=0&&x<O)D[x][E]=o[k]}}}else{let l=Math.max(s,N.y+N.height-1)+2;w4(D,O,l+1);for(let m=s+1;m<=l;m++)D[d][m]=I;let p=Math.min(d,C),h=Math.max(d,C);for(let m=p;m<=h;m++)D[m][l]=w;for(let m=l-1;m>=N.y+N.height;m--)D[C][m]=I;if(S.markerAt===\"from\"){let m=d4(S.type,Z,\"down\"),g=s+1;if(g<A)for(let o=0;o<m.length;o++){let E=d-Math.floor(m.length/2)+o;if(E>=0&&E<O)D[E][g]=m[o]}}if(S.markerAt===\"to\"){let m=d4(S.type,Z,\"up\"),g=N.y+N.height;if(g<A)for(let o=0;o<m.length;o++){let E=C-Math.floor(m.length/2)+o;if(E>=0&&E<O)D[E][g]=m[o]}}}if(f.label){let l=` ${f.label} `,p=Math.floor((d+C)/2),h;if(s<c)h=Math.floor((s+1+c-1)/2);else if(N.y+N.height-1<X.y){let g=N.y+N.height-1;h=Math.floor((g+1+X.y-1)/2)}else h=Math.max(s,N.y+N.height-1)+2;let m=p-Math.floor(l.length/2);for(let g=0;g<l.length;g++){let o=m+g;if(o>=0&&o<O&&h>=0&&h<A)D[o][h]=l[g]}}}return S4(D)}function j6($){let J={entities:[],relationships:[]},K=new Map,Q=null;for(let Z=1;Z<$.length;Z++){let V=$[Z];if(Q){if(V===\"}\"){Q=null;continue}let j=d9(V);if(j)Q.attributes.push(j);continue}let B=V.match(/^(\\S+)\\s*\\{$/);if(B){let j=B[1];Q=N6(K,j);continue}let U=g9(V);if(U){N6(K,U.entity1),N6(K,U.entity2),J.relationships.push(U);continue}}return J.entities=[...K.values()],J}function N6($,J){let K=$.get(J);if(!K)K={id:J,label:J,attributes:[]},$.set(J,K);return K}function d9($){let J=$.match(/^(\\S+)\\s+(\\S+)(?:\\s+(.+))?$/);if(!J)return null;let K=J[1],Q=J[2],Z=J[3]?.trim()??\"\",V=[],B,U=Z.match(/\"([^\"]*)\"/);if(U)B=U[1];let j=Z.replace(/\"[^\"]*\"/,\"\").trim();for(let P of j.split(/\\s+/)){let G=P.toUpperCase();if(G===\"PK\"||G===\"FK\"||G===\"UK\")V.push(G)}return{type:K,name:Q,keys:V,comment:B}}function g9($){let J=$.match(/^(\\S+)\\s+([|o}{]+(?:--|\\.\\.)[|o}{]+)\\s+(\\S+)\\s*:\\s*(.+)$/);if(!J)return null;let K=J[1],Q=J[2],Z=J[3],V=J[4].trim(),B=Q.match(/^([|o}{]+)(--|\\.\\.?)([|o}{]+)$/);if(!B)return null;let U=B[1],j=B[2],P=B[3],G=q7(U),q=q7(P),z=j===\"--\";if(!G||!q)return null;return{entity1:K,entity2:Z,cardinality1:G,cardinality2:q,label:V,identifying:z}}function q7($){let J=$.split(\"\").sort().join(\"\");if(J===\"||\")return\"one\";if(J===\"o|\")return\"zero-one\";if(J===\"|}\"||J===\"{|\")return\"many\";if(J===\"{o\"||J===\"o{\")return\"zero-many\";return null}function n9($){return`${$.keys.length>0?$.keys.join(\",\")+\" \":\"   \"}${$.type} ${$.name}`}function o9($){let J=[$.label],K=$.attributes.map(n9);if(K.length===0)return[J];return[J,K]}function B6($,J){if(J)switch($){case\"one\":return\"||\";case\"zero-one\":return\"o|\";case\"many\":return\"}|\";case\"zero-many\":return\"o{\"}else switch($){case\"one\":return\"║\";case\"zero-one\":return\"o║\";case\"many\":return\"╟\";case\"zero-many\":return\"o╟\"}}function W7($,J){let K=$.split(`\n`).map((A)=>A.trim()).filter((A)=>A.length>0&&!A.startsWith(\"%%\")),Q=j6(K);if(Q.entities.length===0)return\"\";let Z=J.useAscii,V=6,B=4,U=new Map,j=new Map,P=new Map;for(let A of Q.entities){let D=o9(A);U.set(A.id,D);let T=0;for(let f of D)for(let X of f)T=Math.max(T,X.length);let v=T+4,u=0;for(let f of D)u+=Math.max(f.length,1);let y=u+(D.length-1)+2;j.set(A.id,v),P.set(A.id,y)}let G=Math.max(2,Math.ceil(Math.sqrt(Q.entities.length))),q=new Map,z=0,_=0,W=0,F=0;for(let A of Q.entities){let D=j.get(A.id),T=P.get(A.id);if(F>=G)_+=W+B,z=0,W=0,F=0;q.set(A.id,{entity:A,sections:U.get(A.id),x:z,y:_,width:D,height:T}),z+=D+V,W=Math.max(W,T),F++}let H=0,M=0;for(let A of q.values())H=Math.max(H,A.x+A.width),M=Math.max(M,A.y+A.height);H+=4,M+=2;let R=F4(H-1,M-1);for(let A of q.values()){let D=U6(A.sections,Z);for(let T=0;T<D.length;T++)for(let v=0;v<D[0].length;v++){let u=D[T][v];if(u!==\" \"){let y=A.x+T,f=A.y+v;if(y<H&&f<M)R[y][f]=u}}}let Y=Z?\"-\":\"─\",L=Z?\"|\":\"│\",b=Z?\".\":\"╌\",O=Z?\":\":\"┊\";for(let A of Q.relationships){let D=q.get(A.entity1),T=q.get(A.entity2);if(!D||!T)continue;let v=A.identifying?Y:b,u=A.identifying?L:O,y=D.x+Math.floor(D.width/2),f=D.y+Math.floor(D.height/2),X=T.x+Math.floor(T.width/2),N=T.y+Math.floor(T.height/2);if(Math.abs(f-N)<Math.max(D.height,T.height)){let[w,I]=y<X?[D,T]:[T,D],[d,s]=y<X?[A.cardinality1,A.cardinality2]:[A.cardinality2,A.cardinality1],C=w.x+w.width,c=I.x-1,l=w.y+Math.floor(w.height/2);for(let m=C;m<=c;m++)if(m<H)R[m][l]=v;let p=B6(d,Z);for(let m=0;m<p.length;m++){let g=C+m;if(g<H)R[g][l]=p[m]}let h=B6(s,Z);for(let m=0;m<h.length;m++){let g=c-h.length+1+m;if(g>=0&&g<H)R[g][l]=h[m]}if(A.label){let m=Math.floor((C+c)/2),g=Math.max(C,m-Math.floor(A.label.length/2)),o=l-1;if(o>=0)for(let E=0;E<A.label.length;E++){let k=g+E;if(k>=C&&k<=c&&k<H)R[k][o]=A.label[E]}}}else{let[w,I]=f<N?[D,T]:[T,D],[d,s]=f<N?[A.cardinality1,A.cardinality2]:[A.cardinality2,A.cardinality1],C=w.y+w.height,c=I.y-1,l=w.x+Math.floor(w.width/2);for(let o=C;o<=c;o++)if(o<M)R[l][o]=u;let p=I.x+Math.floor(I.width/2);if(l!==p){let o=Math.floor((C+c)/2),E=Math.min(l,p),k=Math.max(l,p);for(let x=E;x<=k;x++)if(x<H&&o<M)R[x][o]=v;for(let x=o+1;x<=c;x++)if(x<M)R[p][x]=u}let h=B6(d,Z);if(C<M)for(let o=0;o<h.length;o++){let E=l-Math.floor(h.length/2)+o;if(E>=0&&E<H)R[E][C]=h[o]}let m=l!==p?p:l,g=B6(s,Z);if(c>=0&&c<M)for(let o=0;o<g.length;o++){let E=m-Math.floor(g.length/2)+o;if(E>=0&&E<H)R[E][c]=g[o]}if(A.label){let o=Math.floor((C+c)/2),E=l+2;if(o>=0)for(let k=0;k<A.label.length;k++){let x=E+k;if(x>=0)w4(R,x+1,o+1),R[x][o]=A.label[k]}}}}return S4(R)}function s9($){let J=$.trim().split(/[\\n;]/)[0]?.trim().toLowerCase()??\"\";if(/^sequencediagram\\s*$/.test(J))return\"sequence\";if(/^classdiagram\\s*$/.test(J))return\"class\";if(/^erdiagram\\s*$/.test(J))return\"er\";return\"flowchart\"}function E6($,J={}){let K={useAscii:J.useAscii??!1,paddingX:J.paddingX??5,paddingY:J.paddingY??5,boxBorderPadding:J.boxBorderPadding??1,graphDirection:\"TD\"};switch(s9($)){case\"sequence\":return G7($,K);case\"class\":return _7($,K);case\"er\":return W7($,K);case\"flowchart\":default:{let Z=s4($);if(Z.direction===\"LR\"||Z.direction===\"RL\")K.graphDirection=\"LR\";else K.graphDirection=\"TD\";let V=$7(Z,K);if(B7(V),V7(V),Z.direction===\"BT\")r6(V.canvas);return S4(V.canvas)}}}var J6=H6(G6(),1);function U4($,J,K){let Q=K>=600?0.58:K>=500?0.55:0.52;return $.length*J*Q}function P6($,J){return $.length*J*0.6}var i9=\"'JetBrains Mono'\",n5=`${i9}, 'SF Mono', 'Fira Code', ui-monospace, monospace`,r={nodeLabel:13,edgeLabel:11,groupHeader:12},$4={nodeLabel:500,edgeLabel:400,groupHeader:600},H7=8,v4={horizontal:16,vertical:10,diamondExtra:24},V4={outerBox:1,innerBox:0.75,connector:0.75},z4=\"0.35em\",g4={width:8,height:4.8};function E4($,J,K,Q){return{x:$-K/2,y:J-Q/2}}function e4($,J,K,Q,Z){let V=$.x-J,B=$.y-K;if(Math.abs(V)<0.5&&Math.abs(B)<0.5)return $;let U=1/(Math.abs(V)/Q+Math.abs(B)/Z);return{x:J+U*V,y:K+U*B}}function $6($,J,K,Q){let Z=$.x-J,V=$.y-K,B=Math.sqrt(Z*Z+V*V);if(B<0.5)return $;let U=Q/B;return{x:J+U*Z,y:K+U*V}}function b4($,J=!0){if($.length<2)return $;let K=[$[0]];for(let Q=1;Q<$.length;Q++){let Z=K[K.length-1],V=$[Q],B=Math.abs(V.x-Z.x),U=Math.abs(V.y-Z.y);if(B<1||U<1){K.push(V);continue}if(J)K.push({x:Z.x,y:V.y});else K.push({x:V.x,y:Z.y});K.push(V)}return a9(K)}function a9($){if($.length<3)return $;let J=[$[0]];for(let K=1;K<$.length-1;K++){let Q=J[J.length-1],Z=$[K],V=$[K+1],B=Math.abs(Q.x-Z.x)<1&&Math.abs(Z.x-V.x)<1,U=Math.abs(Q.y-Z.y)<1&&Math.abs(Z.y-V.y)<1;if(B||U)continue;J.push(Z)}return J.push($[$.length-1]),J}function u4($,J,K){if($.length<2)return $;let Q=$.map((Z)=>({...Z}));if(K){let Z=Q.length-1;if($.length===2){let V=Q[0],B=Q[Z],U=Math.abs(B.x-V.x);if(Math.abs(B.y-V.y)>=U){let G=B.y>V.y?K.cy-K.hh:K.cy+K.hh;Q[Z]={x:B.x,y:G}}else{let G=B.x>V.x?K.cx-K.hw:K.cx+K.hw;Q[Z]={x:G,y:B.y}}}else{let V=Q[Z-1],B=Q[Z],U=Math.abs(B.x-V.x),j=Math.abs(B.y-V.y),P=j<1&&U>=1,G=U<1&&j>=1,q=!P&&!G&&j<U,z=!P&&!G&&U<j;if(P){let W=B.x>V.x?K.cx-K.hw:K.cx+K.hw;Q[Z]={x:W,y:K.cy},Q[Z-1]={...V,y:K.cy}}else if(G){let W=B.y>V.y?K.cy-K.hh:K.cy+K.hh;Q[Z]={x:K.cx,y:W},Q[Z-1]={...V,x:K.cx}}else if(q){let W=B.x>V.x?K.cx-K.hw:K.cx+K.hw;if(V.y>=K.cy-K.hh&&V.y<=K.cy+K.hh)Q[Z]={x:W,y:V.y};else Q[Z]={x:W,y:K.cy},Q[Z-1]={...V,y:K.cy}}else if(z){let W=B.y>V.y?K.cy-K.hh:K.cy+K.hh;if(V.x>=K.cx-K.hw&&V.x<=K.cx+K.hw)Q[Z]={x:V.x,y:W};else Q[Z]={x:K.cx,y:W},Q[Z-1]={...V,x:K.cx}}}}if(J&&$.length>=3){let Z=Q[0],V=Q[1],B=Math.abs(V.x-Z.x),U=Math.abs(V.y-Z.y),j=U<1&&B>=1,P=B<1&&U>=1,G=!j&&!P&&U<B,q=!j&&!P&&B<U;if(j){let _=V.x>Z.x?J.cx+J.hw:J.cx-J.hw;Q[0]={x:_,y:J.cy},Q[1]={...Q[1],y:J.cy}}else if(P){let _=V.y>Z.y?J.cy+J.hh:J.cy-J.hh;Q[0]={x:J.cx,y:_},Q[1]={...Q[1],x:J.cx}}else if(G){let _=V.x>Z.x?J.cx+J.hw:J.cx-J.hw;if(V.y>=J.cy-J.hh&&V.y<=J.cy+J.hh)Q[0]={x:_,y:V.y};else Q[0]={x:_,y:J.cy},Q[1]={...Q[1],y:J.cy}}else if(q){let _=V.y>Z.y?J.cy+J.hh:J.cy-J.hh;if(V.x>=J.cx-J.hw&&V.x<=J.cx+J.hw)Q[0]={x:V.x,y:_};else Q[0]={x:J.cx,y:_},Q[1]={...Q[1],x:J.cx}}}return Q}var F6=new Set([\"circle\",\"doublecircle\",\"state-start\",\"state-end\"]),_6=new Set([\"diamond\",\"circle\",\"doublecircle\",\"state-start\",\"state-end\"]),r9={font:\"Inter\",padding:40,nodeSpacing:24,layerSpacing:40};function t9($,J,K){let Q=new J6.default.graphlib.Graph({directed:!0,compound:!0});Q.setGraph({rankdir:O7($.direction),acyclicer:\"greedy\",nodesep:K.nodeSpacing,ranksep:K.layerSpacing,marginx:16,marginy:12}),Q.setDefaultEdgeLabel(()=>({}));let Z=new Set;Z.add($.id),S6($,Z);for(let z of $.nodeIds){let _=J.nodes.get(z);if(_){let W=k6(z,_.label,_.shape);Q.setNode(z,{label:_.label,width:W.width,height:W.height})}}for(let z of $.children)w6(Q,z,J);let V=new Set;for(let z=0;z<J.edges.length;z++){let _=J.edges[z];if(Z.has(_.source)&&Z.has(_.target)){V.add(z);let W={_index:z};if(_.label)W.label=_.label,W.width=U4(_.label,r.edgeLabel,$4.edgeLabel)+8,W.height=r.edgeLabel+6,W.labelpos=\"c\";Q.setEdge(_.source,_.target,W)}}J6.default.layout(Q);let B=$.direction===\"TD\"||$.direction===\"TB\"||$.direction===\"BT\",U=new Set;for(let z of $.children)b6(z,U);let j=[];for(let z of Q.nodes()){if(U.has(z))continue;let _=J.nodes.get(z);if(!_)continue;let W=Q.node(z);if(!W)continue;let F=E4(W.x,W.y,W.width,W.height);j.push({id:z,label:_.label,shape:_.shape,x:F.x,y:F.y,width:W.width,height:W.height,inlineStyle:D7(J,z)})}let P=Q.edges().map((z)=>{let _=Q.edge(z),W=J.edges[_._index],F=_.points??[];if(F.length>0){let A=J.nodes.get(z.v)?.shape;if(A===\"diamond\"){let T=Q.node(z.v);F[0]=e4(F[0],T.x,T.y,T.width/2,T.height/2)}else if(A&&F6.has(A)){let T=Q.node(z.v);F[0]=$6(F[0],T.x,T.y,Math.min(T.width,T.height)/2)}let D=J.nodes.get(z.w)?.shape;if(D===\"diamond\"){let T=Q.node(z.w),v=F.length-1;F[v]=e4(F[v],T.x,T.y,T.width/2,T.height/2)}else if(D&&F6.has(D)){let T=Q.node(z.w),v=F.length-1;F[v]=$6(F[v],T.x,T.y,Math.min(T.width,T.height)/2)}}let H=b4(F,B),M=J.nodes.get(z.v)?.shape,R=J.nodes.get(z.w)?.shape,Y=M&&!_6.has(M)||!M?(()=>{let A=Q.node(z.v);return A?{cx:A.x,cy:A.y,hw:A.width/2,hh:A.height/2}:null})():null,L=R&&!_6.has(R)||!R?(()=>{let A=Q.node(z.w);return A?{cx:A.x,cy:A.y,hw:A.width/2,hh:A.height/2}:null})():null,b=u4(H,Y,L),O;if(W.label&&_.x!=null&&_.y!=null)O={x:_.x,y:_.y};return{source:W.source,target:W.target,label:W.label,style:W.style,hasArrowStart:W.hasArrowStart,hasArrowEnd:W.hasArrowEnd,points:b,labelPosition:O}}),G=$.children.map((z)=>f6(Q,z)),q=Q.graph();return{id:$.id,label:$.label,width:q.width??200,height:q.height??100,nodes:j,edges:P,groups:G,nodeIds:Z,internalEdgeIndices:V}}async function R7($,J={}){let K={...r9,...J},Q=new Map;for(let G of $.subgraphs)if(G.direction&&G.direction!==$.direction)Q.set(G.id,t9(G,$,K));let Z=new J6.default.graphlib.Graph({directed:!0,compound:!0});Z.setGraph({rankdir:O7($.direction),acyclicer:\"greedy\",nodesep:K.nodeSpacing,ranksep:K.layerSpacing,marginx:K.padding,marginy:K.padding}),Z.setDefaultEdgeLabel(()=>({}));let V=new Set;for(let G of $.subgraphs)V.add(G.id),S6(G,V);for(let[G,q]of $.nodes)if(!V.has(G)){let z=k6(G,q.label,q.shape);Z.setNode(G,{label:q.label,width:z.width,height:z.height})}for(let G of $.subgraphs)if(Q.has(G.id)){let q=Q.get(G.id);Z.setNode(G.id,{width:q.width,height:q.height})}else w6(Z,G,$);let B=new Map,U=new Map;for(let G of $.subgraphs)if(!Q.has(G.id))A7(G,B,U);for(let[G,q]of Q)for(let z of q.nodeIds)B.set(z,G),U.set(z,G);let j=new Set;for(let G of Q.values())for(let q of G.internalEdgeIndices)j.add(q);let P=new Set;for(let G=0;G<$.edges.length;G++){if(j.has(G))continue;let q=$.edges[G],z=U.get(q.source)??q.source,_=B.get(q.target)??q.target,W={_index:G};if(q.label)W.label=q.label,W.width=U4(q.label,r.edgeLabel,$4.edgeLabel)+8,W.height=r.edgeLabel+6,W.labelpos=\"c\";if(!P.has(_))W.weight=2,P.add(_);Z.setEdge(z,_,W)}try{J6.default.layout(Z)}catch(G){let q=G instanceof Error?G.message:String(G);throw Error(`Dagre layout failed: ${q}`)}return e9(Z,$,K.padding,Q)}function O7($){switch($){case\"LR\":return\"LR\";case\"RL\":return\"RL\";case\"BT\":return\"BT\";case\"TD\":case\"TB\":default:return\"TB\"}}function k6($,J,K){let Z=U4(J,r.nodeLabel,$4.nodeLabel)+v4.horizontal*2,V=r.nodeLabel+v4.vertical*2;if(K===\"diamond\"){let B=Math.max(Z,V)+v4.diamondExtra;Z=B,V=B}if(K===\"circle\"||K===\"doublecircle\"){let B=Math.ceil(Math.sqrt(Z*Z+V*V))+8;Z=K===\"doublecircle\"?B+12:B,V=Z}if(K===\"hexagon\")Z+=v4.horizontal;if(K===\"trapezoid\"||K===\"trapezoid-alt\")Z+=v4.horizontal;if(K===\"asymmetric\")Z+=12;if(K===\"cylinder\")V+=14;if(K===\"state-start\"||K===\"state-end\")Z=28,V=28;return Z=Math.max(Z,60),V=Math.max(V,36),{width:Z,height:V}}function w6($,J,K,Q){if($.setNode(J.id,{label:J.label}),Q)$.setParent(J.id,Q);for(let Z of J.nodeIds){let V=K.nodes.get(Z);if(V){let B=k6(Z,V.label,V.shape);$.setNode(Z,{label:V.label,width:B.width,height:B.height}),$.setParent(Z,J.id)}}for(let Z of J.children)w6($,Z,K,J.id)}function A7($,J,K){for(let B of $.children)A7(B,J,K);let Q=[...$.nodeIds,...$.children.map((B)=>B.id)];if(Q.length===0){J.set($.id,$.id),K.set($.id,$.id);return}let Z=Q[0],V=Q[Q.length-1];J.set($.id,J.get(Z)??Z),K.set($.id,K.get(V)??V)}function D7($,J){let K=$.classAssignments.get(J),Q=K?$.classDefs.get(K):void 0,Z=$.nodeStyles.get(J);if(!Q&&!Z)return;return{...Q,...Z}}function S6($,J){for(let K of $.nodeIds)J.add(K);for(let K of $.children)S6(K,J)}function e9($,J,K,Q){let Z=[],V=[],B=new Set;for(let M of J.subgraphs)b6(M,B);let U=new Set;if(Q)for(let M of Q.values())for(let R of M.nodeIds)U.add(R);for(let M of $.nodes()){if(B.has(M))continue;let R=J.nodes.get(M);if(!R)continue;let Y=$.node(M);if(!Y)continue;let L=E4(Y.x,Y.y,Y.width,Y.height);Z.push({id:M,label:R.label,shape:R.shape,x:L.x,y:L.y,width:Y.width,height:Y.height,inlineStyle:D7(J,M)})}for(let M of J.subgraphs)V.push(f6($,M));let j=J.direction===\"TD\"||J.direction===\"TB\"||J.direction===\"BT\",P=$.edges().map((M)=>{let R=$.edge(M),Y=J.edges[R._index],L=R.points??[];if(L.length>0){let y=J.nodes.get(M.v)?.shape;if(y===\"diamond\"){let X=$.node(M.v);L[0]=e4(L[0],X.x,X.y,X.width/2,X.height/2)}else if(y&&F6.has(y)){let X=$.node(M.v);L[0]=$6(L[0],X.x,X.y,Math.min(X.width,X.height)/2)}let f=J.nodes.get(M.w)?.shape;if(f===\"diamond\"){let X=$.node(M.w),N=L.length-1;L[N]=e4(L[N],X.x,X.y,X.width/2,X.height/2)}else if(f&&F6.has(f)){let X=$.node(M.w),N=L.length-1;L[N]=$6(L[N],X.x,X.y,Math.min(X.width,X.height)/2)}}let b=b4(L,j),O=J.nodes.get(M.v)?.shape,A=J.nodes.get(M.w)?.shape,D=O&&!_6.has(O)||!O?(()=>{let y=$.node(M.v);return y?{cx:y.x,cy:y.y,hw:y.width/2,hh:y.height/2}:null})():null,T=A&&!_6.has(A)||!A?(()=>{let y=$.node(M.w);return y?{cx:y.x,cy:y.y,hw:y.width/2,hh:y.height/2}:null})():null,v=u4(b,D,T),u;if(Y.label&&R.x!=null&&R.y!=null)u={x:R.x,y:R.y};return{source:Y.source,target:Y.target,label:Y.label,style:Y.style,hasArrowStart:Y.hasArrowStart,hasArrowEnd:Y.hasArrowEnd,points:v,labelPosition:u}});if(Q&&Q.size>0){let M=new Map;for(let R of Z)M.set(R.id,{cx:R.x+R.width/2,cy:R.y+R.height/2});for(let[R,Y]of Q){let L=$.node(R);if(!L)continue;let b=E4(L.x,L.y,L.width,L.height);for(let A of Y.nodes){let D={...A,x:A.x+b.x,y:A.y+b.y};Z.push(D),M.set(D.id,{cx:D.x+D.width/2,cy:D.y+D.height/2})}for(let A of Y.edges)P.push({...A,points:A.points.map((D)=>({x:D.x+b.x,y:D.y+b.y})),labelPosition:A.labelPosition?{x:A.labelPosition.x+b.x,y:A.labelPosition.y+b.y}:void 0});let O=I7(V,R);if(O&&Y.groups.length>0)O.children=Y.groups.map((A)=>C7(A,b.x,b.y))}for(let R of P){if(U.has(R.source)&&U.has(R.target))continue;let Y=!1;if(U.has(R.source)){let L=M.get(R.source);if(L&&R.points.length>0)R.points[0]={x:L.cx,y:L.cy},Y=!0}if(U.has(R.target)){let L=M.get(R.target);if(L&&R.points.length>0)R.points[R.points.length-1]={x:L.cx,y:L.cy},Y=!0}if(Y)R.points=b4(R.points,j)}}let G=r.groupHeader+16;$$(V,G);let q=X7(V),z=[...Z.map((M)=>M.y),...q.map((M)=>M.y)],_=z.length>0?Math.min(...z):K,W=$.graph().width??800,F=$.graph().height??600;if(_<K){let M=K-_;for(let R of Z)R.y+=M;for(let R of P){for(let Y of R.points)Y.y+=M;if(R.labelPosition)R.labelPosition.y+=M}for(let R of q)R.y+=M;F+=M}let H=Math.max(...Z.map((M)=>M.y+M.height),...q.map((M)=>M.y+M.height),...P.flatMap((M)=>M.points.map((R)=>R.y)));if(H+K>F)F=H+K;return{width:W,height:F,nodes:Z,edges:P,groups:V}}function f6($,J){let K=$.node(J.id),Q=K?E4(K.x,K.y,K.width,K.height):{x:0,y:0};return{id:J.id,label:J.label,x:Q.x,y:Q.y,width:K?.width??0,height:K?.height??0,children:J.children.map((Z)=>f6($,Z))}}function $$($,J){for(let K of $)Y7(K,J)}function Y7($,J){for(let K of $.children)Y7(K,J);if($.children.length>0){let K=$.y,Q=$.y+$.height;for(let Z of $.children)K=Math.min(K,Z.y),Q=Math.max(Q,Z.y+Z.height);$.height=Q-K,$.y=K}if($.label){let K=J+H7;$.y-=K,$.height+=K}}function X7($){let J=[];for(let K of $)J.push(K),J.push(...X7(K.children));return J}function I7($,J){for(let K of $){if(K.id===J)return K;let Q=I7(K.children,J);if(Q)return Q}return}function C7($,J,K){return{...$,x:$.x+J,y:$.y+K,children:$.children.map((Q)=>C7(Q,J,K))}}function b6($,J){J.add($.id);for(let K of $.children)b6(K,J)}function N7($,J,K=\"Inter\",Q=!1){let Z=[];Z.push(Y4($.width,$.height,J,Q)),Z.push(D4(K,!1)),Z.push(\"<defs>\"),Z.push(J$()),Z.push(\"</defs>\");for(let V of $.groups)Z.push(E7(V,K));for(let V of $.edges)Z.push(Q$(V));for(let V of $.edges)if(V.label)Z.push(Z$(V,K));for(let V of $.nodes)Z.push(V$(V));for(let V of $.nodes)Z.push(D$(V,K));return Z.push(\"</svg>\"),Z.join(`\n`)}function J$(){let $=g4.width,J=g4.height;return`  <marker id=\"arrowhead\" markerWidth=\"${$}\" markerHeight=\"${J}\" refX=\"${$}\" refY=\"${J/2}\" orient=\"auto\">\n    <polygon points=\"0 0, ${$} ${J/2}, 0 ${J}\" fill=\"var(--_arrow)\" />\n  </marker>\n  <marker id=\"arrowhead-start\" markerWidth=\"${$}\" markerHeight=\"${J}\" refX=\"0\" refY=\"${J/2}\" orient=\"auto-start-reverse\">\n    <polygon points=\"${$} 0, 0 ${J/2}, ${$} ${J}\" fill=\"var(--_arrow)\" />\n  </marker>`}function E7($,J){let K=r.groupHeader+16,Q=[];Q.push(`<rect x=\"${$.x}\" y=\"${$.y}\" width=\"${$.width}\" height=\"${$.height}\" rx=\"0\" ry=\"0\" fill=\"var(--_group-fill)\" stroke=\"var(--_node-stroke)\" stroke-width=\"${V4.outerBox}\" />`),Q.push(`<rect x=\"${$.x}\" y=\"${$.y}\" width=\"${$.width}\" height=\"${K}\" rx=\"0\" ry=\"0\" fill=\"var(--_group-hdr)\" stroke=\"var(--_node-stroke)\" stroke-width=\"${V4.outerBox}\" />`),Q.push(`<text x=\"${$.x+12}\" y=\"${$.y+K/2}\" dy=\"${z4}\" font-size=\"${r.groupHeader}\" font-weight=\"${$4.groupHeader}\" fill=\"var(--_text-sec)\">${m4($.label)}</text>`);for(let Z of $.children)Q.push(E7(Z,J));return Q.join(`\n`)}function Q$($){if($.points.length<2)return\"\";let J=K$($.points),K=$.style===\"dotted\"?' stroke-dasharray=\"4 4\"':\"\",Q=$.style===\"thick\"?V4.connector*2:V4.connector,Z=\"\";if($.hasArrowEnd)Z+=' marker-end=\"url(#arrowhead)\"';if($.hasArrowStart)Z+=' marker-start=\"url(#arrowhead-start)\"';return`<polyline points=\"${J}\" fill=\"none\" stroke=\"var(--_line)\" stroke-width=\"${Q}\"${K}${Z} />`}function K$($){return $.map((J)=>`${J.x},${J.y}`).join(\" \")}function Z$($,J){let K=$.labelPosition??U$($.points),Q=$.label,Z=U4(Q,r.edgeLabel,$4.edgeLabel),V=8,B=Z+16,U=r.edgeLabel+16;return`<rect x=\"${K.x-B/2}\" y=\"${K.y-U/2}\" width=\"${B}\" height=\"${U}\" rx=\"4\" ry=\"4\" fill=\"var(--bg)\" stroke=\"var(--_inner-stroke)\" stroke-width=\"0.5\" />\n<text x=\"${K.x}\" y=\"${K.y}\" text-anchor=\"middle\" dy=\"${z4}\" font-size=\"${r.edgeLabel}\" font-weight=\"${$4.edgeLabel}\" fill=\"var(--_text-muted)\">${m4(Q)}</text>`}function U$($){if($.length===0)return{x:0,y:0};if($.length===1)return $[0];let J=0;for(let Q=1;Q<$.length;Q++)J+=L7($[Q-1],$[Q]);let K=J/2;for(let Q=1;Q<$.length;Q++){let Z=L7($[Q-1],$[Q]);if(K<=Z){let V=K/Z;return{x:$[Q-1].x+V*($[Q].x-$[Q-1].x),y:$[Q-1].y+V*($[Q].y-$[Q-1].y)}}K-=Z}return $[$.length-1]}function L7($,J){return Math.sqrt((J.x-$.x)**2+(J.y-$.y)**2)}function V$($){let{x:J,y:K,width:Q,height:Z,shape:V,inlineStyle:B}=$,U=m4(B?.fill??\"var(--_node-fill)\"),j=m4(B?.stroke??\"var(--_node-stroke)\"),P=m4(B?.[\"stroke-width\"]??String(V4.innerBox));switch(V){case\"diamond\":return P$(J,K,Q,Z,U,j,P);case\"rounded\":return j$(J,K,Q,Z,U,j,P);case\"stadium\":return B$(J,K,Q,Z,U,j,P);case\"circle\":return G$(J,K,Q,Z,U,j,P);case\"subroutine\":return F$(J,K,Q,Z,U,j,P);case\"doublecircle\":return _$(J,K,Q,Z,U,j,P);case\"hexagon\":return q$(J,K,Q,Z,U,j,P);case\"cylinder\":return W$(J,K,Q,Z,U,j,P);case\"asymmetric\":return M$(J,K,Q,Z,U,j,P);case\"trapezoid\":return H$(J,K,Q,Z,U,j,P);case\"trapezoid-alt\":return R$(J,K,Q,Z,U,j,P);case\"state-start\":return O$(J,K,Q,Z);case\"state-end\":return A$(J,K,Q,Z);case\"rectangle\":default:return z$(J,K,Q,Z,U,j,P)}}function z$($,J,K,Q,Z,V,B){return`<rect x=\"${$}\" y=\"${J}\" width=\"${K}\" height=\"${Q}\" rx=\"0\" ry=\"0\" fill=\"${Z}\" stroke=\"${V}\" stroke-width=\"${B}\" />`}function j$($,J,K,Q,Z,V,B){return`<rect x=\"${$}\" y=\"${J}\" width=\"${K}\" height=\"${Q}\" rx=\"6\" ry=\"6\" fill=\"${Z}\" stroke=\"${V}\" stroke-width=\"${B}\" />`}function B$($,J,K,Q,Z,V,B){let U=Q/2;return`<rect x=\"${$}\" y=\"${J}\" width=\"${K}\" height=\"${Q}\" rx=\"${U}\" ry=\"${U}\" fill=\"${Z}\" stroke=\"${V}\" stroke-width=\"${B}\" />`}function G$($,J,K,Q,Z,V,B){let U=$+K/2,j=J+Q/2,P=Math.min(K,Q)/2;return`<circle cx=\"${U}\" cy=\"${j}\" r=\"${P}\" fill=\"${Z}\" stroke=\"${V}\" stroke-width=\"${B}\" />`}function P$($,J,K,Q,Z,V,B){let U=$+K/2,j=J+Q/2,P=K/2,G=Q/2;return`<polygon points=\"${[`${U},${j-G}`,`${U+P},${j}`,`${U},${j+G}`,`${U-P},${j}`].join(\" \")}\" fill=\"${Z}\" stroke=\"${V}\" stroke-width=\"${B}\" />`}function F$($,J,K,Q,Z,V,B){return`<rect x=\"${$}\" y=\"${J}\" width=\"${K}\" height=\"${Q}\" rx=\"0\" ry=\"0\" fill=\"${Z}\" stroke=\"${V}\" stroke-width=\"${B}\" />\n<line x1=\"${$+8}\" y1=\"${J}\" x2=\"${$+8}\" y2=\"${J+Q}\" stroke=\"${V}\" stroke-width=\"${B}\" />\n<line x1=\"${$+K-8}\" y1=\"${J}\" x2=\"${$+K-8}\" y2=\"${J+Q}\" stroke=\"${V}\" stroke-width=\"${B}\" />`}function _$($,J,K,Q,Z,V,B){let U=$+K/2,j=J+Q/2,P=Math.min(K,Q)/2,G=P-5;return`<circle cx=\"${U}\" cy=\"${j}\" r=\"${P}\" fill=\"${Z}\" stroke=\"${V}\" stroke-width=\"${B}\" />\n<circle cx=\"${U}\" cy=\"${j}\" r=\"${G}\" fill=\"${Z}\" stroke=\"${V}\" stroke-width=\"${B}\" />`}function q$($,J,K,Q,Z,V,B){let U=Q/4;return`<polygon points=\"${[`${$+U},${J}`,`${$+K-U},${J}`,`${$+K},${J+Q/2}`,`${$+K-U},${J+Q}`,`${$+U},${J+Q}`,`${$},${J+Q/2}`].join(\" \")}\" fill=\"${Z}\" stroke=\"${V}\" stroke-width=\"${B}\" />`}function W$($,J,K,Q,Z,V,B){let j=$+K/2,P=J+7,G=Q-14;return`<rect x=\"${$}\" y=\"${P}\" width=\"${K}\" height=\"${G}\" fill=\"${Z}\" stroke=\"none\" />\n<line x1=\"${$}\" y1=\"${P}\" x2=\"${$}\" y2=\"${P+G}\" stroke=\"${V}\" stroke-width=\"${B}\" />\n<line x1=\"${$+K}\" y1=\"${P}\" x2=\"${$+K}\" y2=\"${P+G}\" stroke=\"${V}\" stroke-width=\"${B}\" />\n<ellipse cx=\"${j}\" cy=\"${J+Q-7}\" rx=\"${K/2}\" ry=\"7\" fill=\"${Z}\" stroke=\"${V}\" stroke-width=\"${B}\" />\n<ellipse cx=\"${j}\" cy=\"${P}\" rx=\"${K/2}\" ry=\"7\" fill=\"${Z}\" stroke=\"${V}\" stroke-width=\"${B}\" />`}function M$($,J,K,Q,Z,V,B){return`<polygon points=\"${[`${$+12},${J}`,`${$+K},${J}`,`${$+K},${J+Q}`,`${$+12},${J+Q}`,`${$},${J+Q/2}`].join(\" \")}\" fill=\"${Z}\" stroke=\"${V}\" stroke-width=\"${B}\" />`}function H$($,J,K,Q,Z,V,B){let U=K*0.15;return`<polygon points=\"${[`${$+U},${J}`,`${$+K-U},${J}`,`${$+K},${J+Q}`,`${$},${J+Q}`].join(\" \")}\" fill=\"${Z}\" stroke=\"${V}\" stroke-width=\"${B}\" />`}function R$($,J,K,Q,Z,V,B){let U=K*0.15;return`<polygon points=\"${[`${$},${J}`,`${$+K},${J}`,`${$+K-U},${J+Q}`,`${$+U},${J+Q}`].join(\" \")}\" fill=\"${Z}\" stroke=\"${V}\" stroke-width=\"${B}\" />`}function O$($,J,K,Q){let Z=$+K/2,V=J+Q/2,B=Math.min(K,Q)/2-2;return`<circle cx=\"${Z}\" cy=\"${V}\" r=\"${B}\" fill=\"var(--_text)\" stroke=\"none\" />`}function A$($,J,K,Q){let Z=$+K/2,V=J+Q/2,B=Math.min(K,Q)/2-2,U=B-4;return`<circle cx=\"${Z}\" cy=\"${V}\" r=\"${B}\" fill=\"none\" stroke=\"var(--_text)\" stroke-width=\"${V4.innerBox*2}\" />\n<circle cx=\"${Z}\" cy=\"${V}\" r=\"${U}\" fill=\"var(--_text)\" stroke=\"none\" />`}function D$($,J){if($.shape===\"state-start\"||$.shape===\"state-end\"){if(!$.label)return\"\"}let K=$.x+$.width/2,Q=$.y+$.height/2,Z=m4($.inlineStyle?.color??\"var(--_text)\");return`<text x=\"${K}\" y=\"${Q}\" text-anchor=\"middle\" dy=\"${z4}\" font-size=\"${r.nodeLabel}\" font-weight=\"${$4.nodeLabel}\" fill=\"${Z}\">${m4($.label)}</text>`}function m4($){return $.replace(/&/g,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\").replace(/'/g,\"&#39;\")}var Q4={padding:30,actorGap:140,actorHeight:40,actorPadX:16,headerGap:20,messageRowHeight:40,selfMessageHeight:30,activationWidth:10,blockPadX:10,blockPadTop:40,blockPadBottom:8,blockHeaderExtra:28,dividerExtra:24,noteWidth:120,notePadding:8,noteGap:10};function T7($,J={}){if($.actors.length===0)return{width:0,height:0,actors:[],lifelines:[],messages:[],activations:[],blocks:[],notes:[]};let K=$.actors.map((O)=>{let A=U4(O.label,r.nodeLabel,$4.nodeLabel);return Math.max(A+Q4.actorPadX*2,80)}),Q=[],Z=Q4.padding+K[0]/2;for(let O=0;O<$.actors.length;O++){if(O>0){let A=Math.max(Q4.actorGap,(K[O-1]+K[O])/2+40);Z+=A}Q.push(Z)}let V=new Map;for(let O=0;O<$.actors.length;O++)V.set($.actors[O].id,O);let B=Q4.padding,U=$.actors.map((O,A)=>({id:O.id,label:O.label,type:O.type,x:Q[A],y:B,width:K[A],height:Q4.actorHeight})),j=B+Q4.actorHeight+Q4.headerGap,P=[],G=new Map;for(let O of $.blocks){let A=G.get(O.startIndex)??0;G.set(O.startIndex,Math.max(A,Q4.blockHeaderExtra));for(let D of O.dividers){let T=G.get(D.index)??0;G.set(D.index,Math.max(T,Q4.dividerExtra))}}let q=new Map,z=[];for(let O=0;O<$.messages.length;O++){let A=$.messages[O],D=V.get(A.from)??0,T=V.get(A.to)??0,v=A.from===A.to,u=G.get(O)??0;if(u>0)j+=u;let y=Q[D],f=Q[T];if(P.push({from:A.from,to:A.to,label:A.label,lineStyle:A.lineStyle,arrowHead:A.arrowHead,x1:y,x2:f,y:j,isSelf:v}),A.activate){if(!q.has(A.to))q.set(A.to,[]);q.get(A.to).push(j)}if(A.deactivate){let X=q.get(A.from);if(X&&X.length>0){let N=X.pop(),S=V.get(A.from)??0;z.push({actorId:A.from,x:Q[S]-Q4.activationWidth/2,topY:N,bottomY:j,width:Q4.activationWidth})}}j+=v?Q4.selfMessageHeight+Q4.messageRowHeight:Q4.messageRowHeight}for(let[O,A]of q)for(let D of A){let T=V.get(O)??0;z.push({actorId:O,x:Q[T]-Q4.activationWidth/2,topY:D,bottomY:j-Q4.messageRowHeight/2,width:Q4.activationWidth})}let _=$.blocks.map((O)=>{let A=P[O.startIndex],D=P[O.endIndex],T=(A?.y??j)-Q4.blockPadTop,v=(D?.y??j)+Q4.blockPadBottom+12,u=new Set;for(let w=O.startIndex;w<=O.endIndex;w++){let I=$.messages[w];if(I)u.add(V.get(I.from)??0),u.add(V.get(I.to)??0)}if(u.size===0)for(let w=0;w<$.actors.length;w++)u.add(w);let y=Math.min(...u),f=Math.max(...u),X=Q[y]-K[y]/2-Q4.blockPadX,N=Q[f]+K[f]/2+Q4.blockPadX,S=O.dividers.map((w)=>{let I=P[w.index],d=I?.y??j,s=28;if(w.label&&I?.label){let C=`[${w.label}]`,c=U4(C,r.edgeLabel,$4.edgeLabel),l=X+8,p=l+c,h=U4(I.label,r.edgeLabel,$4.edgeLabel),m=I.isSelf?I.x1+36:(I.x1+I.x2)/2-h/2,g=m+h;if(p>m&&l<g)s=36}return{y:d-s,label:w.label}});return{type:O.type,label:O.label,x:X,y:T,width:N-X,height:v-T,dividers:S}}),W=$.notes.map((O)=>{let A=Math.max(Q4.noteWidth,U4(O.text,r.edgeLabel,$4.edgeLabel)+Q4.notePadding*2),D=r.edgeLabel+Q4.notePadding*2,v=(P[O.afterIndex]?.y??B+Q4.actorHeight)+4,u=V.get(O.actorIds[0]??\"\")??0,y;if(O.position===\"left\")y=Q[u]-K[u]/2-A-Q4.noteGap;else if(O.position===\"right\")y=Q[u]+K[u]/2+Q4.noteGap;else if(O.actorIds.length>1){let f=V.get(O.actorIds[O.actorIds.length-1]??\"\")??u;y=(Q[u]+Q[f])/2-A/2}else y=Q[u]-A/2;return{text:O.text,x:y,y:v,width:A,height:D}}),F=j+Q4.padding,H=Q4.padding,M=0;for(let O of U)H=Math.min(H,O.x-O.width/2),M=Math.max(M,O.x+O.width/2);for(let O of _)H=Math.min(H,O.x),M=Math.max(M,O.x+O.width);for(let O of W)H=Math.min(H,O.x),M=Math.max(M,O.x+O.width);let R=H<Q4.padding?Q4.padding-H:0;if(R>0){for(let O of U)O.x+=R;for(let O of P)O.x1+=R,O.x2+=R;for(let O of z)O.x+=R;for(let O of _)O.x+=R;for(let O of W)O.x+=R;for(let O=0;O<Q.length;O++)Q[O]+=R}let Y=$.actors.map((O,A)=>({actorId:O.id,x:Q[A],topY:B+Q4.actorHeight,bottomY:F-Q4.padding})),L=M+R+Q4.padding,b=F;return{width:Math.max(L,200),height:Math.max(b,100),actors:U,lifelines:Y,messages:P,activations:z,blocks:_,notes:W}}function k7($,J,K=\"Inter\",Q=!1){let Z=[];Z.push(Y4($.width,$.height,J,Q)),Z.push(D4(K,!1)),Z.push(\"<defs>\"),Z.push(Y$()),Z.push(\"</defs>\");for(let V of $.blocks)Z.push(N$(V));for(let V of $.lifelines)Z.push(I$(V));for(let V of $.activations)Z.push(C$(V));for(let V of $.messages)Z.push(L$(V));for(let V of $.notes)Z.push(E$(V));for(let V of $.actors)Z.push(X$(V));return Z.push(\"</svg>\"),Z.join(`\n`)}function Y$(){let $=g4.width,J=g4.height;return`  <marker id=\"seq-arrow\" markerWidth=\"${$}\" markerHeight=\"${J}\" refX=\"${$}\" refY=\"${J/2}\" orient=\"auto-start-reverse\">\n    <polygon points=\"0 0, ${$} ${J/2}, 0 ${J}\" fill=\"var(--_arrow)\" />\n  </marker>\n  <marker id=\"seq-arrow-open\" markerWidth=\"${$}\" markerHeight=\"${J}\" refX=\"${$}\" refY=\"${J/2}\" orient=\"auto-start-reverse\">\n    <polyline points=\"0 0, ${$} ${J/2}, 0 ${J}\" fill=\"none\" stroke=\"var(--_arrow)\" stroke-width=\"1\" />\n  </marker>`}function X$($){let{x:J,y:K,width:Q,height:Z,label:V,type:B}=$;if(B===\"actor\"){let j=Z/24*0.9,P=J-12*j,G=K+(Z-24*j)/2,q=V4.outerBox/j,z=\"var(--_line)\";return`<g transform=\"translate(${P},${G}) scale(${j})\">\n  <path d=\"M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z\" fill=\"none\" stroke=\"var(--_line)\" stroke-width=\"${q}\" />\n  <path d=\"M15 10C15 11.6569 13.6569 13 12 13C10.3431 13 9 11.6569 9 10C9 8.34315 10.3431 7 12 7C13.6569 7 15 8.34315 15 10Z\" fill=\"none\" stroke=\"var(--_line)\" stroke-width=\"${q}\" />\n  <path d=\"M5.62842 18.3563C7.08963 17.0398 9.39997 16 12 16C14.6 16 16.9104 17.0398 18.3716 18.3563\" fill=\"none\" stroke=\"var(--_line)\" stroke-width=\"${q}\" />\n</g>\n<text x=\"${J}\" y=\"${K+Z+14}\" text-anchor=\"middle\" font-size=\"${r.nodeLabel}\" font-weight=\"${$4.nodeLabel}\" fill=\"var(--_text)\">${h4(V)}</text>`}return`<rect x=\"${J-Q/2}\" y=\"${K}\" width=\"${Q}\" height=\"${Z}\" rx=\"4\" ry=\"4\" fill=\"var(--_node-fill)\" stroke=\"var(--_node-stroke)\" stroke-width=\"${V4.outerBox}\" />\n<text x=\"${J}\" y=\"${K+Z/2}\" text-anchor=\"middle\" dy=\"${z4}\" font-size=\"${r.nodeLabel}\" font-weight=\"${$4.nodeLabel}\" fill=\"var(--_text)\">${h4(V)}</text>`}function I$($){return`<line x1=\"${$.x}\" y1=\"${$.topY}\" x2=\"${$.x}\" y2=\"${$.bottomY}\" stroke=\"var(--_line)\" stroke-width=\"0.75\" stroke-dasharray=\"6 4\" />`}function C$($){return`<rect x=\"${$.x}\" y=\"${$.topY}\" width=\"${$.width}\" height=\"${$.bottomY-$.topY}\" fill=\"var(--_node-fill)\" stroke=\"var(--_node-stroke)\" stroke-width=\"${V4.innerBox}\" />`}function L$($){let J=[],K=$.lineStyle===\"dashed\"?' stroke-dasharray=\"6 4\"':\"\",Q=$.arrowHead===\"filled\"?\"seq-arrow\":\"seq-arrow-open\";if($.isSelf)J.push(`<polyline points=\"${$.x1},${$.y} ${$.x1+30},${$.y} ${$.x1+30},${$.y+20} ${$.x2},${$.y+20}\" fill=\"none\" stroke=\"var(--_line)\" stroke-width=\"${V4.connector}\"${K} marker-end=\"url(#${Q})\" />`),J.push(`<text x=\"${$.x1+30+6}\" y=\"${$.y+10}\" dy=\"${z4}\" font-size=\"${r.edgeLabel}\" font-weight=\"${$4.edgeLabel}\" fill=\"var(--_text-muted)\">${h4($.label)}</text>`);else{J.push(`<line x1=\"${$.x1}\" y1=\"${$.y}\" x2=\"${$.x2}\" y2=\"${$.y}\" stroke=\"var(--_line)\" stroke-width=\"${V4.connector}\"${K} marker-end=\"url(#${Q})\" />`);let Z=($.x1+$.x2)/2;J.push(`<text x=\"${Z}\" y=\"${$.y-6}\" text-anchor=\"middle\" font-size=\"${r.edgeLabel}\" font-weight=\"${$4.edgeLabel}\" fill=\"var(--_text-muted)\">${h4($.label)}</text>`)}return J.join(`\n`)}function N$($){let J=[];J.push(`<rect x=\"${$.x}\" y=\"${$.y}\" width=\"${$.width}\" height=\"${$.height}\" rx=\"0\" ry=\"0\" fill=\"none\" stroke=\"var(--_node-stroke)\" stroke-width=\"${V4.outerBox}\" />`);let K=`${$.type}${$.label?` [${$.label}]`:\"\"}`,Q=U4(K,r.edgeLabel,$4.groupHeader)+16,Z=18;J.push(`<rect x=\"${$.x}\" y=\"${$.y}\" width=\"${Q}\" height=\"${Z}\" fill=\"var(--_group-hdr)\" stroke=\"var(--_node-stroke)\" stroke-width=\"${V4.outerBox}\" />`),J.push(`<text x=\"${$.x+6}\" y=\"${$.y+Z/2}\" dy=\"${z4}\" font-size=\"${r.edgeLabel}\" font-weight=\"${$4.groupHeader}\" fill=\"var(--_text-sec)\">${h4(K)}</text>`);for(let V of $.dividers)if(J.push(`<line x1=\"${$.x}\" y1=\"${V.y}\" x2=\"${$.x+$.width}\" y2=\"${V.y}\" stroke=\"var(--_line)\" stroke-width=\"0.75\" stroke-dasharray=\"6 4\" />`),V.label)J.push(`<text x=\"${$.x+8}\" y=\"${V.y+14}\" font-size=\"${r.edgeLabel}\" font-weight=\"${$4.edgeLabel}\" fill=\"var(--_text-muted)\">[${h4(V.label)}]</text>`);return J.join(`\n`)}function E$($){return`<rect x=\"${$.x}\" y=\"${$.y}\" width=\"${$.width}\" height=\"${$.height}\" fill=\"var(--_group-hdr)\" stroke=\"var(--_node-stroke)\" stroke-width=\"${V4.innerBox}\" />\n<polygon points=\"${$.x+$.width-6},${$.y} ${$.x+$.width},${$.y+6} ${$.x+$.width-6},${$.y+6}\" fill=\"var(--_inner-stroke)\" />\n<text x=\"${$.x+$.width/2}\" y=\"${$.y+$.height/2}\" text-anchor=\"middle\" dy=\"${z4}\" font-size=\"${r.edgeLabel}\" font-weight=\"${$4.edgeLabel}\" fill=\"var(--_text-muted)\">${h4($.text)}</text>`}function h4($){return $.replace(/&/g,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\").replace(/'/g,\"&#39;\")}var y6=H6(G6(),1);var P4={padding:40,boxPadX:8,headerBaseHeight:32,annotationHeight:16,memberRowHeight:20,sectionPadY:8,emptySectionHeight:8,minWidth:120,memberFontSize:11,memberFontWeight:400,nodeSpacing:40,layerSpacing:60};async function S7($,J={}){if($.classes.length===0)return{width:0,height:0,classes:[],relationships:[]};let K=new Map;for(let U of $.classes){let j=U.annotation?P4.headerBaseHeight+P4.annotationHeight:P4.headerBaseHeight,P=U.attributes.length>0?U.attributes.length*P4.memberRowHeight+P4.sectionPadY:P4.emptySectionHeight,G=U.methods.length>0?U.methods.length*P4.memberRowHeight+P4.sectionPadY:P4.emptySectionHeight,q=U4(U.label,r.nodeLabel,$4.nodeLabel),z=w7(U.attributes),_=w7(U.methods),W=Math.max(P4.minWidth,q+P4.boxPadX*2,z+P4.boxPadX*2,_+P4.boxPadX*2),F=j+P+G;K.set(U.id,{width:W,height:F,headerHeight:j,attrHeight:P,methodHeight:G})}let Q=new y6.default.graphlib.Graph({directed:!0});Q.setGraph({rankdir:\"TB\",acyclicer:\"greedy\",nodesep:P4.nodeSpacing,ranksep:P4.layerSpacing,marginx:P4.padding,marginy:P4.padding}),Q.setDefaultEdgeLabel(()=>({}));for(let U of $.classes){let j=K.get(U.id);Q.setNode(U.id,{width:j.width,height:j.height})}for(let U=0;U<$.relationships.length;U++){let j=$.relationships[U],P={_index:U};if(j.label)P.label=j.label,P.width=U4(j.label,r.edgeLabel,$4.edgeLabel)+8,P.height=r.edgeLabel+6,P.labelpos=\"c\";Q.setEdge(j.from,j.to,P)}try{y6.default.layout(Q)}catch(U){let j=U instanceof Error?U.message:String(U);throw Error(`Dagre layout failed (class diagram): ${j}`)}let Z=new Map;for(let U of $.classes)Z.set(U.id,U);let V=$.classes.map((U)=>{let j=Q.node(U.id),P=K.get(U.id),G=E4(j.x,j.y,j.width,j.height);return{id:U.id,label:U.label,annotation:U.annotation,attributes:U.attributes,methods:U.methods,x:G.x,y:G.y,width:j.width??P.width,height:j.height??P.height,headerHeight:P.headerHeight,attrHeight:P.attrHeight,methodHeight:P.methodHeight}}),B=Q.edges().map((U)=>{let j=Q.edge(U),P=$.relationships[j._index],G=j.points??[],q=b4(G,!0),z=Q.node(U.v),_=Q.node(U.w),W=u4(q,z?{cx:z.x,cy:z.y,hw:z.width/2,hh:z.height/2}:null,_?{cx:_.x,cy:_.y,hw:_.width/2,hh:_.height/2}:null),F;if(P.label&&j.x!=null&&j.y!=null)F={x:j.x,y:j.y};return{from:P.from,to:P.to,type:P.type,markerAt:P.markerAt,label:P.label,fromCardinality:P.fromCardinality,toCardinality:P.toCardinality,points:W,labelPosition:F}});return{width:Q.graph().width??600,height:Q.graph().height??400,classes:V,relationships:B}}function w7($){if($.length===0)return 0;let J=0;for(let K of $){let Q=T$(K),Z=P6(Q,P4.memberFontSize);if(Z>J)J=Z}return J}function T$($){let J=$.visibility?`${$.visibility} `:\"\",K=$.type?`: ${$.type}`:\"\";return`${J}${$.name}${K}`}var q6={memberSize:11,memberWeight:400,annotationSize:10,annotationWeight:500};function y7($,J,K=\"Inter\",Q=!1){let Z=[];Z.push(Y4($.width,$.height,J,Q)),Z.push(D4(K,!0)),Z.push(\"<defs>\"),Z.push(k$()),Z.push(\"</defs>\");for(let V of $.relationships)Z.push(S$(V));for(let V of $.classes)Z.push(w$(V));for(let V of $.relationships)Z.push(y$(V));return Z.push(\"</svg>\"),Z.join(`\n`)}function k$(){return`  <marker id=\"cls-inherit\" markerWidth=\"12\" markerHeight=\"10\" refX=\"12\" refY=\"5\" orient=\"auto-start-reverse\">\n    <polygon points=\"0 0, 12 5, 0 10\" fill=\"var(--bg)\" stroke=\"var(--_arrow)\" stroke-width=\"1.5\" />\n  </marker>\n  <marker id=\"cls-composition\" markerWidth=\"12\" markerHeight=\"10\" refX=\"0\" refY=\"5\" orient=\"auto-start-reverse\">\n    <polygon points=\"6 0, 12 5, 6 10, 0 5\" fill=\"var(--_arrow)\" stroke=\"var(--_arrow)\" stroke-width=\"1\" />\n  </marker>\n  <marker id=\"cls-aggregation\" markerWidth=\"12\" markerHeight=\"10\" refX=\"0\" refY=\"5\" orient=\"auto-start-reverse\">\n    <polygon points=\"6 0, 12 5, 6 10, 0 5\" fill=\"var(--bg)\" stroke=\"var(--_arrow)\" stroke-width=\"1.5\" />\n  </marker>\n  <marker id=\"cls-arrow\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto-start-reverse\">\n    <polyline points=\"0 0, 8 3, 0 6\" fill=\"none\" stroke=\"var(--_arrow)\" stroke-width=\"1.5\" />\n  </marker>`}function w$($){let{x:J,y:K,width:Q,height:Z,headerHeight:V,attrHeight:B,methodHeight:U}=$,j=[];j.push(`<rect x=\"${J}\" y=\"${K}\" width=\"${Q}\" height=\"${Z}\" rx=\"0\" ry=\"0\" fill=\"var(--_node-fill)\" stroke=\"var(--_node-stroke)\" stroke-width=\"${V4.outerBox}\" />`),j.push(`<rect x=\"${J}\" y=\"${K}\" width=\"${Q}\" height=\"${V}\" rx=\"0\" ry=\"0\" fill=\"var(--_group-hdr)\" stroke=\"var(--_node-stroke)\" stroke-width=\"${V4.outerBox}\" />`);let P=K+V/2;if($.annotation){let _=K+12;j.push(`<text x=\"${J+Q/2}\" y=\"${_}\" text-anchor=\"middle\" dy=\"${z4}\" font-size=\"${q6.annotationSize}\" font-weight=\"${q6.annotationWeight}\" font-style=\"italic\" fill=\"var(--_text-muted)\">&lt;&lt;${y4($.annotation)}&gt;&gt;</text>`),P=K+V/2+6}j.push(`<text x=\"${J+Q/2}\" y=\"${P}\" text-anchor=\"middle\" dy=\"${z4}\" font-size=\"${r.nodeLabel}\" font-weight=\"700\" fill=\"var(--_text)\">${y4($.label)}</text>`);let G=K+V;j.push(`<line x1=\"${J}\" y1=\"${G}\" x2=\"${J+Q}\" y2=\"${G}\" stroke=\"var(--_node-stroke)\" stroke-width=\"${V4.innerBox}\" />`);let q=20;for(let _=0;_<$.attributes.length;_++){let W=$.attributes[_],F=G+4+_*q+q/2;j.push(f7(W,J+P4.boxPadX,F))}let z=G+B;j.push(`<line x1=\"${J}\" y1=\"${z}\" x2=\"${J+Q}\" y2=\"${z}\" stroke=\"var(--_node-stroke)\" stroke-width=\"${V4.innerBox}\" />`);for(let _=0;_<$.methods.length;_++){let W=$.methods[_],F=z+4+_*q+q/2;j.push(f7(W,J+P4.boxPadX,F))}return j.join(`\n`)}function f7($,J,K){let Q=$.isAbstract?' font-style=\"italic\"':\"\",Z=$.isStatic?' text-decoration=\"underline\"':\"\",V=[];if($.visibility)V.push(`<tspan fill=\"var(--_text-faint)\">${y4($.visibility)} </tspan>`);if(V.push(`<tspan fill=\"var(--_text-sec)\">${y4($.name)}</tspan>`),$.type)V.push('<tspan fill=\"var(--_text-faint)\">: </tspan>'),V.push(`<tspan fill=\"var(--_text-muted)\">${y4($.type)}</tspan>`);return`<text x=\"${J}\" y=\"${K}\" class=\"mono\" dy=\"${z4}\" font-size=\"${q6.memberSize}\" font-weight=\"${q6.memberWeight}\"${Q}${Z}>${V.join(\"\")}</text>`}function S$($){if($.points.length<2)return\"\";let J=$.points.map((V)=>`${V.x},${V.y}`).join(\" \"),Q=$.type===\"dependency\"||$.type===\"realization\"?' stroke-dasharray=\"6 4\"':\"\",Z=f$($.type,$.markerAt);return`<polyline points=\"${J}\" fill=\"none\" stroke=\"var(--_line)\" stroke-width=\"${V4.connector}\"${Q}${Z} />`}function f$($,J){let K=b$($);if(!K)return\"\";if(J===\"from\")return` marker-start=\"url(#${K})\"`;else return` marker-end=\"url(#${K})\"`}function b$($){switch($){case\"inheritance\":case\"realization\":return\"cls-inherit\";case\"composition\":return\"cls-composition\";case\"aggregation\":return\"cls-aggregation\";case\"association\":case\"dependency\":return\"cls-arrow\";default:return null}}function y$($){if(!$.label&&!$.fromCardinality&&!$.toCardinality)return\"\";if($.points.length<2)return\"\";let J=[];if($.label){let K=$.labelPosition??x$($.points);J.push(`<text x=\"${K.x}\" y=\"${K.y-8}\" text-anchor=\"middle\" font-size=\"${r.edgeLabel}\" font-weight=\"${$4.edgeLabel}\" fill=\"var(--_text-muted)\">${y4($.label)}</text>`)}if($.fromCardinality){let K=$.points[0],Q=$.points[1],Z=b7(K,Q);J.push(`<text x=\"${K.x+Z.x}\" y=\"${K.y+Z.y}\" text-anchor=\"middle\" font-size=\"${r.edgeLabel}\" font-weight=\"${$4.edgeLabel}\" fill=\"var(--_text-muted)\">${y4($.fromCardinality)}</text>`)}if($.toCardinality){let K=$.points[$.points.length-1],Q=$.points[$.points.length-2],Z=b7(K,Q);J.push(`<text x=\"${K.x+Z.x}\" y=\"${K.y+Z.y}\" text-anchor=\"middle\" font-size=\"${r.edgeLabel}\" font-weight=\"${$4.edgeLabel}\" fill=\"var(--_text-muted)\">${y4($.toCardinality)}</text>`)}return J.join(`\n`)}function x$($){if($.length===0)return{x:0,y:0};let J=Math.floor($.length/2);return $[J]}function b7($,J){let K=J.x-$.x,Q=J.y-$.y;if(Math.abs(K)>Math.abs(Q))return{x:K>0?14:-14,y:-10};return{x:-14,y:Q>0?14:-14}}function y4($){return $.replace(/&/g,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\").replace(/'/g,\"&#39;\")}var x6=H6(G6(),1);var H4={padding:40,boxPadX:12,headerHeight:32,rowHeight:22,minWidth:140,attrFontSize:11,attrFontWeight:400,nodeSpacing:50,layerSpacing:70};async function x7($,J={}){if($.entities.length===0)return{width:0,height:0,entities:[],relationships:[]};let K=new Map;for(let U of $.entities){let j=U4(U.label,r.nodeLabel,$4.nodeLabel),P=0;for(let z of U.attributes){let _=`${z.type}  ${z.name}${z.keys.length>0?\"  \"+z.keys.join(\",\"):\"\"}`,W=P6(_,H4.attrFontSize);if(W>P)P=W}let G=Math.max(H4.minWidth,j+H4.boxPadX*2,P+H4.boxPadX*2),q=H4.headerHeight+Math.max(U.attributes.length,1)*H4.rowHeight;K.set(U.id,{width:G,height:q})}let Q=new x6.default.graphlib.Graph({directed:!0});Q.setGraph({rankdir:\"LR\",acyclicer:\"greedy\",nodesep:H4.nodeSpacing,ranksep:H4.layerSpacing,marginx:H4.padding,marginy:H4.padding}),Q.setDefaultEdgeLabel(()=>({}));for(let U of $.entities){let j=K.get(U.id);Q.setNode(U.id,{width:j.width,height:j.height})}for(let U=0;U<$.relationships.length;U++){let j=$.relationships[U];Q.setEdge(j.entity1,j.entity2,{_index:U,label:j.label,width:U4(j.label,r.edgeLabel,$4.edgeLabel)+8,height:r.edgeLabel+6,labelpos:\"c\"})}try{x6.default.layout(Q)}catch(U){let j=U instanceof Error?U.message:String(U);throw Error(`Dagre layout failed (ER diagram): ${j}`)}let Z=new Map;for(let U of $.entities)Z.set(U.id,U);let V=$.entities.map((U)=>{let j=Q.node(U.id),P=E4(j.x,j.y,j.width,j.height);return{id:U.id,label:U.label,attributes:U.attributes,x:P.x,y:P.y,width:j.width??K.get(U.id).width,height:j.height??K.get(U.id).height,headerHeight:H4.headerHeight,rowHeight:H4.rowHeight}}),B=Q.edges().map((U)=>{let j=Q.edge(U),P=$.relationships[j._index],G=j.points??[],q=b4(G,!1),z=Q.node(U.v),_=Q.node(U.w),W=u4(q,z?{cx:z.x,cy:z.y,hw:z.width/2,hh:z.height/2}:null,_?{cx:_.x,cy:_.y,hw:_.width/2,hh:_.height/2}:null);return{entity1:P.entity1,entity2:P.entity2,cardinality1:P.cardinality1,cardinality2:P.cardinality2,label:P.label,identifying:P.identifying,points:W}});return{width:Q.graph().width??600,height:Q.graph().height??400,entities:V,relationships:B}}var T4={attrSize:11,attrWeight:400,keySize:9,keyWeight:600};function u7($,J,K=\"Inter\",Q=!1){let Z=[];Z.push(Y4($.width,$.height,J,Q)),Z.push(D4(K,!0)),Z.push(\"<defs>\"),Z.push(\"</defs>\");for(let V of $.relationships)Z.push(m$(V));for(let V of $.entities)Z.push(v$(V));for(let V of $.relationships)Z.push(c$(V));for(let V of $.relationships)Z.push(h$(V));return Z.push(\"</svg>\"),Z.join(`\n`)}function v$($){let{x:J,y:K,width:Q,height:Z,headerHeight:V,rowHeight:B,label:U,attributes:j}=$,P=[];P.push(`<rect x=\"${J}\" y=\"${K}\" width=\"${Q}\" height=\"${Z}\" rx=\"0\" ry=\"0\" fill=\"var(--_node-fill)\" stroke=\"var(--_node-stroke)\" stroke-width=\"${V4.outerBox}\" />`),P.push(`<rect x=\"${J}\" y=\"${K}\" width=\"${Q}\" height=\"${V}\" rx=\"0\" ry=\"0\" fill=\"var(--_group-hdr)\" stroke=\"var(--_node-stroke)\" stroke-width=\"${V4.outerBox}\" />`),P.push(`<text x=\"${J+Q/2}\" y=\"${K+V/2}\" text-anchor=\"middle\" dy=\"${z4}\" font-size=\"${r.nodeLabel}\" font-weight=\"700\" fill=\"var(--_text)\">${W6(U)}</text>`);let G=K+V;P.push(`<line x1=\"${J}\" y1=\"${G}\" x2=\"${J+Q}\" y2=\"${G}\" stroke=\"var(--_node-stroke)\" stroke-width=\"${V4.innerBox}\" />`);for(let q=0;q<j.length;q++){let z=j[q],_=G+q*B+B/2;P.push(u$(z,J,_,Q))}if(j.length===0)P.push(`<text x=\"${J+Q/2}\" y=\"${G+B/2}\" text-anchor=\"middle\" dy=\"${z4}\" font-size=\"${T4.attrSize}\" fill=\"var(--_text-faint)\" font-style=\"italic\">(no attributes)</text>`);return P.join(`\n`)}function u$($,J,K,Q){let Z=[],V=0;if($.keys.length>0){let j=$.keys.join(\",\");V=U4(j,T4.keySize,T4.keyWeight)+8,Z.push(`<rect x=\"${J+6}\" y=\"${K-7}\" width=\"${V}\" height=\"14\" rx=\"2\" ry=\"2\" fill=\"var(--_key-badge)\" />`),Z.push(`<text x=\"${J+6+V/2}\" y=\"${K}\" text-anchor=\"middle\" dy=\"${z4}\" font-size=\"${T4.keySize}\" font-weight=\"${T4.keyWeight}\" fill=\"var(--_text-sec)\">${$.keys.join(\",\")}</text>`)}let B=J+8+(V>0?V+6:0);Z.push(`<text x=\"${B}\" y=\"${K}\" class=\"mono\" dy=\"${z4}\" font-size=\"${T4.attrSize}\" font-weight=\"${T4.attrWeight}\"><tspan fill=\"var(--_text-muted)\">${W6($.type)}</tspan></text>`);let U=J+Q-8;return Z.push(`<text x=\"${U}\" y=\"${K}\" class=\"mono\" text-anchor=\"end\" dy=\"${z4}\" font-size=\"${T4.attrSize}\" font-weight=\"${T4.attrWeight}\"><tspan fill=\"var(--_text-sec)\">${W6($.name)}</tspan></text>`),Z.join(`\n`)}function m$($){if($.points.length<2)return\"\";let J=$.points.map((Q)=>`${Q.x},${Q.y}`).join(\" \"),K=!$.identifying?' stroke-dasharray=\"6 4\"':\"\";return`<polyline points=\"${J}\" fill=\"none\" stroke=\"var(--_line)\" stroke-width=\"${V4.connector}\"${K} />`}function h$($){if(!$.label||$.points.length<2)return\"\";let J=p$($.points),Q=U4($.label,r.edgeLabel,$4.edgeLabel)+8,Z=r.edgeLabel+6;return`<rect x=\"${J.x-Q/2}\" y=\"${J.y-Z/2}\" width=\"${Q}\" height=\"${Z}\" rx=\"2\" ry=\"2\" fill=\"var(--bg)\" stroke=\"var(--_inner-stroke)\" stroke-width=\"0.5\" />\n<text x=\"${J.x}\" y=\"${J.y}\" text-anchor=\"middle\" dy=\"${z4}\" font-size=\"${r.edgeLabel}\" font-weight=\"${$4.edgeLabel}\" fill=\"var(--_text-muted)\">${W6($.label)}</text>`}function c$($){if($.points.length<2)return\"\";let J=[],K=$.points[0],Q=$.points[1];J.push(v7(K,Q,$.cardinality1));let Z=$.points[$.points.length-1],V=$.points[$.points.length-2];return J.push(v7(Z,V,$.cardinality2)),J.join(`\n`)}function v7($,J,K){let Q=[],Z=V4.connector+0.25,V=$.x-J.x,B=$.y-J.y,U=Math.sqrt(V*V+B*B);if(U===0)return\"\";let j=V/U,P=B/U,G=-P,q=j,z=$.x-j*4,_=$.y-P*4,W=$.x-j*16,F=$.y-P*16,H=K===\"one\"||K===\"zero-one\",M=K===\"many\"||K===\"zero-many\",R=K===\"zero-one\"||K===\"zero-many\";if(H){Q.push(`<line x1=\"${z+G*6}\" y1=\"${_+q*6}\" x2=\"${z-G*6}\" y2=\"${_-q*6}\" stroke=\"var(--_line)\" stroke-width=\"${Z}\" />`);let L=z-j*4,b=_-P*4;Q.push(`<line x1=\"${L+G*6}\" y1=\"${b+q*6}\" x2=\"${L-G*6}\" y2=\"${b-q*6}\" stroke=\"var(--_line)\" stroke-width=\"${Z}\" />`)}if(M){let L=z,b=_;Q.push(`<line x1=\"${L+G*7}\" y1=\"${b+q*7}\" x2=\"${W}\" y2=\"${F}\" stroke=\"var(--_line)\" stroke-width=\"${Z}\" />`),Q.push(`<line x1=\"${L}\" y1=\"${b}\" x2=\"${W}\" y2=\"${F}\" stroke=\"var(--_line)\" stroke-width=\"${Z}\" />`),Q.push(`<line x1=\"${L-G*7}\" y1=\"${b-q*7}\" x2=\"${W}\" y2=\"${F}\" stroke=\"var(--_line)\" stroke-width=\"${Z}\" />`)}if(R){let Y=M?20:12,L=$.x-j*Y,b=$.y-P*Y;Q.push(`<circle cx=\"${L}\" cy=\"${b}\" r=\"4\" fill=\"var(--bg)\" stroke=\"var(--_line)\" stroke-width=\"${Z}\" />`)}return Q.join(`\n`)}function p$($){if($.length===0)return{x:0,y:0};if($.length===1)return $[0];let J=0;for(let Z=1;Z<$.length;Z++){let V=$[Z].x-$[Z-1].x,B=$[Z].y-$[Z-1].y;J+=Math.sqrt(V*V+B*B)}if(J===0)return $[0];let K=J/2,Q=0;for(let Z=1;Z<$.length;Z++){let V=$[Z].x-$[Z-1].x,B=$[Z].y-$[Z-1].y,U=Math.sqrt(V*V+B*B);if(Q+U>=K){let j=U>0?(K-Q)/U:0;return{x:$[Z-1].x+V*j,y:$[Z-1].y+B*j}}Q+=U}return $[$.length-1]}function W6($){return $.replace(/&/g,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\").replace(/'/g,\"&#39;\")}function h7($){let J={},K={},Q=[],Z,V=!1;for(let B of $){if(/^xychart(-beta)?\\b/i.test(B)){if(/\\bhorizontal\\b/i.test(B))V=!0;continue}let U=B.match(/^title\\s+\"([^\"]+)\"/);if(U){Z=U[1];continue}let j=B.match(/^x-axis\\s+(?:\"([^\"]*)\"\\s*)?\\[([^\\]]+)\\]/);if(j){if(j[1])J.title=j[1];J.categories=j[2].split(\",\").map((W)=>W.trim());continue}let P=B.match(/^x-axis\\s+(?:\"([^\"]*)\"\\s+)?(-?\\d+(?:\\.\\d+)?)\\s*-->\\s*(-?\\d+(?:\\.\\d+)?)/);if(P){if(P[1])J.title=P[1];J.range={min:parseFloat(P[2]),max:parseFloat(P[3])};continue}let G=B.match(/^y-axis\\s+(?:\"([^\"]*)\"\\s+)?(-?\\d+(?:\\.\\d+)?)\\s*-->\\s*(-?\\d+(?:\\.\\d+)?)/);if(G){if(G[1])K.title=G[1];K.range={min:parseFloat(G[2]),max:parseFloat(G[3])};continue}let q=B.match(/^y-axis\\s+\"([^\"]+)\"\\s*$/);if(q){K.title=q[1];continue}let z=B.match(/^bar\\s+\\[([^\\]]+)\\]/);if(z){Q.push({type:\"bar\",data:m7(z[1])});continue}let _=B.match(/^line\\s+\\[([^\\]]+)\\]/);if(_){Q.push({type:\"line\",data:m7(_[1])});continue}}if(!K.range&&Q.length>0){let B=Q.flatMap((G)=>G.data),U=Math.min(...B),j=Math.max(...B),P=j-U||1;if(U=U-P*0.1,j=j+P*0.1,U>0&&U<P*0.5)U=0;K.range={min:U,max:j}}if(!K.range)K.range={min:0,max:100};return{title:Z,horizontal:V,xAxis:J,yAxis:K,series:Q}}function m7($){return $.split(\",\").map((J)=>parseFloat(J.trim()))}var a={plotWidth:600,plotHeight:300,padding:16,titleFontSize:14,titleFontWeight:600,titleHeight:28,axisLabelFontSize:12,axisLabelFontWeight:400,axisTitleFontSize:12,axisTitleFontWeight:500,xLabelHeight:28,yLabelWidth:50,axisTitlePad:20,tickLength:5,barPadRatio:0.2,barGroupGap:2,legendFontSize:12,legendFontWeight:400,legendHeight:22,legendSwatchW:40,legendSwatchH:12,legendGap:6,legendItemGap:16};async function c7($,J={}){if($.horizontal)return d$($);return l$($)}function l$($){let J=!!$.title,K=!!$.xAxis.title,Q=!!$.yAxis.title,Z=$.series.length>1,V=$.yAxis.range,B=p7(V.min,V.max),U=Math.max(...B.map((I)=>U4(M6(I),a.axisLabelFontSize,a.axisLabelFontWeight)),a.yLabelWidth),j=a.padding+(J?a.titleHeight:0)+(Z?a.legendHeight:0),P=a.padding+a.xLabelHeight+(K?a.axisTitlePad:0),G=a.padding+U+8+(Q?a.axisTitlePad:0),q=a.padding,z=a.plotWidth,_=a.plotHeight,W=G+z+q,F=j+_+P,H={x:G,y:j,width:z,height:_},M=v6($),R=(I)=>G+(I+0.5)*(z/M),Y=z/M,L=(I)=>{let d=(I-V.min)/(V.max-V.min||1);return j+_-d*_},b=g$($,R,j+_,Y),O=B.map((I)=>({label:M6(I),x:G,y:L(I),tx:G-a.tickLength,ty:L(I),labelX:G-8,labelY:L(I),textAnchor:\"end\"})),A=B.map((I)=>({x1:G,y1:L(I),x2:G+z,y2:L(I)})),D=u6($,M),T=n$($,R,L,Y,V.min,D),v=o$($,R,L,D),u=a.padding+(J?a.titleHeight:0)+a.legendHeight/2,y=Z?l7($,W/2,u):[],f={x1:G,y1:j+_,x2:G+z,y2:j+_},X={x1:G,y1:j,x2:G,y2:j+_},N={ticks:b,line:f,...K?{title:{text:$.xAxis.title,x:G+z/2,y:F-a.padding}}:{}},S={ticks:O,line:X,...Q?{title:{text:$.yAxis.title,x:a.padding+4,y:j+_/2,rotate:-90}}:{}},w=J?{text:$.title,x:W/2,y:a.padding+a.titleFontSize}:void 0;return{width:W,height:F,title:w,xAxis:N,yAxis:S,plotArea:H,bars:T,lines:v,gridLines:A,legend:y}}function d$($){let J=!!$.title,K=!!$.xAxis.title,Q=!!$.yAxis.title,Z=$.series.length>1,V=$.yAxis.range,B=p7(V.min,V.max),U=v6($),j=u6($,U),P=Math.max(...j.map((C)=>U4(C,a.axisLabelFontSize,a.axisLabelFontWeight)),40),G=a.padding+(J?a.titleHeight:0)+(Z?a.legendHeight:0),q=a.padding+a.xLabelHeight+(Q?a.axisTitlePad:0),z=a.padding+P+8+(K?a.axisTitlePad:0),_=a.padding,W=a.plotWidth,F=a.plotHeight,H=z+W+_,M=G+F+q,R={x:z,y:G,width:W,height:F},Y=(C)=>{let c=(C-V.min)/(V.max-V.min||1);return z+c*W},L=F/U,b=(C)=>G+(C+0.5)*L,O=B.map((C)=>({label:M6(C),x:Y(C),y:G+F,tx:Y(C),ty:G+F+a.tickLength,labelX:Y(C),labelY:G+F+18,textAnchor:\"middle\"})),A=j.map((C,c)=>({label:C,x:z,y:b(c),tx:z-a.tickLength,ty:b(c),labelX:z-8,labelY:b(c),textAnchor:\"end\"})),D=B.map((C)=>({x1:Y(C),y1:G,x2:Y(C),y2:G+F})),T=$.series.filter((C)=>C.type===\"bar\"),v=T.length,u=[];if(v>0){let C=L*(1-a.barPadRatio),c=v>1?(C-(v-1)*a.barGroupGap)/v:C,l=0;for(let p of T){for(let h=0;h<p.data.length;h++){let o=b(h)-C/2+l*(c+a.barGroupGap),E=Y(Math.max(p.data[h],V.min)),k=Y(Math.max(0,V.min));u.push({x:Math.min(k,E),y:o,width:Math.abs(E-k),height:c,value:p.data[h],label:j[h],seriesIndex:l})}l++}}let y=[],f=0;for(let C of $.series){if(C.type!==\"line\")continue;let c=C.data.map((l,p)=>({x:Y(l),y:b(p),value:l,label:j[p]}));y.push({points:c,seriesIndex:f}),f++}let X={x1:z,y1:G+F,x2:z+W,y2:G+F},N={x1:z,y1:G,x2:z,y2:G+F},S={ticks:O,line:X,...Q?{title:{text:$.yAxis.title,x:z+W/2,y:M-a.padding}}:{}},w={ticks:A,line:N,...K?{title:{text:$.xAxis.title,x:a.padding+4,y:G+F/2,rotate:-90}}:{}},I=J?{text:$.title,x:H/2,y:a.padding+a.titleFontSize}:void 0,d=a.padding+(J?a.titleHeight:0)+a.legendHeight/2,s=Z?l7($,H/2,d):[];return{width:H,height:M,title:I,xAxis:S,yAxis:w,plotArea:R,bars:u,lines:y,gridLines:D,legend:s}}function v6($){if($.xAxis.categories)return $.xAxis.categories.length;for(let J of $.series)if(J.data.length>0)return J.data.length;return 1}function u6($,J){if($.xAxis.categories)return $.xAxis.categories;if($.xAxis.range){let{min:K,max:Q}=$.xAxis.range,Z=J>1?(Q-K)/(J-1):0;return Array.from({length:J},(V,B)=>M6(K+Z*B))}return Array.from({length:J},(K,Q)=>String(Q+1))}function g$($,J,K,Q){let Z=v6($);return u6($,Z).map((B,U)=>({label:B,x:J(U),y:K,tx:J(U),ty:K+a.tickLength,labelX:J(U),labelY:K+18,textAnchor:\"middle\"}))}function n$($,J,K,Q,Z,V){let B=$.series.filter((z)=>z.type===\"bar\"),U=B.length;if(U===0)return[];let j=Q*(1-a.barPadRatio),P=U>1?(j-(U-1)*a.barGroupGap)/U:j,G=[],q=0;for(let z of B){for(let _=0;_<z.data.length;_++){let H=J(_)-j/2+q*(P+a.barGroupGap),M=K(z.data[_]),R=K(Math.max(0,Z));G.push({x:H,y:Math.min(M,R),width:P,height:Math.abs(R-M),value:z.data[_],label:V[_],seriesIndex:q})}q++}return G}function o$($,J,K,Q){let Z=[],V=0;for(let B of $.series){if(B.type!==\"line\")continue;let U=B.data.map((j,P)=>({x:J(P),y:K(j),value:j,label:Q[P]}));Z.push({points:U,seriesIndex:V}),V++}return Z}function p7($,J){let K=J-$;if(K<=0)return[$];let Q=K/6,Z=Math.pow(10,Math.floor(Math.log10(Q))),V=Q/Z,B;if(V<=1.5)B=Z;else if(V<=3)B=2*Z;else if(V<=7)B=5*Z;else B=10*Z;let U=Math.ceil($/B)*B,j=[];for(let P=U;P<=J+B*0.001;P+=B)j.push(Math.round(P*10000000000)/10000000000);return j}function M6($){if(Number.isInteger($))return String($);return $.toFixed(Math.abs($)<10?1:0)}function l7($,J,K){let Q=[],Z=0,V=0;for(let P of $.series){let G=P.type===\"bar\"?`Bar ${Z+1}`:`Line ${V+1}`;if(Q.push({label:G,x:0,y:K,type:P.type,seriesIndex:P.type===\"bar\"?Z:V}),P.type===\"bar\")Z++;else V++}let B=Q.map((P)=>{let G=U4(P.label,a.legendFontSize,a.legendFontWeight);return a.legendSwatchW+a.legendGap+G}),U=B.reduce((P,G)=>P+G,0)+(Q.length-1)*a.legendItemGap,j=J-U/2;for(let P=0;P<Q.length;P++)Q[P].x=j,j+=B[P]+a.legendItemGap;return Q}var _4={titleSize:14,titleWeight:600,axisTitleSize:12,axisTitleWeight:500,labelSize:12,labelWeight:400,legendSize:12,legendWeight:400,dotRadius:3,lineWidth:2},k4={fontSize:11,fontWeight:500,height:18,padX:8,offsetY:6,rx:4,minY:4};function s7($,J,K=\"Inter\",Q=!1,Z=!1){let V=[];V.push(Y4($.width,$.height,J,Q)),V.push(D4(K,!1)),V.push(s$($,Z));for(let j of $.gridLines)V.push(`<line x1=\"${j.x1}\" y1=\"${j.y1}\" x2=\"${j.x2}\" y2=\"${j.y2}\" class=\"xychart-grid\"/>`);for(let j of $.bars){let P=` data-value=\"${j.value}\"${j.label?` data-label=\"${O4(j.label)}\"`:\"\"}`;if(Z){let G=o7(j.value),q=j.label?`${j.label}: ${G}`:G,z=n7(j.x+j.width/2,j.y,G);V.push(`<g class=\"xychart-bar-group\"><rect x=\"${j.x}\" y=\"${j.y}\" width=\"${j.width}\" height=\"${j.height}\" rx=\"2\" class=\"xychart-bar xychart-bar-${j.seriesIndex}\"${P}/><title>${O4(q)}</title>`+z+\"</g>\")}else V.push(`<rect x=\"${j.x}\" y=\"${j.y}\" width=\"${j.width}\" height=\"${j.height}\" rx=\"2\" class=\"xychart-bar xychart-bar-${j.seriesIndex}\"${P}/>`)}for(let j of $.lines){if(j.points.length===0)continue;let P=j.points.map((G,q)=>`${q===0?\"M\":\"L\"}${G.x},${G.y}`).join(\" \");V.push(`<path d=\"${P}\" class=\"xychart-line xychart-line-${j.seriesIndex}\"/>`);for(let G of j.points){let q=` data-value=\"${G.value}\"${G.label?` data-label=\"${O4(G.label)}\"`:\"\"}`;if(Z){let z=o7(G.value),_=G.label?`${G.label}: ${z}`:z,W=n7(G.x,G.y-_4.dotRadius,z);V.push(`<g class=\"xychart-dot-group\"><circle cx=\"${G.x}\" cy=\"${G.y}\" r=\"${_4.dotRadius}\" class=\"xychart-dot xychart-line-${j.seriesIndex}\"${q}/><title>${O4(_)}</title>`+W+\"</g>\")}else V.push(`<circle cx=\"${G.x}\" cy=\"${G.y}\" r=\"${_4.dotRadius}\" class=\"xychart-dot xychart-line-${j.seriesIndex}\"${q}/>`)}}let B=$.xAxis.line,U=$.yAxis.line;V.push(`<line x1=\"${B.x1}\" y1=\"${B.y1}\" x2=\"${B.x2}\" y2=\"${B.y2}\" class=\"xychart-axis\"/>`),V.push(`<line x1=\"${U.x1}\" y1=\"${U.y1}\" x2=\"${U.x2}\" y2=\"${U.y2}\" class=\"xychart-axis\"/>`);for(let j of $.xAxis.ticks)V.push(`<line x1=\"${j.x}\" y1=\"${j.y}\" x2=\"${j.tx}\" y2=\"${j.ty}\" class=\"xychart-axis\"/>`);for(let j of $.yAxis.ticks)V.push(`<line x1=\"${j.x}\" y1=\"${j.y}\" x2=\"${j.tx}\" y2=\"${j.ty}\" class=\"xychart-axis\"/>`);for(let j of $.xAxis.ticks)V.push(`<text x=\"${j.labelX}\" y=\"${j.labelY}\" text-anchor=\"${j.textAnchor}\" font-size=\"${_4.labelSize}\" font-weight=\"${_4.labelWeight}\" dy=\"${z4}\" class=\"xychart-label\">${O4(j.label)}</text>`);for(let j of $.yAxis.ticks)V.push(`<text x=\"${j.labelX}\" y=\"${j.labelY}\" text-anchor=\"${j.textAnchor}\" font-size=\"${_4.labelSize}\" font-weight=\"${_4.labelWeight}\" dy=\"${z4}\" class=\"xychart-label\">${O4(j.label)}</text>`);if($.xAxis.title){let j=$.xAxis.title,P=j.rotate?` transform=\"rotate(${j.rotate},${j.x},${j.y})\"`:\"\";V.push(`<text x=\"${j.x}\" y=\"${j.y}\" text-anchor=\"middle\"${P} font-size=\"${_4.axisTitleSize}\" font-weight=\"${_4.axisTitleWeight}\" dy=\"${z4}\" class=\"xychart-axis-title\">${O4(j.text)}</text>`)}if($.yAxis.title){let j=$.yAxis.title,P=j.rotate?` transform=\"rotate(${j.rotate},${j.x},${j.y})\"`:\"\";V.push(`<text x=\"${j.x}\" y=\"${j.y}\" text-anchor=\"middle\"${P} font-size=\"${_4.axisTitleSize}\" font-weight=\"${_4.axisTitleWeight}\" dy=\"${z4}\" class=\"xychart-axis-title\">${O4(j.text)}</text>`)}if($.title)V.push(`<text x=\"${$.title.x}\" y=\"${$.title.y}\" text-anchor=\"middle\" font-size=\"${_4.titleSize}\" font-weight=\"${_4.titleWeight}\" dy=\"${z4}\" class=\"xychart-title\">${O4($.title.text)}</text>`);for(let j of $.legend){if(j.type===\"bar\")V.push(`<rect x=\"${j.x}\" y=\"${j.y-6}\" width=\"40\" height=\"12\" rx=\"1\" class=\"xychart-bar xychart-bar-${j.seriesIndex}\"/>`);else{let z=j.y;V.push(`<line x1=\"${j.x}\" y1=\"${z}\" x2=\"${j.x+40}\" y2=\"${z}\" stroke-width=\"${_4.lineWidth}\" stroke-linecap=\"round\" class=\"xychart-legend-line xychart-line-${j.seriesIndex}\"/><circle cx=\"${j.x+20}\" cy=\"${z}\" r=\"${_4.dotRadius}\" class=\"xychart-dot xychart-line-${j.seriesIndex}\"/>`)}V.push(`<text x=\"${j.x+40+6}\" y=\"${j.y}\" text-anchor=\"start\" font-size=\"${_4.legendSize}\" font-weight=\"${_4.legendWeight}\" dy=\"${z4}\" class=\"xychart-label\">${O4(j.label)}</text>`)}return V.push(\"</svg>\"),V.join(`\n`)}function s$($,J){let K=new Set($.bars.map((B)=>B.seriesIndex)).size,Q=new Set($.lines.map((B)=>B.seriesIndex)).size,Z=[];for(let B=0;B<K;B++)if(K<=1)Z.push(`  .xychart-bar-${B} { fill: color-mix(in srgb, var(--_arrow) 70%, var(--bg)); stroke: var(--_arrow); stroke-width: 0.5; }`);else{let U=d7[B%d7.length];Z.push(`  .xychart-bar-${B} { fill: ${U}; fill-opacity: 0.7; stroke: ${U}; stroke-width: 1; }`)}for(let B=0;B<Q;B++)if(Q<=1&&K===0)Z.push(`  path.xychart-line-${B}, line.xychart-line-${B} { stroke: var(--_arrow); }`),Z.push(`  circle.xychart-line-${B} { fill: var(--_arrow); }`);else{let U=g7[B%g7.length];Z.push(`  path.xychart-line-${B}, line.xychart-line-${B} { stroke: ${U}; }`),Z.push(`  circle.xychart-line-${B} { fill: ${U}; }`)}let V=J?`\n  .xychart-tip { opacity: 0; pointer-events: none; transition: opacity 0.15s ease; }\n  .xychart-tip-bg { fill: var(--_text); }\n  .xychart-tip-text { fill: var(--bg); font-size: ${k4.fontSize}px; font-weight: ${k4.fontWeight}; }\n  .xychart-bar-group:hover .xychart-tip,\n  .xychart-dot-group:hover .xychart-tip { opacity: 1; }\n  .xychart-bar-group:hover .xychart-bar { filter: brightness(1.1); }\n  .xychart-dot-group:hover .xychart-dot { r: 5; transition: r 0.15s ease; }`:\"\";return`<style>\n  .xychart-grid { stroke: var(--_inner-stroke); stroke-width: 1; }\n  .xychart-bar { rx: 2; }\n  .xychart-line { fill: none; stroke-width: ${_4.lineWidth}; stroke-linecap: round; stroke-linejoin: round; }\n  .xychart-dot { stroke: var(--bg); stroke-width: 1.5; }\n  .xychart-axis { stroke: var(--_line); stroke-width: 1; }\n  .xychart-label { fill: var(--_text-muted); }\n  .xychart-axis-title { fill: var(--_text-sec); }\n  .xychart-title { fill: var(--_text); }\n${Z.join(`\n`)}${V}\n</style>`}var d7=[\"#3b82f6\",\"#10b981\",\"#f59e0b\",\"#ef4444\",\"#8b5cf6\",\"#ec4899\",\"#06b6d4\",\"#84cc16\"],g7=[\"#ef4444\",\"#10b981\",\"#f59e0b\",\"#8b5cf6\",\"#06b6d4\",\"#ec4899\",\"#3b82f6\",\"#84cc16\"];function n7($,J,K){let Z=U4(K,k4.fontSize,k4.fontWeight)+k4.padX*2,V=k4.height,B=Math.max(k4.minY,J-k4.offsetY-V),U=$-Z/2,j=$,P=B+V/2;return`<rect x=\"${Q6(U)}\" y=\"${Q6(B)}\" width=\"${Q6(Z)}\" height=\"${V}\" rx=\"${k4.rx}\" class=\"xychart-tip xychart-tip-bg\"/><text x=\"${Q6(j)}\" y=\"${Q6(P)}\" text-anchor=\"middle\" dy=\"${z4}\" class=\"xychart-tip xychart-tip-text\">${O4(K)}</text>`}function o7($){if(Number.isInteger($))return String($);return $.toFixed(Math.abs($)<10?1:0)}function Q6($){return String(Math.round($*10)/10)}function O4($){return $.replace(/&/g,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\")}function i$($){let J=$.trim().split(/[\\n;]/)[0]?.trim().toLowerCase()??\"\";if(/^xychart(-beta)?\\b/.test(J))return\"xychart\";if(/^sequencediagram\\s*$/.test(J))return\"sequence\";if(/^classdiagram\\s*$/.test(J))return\"class\";if(/^erdiagram\\s*$/.test(J))return\"er\";return\"flowchart\"}function a$($){return{bg:$.bg??c4.bg,fg:$.fg??c4.fg,line:$.line,accent:$.accent,muted:$.muted,surface:$.surface,border:$.border}}async function i7($,J={}){let K=a$(J),Q=J.font??\"Inter\",Z=J.transparent??!1,V=i$($),B=$.split(/[\\n;]/).map((U)=>U.trim()).filter((U)=>U.length>0&&!U.startsWith(\"%%\"));switch(V){case\"sequence\":{let U=V6(B),j=T7(U,J);return k7(j,K,Q,Z)}case\"class\":{let U=z6(B),j=await S7(U,J);return y7(j,K,Q,Z)}case\"er\":{let U=j6(B),j=await x7(U,J);return u7(j,K,Q,Z)}case\"xychart\":{let U=h7(B),j=await c7(U,J);return s7(j,K,Q,Z,J.interactive??!1)}case\"flowchart\":default:{let U=s4($),j=await R7(U,J);return N7(j,K,Q,Z)}}}if(typeof window<\"u\")window.__mermaid={renderMermaid:i7,renderMermaidAscii:E6,THEMES:O6,DEFAULTS:c4,fromShikiTheme:A6};export{E6 as renderMermaidAscii,i7 as renderMermaid,A6 as fromShikiTheme,O6 as THEMES,c4 as DEFAULTS};\n\n// Module has loaded — window.__mermaid is now set. Trigger initial render.\nif (window.__renderAllSvgs) window.__renderAllSvgs(window.__initThemeKey);\n  </script>\n</body>\n</html>"
  },
  {
    "path": "xychart-test.ts",
    "content": "/**\n * Generates xychart-test.html showcasing xychart-beta Mermaid examples.\n *\n * Usage: bun run xychart-test.ts\n *\n * For each example, renders a 3-column grid:\n *   1. Shiki-highlighted mermaid source\n *   2. Chart.js reference rendering\n *   3. beautiful-mermaid SVG rendering (client-side via bundled renderer)\n */\n\nimport { xychartSamples } from './xychart-samples-data.ts'\nimport { THEMES } from './src/theme.ts'\nimport { createHighlighter } from 'shiki'\n\nfunction escapeHtml(text: string): string {\n  return text\n    .replace(/&/g, '&amp;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n    .replace(/\"/g, '&quot;')\n}\n\nfunction formatDescription(text: string): string {\n  return text.replace(/`([^`]+)`/g, '<code>$1</code>')\n}\n\nconst THEME_LABELS: Record<string, string> = {\n  'zinc-dark': 'Zinc Dark',\n  'tokyo-night': 'Tokyo Night',\n  'tokyo-night-storm': 'Tokyo Storm',\n  'tokyo-night-light': 'Tokyo Light',\n  'catppuccin-mocha': 'Catppuccin',\n  'catppuccin-latte': 'Latte',\n  'nord': 'Nord',\n  'nord-light': 'Nord Light',\n  'dracula': 'Dracula',\n  'github-light': 'GitHub',\n  'github-dark': 'GitHub Dark',\n  'solarized-light': 'Solarized',\n  'solarized-dark': 'Solar Dark',\n  'one-dark': 'One Dark',\n}\n\nasync function generateHtml(): Promise<string> {\n  const highlighter = await createHighlighter({\n    langs: ['mermaid'],\n    themes: ['github-light'],\n  })\n\n  // Bundle the mermaid renderer for client-side SVG rendering\n  const buildResult = await Bun.build({\n    entrypoints: [new URL('./src/browser.ts', import.meta.url).pathname],\n    target: 'browser',\n    format: 'esm',\n    minify: true,\n  })\n  const bundleJs = await buildResult.outputs[0].text()\n\n  // Group samples by category for TOC\n  const categories = new Map<string, number[]>()\n  xychartSamples.forEach((sample, i) => {\n    const cat = sample.category ?? 'Other'\n    if (!categories.has(cat)) categories.set(cat, [])\n    categories.get(cat)!.push(i)\n  })\n\n  const tocSections = [...categories.entries()].map(([cat, indices]) => {\n    const items = indices.map(i => {\n      const title = xychartSamples[i]!.title\n      return `<li><a href=\"#sample-${i}\"><span class=\"toc-num\">${i + 1}.</span> ${escapeHtml(title)}</a></li>`\n    }).join('\\n            ')\n    return `\n        <div class=\"toc-category\">\n          <h3>${escapeHtml(cat)} (${indices.length})</h3>\n          <ol start=\"${indices[0]! + 1}\">\n            ${items}\n          </ol>\n        </div>`\n  }).join('\\n')\n\n  // Theme pills\n  const VISIBLE_THEMES = new Set(['dracula', 'solarized-light'])\n\n  function buildThemePill(key: string, colors: { bg: string; fg: string }, active = false): string {\n    const isDark = parseInt(colors.bg.replace('#', '').slice(0, 2), 16) < 0x80\n    const shadow = isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'\n    const label = key === '' ? 'Default' : (THEME_LABELS[key] ?? key)\n    const activeClass = active ? ' active' : ''\n    return `<button class=\"theme-pill shadow-minimal${activeClass}\" data-theme=\"${key}\"><span class=\"theme-swatch\" style=\"background:${colors.bg};box-shadow:inset 0 0 0 1px ${shadow}\"></span>${escapeHtml(label)}</button>`\n  }\n\n  const themeEntries = Object.entries(THEMES)\n  const visiblePills = [\n    '<button class=\"theme-pill shadow-minimal active\" data-theme=\"\"><span class=\"theme-swatch\" style=\"background:#FFFFFF;box-shadow:inset 0 0 0 1px rgba(0,0,0,0.1)\"></span>Default</button>',\n    ...themeEntries\n      .filter(([key]) => VISIBLE_THEMES.has(key))\n      .map(([key, colors]) => buildThemePill(key, colors)),\n  ]\n  const allDropdownPills = [\n    buildThemePill('', { bg: '#FFFFFF', fg: '#27272A' }, true),\n    ...themeEntries.map(([key, colors]) => buildThemePill(key, colors)),\n  ]\n  const totalThemes = allDropdownPills.length\n\n  const themePillsHtml = `\n    <div class=\"theme-pills-inline\">\n      ${visiblePills.join('\\n      ')}\n    </div>\n    <div class=\"theme-more-wrapper\">\n      <button class=\"theme-pill shadow-minimal\" id=\"theme-more-btn\">${totalThemes} Themes</button>\n      <div class=\"theme-more-dropdown shadow-modal-small\" id=\"theme-more-dropdown\">\n        ${allDropdownPills.join('\\n        ')}\n      </div>\n    </div>`\n\n  // Pre-highlight sources with Shiki\n  const highlightedSources = xychartSamples.map(sample => {\n    const fenced = '```mermaid\\n' + sample.source.trim() + '\\n```'\n    const html = highlighter.codeToHtml(fenced, {\n      lang: 'mermaid',\n      theme: 'github-light',\n    })\n    return html.replace(\n      /(<code>)<span class=\"line\">.*?<\\/span>\\n/,\n      '$1'\n    ).replace(\n      /\\n<span class=\"line\">.*?<\\/span>(<\\/code>)/,\n      '$1'\n    )\n  })\n\n  // Build sample cards grouped by category\n  let currentCategory = ''\n  const sampleCards = xychartSamples.map((sample, i) => {\n    const cat = sample.category ?? 'Other'\n    let sectionHeader = ''\n    if (cat !== currentCategory) {\n      currentCategory = cat\n      sectionHeader = `\\n  <h2 class=\"section-title\" id=\"cat-${cat.toLowerCase().replace(/[^a-z0-9]+/g, '-')}\">${escapeHtml(cat)}</h2>\\n`\n    }\n\n    return `${sectionHeader}\n    <section class=\"sample\" id=\"sample-${i}\">\n      <div class=\"sample-header\">\n        <h2>${escapeHtml(sample.title)}</h2>\n        <p class=\"description\">${formatDescription(sample.description)}</p>\n      </div>\n      <div class=\"sample-content\">\n        <div class=\"source-panel\">\n          ${highlightedSources[i]}\n        </div>\n        <div class=\"chart-panel\" id=\"chart-panel-${i}\">\n          <canvas id=\"chart-${i}\"></canvas>\n        </div>\n        <div class=\"svg-panel\" id=\"svg-panel-${i}\">\n          <div class=\"svg-loading\">Rendering&hellip;</div>\n        </div>\n      </div>\n    </section>`\n  }).join('\\n')\n\n  // Build THEMES JSON for client-side use\n  const themesJson = JSON.stringify(THEMES)\n\n  // Build sources JSON for the parser\n  const sourcesJson = JSON.stringify(xychartSamples.map(s => s.source))\n\n  return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n  <meta name=\"theme-color\" id=\"theme-color-meta\" content=\"#f9f9fa\" />\n  <title>XY Chart Test — Beautiful Mermaid</title>\n  <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n  <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\n  <link href=\"https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap\" rel=\"stylesheet\" />\n  <script src=\"https://cdn.jsdelivr.net/npm/chart.js\"></script>\n  <style>\n    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n\n    body {\n      --t-bg: #FFFFFF;\n      --t-fg: #27272A;\n      --t-accent: #3b82f6;\n      --foreground-rgb: 39, 39, 42;\n      --accent-rgb: 59, 130, 246;\n      --shadow-border-opacity: 0.08;\n      --shadow-blur-opacity: 0.06;\n      --theme-bar-bg: #f9f9fa;\n\n      font-family: 'Geist', system-ui, -apple-system, sans-serif;\n      background: color-mix(in srgb, var(--t-fg) 4%, var(--t-bg));\n      color: var(--t-fg);\n      line-height: 1.6;\n      margin: 0;\n      transition: background 0.2s, color 0.2s;\n    }\n    .content-wrapper {\n      max-width: 1440px;\n      margin: 0 auto;\n      padding: 2rem;\n      padding-top: 0;\n    }\n    @media (min-width: 1000px) {\n      .content-wrapper { padding: 3rem; padding-top: 0; }\n    }\n\n    body::before, body::after {\n      content: '';\n      position: fixed;\n      left: 0; right: 0;\n      height: 64px;\n      pointer-events: none;\n      z-index: 1000;\n      will-change: transform;\n    }\n    body::before {\n      top: 0;\n      background: linear-gradient(to bottom, var(--theme-bar-bg) 0%, transparent 100%);\n    }\n    body::after {\n      bottom: 0;\n      background: linear-gradient(to top, var(--theme-bar-bg) 0%, transparent 100%);\n    }\n\n    /* Theme bar */\n    .theme-bar {\n      position: sticky; top: 0; z-index: 1001;\n      background: transparent;\n      padding: 0.5rem 2rem;\n      display: flex; align-items: center; gap: 0.75rem;\n      overflow: visible;\n    }\n    .theme-label {\n      font-size: 0.7rem; font-weight: 600;\n      text-transform: uppercase; letter-spacing: 0.06em;\n      color: color-mix(in srgb, var(--t-fg) 35%, var(--t-bg));\n      white-space: nowrap;\n    }\n    .theme-pills {\n      display: flex; gap: 0.3rem; overflow: visible;\n      padding: 4px; margin: -4px; margin-left: auto;\n      position: relative; z-index: 2;\n    }\n    .theme-pills-inline { display: flex; gap: 0.3rem; }\n    @media (max-width: 1024px) {\n      .theme-pills-inline { display: none; }\n    }\n    .theme-pill {\n      display: flex; align-items: center; height: 30px;\n      gap: 8px; padding: 0 14px 0 12px; border: none; border-radius: 8px;\n      background: color-mix(in srgb, var(--t-bg) 97%, var(--t-fg));\n      color: color-mix(in srgb, var(--t-fg) 80%, var(--t-bg));\n      font-size: 12px; font-weight: 500; font-family: inherit;\n      cursor: pointer; white-space: nowrap;\n      transition: color 0.15s, background 0.15s, box-shadow 0.2s, transform 0.1s;\n    }\n    .theme-pill:hover {\n      color: var(--t-fg);\n      background: color-mix(in srgb, var(--t-bg) 92%, var(--t-fg));\n    }\n    .theme-pill.active {\n      color: var(--t-fg); background: var(--t-bg); font-weight: 600;\n    }\n    .theme-pill:active { transform: translateY(0.5px); }\n    .theme-swatch {\n      display: inline-block; width: 14px; height: 14px;\n      border-radius: 50%; flex-shrink: 0;\n    }\n\n    .theme-more-wrapper { position: relative; }\n    .theme-more-dropdown {\n      display: none; position: absolute; top: calc(100% + 6px); right: 0;\n      background: var(--t-bg); border-radius: 12px; padding: 6px;\n      flex-direction: column; gap: 2px; min-width: 160px; z-index: 1002;\n    }\n    .theme-more-dropdown.open { display: flex; }\n    .theme-more-dropdown .theme-pill {\n      width: 100%; justify-content: flex-start;\n      background: transparent; box-shadow: none;\n    }\n    .theme-more-dropdown .theme-pill:hover {\n      background: color-mix(in srgb, var(--t-bg) 92%, var(--t-fg));\n    }\n    .theme-more-dropdown .theme-pill.active,\n    .theme-more-dropdown .theme-pill.shadow-tinted {\n      background: var(--t-bg);\n      box-shadow:\n        rgba(0,0,0,0) 0px 0px 0px 0px, rgba(0,0,0,0) 0px 0px 0px 0px,\n        rgba(var(--foreground-rgb), 0.06) 0px 0px 0px 1px,\n        rgba(0,0,0,var(--shadow-blur-opacity)) 0px 1px 1px -0.5px,\n        rgba(0,0,0,var(--shadow-blur-opacity)) 0px 3px 3px -1.5px;\n    }\n\n    /* Contents button */\n    .contents-btn {\n      position: absolute; left: 50%; transform: translateX(-50%);\n      display: flex; align-items: center; height: 30px; gap: 6px;\n      padding: 0 12px; border: none; border-radius: 8px;\n      background: color-mix(in srgb, var(--t-bg) 97%, var(--t-fg));\n      color: color-mix(in srgb, var(--t-fg) 80%, var(--t-bg));\n      font-size: 12px; font-weight: 500; font-family: inherit;\n      cursor: pointer; white-space: nowrap;\n      transition: color 0.15s, background 0.15s, box-shadow 0.2s, transform 0.1s;\n    }\n    .contents-btn:hover {\n      color: var(--t-fg);\n      background: color-mix(in srgb, var(--t-bg) 92%, var(--t-fg));\n    }\n    .contents-btn.active { color: var(--t-fg); background: var(--t-bg); }\n    .contents-btn:active { transform: translateX(-50%) translateY(0.5px); }\n    .contents-btn svg { width: 14px; height: 14px; flex-shrink: 0; }\n\n    /* Shadow utilities */\n    .shadow-minimal {\n      box-shadow:\n        rgba(0,0,0,0) 0px 0px 0px 0px, rgba(0,0,0,0) 0px 0px 0px 0px,\n        rgba(var(--foreground-rgb), 0.06) 0px 0px 0px 1px,\n        rgba(0,0,0,var(--shadow-blur-opacity)) 0px 1px 1px -0.5px,\n        rgba(0,0,0,var(--shadow-blur-opacity)) 0px 3px 3px -1.5px;\n    }\n    .shadow-modal-small {\n      box-shadow:\n        rgba(0,0,0,0) 0px 0px 0px 0px, rgba(0,0,0,0) 0px 0px 0px 0px,\n        rgba(var(--foreground-rgb), 0.06) 0px 0px 0px 1px,\n        rgba(0,0,0,calc(var(--shadow-blur-opacity)*0.67)) 0px 1px 1px -0.5px,\n        rgba(0,0,0,calc(var(--shadow-blur-opacity)*0.67)) 0px 3px 3px 0px,\n        rgba(0,0,0,calc(var(--shadow-blur-opacity)*0.33)) 0px 6px 6px 0px,\n        rgba(0,0,0,calc(var(--shadow-blur-opacity)*0.33)) 0px 12px 12px 0px,\n        rgba(0,0,0,calc(var(--shadow-blur-opacity)*0.33)) 0px 24px 24px 0px;\n    }\n    .shadow-tinted {\n      --shadow-color: 0, 0, 0;\n      box-shadow:\n        rgba(var(--shadow-color),0) 0px 0px 0px 0px, rgba(var(--shadow-color),0) 0px 0px 0px 0px,\n        rgba(var(--shadow-color),calc(var(--shadow-border-opacity)*1.5)) 0px 0px 0px 1px,\n        rgba(var(--shadow-color),var(--shadow-border-opacity)) 0px 1px 1px -0.5px,\n        rgba(var(--shadow-color),var(--shadow-blur-opacity)) 0px 3px 3px -1.5px,\n        rgba(var(--shadow-color),calc(var(--shadow-blur-opacity)*0.67)) 0px 6px 6px -3px;\n    }\n\n    /* Mega menu */\n    .mega-menu {\n      display: none; position: absolute; top: calc(100% + 6px);\n      left: 50%; transform: translateX(-50%);\n      max-width: 1180px; width: max-content;\n      background: var(--t-bg); border-radius: 12px;\n      padding: 1.5rem 2rem; max-height: 70vh;\n      overflow-y: auto; z-index: 998;\n    }\n    .mega-menu.open { display: block; }\n    .toc-grid { columns: 3; column-gap: 2rem; }\n    .toc-category {\n      display: inline-block; width: 100%;\n      margin: 0; padding-bottom: 1rem;\n    }\n    .toc-category h3 {\n      font-size: 0.85rem; font-weight: 600;\n      margin: 0 0 0.5rem 0; color: var(--t-fg);\n      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n    }\n    .toc-category ol {\n      padding: 0; margin: 0; list-style: none; font-size: 0.8rem;\n    }\n    .toc-category li {\n      margin-bottom: 0.15rem;\n      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n    }\n    .toc-category a { color: var(--t-fg); text-decoration: none; }\n    .toc-category a:hover { text-decoration: underline; }\n    .toc-num { color: color-mix(in srgb, var(--t-fg) 30%, var(--t-bg)); }\n\n    /* Sample card */\n    .sample {\n      background: var(--t-bg);\n      margin-bottom: 2rem;\n      overflow: hidden;\n    }\n    .sample-header {\n      padding: 1.25rem 1.5rem;\n      max-width: 48rem;\n      border-bottom: 1px solid color-mix(in srgb, var(--t-fg) 5%, var(--t-bg));\n    }\n    .sample-header h2 {\n      font-size: 1.5rem; font-weight: 500; color: var(--t-fg);\n    }\n    .description {\n      color: color-mix(in srgb, var(--t-fg) 50%, var(--t-bg));\n      font-size: 1rem; font-weight: 400; margin-top: 0.1rem;\n    }\n    .description code {\n      font-family: 'JetBrains Mono', 'Fira Code', monospace;\n      font-size: 0.875em;\n      color: color-mix(in srgb, var(--t-fg) 85%, var(--t-bg));\n      background: color-mix(in srgb, var(--t-fg) 6%, var(--t-bg));\n      padding: 0.15rem 0.4rem; border-radius: 3px;\n    }\n\n    .sample-content {\n      display: grid;\n      grid-template-columns: minmax(180px, 0.8fr) minmax(280px, 1fr) minmax(280px, 1fr);\n      min-height: 350px;\n    }\n    @media (max-width: 900px) {\n      .sample-content { grid-template-columns: 1fr; }\n      .chart-panel { border-left: none !important; border-top: 1px solid color-mix(in srgb, var(--t-fg) 5%, var(--t-bg)) !important; }\n      .svg-panel { border-left: none !important; border-top: 1px solid color-mix(in srgb, var(--t-fg) 5%, var(--t-bg)) !important; }\n    }\n\n    /* Source panel */\n    .source-panel {\n      padding: 0.75rem 1.5rem;\n      border-right: 1px solid color-mix(in srgb, var(--t-fg) 5%, var(--t-bg));\n      min-width: 0; overflow-y: auto;\n      background: color-mix(in srgb, var(--t-fg) 1.5%, var(--t-bg));\n    }\n    .source-panel .shiki {\n      background: transparent !important;\n      padding: 0.5rem 0; font-size: 0.8rem; line-height: 1.5;\n      overflow-x: auto; white-space: pre-wrap; word-break: break-word; margin: 0;\n    }\n    .source-panel .shiki code {\n      background: transparent;\n      font-family: 'JetBrains Mono', 'Fira Code', monospace;\n    }\n    .source-panel .shiki,\n    .source-panel .shiki span[style*=\"#24292e\"],\n    .source-panel .shiki span[style*=\"#24292E\"] {\n      color: color-mix(in srgb, var(--t-fg) 70%, var(--t-bg)) !important;\n    }\n    .source-panel .shiki span[style*=\"#D73A49\"],\n    .source-panel .shiki span[style*=\"#d73a49\"] {\n      color: color-mix(in srgb, var(--t-fg) 90%, var(--t-bg)) !important;\n      font-weight: 500;\n    }\n    .source-panel .shiki span[style*=\"#6F42C1\"],\n    .source-panel .shiki span[style*=\"#6f42c1\"] {\n      color: color-mix(in srgb, var(--t-fg) 65%, var(--t-bg)) !important;\n    }\n    .source-panel .shiki span[style*=\"#E36209\"],\n    .source-panel .shiki span[style*=\"#e36209\"] {\n      color: color-mix(in srgb, var(--t-fg) 75%, var(--t-bg)) !important;\n    }\n    .source-panel .shiki span[style*=\"#032F62\"],\n    .source-panel .shiki span[style*=\"#032f62\"] {\n      color: color-mix(in srgb, var(--t-fg) 50%, var(--t-bg)) !important;\n    }\n\n    /* Chart panel */\n    .chart-panel {\n      padding: 1.25rem 1.5rem;\n      display: flex; align-items: center; justify-content: center;\n      min-width: 0;\n      border-right: 1px solid color-mix(in srgb, var(--t-fg) 5%, var(--t-bg));\n    }\n    .chart-panel canvas {\n      max-width: 100%;\n      max-height: 320px;\n    }\n\n    /* SVG panel */\n    .svg-panel {\n      padding: 1.25rem 1.5rem;\n      display: flex; align-items: center; justify-content: center;\n      min-width: 0;\n      background: color-mix(in srgb, var(--t-fg) 1.5%, var(--t-bg));\n    }\n    .svg-panel svg {\n      max-width: 100%;\n      max-height: 420px;\n      height: auto;\n    }\n    .svg-loading {\n      font-size: 0.85rem; font-weight: 400;\n      color: color-mix(in srgb, var(--t-fg) 25%, var(--t-bg));\n    }\n\n    /* Section title */\n    .section-title {\n      font-size: 1.875rem; font-weight: 800; line-height: 1.2;\n      margin: 0; padding: 2.5rem 0 1.5rem; color: var(--t-fg);\n    }\n\n    /* Hero header */\n    .hero-header {\n      max-width: 1440px; margin: 0 auto;\n      padding: 6rem 2rem 2rem; text-align: left;\n    }\n    @media (min-width: 1000px) {\n      .hero-header { padding: 6rem 3rem 2rem; }\n    }\n    .hero-title {\n      font-size: 2.25rem; font-weight: 800; line-height: 1.2;\n      margin: 0 0 0.25rem; color: var(--t-fg);\n    }\n    .hero-tagline {\n      font-size: 1rem; font-weight: 500;\n      color: color-mix(in srgb, var(--t-fg) 50%, var(--t-bg));\n      margin: 0 0 1rem;\n    }\n    .hero-description {\n      font-size: 0.95rem; line-height: 1.6;\n      color: color-mix(in srgb, var(--t-fg) 70%, var(--t-bg));\n      margin: 0 0 1.5rem; max-width: 680px;\n    }\n    .hero-meta { margin-top: 1.25rem; }\n    .hero-meta .meta {\n      font-size: 0.85rem;\n      color: color-mix(in srgb, var(--t-fg) 40%, var(--t-bg));\n      margin: 0.15rem 0;\n    }\n\n    /* Footer */\n    .site-footer {\n      position: relative; z-index: 10;\n      padding: 1.5rem 2rem 2rem;\n      display: flex; align-items: center; justify-content: space-between;\n      max-width: 1440px; width: 100%; margin: 0 auto;\n      font-size: 12px;\n      color: color-mix(in srgb, var(--t-fg) 50%, var(--t-bg));\n    }\n    @media (min-width: 1000px) {\n      .site-footer { padding: 1.5rem 3rem 2rem; }\n    }\n  </style>\n</head>\n<body>\n  <div id=\"safari-theme-color\" style=\"position:fixed;top:0;left:0;right:0;height:1px;background:var(--theme-bar-bg);z-index:9999;pointer-events:none;\"></div>\n\n  <div class=\"theme-bar\" id=\"theme-bar\">\n    <div style=\"font-size:12px;font-weight:600;color:color-mix(in srgb, var(--t-fg) 80%, var(--t-bg));white-space:nowrap;\">XY Chart Test</div>\n    <button class=\"contents-btn shadow-minimal\" id=\"contents-btn\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"><line x1=\"3\" y1=\"4\" x2=\"13\" y2=\"4\"/><line x1=\"3\" y1=\"8\" x2=\"13\" y2=\"8\"/><line x1=\"3\" y1=\"12\" x2=\"10\" y2=\"12\"/></svg>Contents</button>\n    <div class=\"theme-pills\" id=\"theme-pills\">\n      ${themePillsHtml}\n    </div>\n    <div class=\"mega-menu shadow-modal-small\" id=\"mega-menu\">\n      <div class=\"toc-grid\">\n        ${tocSections}\n      </div>\n    </div>\n  </div>\n\n  <header class=\"hero-header\">\n    <h1 class=\"hero-title\">XY Chart Test</h1>\n    <p class=\"hero-tagline\">xychart-beta rendering comparison: Chart.js vs beautiful-mermaid</p>\n    <p class=\"hero-description\">\n      Column 1 shows the Mermaid source, Column 2 renders a Chart.js reference,\n      and Column 3 shows the beautiful-mermaid SVG rendering with theme support.\n    </p>\n    <div class=\"hero-meta\">\n      <p class=\"meta\">${xychartSamples.length} xychart-beta examples across ${categories.size} categories</p>\n      <p class=\"meta\">Chart.js reference + beautiful-mermaid SVG comparison</p>\n    </div>\n  </header>\n\n  <div class=\"content-wrapper\">\n${sampleCards}\n  </div>\n\n  <footer class=\"site-footer\">\n    <span>&copy; 2026 Luki Labs. MIT License.</span>\n  </footer>\n\n  <script>\n  // ============================================================================\n  // Theme system\n  // ============================================================================\n  var THEMES = ${themesJson};\n  var chartInstances = [];\n\n  function hexToRgb(hex) {\n    if (!hex || typeof hex !== 'string') return null;\n    var v = hex.trim();\n    if (v[0] === '#') v = v.slice(1);\n    if (v.length === 3) v = v[0]+v[0]+v[1]+v[1]+v[2]+v[2];\n    if (v.length !== 6) return null;\n    var n = parseInt(v, 16);\n    if (Number.isNaN(n)) return null;\n    return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };\n  }\n\n  function setShadowVars(theme) {\n    var body = document.body;\n    var fg = theme ? theme.fg : '#27272A';\n    var bg = theme ? theme.bg : '#FFFFFF';\n    var fgRgb = hexToRgb(fg) || { r: 39, g: 39, b: 42 };\n    var bgRgb = hexToRgb(bg) || { r: 255, g: 255, b: 255 };\n    var brightness = (bgRgb.r * 299 + bgRgb.g * 587 + bgRgb.b * 114) / 1000;\n    var darkMode = brightness < 140;\n    body.style.setProperty('--foreground-rgb', fgRgb.r + ', ' + fgRgb.g + ', ' + fgRgb.b);\n    body.style.setProperty('--shadow-border-opacity', darkMode ? '0.15' : '0.08');\n    body.style.setProperty('--shadow-blur-opacity', darkMode ? '0.12' : '0.06');\n  }\n\n  function updateThemeColor(fg, bg) {\n    var fgRgb = hexToRgb(fg) || { r: 39, g: 39, b: 42 };\n    var bgRgb = hexToRgb(bg) || { r: 255, g: 255, b: 255 };\n    var r = Math.round(bgRgb.r * 0.96 + fgRgb.r * 0.04);\n    var g = Math.round(bgRgb.g * 0.96 + fgRgb.g * 0.04);\n    var b = Math.round(bgRgb.b * 0.96 + fgRgb.b * 0.04);\n    var hex = '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);\n    document.getElementById('theme-color-meta').setAttribute('content', hex);\n    document.body.style.setProperty('--theme-bar-bg', hex);\n    var safariDiv = document.getElementById('safari-theme-color');\n    safariDiv.style.background = hex;\n    safariDiv.style.display = 'none';\n    void safariDiv.offsetHeight;\n    safariDiv.style.display = '';\n  }\n\n  function getChartColors(theme) {\n    var fg = theme ? theme.fg : '#27272A';\n    var bg = theme ? theme.bg : '#FFFFFF';\n    var fgRgb = hexToRgb(fg) || { r: 39, g: 39, b: 42 };\n    var bgRgb = hexToRgb(bg) || { r: 255, g: 255, b: 255 };\n    // Grid lines: 12% fg mixed into bg\n    var gridR = Math.round(bgRgb.r * 0.88 + fgRgb.r * 0.12);\n    var gridG = Math.round(bgRgb.g * 0.88 + fgRgb.g * 0.12);\n    var gridB = Math.round(bgRgb.b * 0.88 + fgRgb.b * 0.12);\n    // Tick text: 60% fg mixed into bg\n    var tickR = Math.round(bgRgb.r * 0.4 + fgRgb.r * 0.6);\n    var tickG = Math.round(bgRgb.g * 0.4 + fgRgb.g * 0.6);\n    var tickB = Math.round(bgRgb.b * 0.4 + fgRgb.b * 0.6);\n    return {\n      fg: fg,\n      bg: bg,\n      grid: 'rgb(' + gridR + ',' + gridG + ',' + gridB + ')',\n      tick: 'rgb(' + tickR + ',' + tickG + ',' + tickB + ')',\n      accent: theme && theme.accent ? theme.accent : '#3b82f6',\n    };\n  }\n\n  var BAR_PALETTE = [\n    'rgba(59,130,246,0.7)',   // blue\n    'rgba(16,185,129,0.7)',   // emerald\n    'rgba(245,158,11,0.7)',   // amber\n    'rgba(239,68,68,0.7)',    // red\n    'rgba(139,92,246,0.7)',   // violet\n    'rgba(236,72,153,0.7)',   // pink\n    'rgba(6,182,212,0.7)',    // cyan\n    'rgba(132,204,22,0.7)',   // lime\n  ];\n\n  var LINE_PALETTE = [\n    'rgba(239,68,68,0.9)',    // red\n    'rgba(16,185,129,0.9)',   // emerald\n    'rgba(245,158,11,0.9)',   // amber\n    'rgba(139,92,246,0.9)',   // violet\n    'rgba(6,182,212,0.9)',    // cyan\n    'rgba(236,72,153,0.9)',   // pink\n    'rgba(59,130,246,0.9)',   // blue\n    'rgba(132,204,22,0.9)',   // lime\n  ];\n\n  // ============================================================================\n  // XYChart parser\n  // ============================================================================\n  function parseXYChart(source) {\n    var lines = source.split('\\\\n').map(function(l) { return l.trim(); }).filter(Boolean);\n    var result = {\n      title: '',\n      horizontal: false,\n      xLabels: null,\n      xRange: null,\n      xTitle: '',\n      yRange: null,\n      yTitle: '',\n      bars: [],\n      lines: [],\n    };\n\n    // Check for horizontal\n    if (lines.length > 0 && lines[0].indexOf('horizontal') !== -1) {\n      result.horizontal = true;\n    }\n\n    for (var i = 0; i < lines.length; i++) {\n      var line = lines[i];\n\n      // Title\n      var titleMatch = line.match(/^title\\\\s+\"([^\"]+)\"/);\n      if (titleMatch) {\n        result.title = titleMatch[1];\n        continue;\n      }\n\n      // x-axis with labels: x-axis \"Title\" [a, b, c] or x-axis [a, b, c]\n      var xLabelMatch = line.match(/^x-axis\\\\s+(?:\"([^\"]*)\"\\\\s*)?\\\\[([^\\\\]]+)\\\\]/);\n      if (xLabelMatch) {\n        if (xLabelMatch[1]) result.xTitle = xLabelMatch[1];\n        result.xLabels = xLabelMatch[2].split(',').map(function(s) { return s.trim(); });\n        continue;\n      }\n\n      // x-axis with range: x-axis \"Title\" min --> max or x-axis min --> max\n      var xRangeMatch = line.match(/^x-axis\\\\s+(?:\"([^\"]*)\"\\\\s+)?(\\\\d+(?:\\\\.\\\\d+)?)\\\\s*-->\\\\s*(\\\\d+(?:\\\\.\\\\d+)?)/);\n      if (xRangeMatch) {\n        if (xRangeMatch[1]) result.xTitle = xRangeMatch[1];\n        result.xRange = [parseFloat(xRangeMatch[2]), parseFloat(xRangeMatch[3])];\n        continue;\n      }\n\n      // y-axis: y-axis \"Title\" min --> max or y-axis min --> max\n      var yRangeMatch = line.match(/^y-axis\\\\s+(?:\"([^\"]*)\"\\\\s+)?(\\\\d+(?:\\\\.\\\\d+)?)\\\\s*-->\\\\s*(\\\\d+(?:\\\\.\\\\d+)?)/);\n      if (yRangeMatch) {\n        if (yRangeMatch[1]) result.yTitle = yRangeMatch[1];\n        result.yRange = [parseFloat(yRangeMatch[2]), parseFloat(yRangeMatch[3])];\n        continue;\n      }\n\n      // y-axis with just title (no range)\n      var yTitleOnly = line.match(/^y-axis\\\\s+\"([^\"]+)\"$/);\n      if (yTitleOnly) {\n        result.yTitle = yTitleOnly[1];\n        continue;\n      }\n\n      // bar [...]\n      var barMatch = line.match(/^bar\\\\s+\\\\[([^\\\\]]+)\\\\]/);\n      if (barMatch) {\n        result.bars.push(barMatch[1].split(',').map(function(s) { return parseFloat(s.trim()); }));\n        continue;\n      }\n\n      // line [...]\n      var lineMatch = line.match(/^line\\\\s+\\\\[([^\\\\]]+)\\\\]/);\n      if (lineMatch) {\n        result.lines.push(lineMatch[1].split(',').map(function(s) { return parseFloat(s.trim()); }));\n        continue;\n      }\n    }\n\n    return result;\n  }\n\n  function buildChartConfig(parsed, colors) {\n    var datasets = [];\n    var labels = parsed.xLabels;\n\n    // If numeric range and no labels, generate labels from range\n    if (!labels && parsed.xRange) {\n      var dataLen = 0;\n      if (parsed.bars.length > 0) dataLen = parsed.bars[0].length;\n      else if (parsed.lines.length > 0) dataLen = parsed.lines[0].length;\n      if (dataLen > 0) {\n        labels = [];\n        var step = (parsed.xRange[1] - parsed.xRange[0]) / (dataLen - 1 || 1);\n        for (var k = 0; k < dataLen; k++) {\n          labels.push(String(Math.round((parsed.xRange[0] + step * k) * 100) / 100));\n        }\n      }\n    }\n\n    // If still no labels, generate numbered labels\n    if (!labels) {\n      var maxLen = 0;\n      parsed.bars.forEach(function(b) { if (b.length > maxLen) maxLen = b.length; });\n      parsed.lines.forEach(function(l) { if (l.length > maxLen) maxLen = l.length; });\n      labels = [];\n      for (var k = 0; k < maxLen; k++) labels.push(String(k + 1));\n    }\n\n    // Add bar datasets\n    for (var b = 0; b < parsed.bars.length; b++) {\n      datasets.push({\n        type: 'bar',\n        label: 'Bar ' + (b + 1),\n        data: parsed.bars[b],\n        backgroundColor: BAR_PALETTE[b % BAR_PALETTE.length],\n        borderColor: BAR_PALETTE[b % BAR_PALETTE.length].replace('0.7', '1'),\n        borderWidth: 1,\n        order: 2,\n      });\n    }\n\n    // Add line datasets\n    for (var l = 0; l < parsed.lines.length; l++) {\n      datasets.push({\n        type: 'line',\n        label: 'Line ' + (l + 1),\n        data: parsed.lines[l],\n        borderColor: LINE_PALETTE[l % LINE_PALETTE.length],\n        backgroundColor: LINE_PALETTE[l % LINE_PALETTE.length].replace('0.9', '0.1'),\n        borderWidth: 2,\n        pointRadius: 3,\n        pointHoverRadius: 5,\n        tension: 0.1,\n        fill: false,\n        order: 1,\n      });\n    }\n\n    var scales = {\n      x: {\n        title: { display: !!parsed.xTitle, text: parsed.xTitle, color: colors.tick },\n        ticks: { color: colors.tick },\n        grid: { color: colors.grid },\n      },\n      y: {\n        title: { display: !!parsed.yTitle, text: parsed.yTitle, color: colors.tick },\n        ticks: { color: colors.tick },\n        grid: { color: colors.grid },\n      },\n    };\n\n    if (parsed.yRange) {\n      scales.y.min = parsed.yRange[0];\n      scales.y.max = parsed.yRange[1];\n    }\n\n    var config = {\n      type: datasets.length === 1 ? datasets[0].type : 'bar',\n      data: { labels: labels, datasets: datasets },\n      options: {\n        responsive: true,\n        maintainAspectRatio: true,\n        indexAxis: parsed.horizontal ? 'y' : 'x',\n        plugins: {\n          title: {\n            display: !!parsed.title,\n            text: parsed.title,\n            color: colors.fg,\n            font: { size: 14, weight: '600', family: \"'Geist', system-ui, sans-serif\" },\n          },\n          legend: { display: datasets.length > 1, labels: { color: colors.tick } },\n        },\n        scales: scales,\n      },\n    };\n\n    return config;\n  }\n\n  function updateChartColors(chart, colors) {\n    var opts = chart.options;\n    if (opts.plugins.title) opts.plugins.title.color = colors.fg;\n    if (opts.plugins.legend && opts.plugins.legend.labels) opts.plugins.legend.labels.color = colors.tick;\n    if (opts.scales.x) {\n      if (opts.scales.x.title) opts.scales.x.title.color = colors.tick;\n      opts.scales.x.ticks.color = colors.tick;\n      opts.scales.x.grid.color = colors.grid;\n    }\n    if (opts.scales.y) {\n      if (opts.scales.y.title) opts.scales.y.title.color = colors.tick;\n      opts.scales.y.ticks.color = colors.tick;\n      opts.scales.y.grid.color = colors.grid;\n    }\n    chart.update('none');\n  }\n\n  // ============================================================================\n  // Theme application\n  // ============================================================================\n  function applyTheme(themeKey) {\n    var theme = themeKey ? THEMES[themeKey] : null;\n    var body = document.body;\n\n    if (theme) {\n      body.style.setProperty('--t-bg', theme.bg);\n      body.style.setProperty('--t-fg', theme.fg);\n      body.style.setProperty('--t-accent', theme.accent || '#3b82f6');\n    } else {\n      body.style.setProperty('--t-bg', '#FFFFFF');\n      body.style.setProperty('--t-fg', '#27272A');\n      body.style.setProperty('--t-accent', '#3b82f6');\n    }\n    setShadowVars(theme);\n    updateThemeColor(theme ? theme.fg : '#27272A', theme ? theme.bg : '#FFFFFF');\n\n    // Update chart panel backgrounds\n    for (var j = 0; j < ${xychartSamples.length}; j++) {\n      var panel = document.getElementById('chart-panel-' + j);\n      if (panel) panel.style.background = theme ? theme.bg : '';\n    }\n\n    // Update all Chart.js instances\n    var colors = getChartColors(theme);\n    for (var j = 0; j < chartInstances.length; j++) {\n      updateChartColors(chartInstances[j], colors);\n    }\n\n    // Re-render all SVGs with new theme\n    renderAllSvgs(themeKey);\n\n    // Update active pill\n    var pills = document.querySelectorAll('.theme-pill');\n    for (var j = 0; j < pills.length; j++) {\n      var isActive = pills[j].getAttribute('data-theme') === themeKey;\n      pills[j].classList.toggle('active', isActive);\n      pills[j].classList.toggle('shadow-tinted', isActive);\n    }\n\n    if (themeKey) localStorage.setItem('xychart-theme', themeKey);\n    else localStorage.removeItem('xychart-theme');\n  }\n\n  // ============================================================================\n  // Event handlers\n  // ============================================================================\n\n  // Theme pills\n  document.getElementById('theme-pills').addEventListener('click', function(e) {\n    var pill = e.target.closest('.theme-pill');\n    if (!pill || pill.id === 'theme-more-btn') return;\n    applyTheme(pill.getAttribute('data-theme') || '');\n    var dd = document.getElementById('theme-more-dropdown');\n    if (dd && dd.classList.contains('open')) dd.classList.remove('open');\n  });\n\n  // More themes dropdown\n  var moreBtn = document.getElementById('theme-more-btn');\n  var moreDropdown = document.getElementById('theme-more-dropdown');\n  if (moreBtn && moreDropdown) {\n    moreBtn.addEventListener('click', function(e) {\n      e.stopPropagation();\n      moreDropdown.classList.toggle('open');\n    });\n    document.addEventListener('click', function(e) {\n      if (!moreDropdown.classList.contains('open')) return;\n      if (!e.target.closest('.theme-more-wrapper')) moreDropdown.classList.remove('open');\n    });\n    document.addEventListener('keydown', function(e) {\n      if (e.key === 'Escape' && moreDropdown.classList.contains('open')) moreDropdown.classList.remove('open');\n    });\n  }\n\n  // Contents mega-menu\n  var contentsBtn = document.getElementById('contents-btn');\n  var megaMenu = document.getElementById('mega-menu');\n  contentsBtn.addEventListener('click', function(e) {\n    e.stopPropagation();\n    var isOpen = megaMenu.classList.toggle('open');\n    contentsBtn.classList.toggle('active', isOpen);\n    contentsBtn.classList.toggle('shadow-tinted', isOpen);\n  });\n  megaMenu.addEventListener('click', function(e) {\n    var link = e.target.closest('a');\n    if (!link) return;\n    e.preventDefault();\n    megaMenu.classList.remove('open');\n    contentsBtn.classList.remove('active');\n    contentsBtn.classList.remove('shadow-tinted');\n    var target = document.querySelector(link.getAttribute('href'));\n    if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });\n  });\n  document.addEventListener('click', function(e) {\n    if (!megaMenu.classList.contains('open')) return;\n    if (!e.target.closest('.mega-menu') && !e.target.closest('.contents-btn')) {\n      megaMenu.classList.remove('open');\n      contentsBtn.classList.remove('active');\n      contentsBtn.classList.remove('shadow-tinted');\n    }\n  });\n  document.addEventListener('keydown', function(e) {\n    if (e.key === 'Escape' && megaMenu.classList.contains('open')) {\n      megaMenu.classList.remove('open');\n      contentsBtn.classList.remove('active');\n      contentsBtn.classList.remove('shadow-tinted');\n    }\n  });\n\n  // ============================================================================\n  // Restore saved theme\n  // ============================================================================\n  var savedTheme = localStorage.getItem('xychart-theme');\n  if (savedTheme && THEMES[savedTheme]) {\n    document.body.style.setProperty('--t-bg', THEMES[savedTheme].bg);\n    document.body.style.setProperty('--t-fg', THEMES[savedTheme].fg);\n    document.body.style.setProperty('--t-accent', THEMES[savedTheme].accent || '#3b82f6');\n    setShadowVars(THEMES[savedTheme]);\n    updateThemeColor(THEMES[savedTheme].fg, THEMES[savedTheme].bg);\n    var pills = document.querySelectorAll('.theme-pill');\n    for (var j = 0; j < pills.length; j++) {\n      var isActive = pills[j].getAttribute('data-theme') === savedTheme;\n      pills[j].classList.toggle('active', isActive);\n      pills[j].classList.toggle('shadow-tinted', isActive);\n    }\n  } else {\n    setShadowVars(null);\n  }\n\n  // ============================================================================\n  // Render all charts\n  // ============================================================================\n  var sources = ${sourcesJson};\n  var activeTheme = savedTheme && THEMES[savedTheme] ? THEMES[savedTheme] : null;\n  var colors = getChartColors(activeTheme);\n\n  Chart.defaults.font.family = \"'Geist', system-ui, sans-serif\";\n\n  for (var i = 0; i < sources.length; i++) {\n    var canvas = document.getElementById('chart-' + i);\n    if (!canvas) continue;\n    var parsed = parseXYChart(sources[i]);\n    var config = buildChartConfig(parsed, colors);\n    try {\n      var chart = new Chart(canvas, config);\n      chartInstances.push(chart);\n    } catch (err) {\n      console.error('Chart ' + i + ' failed:', err);\n    }\n\n    // Set panel bg if theme is active\n    if (activeTheme) {\n      var panel = document.getElementById('chart-panel-' + i);\n      if (panel) panel.style.background = activeTheme.bg;\n    }\n  }\n\n  // ============================================================================\n  // Render beautiful-mermaid SVGs\n  // ============================================================================\n  function renderAllSvgs(themeKey) {\n    var theme = themeKey ? THEMES[themeKey] : null;\n    var opts = theme ? { bg: theme.bg, fg: theme.fg, interactive: true } : { interactive: true };\n    if (theme) {\n      if (theme.line) opts.line = theme.line;\n      if (theme.accent) opts.accent = theme.accent;\n      if (theme.muted) opts.muted = theme.muted;\n      if (theme.surface) opts.surface = theme.surface;\n      if (theme.border) opts.border = theme.border;\n    }\n    for (var i = 0; i < sources.length; i++) {\n      (function(idx) {\n        window.__mermaid.renderMermaid(sources[idx], opts).then(function(svg) {\n          var panel = document.getElementById('svg-panel-' + idx);\n          if (panel) panel.innerHTML = svg;\n        }).catch(function(err) {\n          console.error('SVG ' + idx + ' failed:', err);\n          var panel = document.getElementById('svg-panel-' + idx);\n          if (panel) panel.innerHTML = '<div class=\"svg-loading\">Error: ' + err.message + '</div>';\n        });\n      })(i);\n    }\n  }\n\n  // Initial SVG render — wait for the module bundle to set window.__mermaid\n  window.__renderAllSvgs = renderAllSvgs;\n  window.__initThemeKey = savedTheme || '';\n  </script>\n  <script type=\"module\">\n${bundleJs}\n// Module has loaded — window.__mermaid is now set. Trigger initial render.\nif (window.__renderAllSvgs) window.__renderAllSvgs(window.__initThemeKey);\n  </script>\n</body>\n</html>`\n}\n\nconst html = await generateHtml()\nconst outPath = new URL('./xychart-test.html', import.meta.url).pathname\nawait Bun.write(outPath, html)\nconsole.log(`Written to ${outPath} (${(html.length / 1024).toFixed(1)} KB)`)\n"
  }
]