@the-forge-flow/visual-explainer-pi 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;AA+JlE;;GAEG;AACH,MAAM,CAAC,OAAO,UAAU,wBAAwB,CAAC,EAAE,EAAE,YAAY,QAiJhE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;AAgKlE;;GAEG;AACH,MAAM,CAAC,OAAO,UAAU,wBAAwB,CAAC,EAAE,EAAE,YAAY,QAiJhE"}
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * Based on nicobailon/visual-explainer design principles.
8
8
  */
9
9
  import { StringEnum } from "@mariozechner/pi-ai";
10
- import { defineTool, truncateHead } from "@mariozechner/pi-coding-agent";
10
+ import { defineTool } from "@mariozechner/pi-coding-agent";
11
11
  import { Type } from "@sinclair/typebox";
12
12
  import { generateArchitectureTemplate } from "./templates/architecture.js";
13
13
  import { generateTableTemplate } from "./templates/data-table.js";
@@ -81,14 +81,16 @@ async function generateVisual(params, pi) {
81
81
  default:
82
82
  throw new Error(`Unsupported visual type: ${params.type}`);
83
83
  }
84
- // Safety check: truncate if too large
85
- const truncated = truncateHead(html, { maxBytes: 50000, maxLines: 2000 });
86
84
  // Generate filename
87
85
  const filename = params.filename
88
86
  ? sanitizeFilename(params.filename)
89
87
  : generateDefaultFilename(params.title);
90
- // Write file
91
- const filePath = await writeHtmlFile(filename, truncated.content, state);
88
+ // Write the full HTML to disk. We intentionally do NOT run the pi
89
+ // truncateHead helper here that utility clips LLM-facing tool output,
90
+ // not files on disk. Cutting the HTML at an arbitrary byte boundary would
91
+ // leave unclosed tags/scripts and silently corrupt every non-trivial
92
+ // diagram. See bug report: "mermaid graphs are always bugged".
93
+ const filePath = await writeHtmlFile(filename, html, state);
92
94
  // Open in browser
93
95
  await openInBrowser(filePath, pi);
94
96
  // Generate preview snippet (first 500 chars of content summary)
@@ -1,6 +1,20 @@
1
1
  /**
2
2
  * Mermaid template for flowcharts, sequence diagrams, ER diagrams, etc.
3
- * Based on nicobailon/visual-explainer mermaid-flowchart.html
3
+ * Based on nicobailon/visual-explainer mermaid-flowchart.html.
4
+ *
5
+ * Key invariants (each one exists because it was a real bug):
6
+ * 1. Mermaid source is placed inside `<script type="text/plain" class="diagram-source">`
7
+ * — the HTML parser treats this as opaque data, so `<br/>`, `<`, `>`,
8
+ * `&` in the source survive untouched. Never use `<pre class="mermaid">`.
9
+ * 2. Rendering is explicit via `mermaid.render(id, code)` with
10
+ * `startOnLoad: false`. This avoids the timing race between "mermaid
11
+ * finished mutating the DOM" and "our zoom/pan handlers bind".
12
+ * 3. `themeVariables.fontFamily` uses a concrete font stack, not a CSS
13
+ * custom property. Mermaid bakes the value into SVG inline styles and
14
+ * CSS vars don't resolve when the SVG is exported to a new tab.
15
+ * 4. The new-tab export reads the page background from a `data-bg`
16
+ * attribute on `.mermaid-wrap` so the exported view matches the
17
+ * baked-in SVG colors.
4
18
  */
5
19
  import type { Aesthetic } from "../types.js";
6
20
  export interface MermaidContent {
@@ -8,4 +22,12 @@ export interface MermaidContent {
8
22
  caption?: string;
9
23
  }
10
24
  export declare function generateMermaidTemplate(title: string, content: MermaidContent, aesthetic: Aesthetic, isDark: boolean): string;
25
+ /**
26
+ * Escape mermaid source for safe inclusion in a `<script type="text/plain">`.
27
+ * Browsers parse a script element's contents only looking for `</script>` —
28
+ * so we only need to neutralize the literal sequence `</script` anywhere in
29
+ * the source. Everything else (HTML entities, `<br/>`, `<`, `>`, `&`) passes
30
+ * through untouched, which is exactly what Mermaid wants.
31
+ */
32
+ export declare function escapeMermaidSource(source: string): string;
11
33
  //# sourceMappingURL=mermaid.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"mermaid.d.ts","sourceRoot":"","sources":["../../src/templates/mermaid.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAW7C,MAAM,WAAW,cAAc;IAC9B,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,uBAAuB,CACtC,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,SAAS,EACpB,MAAM,EAAE,OAAO,GACb,MAAM,CAgBR"}
1
+ {"version":3,"file":"mermaid.d.ts","sourceRoot":"","sources":["../../src/templates/mermaid.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAW7C,MAAM,WAAW,cAAc;IAC9B,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,uBAAuB,CACtC,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,SAAS,EACpB,MAAM,EAAE,OAAO,GACb,MAAM,CAgBR;AAoCD;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAE1D"}
@@ -1,64 +1,102 @@
1
1
  /**
2
2
  * Mermaid template for flowcharts, sequence diagrams, ER diagrams, etc.
3
- * Based on nicobailon/visual-explainer mermaid-flowchart.html
3
+ * Based on nicobailon/visual-explainer mermaid-flowchart.html.
4
+ *
5
+ * Key invariants (each one exists because it was a real bug):
6
+ * 1. Mermaid source is placed inside `<script type="text/plain" class="diagram-source">`
7
+ * — the HTML parser treats this as opaque data, so `<br/>`, `<`, `>`,
8
+ * `&` in the source survive untouched. Never use `<pre class="mermaid">`.
9
+ * 2. Rendering is explicit via `mermaid.render(id, code)` with
10
+ * `startOnLoad: false`. This avoids the timing race between "mermaid
11
+ * finished mutating the DOM" and "our zoom/pan handlers bind".
12
+ * 3. `themeVariables.fontFamily` uses a concrete font stack, not a CSS
13
+ * custom property. Mermaid bakes the value into SVG inline styles and
14
+ * CSS vars don't resolve when the SVG is exported to a new tab.
15
+ * 4. The new-tab export reads the page background from a `data-bg`
16
+ * attribute on `.mermaid-wrap` so the exported view matches the
17
+ * baked-in SVG colors.
4
18
  */
5
19
  import { FONT_PAIRINGS, MERMAID_SHELL_CSS, MERMAID_SHELL_JS, PALETTES, escapeHtml, generateCSSVariables, generateHtmlShell, } from "./shared.js";
6
20
  export function generateMermaidTemplate(title, content, aesthetic, isDark) {
7
21
  const palette = isDark ? PALETTES[aesthetic].dark : PALETTES[aesthetic].light;
8
22
  const fonts = FONT_PAIRINGS[aesthetic];
9
23
  const cssVars = generateCSSVariables(palette, fonts, isDark);
10
- const mermaidTheme = generateMermaidThemeVariables(palette);
11
- const bodyContent = generateBody(title, content);
12
- return generateHtmlShell(title, bodyContent, aesthetic, cssVars, `${MERMAID_SHELL_CSS}${getExtraCSS()}`, getMermaidScript(mermaidTheme, content.mermaidSyntax));
24
+ const themeConfig = generateMermaidThemeConfig(palette, fonts);
25
+ const bodyContent = generateBody(title, content, palette.bg);
26
+ return generateHtmlShell(title, bodyContent, aesthetic, cssVars, `${MERMAID_SHELL_CSS}${getExtraCSS()}`, getMermaidScript(themeConfig));
13
27
  }
14
- function generateMermaidThemeVariables(palette) {
15
- // Convert palette to Mermaid themeVariables format
28
+ function generateMermaidThemeConfig(palette, fonts) {
29
+ // IMPORTANT: use concrete font strings — not `var(--font-body)` — because
30
+ // Mermaid bakes the value into SVG attributes, and CSS custom properties
31
+ // don't resolve inside an extracted SVG (see openInNewTab).
32
+ const fontFamily = fonts.body.replace(/'/g, "\\'");
16
33
  return `
17
34
  theme: 'base',
35
+ look: 'classic',
36
+ layout: 'elk',
18
37
  themeVariables: {
38
+ fontFamily: '${fontFamily}',
39
+ fontSize: '16px',
19
40
  primaryColor: '${palette.surface}',
20
41
  primaryTextColor: '${palette.text}',
21
42
  primaryBorderColor: '${palette.accent}',
22
43
  lineColor: '${palette.textDim}',
23
44
  secondaryColor: '${palette.surface2}',
45
+ secondaryBorderColor: '${palette.teal ?? palette.accent}',
46
+ secondaryTextColor: '${palette.text}',
24
47
  tertiaryColor: '${palette.surfaceElevated}',
25
- fontFamily: 'var(--font-mono)',
26
- fontSize: '14px'
48
+ tertiaryBorderColor: '${palette.orange ?? palette.accent}',
49
+ tertiaryTextColor: '${palette.text}',
50
+ noteBkgColor: '${palette.surfaceElevated}',
51
+ noteTextColor: '${palette.text}',
52
+ noteBorderColor: '${palette.accent}'
27
53
  },
28
- flowchart: {
29
- useMaxWidth: true,
30
- htmlLabels: true,
31
- curve: 'basis'
32
- },
33
- sequence: {
34
- useMaxWidth: true,
35
- diagramMarginX: 50,
36
- diagramMarginY: 10
37
- }
54
+ flowchart: { useMaxWidth: false, htmlLabels: true, curve: 'basis' },
55
+ sequence: { useMaxWidth: false, diagramMarginX: 50, diagramMarginY: 10 }
38
56
  `;
39
57
  }
40
- function generateBody(title, content) {
58
+ /**
59
+ * Escape mermaid source for safe inclusion in a `<script type="text/plain">`.
60
+ * Browsers parse a script element's contents only looking for `</script>` —
61
+ * so we only need to neutralize the literal sequence `</script` anywhere in
62
+ * the source. Everything else (HTML entities, `<br/>`, `<`, `>`, `&`) passes
63
+ * through untouched, which is exactly what Mermaid wants.
64
+ */
65
+ export function escapeMermaidSource(source) {
66
+ return source.replace(/<\/script/gi, "<\\/script");
67
+ }
68
+ function generateBody(title, content, bg) {
41
69
  const captionHtml = content.caption
42
70
  ? `<p class="caption">${escapeHtml(content.caption)}</p>`
43
71
  : "";
72
+ const safeSource = escapeMermaidSource(content.mermaidSyntax);
73
+ // Escape `"` in the bg value for the attribute — palette values are
74
+ // hex/rgba, but belt-and-braces.
75
+ const bgAttr = bg.replace(/"/g, "&quot;");
44
76
  return `
45
77
  <div class="container">
46
78
  <h1>${escapeHtml(title)}</h1>
47
- <div class="diagram-shell" style="--i:0">
48
- <div class="mermaid-wrap">
79
+ <section class="diagram-shell">
80
+ <p class="diagram-shell__hint">
81
+ Ctrl/Cmd + wheel to zoom. Drag to pan when zoomed. Double-click to fit.
82
+ </p>
83
+ <div class="mermaid-wrap" data-bg="${bgAttr}">
49
84
  <div class="zoom-controls">
50
- <button class="zoom-btn" data-zoom="in" title="Zoom in">+</button>
51
- <button class="zoom-btn" data-zoom="out" title="Zoom out">−</button>
52
- <button class="zoom-btn" data-zoom="reset" title="Reset">⟲</button>
53
- <button class="zoom-btn" data-zoom="expand" title="Open in new tab">⛶</button>
85
+ <button type="button" data-action="zoom-in" title="Zoom in">+</button>
86
+ <button type="button" data-action="zoom-out" title="Zoom out">&minus;</button>
87
+ <button type="button" data-action="zoom-fit" title="Smart fit">&#8634;</button>
88
+ <button type="button" data-action="zoom-one" title="1:1 zoom">1:1</button>
89
+ <button type="button" data-action="zoom-expand" title="Open full size">&#x26F6;</button>
90
+ <span class="zoom-label">Loading…</span>
54
91
  </div>
55
92
  <div class="mermaid-viewport">
56
- <div class="mermaid-canvas">
57
- <pre class="mermaid">${content.mermaidSyntax}</pre>
58
- </div>
93
+ <div class="mermaid mermaid-canvas"></div>
59
94
  </div>
60
95
  </div>
61
- </div>
96
+ <script type="text/plain" class="diagram-source">
97
+ ${safeSource}
98
+ </script>
99
+ </section>
62
100
  ${captionHtml}
63
101
  </div>
64
102
  `;
@@ -93,57 +131,31 @@ h1 {
93
131
  text-align: center;
94
132
  }
95
133
 
96
- /* Mermaid overrides for theming */
97
- .mermaid .node rect,
98
- .mermaid .node circle,
99
- .mermaid .node ellipse,
100
- .mermaid .node polygon {
101
- fill: var(--surface) !important;
102
- stroke: var(--accent) !important;
103
- stroke-width: 2px !important;
104
- }
105
-
106
- .mermaid .node .label {
107
- color: var(--text) !important;
108
- font-family: var(--font-mono) !important;
109
- }
110
-
111
- .mermaid .edgePath .path {
112
- stroke: var(--text-dim) !important;
113
- stroke-width: 2px !important;
114
- }
115
-
116
- .mermaid .edgeLabel {
117
- background: var(--surface) !important;
118
- color: var(--text) !important;
119
- }
120
-
121
- /* Responsive */
122
134
  @media (max-width: 768px) {
123
135
  body { padding: 20px; }
124
136
  h1 { font-size: 24px; }
125
137
  }
126
138
  `;
127
139
  }
128
- function getMermaidScript(themeConfig, mermaidSyntax) {
140
+ function getMermaidScript(themeConfig) {
129
141
  return `
130
142
  <script type="module">
131
- import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
132
-
143
+ import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
144
+ import elkLayouts from 'https://cdn.jsdelivr.net/npm/@mermaid-js/layout-elk/dist/mermaid-layout-elk.esm.min.mjs';
145
+
146
+ mermaid.registerLayoutLoaders(elkLayouts);
133
147
  mermaid.initialize({
134
- startOnLoad: true,
148
+ startOnLoad: false,
135
149
  securityLevel: 'loose',
136
150
  ${themeConfig}
137
151
  });
138
152
 
139
- ${MERMAID_SHELL_JS}
140
-
141
- // Initialize controls after Mermaid renders
142
- setTimeout(() => {
143
- document.querySelectorAll('.mermaid-wrap').forEach(wrap => {
144
- initMermaidControls(wrap);
145
- });
146
- }, 500);
153
+ // Expose on window so the shell JS (classic script, IIFE) can await it.
154
+ window.mermaid = mermaid;
155
+ window.dispatchEvent(new Event('mermaid-ready'));
156
+ </script>
157
+ <script>
158
+ ${MERMAID_SHELL_JS}
147
159
  </script>
148
160
  `;
149
161
  }
@@ -9,8 +9,8 @@ export declare const PALETTES: Record<Aesthetic, {
9
9
  dark: Palette;
10
10
  }>;
11
11
  export declare const SHARED_CSS = "\n/* Reset */\n* { margin: 0; padding: 0; box-sizing: border-box; }\n\n/* Animation keyframes */\n@keyframes fadeUp {\n from { opacity: 0; transform: translateY(12px); }\n to { opacity: 1; transform: translateY(0); }\n}\n\n@keyframes fadeScale {\n from { opacity: 0; transform: scale(0.95); }\n to { opacity: 1; transform: scale(1); }\n}\n\n/* Reduced motion */\n@media (prefers-reduced-motion: reduce) {\n *, *::before, *::after {\n animation-duration: 0.01ms !important;\n animation-delay: 0ms !important;\n transition-duration: 0.01ms !important;\n }\n}\n\n/* Base styles */\nbody {\n min-height: 100vh;\n line-height: 1.6;\n}\n\n/* Section card base */\n.section {\n background: var(--surface);\n border: 1px solid var(--border);\n border-radius: 12px;\n padding: 20px 24px;\n animation: fadeUp 0.4s ease-out both;\n animation-delay: calc(var(--i, 0) * 0.06s);\n}\n\n.section--hero {\n background: var(--surface-elevated);\n border-color: color-mix(in srgb, var(--border) 50%, var(--accent) 50%);\n box-shadow: 0 4px 20px rgba(0,0,0,0.06);\n padding: 28px 32px;\n}\n\n.section--recessed {\n background: var(--surface2);\n box-shadow: inset 0 1px 3px rgba(0,0,0,0.04);\n}\n\n/* Section labels with dot indicators */\n.section-label {\n font-family: var(--font-mono);\n font-size: 11px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 1.5px;\n margin-bottom: 16px;\n display: flex;\n align-items: center;\n gap: 8px;\n}\n\n.section-label .dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n display: inline-block;\n}\n\n/* Flow arrows */\n.flow-arrow {\n display: flex;\n justify-content: center;\n align-items: center;\n gap: 8px;\n color: var(--text-dim);\n font-family: var(--font-mono);\n font-size: 12px;\n padding: 4px 0;\n animation: fadeUp 0.4s ease-out both;\n animation-delay: calc(var(--i, 0) * 0.06s);\n}\n\n.flow-arrow svg {\n width: 20px;\n height: 20px;\n fill: none;\n stroke: var(--border-bright);\n stroke-width: 2;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n\n/* Code styling */\ncode {\n font-family: var(--font-mono);\n font-size: 0.9em;\n background: var(--accent-dim);\n color: var(--accent);\n padding: 2px 6px;\n border-radius: 4px;\n}\n\n/* Responsive */\n@media (max-width: 768px) {\n body { padding: 20px; }\n .section { padding: 16px 20px; }\n .section--hero { padding: 20px 24px; }\n}\n";
12
- export declare const MERMAID_SHELL_CSS = "\n.diagram-shell {\n background: var(--surface);\n border: 1px solid var(--border);\n border-radius: 12px;\n overflow: hidden;\n}\n\n.mermaid-wrap {\n position: relative;\n display: flex;\n justify-content: center;\n padding: 24px;\n}\n\n.mermaid-viewport {\n overflow: hidden;\n cursor: grab;\n}\n\n.mermaid-viewport:active {\n cursor: grabbing;\n}\n\n.mermaid-canvas {\n transform-origin: center center;\n transition: transform 0.1s ease-out;\n}\n\n.zoom-controls {\n position: absolute;\n top: 12px;\n right: 12px;\n display: flex;\n gap: 6px;\n z-index: 10;\n}\n\n.zoom-btn {\n width: 32px;\n height: 32px;\n border: 1px solid var(--border);\n border-radius: 6px;\n background: var(--surface);\n color: var(--text);\n font-size: 16px;\n cursor: pointer;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: all 0.2s;\n}\n\n.zoom-btn:hover {\n background: var(--surface2);\n border-color: var(--border-bright);\n}\n";
13
- export declare const MERMAID_SHELL_JS = "\nfunction initMermaidControls(wrap) {\n const viewport = wrap.querySelector('.mermaid-viewport');\n const canvas = wrap.querySelector('.mermaid-canvas');\n let scale = 1;\n let isDragging = false;\n let startX, startY, translateX = 0, translateY = 0;\n\n function updateTransform() {\n canvas.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;\n }\n\n wrap.querySelector('[data-zoom=\"in\"]').onclick = () => { scale *= 1.2; updateTransform(); };\n wrap.querySelector('[data-zoom=\"out\"]').onclick = () => { scale /= 1.2; updateTransform(); };\n wrap.querySelector('[data-zoom=\"reset\"]').onclick = () => { scale = 1; translateX = 0; translateY = 0; updateTransform(); };\n wrap.querySelector('[data-zoom=\"expand\"]').onclick = () => openMermaidInNewTab(wrap);\n\n viewport.addEventListener('mousedown', (e) => {\n isDragging = true;\n startX = e.clientX - translateX;\n startY = e.clientY - translateY;\n viewport.style.cursor = 'grabbing';\n });\n\n window.addEventListener('mousemove', (e) => {\n if (!isDragging) return;\n translateX = e.clientX - startX;\n translateY = e.clientY - startY;\n updateTransform();\n });\n\n window.addEventListener('mouseup', () => {\n isDragging = false;\n viewport.style.cursor = 'grab';\n });\n\n viewport.addEventListener('wheel', (e) => {\n if (e.ctrlKey || e.metaKey) {\n e.preventDefault();\n scale *= e.deltaY > 0 ? 0.9 : 1.1;\n updateTransform();\n }\n }, { passive: false });\n}\n\nfunction openMermaidInNewTab(wrap) {\n const svg = wrap.querySelector('svg');\n if (!svg) return;\n const blob = new Blob([svg.outerHTML], { type: 'image/svg+xml' });\n const url = URL.createObjectURL(blob);\n window.open(url, '_blank');\n}\n";
12
+ export declare const MERMAID_SHELL_CSS = "\n.diagram-shell {\n position: relative;\n margin-bottom: 24px;\n}\n\n.diagram-shell__hint {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--text-dim);\n margin-bottom: 8px;\n opacity: 0.7;\n}\n\n.mermaid-wrap {\n position: relative;\n background: var(--surface);\n border: 1px solid var(--border);\n border-radius: 12px;\n overflow: hidden;\n min-height: 360px;\n}\n\n.mermaid-viewport {\n position: relative;\n overflow: hidden;\n width: 100%;\n height: 100%;\n min-height: 300px;\n cursor: grab;\n}\n\n.mermaid-wrap.is-panning .mermaid-viewport {\n cursor: grabbing;\n user-select: none;\n}\n\n.mermaid-canvas {\n position: absolute;\n top: 0;\n left: 0;\n transform-origin: 0 0;\n}\n\n.mermaid-canvas svg {\n display: block;\n max-width: none;\n}\n\n.zoom-controls {\n position: absolute;\n top: 8px;\n right: 8px;\n display: flex;\n align-items: center;\n gap: 2px;\n z-index: 10;\n background: var(--surface);\n border: 1px solid var(--border);\n border-radius: 6px;\n padding: 2px 4px;\n}\n\n.zoom-controls button {\n width: 28px;\n height: 28px;\n border: none;\n background: transparent;\n color: var(--text-dim);\n font-family: var(--font-mono);\n font-size: 14px;\n cursor: pointer;\n border-radius: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: background 0.15s ease, color 0.15s ease;\n}\n\n.zoom-controls button:hover {\n background: var(--border);\n color: var(--text);\n}\n\n.zoom-label {\n font-family: var(--font-mono);\n font-size: 10px;\n color: var(--text-dim);\n padding: 0 6px;\n white-space: nowrap;\n}\n\n.mermaid .nodeLabel {\n font-family: var(--font-body) !important;\n}\n\n.mermaid .edgeLabel {\n font-family: var(--font-mono) !important;\n}\n";
13
+ export declare const MERMAID_SHELL_JS = "\n(function initAllDiagrams() {\n const config = {\n fitPadding: 24,\n minHeight: 360,\n maxHeightPx: 960,\n maxHeightVh: 0.84,\n maxInitialZoom: 1.8,\n minZoom: 0.08,\n maxZoom: 6.5,\n zoomStep: 0.14,\n readabilityFloor: 0.58\n };\n\n const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));\n let activeDrag = null;\n\n addEventListener('mousemove', (e) => activeDrag && activeDrag.onMove(e));\n addEventListener('mouseup', () => {\n if (activeDrag) activeDrag.onEnd();\n activeDrag = null;\n });\n\n function initDiagram(shell) {\n const wrap = shell.querySelector('.mermaid-wrap');\n const viewport = shell.querySelector('.mermaid-viewport');\n const canvas = shell.querySelector('.mermaid-canvas');\n const source = shell.querySelector('.diagram-source');\n const label = shell.querySelector('.zoom-label');\n if (!wrap || !viewport || !canvas || !source || !label) {\n console.error('visual-explainer: missing required elements in', shell);\n return;\n }\n\n let zoom = 1;\n let fitMode = 'contain';\n let panX = 0;\n let panY = 0;\n let svgW = 0;\n let svgH = 0;\n let sx = 0;\n let sy = 0;\n let spx = 0;\n let spy = 0;\n let touchDist = 0;\n let touchCx = 0;\n let touchCy = 0;\n\n function constrainPan() {\n const vpW = viewport.clientWidth;\n const vpH = viewport.clientHeight;\n const rW = svgW * zoom;\n const rH = svgH * zoom;\n const pad = config.fitPadding;\n panX = (rW + pad * 2 <= vpW) ? (vpW - rW) / 2 : clamp(panX, vpW - rW - pad, pad);\n panY = (rH + pad * 2 <= vpH) ? (vpH - rH) / 2 : clamp(panY, vpH - rH - pad, pad);\n }\n\n function applyTransform() {\n const svg = canvas.querySelector('svg');\n if (!svg || !svgW) return;\n constrainPan();\n svg.style.width = (svgW * zoom) + 'px';\n svg.style.height = (svgH * zoom) + 'px';\n canvas.style.transform = 'translate(' + panX + 'px, ' + panY + 'px)';\n label.textContent = Math.round(zoom * 100) + '% \u2014 ' + fitMode;\n }\n\n function canPan() {\n const rW = svgW * zoom;\n const rH = svgH * zoom;\n return rW + config.fitPadding * 2 > viewport.clientWidth\n || rH + config.fitPadding * 2 > viewport.clientHeight;\n }\n\n function computeSmartFit() {\n const vpW = viewport.clientWidth;\n const vpH = viewport.clientHeight;\n const aW = Math.max(80, vpW - config.fitPadding * 2);\n const aH = Math.max(80, vpH - config.fitPadding * 2);\n const contain = Math.min(aW / svgW, aH / svgH);\n let z = contain;\n let mode = 'contain';\n if (contain < config.readabilityFloor) {\n const chartR = svgH / svgW;\n const vpR = vpH / Math.max(vpW, 1);\n if (chartR >= vpR) { z = aW / svgW; mode = 'width-priority'; }\n else { z = aH / svgH; mode = 'height-priority'; }\n }\n return { zoom: clamp(z, config.minZoom, config.maxInitialZoom), mode };\n }\n\n function fitDiagram() {\n if (!svgW) return;\n const fit = computeSmartFit();\n zoom = fit.zoom;\n fitMode = fit.mode;\n panX = (viewport.clientWidth - svgW * zoom) / 2;\n panY = (viewport.clientHeight - svgH * zoom) / 2;\n applyTransform();\n }\n\n function setOneToOne() {\n zoom = clamp(1, config.minZoom, config.maxZoom);\n fitMode = '1:1';\n panX = (viewport.clientWidth - svgW * zoom) / 2;\n panY = (viewport.clientHeight - svgH * zoom) / 2;\n applyTransform();\n }\n\n function zoomAround(factor, cx, cy) {\n const next = clamp(zoom * factor, config.minZoom, config.maxZoom);\n const ratio = next / zoom;\n panX = cx - ratio * (cx - panX);\n panY = cy - ratio * (cy - panY);\n zoom = next;\n fitMode = 'custom';\n applyTransform();\n }\n\n function readSvgNaturalSize(svg) {\n let w = 0;\n let h = 0;\n if (svg.viewBox && svg.viewBox.baseVal && svg.viewBox.baseVal.width > 0) {\n w = svg.viewBox.baseVal.width;\n h = svg.viewBox.baseVal.height;\n }\n if (!w) {\n w = parseFloat(svg.getAttribute('width')) || 0;\n h = parseFloat(svg.getAttribute('height')) || 0;\n }\n if (!w) {\n try { const b = svg.getBBox(); w = b.width; h = b.height; } catch {}\n }\n if (!w) {\n const r = svg.getBoundingClientRect();\n w = r.width || 1000;\n h = r.height || 700;\n }\n if (!svg.getAttribute('viewBox')) {\n svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);\n }\n return { w, h };\n }\n\n function setAdaptiveHeight() {\n if (!svgW) return;\n const usableW = Math.max(280, wrap.getBoundingClientRect().width - 2);\n const idealH = (svgH / svgW) * usableW + config.fitPadding * 2;\n const maxVp = Math.floor(innerHeight * config.maxHeightVh);\n const hardMax = Math.min(config.maxHeightPx, Math.max(config.minHeight + 40, maxVp));\n wrap.style.height = Math.round(clamp(idealH, config.minHeight, hardMax)) + 'px';\n }\n\n function openInNewTab() {\n const svg = canvas.querySelector('svg');\n if (!svg) return;\n const clone = svg.cloneNode(true);\n clone.style.width = '';\n clone.style.height = '';\n // Read the page background from the wrap's data-bg attribute (written\n // server-side from the current palette). This guarantees the new tab\n // matches the diagram's baked-in theme.\n const bg = wrap.getAttribute('data-bg') || '#ffffff';\n const html = '<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"UTF-8\">'\n + '<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">'\n + '<title>Diagram</title><style>'\n + 'body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;'\n + 'background:' + bg + ';padding:40px;box-sizing:border-box}'\n + 'svg{max-width:100%;max-height:90vh;height:auto}'\n + '</style></head><body>' + clone.outerHTML + '</body></html>';\n open(URL.createObjectURL(new Blob([html], { type: 'text/html' })), '_blank');\n }\n\n function waitForMermaid() {\n return new Promise((resolve, reject) => {\n if (window.mermaid && typeof window.mermaid.render === 'function') {\n resolve(window.mermaid);\n return;\n }\n const started = Date.now();\n const timer = setInterval(() => {\n if (window.mermaid && typeof window.mermaid.render === 'function') {\n clearInterval(timer);\n resolve(window.mermaid);\n } else if (Date.now() - started > 10000) {\n clearInterval(timer);\n reject(new Error('Mermaid failed to load from CDN (10s timeout)'));\n }\n }, 50);\n });\n }\n\n async function render() {\n try {\n const code = (source.textContent || '').trim();\n if (!code) {\n label.textContent = 'Error: empty source';\n return;\n }\n const mermaid = await waitForMermaid();\n const id = 'diagram-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);\n const { svg } = await mermaid.render(id, code);\n canvas.innerHTML = svg;\n const svgNode = canvas.querySelector('svg');\n if (!svgNode) {\n label.textContent = 'Error: no SVG';\n return;\n }\n const size = readSvgNaturalSize(svgNode);\n svgW = size.w;\n svgH = size.h;\n svgNode.removeAttribute('width');\n svgNode.removeAttribute('height');\n svgNode.style.maxWidth = 'none';\n svgNode.style.display = 'block';\n setAdaptiveHeight();\n fitDiagram();\n } catch (err) {\n console.error('visual-explainer mermaid render failed:', err);\n label.textContent = 'Error: ' + (err && err.message ? err.message : 'render failed');\n canvas.innerHTML = '<pre style=\"padding:20px;color:#b00020;white-space:pre-wrap;font-family:monospace;font-size:12px\">'\n + String(err && err.message ? err.message : err).replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]))\n + '</pre>';\n }\n }\n\n const actions = {\n 'zoom-in': () => zoomAround(1 + config.zoomStep, viewport.clientWidth / 2, viewport.clientHeight / 2),\n 'zoom-out': () => zoomAround(1 / (1 + config.zoomStep), viewport.clientWidth / 2, viewport.clientHeight / 2),\n 'zoom-fit': fitDiagram,\n 'zoom-one': setOneToOne,\n 'zoom-expand': openInNewTab\n };\n Object.keys(actions).forEach((action) => {\n const btn = wrap.querySelector('[data-action=\"' + action + '\"]');\n if (btn) btn.addEventListener('click', actions[action]);\n });\n\n viewport.addEventListener('dblclick', fitDiagram);\n\n viewport.addEventListener('wheel', (e) => {\n if (e.ctrlKey || e.metaKey) {\n e.preventDefault();\n const rect = viewport.getBoundingClientRect();\n const factor = e.deltaY < 0 ? 1 + config.zoomStep : 1 / (1 + config.zoomStep);\n zoomAround(factor, e.clientX - rect.left, e.clientY - rect.top);\n return;\n }\n if (canPan()) {\n e.preventDefault();\n panX -= e.deltaX;\n panY -= e.deltaY;\n applyTransform();\n }\n }, { passive: false });\n\n viewport.addEventListener('mousedown', (e) => {\n if (e.target.closest('.zoom-controls') || !canPan()) return;\n wrap.classList.add('is-panning');\n sx = e.clientX;\n sy = e.clientY;\n spx = panX;\n spy = panY;\n e.preventDefault();\n activeDrag = {\n onMove: (ev) => {\n panX = spx + (ev.clientX - sx);\n panY = spy + (ev.clientY - sy);\n applyTransform();\n },\n onEnd: () => { wrap.classList.remove('is-panning'); }\n };\n });\n\n viewport.addEventListener('touchstart', (e) => {\n if (e.touches.length === 1) {\n sx = e.touches[0].clientX;\n sy = e.touches[0].clientY;\n spx = panX;\n spy = panY;\n } else if (e.touches.length === 2) {\n const dx = e.touches[0].clientX - e.touches[1].clientX;\n const dy = e.touches[0].clientY - e.touches[1].clientY;\n touchDist = Math.sqrt(dx * dx + dy * dy);\n const r = viewport.getBoundingClientRect();\n touchCx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left;\n touchCy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top;\n }\n }, { passive: true });\n\n viewport.addEventListener('touchmove', (e) => {\n if (e.touches.length === 1 && canPan()) {\n if (touchDist > 0) {\n sx = e.touches[0].clientX;\n sy = e.touches[0].clientY;\n spx = panX;\n spy = panY;\n touchDist = 0;\n }\n e.preventDefault();\n panX = spx + (e.touches[0].clientX - sx);\n panY = spy + (e.touches[0].clientY - sy);\n applyTransform();\n } else if (e.touches.length === 2 && touchDist > 0) {\n e.preventDefault();\n const dx = e.touches[0].clientX - e.touches[1].clientX;\n const dy = e.touches[0].clientY - e.touches[1].clientY;\n const d = Math.sqrt(dx * dx + dy * dy);\n zoomAround(d / touchDist, touchCx, touchCy);\n touchDist = d;\n }\n }, { passive: false });\n\n new ResizeObserver(() => {\n if (svgW) { setAdaptiveHeight(); fitDiagram(); }\n }).observe(wrap);\n\n render();\n }\n\n function bootstrap() {\n document.querySelectorAll('.diagram-shell').forEach(initDiagram);\n }\n\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', bootstrap);\n } else {\n bootstrap();\n }\n})();\n";
14
14
  export declare function generateCSSVariables(palette: Palette, fonts: FontPairing, isDark: boolean): string;
15
15
  export declare function generateHtmlShell(title: string, bodyContent: string, aesthetic: string, cssVariables: string, extraHead?: string, extraScripts?: string): string;
16
16
  export declare function escapeHtml(str: string): string;
@@ -1 +1 @@
1
- {"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../src/templates/shared.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAGnE,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,SAAS,EAAE,WAAW,CAiCxD,CAAC;AAGF,eAAO,MAAM,QAAQ,EAAE,MAAM,CAAC,SAAS,EAAE;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,CAiVzE,CAAC;AAGF,eAAO,MAAM,UAAU,63EAgHtB,CAAC;AAGF,eAAO,MAAM,iBAAiB,w9BAyD7B,CAAC;AAEF,eAAO,MAAM,gBAAgB,0vDAoD5B,CAAC;AAGF,wBAAgB,oBAAoB,CACnC,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,WAAW,EAClB,MAAM,EAAE,OAAO,GACb,MAAM,CA0BR;AAGD,wBAAgB,iBAAiB,CAChC,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,MAAM,EACpB,SAAS,SAAK,EACd,YAAY,SAAK,GACf,MAAM,CAqBR;AAGD,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAO9C"}
1
+ {"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../src/templates/shared.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAGnE,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,SAAS,EAAE,WAAW,CAiCxD,CAAC;AAGF,eAAO,MAAM,QAAQ,EAAE,MAAM,CAAC,SAAS,EAAE;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,CAiVzE,CAAC;AAGF,eAAO,MAAM,UAAU,63EAgHtB,CAAC;AAkBF,eAAO,MAAM,iBAAiB,owDAmG7B,CAAC;AAKF,eAAO,MAAM,gBAAgB,+nXA2U5B,CAAC;AAGF,wBAAgB,oBAAoB,CACnC,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,WAAW,EAClB,MAAM,EAAE,OAAO,GACb,MAAM,CA0BR;AAGD,wBAAgB,iBAAiB,CAChC,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,MAAM,EACpB,SAAS,SAAK,EACd,YAAY,SAAK,GACf,MAAM,CAqBR;AAGD,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAO9C"}
@@ -490,117 +490,456 @@ code {
490
490
  .section--hero { padding: 20px 24px; }
491
491
  }
492
492
  `;
493
- // Mermaid zoom/pan CSS and JS
493
+ // Mermaid zoom/pan CSS and JS.
494
+ //
495
+ // This is the proven pattern from nicobailon/visual-explainer's reference
496
+ // mermaid-flowchart.html. The earlier implementation had several subtle bugs:
497
+ // - mermaid source lived in <pre class="mermaid"> so the HTML parser would
498
+ // mangle `<br/>`, `<`, `>`, `&` before Mermaid ever saw the text
499
+ // - `startOnLoad: true` + a 500ms setTimeout to bind zoom controls is a
500
+ // race — on large diagrams the controls would never bind
501
+ // - the viewport had no height, so pan and fit were meaningless
502
+ // - new-tab export produced a blank page (bare SVG blob, no background)
503
+ //
504
+ // Now the source lives in a `<script type="text/plain" class="diagram-source">`
505
+ // which the HTML parser treats as opaque data; we render explicitly via
506
+ // `mermaid.render(id, code)` and await it before binding handlers. Adaptive
507
+ // height is computed from the SVG's intrinsic aspect ratio. The new-tab
508
+ // export wraps the SVG in a minimal HTML shell with the page background.
494
509
  export const MERMAID_SHELL_CSS = `
495
510
  .diagram-shell {
496
- background: var(--surface);
497
- border: 1px solid var(--border);
498
- border-radius: 12px;
499
- overflow: hidden;
511
+ position: relative;
512
+ margin-bottom: 24px;
513
+ }
514
+
515
+ .diagram-shell__hint {
516
+ font-family: var(--font-mono);
517
+ font-size: 11px;
518
+ color: var(--text-dim);
519
+ margin-bottom: 8px;
520
+ opacity: 0.7;
500
521
  }
501
522
 
502
523
  .mermaid-wrap {
503
524
  position: relative;
504
- display: flex;
505
- justify-content: center;
506
- padding: 24px;
525
+ background: var(--surface);
526
+ border: 1px solid var(--border);
527
+ border-radius: 12px;
528
+ overflow: hidden;
529
+ min-height: 360px;
507
530
  }
508
531
 
509
532
  .mermaid-viewport {
533
+ position: relative;
510
534
  overflow: hidden;
535
+ width: 100%;
536
+ height: 100%;
537
+ min-height: 300px;
511
538
  cursor: grab;
512
539
  }
513
540
 
514
- .mermaid-viewport:active {
541
+ .mermaid-wrap.is-panning .mermaid-viewport {
515
542
  cursor: grabbing;
543
+ user-select: none;
516
544
  }
517
545
 
518
546
  .mermaid-canvas {
519
- transform-origin: center center;
520
- transition: transform 0.1s ease-out;
547
+ position: absolute;
548
+ top: 0;
549
+ left: 0;
550
+ transform-origin: 0 0;
551
+ }
552
+
553
+ .mermaid-canvas svg {
554
+ display: block;
555
+ max-width: none;
521
556
  }
522
557
 
523
558
  .zoom-controls {
524
559
  position: absolute;
525
- top: 12px;
526
- right: 12px;
560
+ top: 8px;
561
+ right: 8px;
527
562
  display: flex;
528
- gap: 6px;
563
+ align-items: center;
564
+ gap: 2px;
529
565
  z-index: 10;
530
- }
531
-
532
- .zoom-btn {
533
- width: 32px;
534
- height: 32px;
566
+ background: var(--surface);
535
567
  border: 1px solid var(--border);
536
568
  border-radius: 6px;
537
- background: var(--surface);
538
- color: var(--text);
539
- font-size: 16px;
569
+ padding: 2px 4px;
570
+ }
571
+
572
+ .zoom-controls button {
573
+ width: 28px;
574
+ height: 28px;
575
+ border: none;
576
+ background: transparent;
577
+ color: var(--text-dim);
578
+ font-family: var(--font-mono);
579
+ font-size: 14px;
540
580
  cursor: pointer;
581
+ border-radius: 4px;
541
582
  display: flex;
542
583
  align-items: center;
543
584
  justify-content: center;
544
- transition: all 0.2s;
585
+ transition: background 0.15s ease, color 0.15s ease;
545
586
  }
546
587
 
547
- .zoom-btn:hover {
548
- background: var(--surface2);
549
- border-color: var(--border-bright);
588
+ .zoom-controls button:hover {
589
+ background: var(--border);
590
+ color: var(--text);
591
+ }
592
+
593
+ .zoom-label {
594
+ font-family: var(--font-mono);
595
+ font-size: 10px;
596
+ color: var(--text-dim);
597
+ padding: 0 6px;
598
+ white-space: nowrap;
599
+ }
600
+
601
+ .mermaid .nodeLabel {
602
+ font-family: var(--font-body) !important;
603
+ }
604
+
605
+ .mermaid .edgeLabel {
606
+ font-family: var(--font-mono) !important;
550
607
  }
551
608
  `;
609
+ // Vanilla IIFE — no module scope needed. Handles multiple `.diagram-shell`
610
+ // instances on the same page. Each instance has its own `<script
611
+ // type="text/plain" class="diagram-source">` holding the raw mermaid source.
552
612
  export const MERMAID_SHELL_JS = `
553
- function initMermaidControls(wrap) {
554
- const viewport = wrap.querySelector('.mermaid-viewport');
555
- const canvas = wrap.querySelector('.mermaid-canvas');
556
- let scale = 1;
557
- let isDragging = false;
558
- let startX, startY, translateX = 0, translateY = 0;
559
-
560
- function updateTransform() {
561
- canvas.style.transform = \`translate(\${translateX}px, \${translateY}px) scale(\${scale})\`;
562
- }
613
+ (function initAllDiagrams() {
614
+ const config = {
615
+ fitPadding: 24,
616
+ minHeight: 360,
617
+ maxHeightPx: 960,
618
+ maxHeightVh: 0.84,
619
+ maxInitialZoom: 1.8,
620
+ minZoom: 0.08,
621
+ maxZoom: 6.5,
622
+ zoomStep: 0.14,
623
+ readabilityFloor: 0.58
624
+ };
563
625
 
564
- wrap.querySelector('[data-zoom="in"]').onclick = () => { scale *= 1.2; updateTransform(); };
565
- wrap.querySelector('[data-zoom="out"]').onclick = () => { scale /= 1.2; updateTransform(); };
566
- wrap.querySelector('[data-zoom="reset"]').onclick = () => { scale = 1; translateX = 0; translateY = 0; updateTransform(); };
567
- wrap.querySelector('[data-zoom="expand"]').onclick = () => openMermaidInNewTab(wrap);
626
+ const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
627
+ let activeDrag = null;
568
628
 
569
- viewport.addEventListener('mousedown', (e) => {
570
- isDragging = true;
571
- startX = e.clientX - translateX;
572
- startY = e.clientY - translateY;
573
- viewport.style.cursor = 'grabbing';
629
+ addEventListener('mousemove', (e) => activeDrag && activeDrag.onMove(e));
630
+ addEventListener('mouseup', () => {
631
+ if (activeDrag) activeDrag.onEnd();
632
+ activeDrag = null;
574
633
  });
575
634
 
576
- window.addEventListener('mousemove', (e) => {
577
- if (!isDragging) return;
578
- translateX = e.clientX - startX;
579
- translateY = e.clientY - startY;
580
- updateTransform();
581
- });
635
+ function initDiagram(shell) {
636
+ const wrap = shell.querySelector('.mermaid-wrap');
637
+ const viewport = shell.querySelector('.mermaid-viewport');
638
+ const canvas = shell.querySelector('.mermaid-canvas');
639
+ const source = shell.querySelector('.diagram-source');
640
+ const label = shell.querySelector('.zoom-label');
641
+ if (!wrap || !viewport || !canvas || !source || !label) {
642
+ console.error('visual-explainer: missing required elements in', shell);
643
+ return;
644
+ }
582
645
 
583
- window.addEventListener('mouseup', () => {
584
- isDragging = false;
585
- viewport.style.cursor = 'grab';
586
- });
646
+ let zoom = 1;
647
+ let fitMode = 'contain';
648
+ let panX = 0;
649
+ let panY = 0;
650
+ let svgW = 0;
651
+ let svgH = 0;
652
+ let sx = 0;
653
+ let sy = 0;
654
+ let spx = 0;
655
+ let spy = 0;
656
+ let touchDist = 0;
657
+ let touchCx = 0;
658
+ let touchCy = 0;
587
659
 
588
- viewport.addEventListener('wheel', (e) => {
589
- if (e.ctrlKey || e.metaKey) {
590
- e.preventDefault();
591
- scale *= e.deltaY > 0 ? 0.9 : 1.1;
592
- updateTransform();
660
+ function constrainPan() {
661
+ const vpW = viewport.clientWidth;
662
+ const vpH = viewport.clientHeight;
663
+ const rW = svgW * zoom;
664
+ const rH = svgH * zoom;
665
+ const pad = config.fitPadding;
666
+ panX = (rW + pad * 2 <= vpW) ? (vpW - rW) / 2 : clamp(panX, vpW - rW - pad, pad);
667
+ panY = (rH + pad * 2 <= vpH) ? (vpH - rH) / 2 : clamp(panY, vpH - rH - pad, pad);
593
668
  }
594
- }, { passive: false });
595
- }
596
669
 
597
- function openMermaidInNewTab(wrap) {
598
- const svg = wrap.querySelector('svg');
599
- if (!svg) return;
600
- const blob = new Blob([svg.outerHTML], { type: 'image/svg+xml' });
601
- const url = URL.createObjectURL(blob);
602
- window.open(url, '_blank');
603
- }
670
+ function applyTransform() {
671
+ const svg = canvas.querySelector('svg');
672
+ if (!svg || !svgW) return;
673
+ constrainPan();
674
+ svg.style.width = (svgW * zoom) + 'px';
675
+ svg.style.height = (svgH * zoom) + 'px';
676
+ canvas.style.transform = 'translate(' + panX + 'px, ' + panY + 'px)';
677
+ label.textContent = Math.round(zoom * 100) + '% \u2014 ' + fitMode;
678
+ }
679
+
680
+ function canPan() {
681
+ const rW = svgW * zoom;
682
+ const rH = svgH * zoom;
683
+ return rW + config.fitPadding * 2 > viewport.clientWidth
684
+ || rH + config.fitPadding * 2 > viewport.clientHeight;
685
+ }
686
+
687
+ function computeSmartFit() {
688
+ const vpW = viewport.clientWidth;
689
+ const vpH = viewport.clientHeight;
690
+ const aW = Math.max(80, vpW - config.fitPadding * 2);
691
+ const aH = Math.max(80, vpH - config.fitPadding * 2);
692
+ const contain = Math.min(aW / svgW, aH / svgH);
693
+ let z = contain;
694
+ let mode = 'contain';
695
+ if (contain < config.readabilityFloor) {
696
+ const chartR = svgH / svgW;
697
+ const vpR = vpH / Math.max(vpW, 1);
698
+ if (chartR >= vpR) { z = aW / svgW; mode = 'width-priority'; }
699
+ else { z = aH / svgH; mode = 'height-priority'; }
700
+ }
701
+ return { zoom: clamp(z, config.minZoom, config.maxInitialZoom), mode };
702
+ }
703
+
704
+ function fitDiagram() {
705
+ if (!svgW) return;
706
+ const fit = computeSmartFit();
707
+ zoom = fit.zoom;
708
+ fitMode = fit.mode;
709
+ panX = (viewport.clientWidth - svgW * zoom) / 2;
710
+ panY = (viewport.clientHeight - svgH * zoom) / 2;
711
+ applyTransform();
712
+ }
713
+
714
+ function setOneToOne() {
715
+ zoom = clamp(1, config.minZoom, config.maxZoom);
716
+ fitMode = '1:1';
717
+ panX = (viewport.clientWidth - svgW * zoom) / 2;
718
+ panY = (viewport.clientHeight - svgH * zoom) / 2;
719
+ applyTransform();
720
+ }
721
+
722
+ function zoomAround(factor, cx, cy) {
723
+ const next = clamp(zoom * factor, config.minZoom, config.maxZoom);
724
+ const ratio = next / zoom;
725
+ panX = cx - ratio * (cx - panX);
726
+ panY = cy - ratio * (cy - panY);
727
+ zoom = next;
728
+ fitMode = 'custom';
729
+ applyTransform();
730
+ }
731
+
732
+ function readSvgNaturalSize(svg) {
733
+ let w = 0;
734
+ let h = 0;
735
+ if (svg.viewBox && svg.viewBox.baseVal && svg.viewBox.baseVal.width > 0) {
736
+ w = svg.viewBox.baseVal.width;
737
+ h = svg.viewBox.baseVal.height;
738
+ }
739
+ if (!w) {
740
+ w = parseFloat(svg.getAttribute('width')) || 0;
741
+ h = parseFloat(svg.getAttribute('height')) || 0;
742
+ }
743
+ if (!w) {
744
+ try { const b = svg.getBBox(); w = b.width; h = b.height; } catch {}
745
+ }
746
+ if (!w) {
747
+ const r = svg.getBoundingClientRect();
748
+ w = r.width || 1000;
749
+ h = r.height || 700;
750
+ }
751
+ if (!svg.getAttribute('viewBox')) {
752
+ svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
753
+ }
754
+ return { w, h };
755
+ }
756
+
757
+ function setAdaptiveHeight() {
758
+ if (!svgW) return;
759
+ const usableW = Math.max(280, wrap.getBoundingClientRect().width - 2);
760
+ const idealH = (svgH / svgW) * usableW + config.fitPadding * 2;
761
+ const maxVp = Math.floor(innerHeight * config.maxHeightVh);
762
+ const hardMax = Math.min(config.maxHeightPx, Math.max(config.minHeight + 40, maxVp));
763
+ wrap.style.height = Math.round(clamp(idealH, config.minHeight, hardMax)) + 'px';
764
+ }
765
+
766
+ function openInNewTab() {
767
+ const svg = canvas.querySelector('svg');
768
+ if (!svg) return;
769
+ const clone = svg.cloneNode(true);
770
+ clone.style.width = '';
771
+ clone.style.height = '';
772
+ // Read the page background from the wrap's data-bg attribute (written
773
+ // server-side from the current palette). This guarantees the new tab
774
+ // matches the diagram's baked-in theme.
775
+ const bg = wrap.getAttribute('data-bg') || '#ffffff';
776
+ const html = '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">'
777
+ + '<meta name="viewport" content="width=device-width, initial-scale=1.0">'
778
+ + '<title>Diagram</title><style>'
779
+ + 'body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;'
780
+ + 'background:' + bg + ';padding:40px;box-sizing:border-box}'
781
+ + 'svg{max-width:100%;max-height:90vh;height:auto}'
782
+ + '</style></head><body>' + clone.outerHTML + '</body></html>';
783
+ open(URL.createObjectURL(new Blob([html], { type: 'text/html' })), '_blank');
784
+ }
785
+
786
+ function waitForMermaid() {
787
+ return new Promise((resolve, reject) => {
788
+ if (window.mermaid && typeof window.mermaid.render === 'function') {
789
+ resolve(window.mermaid);
790
+ return;
791
+ }
792
+ const started = Date.now();
793
+ const timer = setInterval(() => {
794
+ if (window.mermaid && typeof window.mermaid.render === 'function') {
795
+ clearInterval(timer);
796
+ resolve(window.mermaid);
797
+ } else if (Date.now() - started > 10000) {
798
+ clearInterval(timer);
799
+ reject(new Error('Mermaid failed to load from CDN (10s timeout)'));
800
+ }
801
+ }, 50);
802
+ });
803
+ }
804
+
805
+ async function render() {
806
+ try {
807
+ const code = (source.textContent || '').trim();
808
+ if (!code) {
809
+ label.textContent = 'Error: empty source';
810
+ return;
811
+ }
812
+ const mermaid = await waitForMermaid();
813
+ const id = 'diagram-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
814
+ const { svg } = await mermaid.render(id, code);
815
+ canvas.innerHTML = svg;
816
+ const svgNode = canvas.querySelector('svg');
817
+ if (!svgNode) {
818
+ label.textContent = 'Error: no SVG';
819
+ return;
820
+ }
821
+ const size = readSvgNaturalSize(svgNode);
822
+ svgW = size.w;
823
+ svgH = size.h;
824
+ svgNode.removeAttribute('width');
825
+ svgNode.removeAttribute('height');
826
+ svgNode.style.maxWidth = 'none';
827
+ svgNode.style.display = 'block';
828
+ setAdaptiveHeight();
829
+ fitDiagram();
830
+ } catch (err) {
831
+ console.error('visual-explainer mermaid render failed:', err);
832
+ label.textContent = 'Error: ' + (err && err.message ? err.message : 'render failed');
833
+ canvas.innerHTML = '<pre style="padding:20px;color:#b00020;white-space:pre-wrap;font-family:monospace;font-size:12px">'
834
+ + String(err && err.message ? err.message : err).replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]))
835
+ + '</pre>';
836
+ }
837
+ }
838
+
839
+ const actions = {
840
+ 'zoom-in': () => zoomAround(1 + config.zoomStep, viewport.clientWidth / 2, viewport.clientHeight / 2),
841
+ 'zoom-out': () => zoomAround(1 / (1 + config.zoomStep), viewport.clientWidth / 2, viewport.clientHeight / 2),
842
+ 'zoom-fit': fitDiagram,
843
+ 'zoom-one': setOneToOne,
844
+ 'zoom-expand': openInNewTab
845
+ };
846
+ Object.keys(actions).forEach((action) => {
847
+ const btn = wrap.querySelector('[data-action="' + action + '"]');
848
+ if (btn) btn.addEventListener('click', actions[action]);
849
+ });
850
+
851
+ viewport.addEventListener('dblclick', fitDiagram);
852
+
853
+ viewport.addEventListener('wheel', (e) => {
854
+ if (e.ctrlKey || e.metaKey) {
855
+ e.preventDefault();
856
+ const rect = viewport.getBoundingClientRect();
857
+ const factor = e.deltaY < 0 ? 1 + config.zoomStep : 1 / (1 + config.zoomStep);
858
+ zoomAround(factor, e.clientX - rect.left, e.clientY - rect.top);
859
+ return;
860
+ }
861
+ if (canPan()) {
862
+ e.preventDefault();
863
+ panX -= e.deltaX;
864
+ panY -= e.deltaY;
865
+ applyTransform();
866
+ }
867
+ }, { passive: false });
868
+
869
+ viewport.addEventListener('mousedown', (e) => {
870
+ if (e.target.closest('.zoom-controls') || !canPan()) return;
871
+ wrap.classList.add('is-panning');
872
+ sx = e.clientX;
873
+ sy = e.clientY;
874
+ spx = panX;
875
+ spy = panY;
876
+ e.preventDefault();
877
+ activeDrag = {
878
+ onMove: (ev) => {
879
+ panX = spx + (ev.clientX - sx);
880
+ panY = spy + (ev.clientY - sy);
881
+ applyTransform();
882
+ },
883
+ onEnd: () => { wrap.classList.remove('is-panning'); }
884
+ };
885
+ });
886
+
887
+ viewport.addEventListener('touchstart', (e) => {
888
+ if (e.touches.length === 1) {
889
+ sx = e.touches[0].clientX;
890
+ sy = e.touches[0].clientY;
891
+ spx = panX;
892
+ spy = panY;
893
+ } else if (e.touches.length === 2) {
894
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
895
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
896
+ touchDist = Math.sqrt(dx * dx + dy * dy);
897
+ const r = viewport.getBoundingClientRect();
898
+ touchCx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left;
899
+ touchCy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top;
900
+ }
901
+ }, { passive: true });
902
+
903
+ viewport.addEventListener('touchmove', (e) => {
904
+ if (e.touches.length === 1 && canPan()) {
905
+ if (touchDist > 0) {
906
+ sx = e.touches[0].clientX;
907
+ sy = e.touches[0].clientY;
908
+ spx = panX;
909
+ spy = panY;
910
+ touchDist = 0;
911
+ }
912
+ e.preventDefault();
913
+ panX = spx + (e.touches[0].clientX - sx);
914
+ panY = spy + (e.touches[0].clientY - sy);
915
+ applyTransform();
916
+ } else if (e.touches.length === 2 && touchDist > 0) {
917
+ e.preventDefault();
918
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
919
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
920
+ const d = Math.sqrt(dx * dx + dy * dy);
921
+ zoomAround(d / touchDist, touchCx, touchCy);
922
+ touchDist = d;
923
+ }
924
+ }, { passive: false });
925
+
926
+ new ResizeObserver(() => {
927
+ if (svgW) { setAdaptiveHeight(); fitDiagram(); }
928
+ }).observe(wrap);
929
+
930
+ render();
931
+ }
932
+
933
+ function bootstrap() {
934
+ document.querySelectorAll('.diagram-shell').forEach(initDiagram);
935
+ }
936
+
937
+ if (document.readyState === 'loading') {
938
+ document.addEventListener('DOMContentLoaded', bootstrap);
939
+ } else {
940
+ bootstrap();
941
+ }
942
+ })();
604
943
  `;
605
944
  // Helper to generate CSS variables from palette
606
945
  export function generateCSSVariables(palette, fonts, isDark) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@the-forge-flow/visual-explainer-pi",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "PI extension for generating beautiful HTML visualizations of diagrams, architecture, and data",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",