build-small-hackathon/CodeFlow
10
1<!DOCTYPE html>2<html lang="en">3<head>4 <meta charset="UTF-8">5 <meta name="viewport" content="width=device-width, initial-scale=1.0">6 <title>CodeFlow — Code to Flowchart</title>7 <!-- All assets are vendored locally (see static/) — no external CDN/API at runtime. -->8 <link rel="stylesheet" href="static/fonts.css">9 <style>10 /* ============================================================11 PINE & SAGE — the CodeFlow theme (green + light).12 Soft sage-paper base, deep pine-green accent (dark enough to clear13 WCAG AA on the light bg), muted moss branch lines. Light by default;14 toggle → forest "charcoal" dark. Colorblind-safe by design: meaning15 rides on text + node shape, never hue — green is purely decorative,16 and luminance contrast does the work.17 NOTE: --cyan / --violet are historical var names kept so every18 reference stays valid; here --cyan = the pine accent and19 --violet = moss green. (Safe future cleanup: rename to20 --accent / --accent-2.)21 ============================================================ */22 :root {23 --bg: #edf0e6; /* sage paper */24 --bg-glow: #e8efdd; /* faint header tint */25 --bg-panel: #f9fbf4; /* near-white card */26 --bg-inset: #e2e7d6; /* sage inset (input / canvas) */27 --border: #d3dac3; /* sage hairline */28 --cyan: #2c6e49; /* ACCENT — pine green (deep, AA-friendly) */29 --cyan-dim: #245a3b;30 --on-accent: #fbfdf8; /* label on the accent — near-white (dark accent → ~6:1 AA) */31 --violet: #6b7a55; /* SECONDARY — moss green (branch lines) */32 --text: #1e251c; /* deep green-black ink */33 --text-dim: #5d6b54;34 --label: #5a7048; /* moss-green section labels */35 --scroll: #c8d3b8;36 --scroll-hi: #b2c29c;37 --gutter: #9aa888;38 /* Editor syntax tokens — green family (pine / gold / olive / moss) */39 --tok-keyword: #2c6e49; /* pine */40 --tok-fn: #876618; /* dark gold */41 --tok-string: #66722f; /* olive */42 --tok-number: #9e521f; /* burnt orange */43 --tok-comment: #8c9a7e; /* sage gray */44 --tok-prop: #6f7d52; /* moss */45 --tok-punct: #5d6b54;46 --tok-var: #1e251c;47 --tok-invalid: #b5362a;48 --mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;49 --sans: 'Hanken Grotesk', system-ui, -apple-system, sans-serif;50 --display: 'Fraunces', Georgia, 'Times New Roman', serif; /* serif for the wordmark */51 }52 53 /* ---- Forest charcoal (dark counterpart, via toggle): same green identity ---- */54 body.dark {55 --bg: #121711;56 --bg-glow: #1f271d;57 --bg-panel: #1a211a;58 --bg-inset: #0e130d;59 --border: #2c372a;60 --cyan: #3f9e63; /* pine lifted for dark */61 --cyan-dim: #348352;62 --on-accent: #122012; /* label on the accent — dark ink (light accent → ~5:1 AA) */63 --violet: #8fa274; /* moss lifted */64 --text: #e7eede; /* green cream */65 --text-dim: #9cac8e;66 --label: #9fc98a;67 --scroll: #34402f;68 --scroll-hi: #45543d;69 --gutter: #59674a;70 --tok-keyword: #5fb37e;71 --tok-fn: #d6a85e;72 --tok-string: #b8c47a;73 --tok-number: #e0a86a;74 --tok-comment: #7e8b70;75 --tok-prop: #9fb381;76 --tok-punct: #9cac8e;77 --tok-var: #e7eede;78 --tok-invalid: #ff6b6b;79 }80 81 /* ============================================================82 RUST & SIENNA — fallback palette (the previous warm theme).83 Activated by adding `rust` to <body> (see the PALETTE flag in JS).84 Same var names, so everything that reads var(--cyan)/etc. — incl.85 the color-mix focus rings — follows automatically; the Mermaid86 diagram is handled by the matching MERMAID_THEMES.rust entry.87 ============================================================ */88 body.rust {89 --bg: #f6ece0;90 --bg-glow: #f0e3d2;91 --bg-panel: #fdf6ec;92 --bg-inset: #efe2d2;93 --border: #e0cdb6;94 --cyan: #b0532e;95 --cyan-dim: #95421f;96 --on-accent: #fffaf0; /* dark accent → near-white label (~4.8 AA) */97 --violet: #9c6f4f;98 --text: #2a201a;99 --text-dim: #75614f;100 --label: #9c5a33;101 --scroll: #d6c4a8;102 --scroll-hi: #c2ab88;103 --gutter: #ad9d7e;104 --tok-keyword: #a8472a;105 --tok-fn: #876618;106 --tok-string: #66722f;107 --tok-number: #9e521f;108 --tok-comment: #a4937b;109 --tok-prop: #92633a;110 --tok-punct: #75614f;111 --tok-var: #2a201a;112 --tok-invalid: #b5362a;113 }114 /* Rust dark — full var set so it wins over green's body.dark on toggle */115 body.rust.dark {116 --bg: #1b1512;117 --bg-glow: #29201a;118 --bg-panel: #231b16;119 --bg-inset: #16100c;120 --border: #392d24;121 --cyan: #d56a3c;122 --cyan-dim: #b85730;123 --on-accent: #1e1408; /* light accent → dark ink label (~4.8 AA) */124 --violet: #b3855f;125 --text: #f0e6d6;126 --text-dim: #b3a489;127 --label: #d98a5a;128 --scroll: #443629;129 --scroll-hi: #594636;130 --gutter: #6c5b47;131 --tok-keyword: #e07a52;132 --tok-fn: #d6a85e;133 --tok-string: #b8c47a;134 --tok-number: #e0a86a;135 --tok-comment: #8f8169;136 --tok-prop: #d39a6e;137 --tok-punct: #b3a489;138 --tok-var: #f0e6d6;139 --tok-invalid: #ff6b6b;140 }141 142 * { box-sizing: border-box; }143 144 body {145 font-family: var(--sans);146 background: radial-gradient(1200px 600px at 50% -10%, var(--bg-glow) 0%, var(--bg) 55%);147 color: var(--text);148 margin: 0;149 padding: 24px 28px 28px;150 min-height: 100vh;151 transition: background .25s, color .25s;152 }153 154 /* ---- Header ---- */155 header {156 display: flex;157 align-items: center;158 gap: 14px;159 margin-bottom: 22px;160 }161 /* Standalone "decision node" mark — no badge; inherits the accent (so it162 flips to amber in dark mode). The svg uses currentColor. */163 .logo-mark {164 display: grid; place-items: center;165 color: var(--cyan);166 }167 .logo-mark svg { width: 34px; height: 34px; }168 .wordmark { font-family: var(--display); font-size: 25px; font-weight: 600; letter-spacing: -0.2px; }169 .wordmark .flow { color: var(--cyan); }170 /* Boxed instruction callout — accent left-bar draws the eye to the "how to" */171 .tagline {172 color: var(--text);173 font-size: 12.5px;174 font-weight: 500;175 margin-left: 10px;176 padding: 8px 14px;177 background: var(--bg-panel);178 border: 1px solid var(--border);179 border-left: 3px solid var(--cyan);180 border-radius: 9px;181 box-shadow: 0 1px 2px rgba(30, 45, 22, .08);182 }183 .tagline b { color: var(--cyan); font-weight: 700; }184 185 /* ---- Theme toggle (far right) ---- */186 .theme-toggle {187 margin-left: auto;188 width: 38px; height: 38px;189 display: grid; place-items: center;190 color: var(--text-dim);191 background: var(--bg-panel);192 border: 1px solid var(--border);193 border-radius: 10px;194 cursor: pointer;195 transition: color .15s, border-color .15s;196 }197 .theme-toggle:hover { color: var(--cyan); border-color: var(--cyan); }198 .theme-toggle svg { width: 18px; height: 18px; }199 /* Show moon in dark mode, sun in light mode */200 .theme-toggle .icon-moon { display: none; } /* default = light → show sun */201 body.dark .theme-toggle .icon-sun { display: none; }202 body.dark .theme-toggle .icon-moon { display: block; }203 204 /* ---- Flow cue between the panels ---- */205 .flow-arrow {206 flex: 0 0 auto;207 align-self: center;208 display: grid; place-items: center;209 color: var(--cyan);210 opacity: .7;211 }212 .flow-arrow svg { width: 26px; height: 26px; }213 214 /* ---- Layout ---- */215 .container {216 display: flex;217 gap: 22px;218 height: calc(100vh - 110px);219 }220 .panel {221 flex: 1;222 min-width: 0;223 display: flex;224 flex-direction: column;225 background: var(--bg-panel);226 border: 1px solid var(--border);227 border-radius: 14px;228 padding: 18px;229 }230 /* Give the diagram more room than the code input so charts read bigger */231 .panel-diagram { flex: 1.5; position: relative; }232 .panel-head {233 display: flex;234 align-items: center;235 justify-content: space-between;236 margin-bottom: 14px;237 gap: 12px;238 }239 .panel-title {240 font-size: 13px;241 font-weight: 600;242 text-transform: uppercase;243 letter-spacing: 1.2px;244 color: var(--label);245 }246 247 /* Subtle "scroll" hint — only shown when the chart overflows its box */248 .scroll-hint {249 position: absolute;250 bottom: 28px;251 left: 50%;252 transform: translateX(-50%);253 display: flex;254 align-items: center;255 gap: 6px;256 background: var(--bg-panel);257 backdrop-filter: blur(4px);258 border: 1px solid var(--border);259 color: var(--text-dim);260 font-size: 11.5px;261 font-weight: 500;262 padding: 5px 12px;263 border-radius: 999px;264 pointer-events: none;265 box-shadow: 0 4px 14px rgba(0, 0, 0, .3);266 }267 .scroll-hint[hidden] { display: none; }268 .scroll-hint svg { width: 13px; height: 13px; stroke: var(--cyan); }269 270 /* ---- Zoom controls (bottom-left of the canvas) ---- */271 .zoom-ctrl {272 position: absolute;273 bottom: 28px;274 left: 28px;275 display: flex;276 align-items: stretch;277 gap: 1px;278 background: var(--bg-panel);279 border: 1px solid var(--border);280 border-radius: 9px;281 overflow: hidden;282 box-shadow: 0 4px 14px rgba(0, 0, 0, .25);283 }284 .zoom-ctrl[hidden] { display: none; }285 .zoom-btn {286 font-family: var(--sans);287 font-size: 14px;288 font-weight: 600;289 color: var(--text-dim);290 background: transparent;291 border: none;292 min-width: 30px;293 padding: 5px 8px;294 cursor: pointer;295 transition: color .12s, background .12s;296 }297 .zoom-btn.zoom-fit { font-size: 11px; text-transform: uppercase; letter-spacing: .5px; border-left: 1px solid var(--border); border-right: 1px solid var(--border); }298 .zoom-btn:hover { color: var(--cyan); background: var(--bg-inset); }299 300 /* ---- Examples dropdown ---- */301 .examples {302 position: relative;303 }304 select {305 font-family: var(--sans);306 font-size: 13px;307 font-weight: 500;308 color: var(--text);309 background: var(--bg-inset);310 border: 1px solid var(--border);311 border-radius: 8px;312 padding: 8px 32px 8px 12px;313 cursor: pointer;314 appearance: none;315 -webkit-appearance: none;316 /* Chevron: warm neutral, theme-aware via the body.dark override below */317 background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%235d6b54' stroke-width='2'><path d='M2 4l4 4 4-4'/></svg>");318 background-repeat: no-repeat;319 background-position: right 12px center;320 transition: border-color .15s;321 }322 select:hover, select:focus { border-color: var(--cyan); outline: none; }323 /* Dark-mode chevron (lighter neutral on the charcoal field) */324 body.dark select {325 background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%239cac8e' stroke-width='2'><path d='M2 4l4 4 4-4'/></svg>");326 }327 328 /* ---- Code input ---- */329 textarea {330 flex: 1;331 background: var(--bg-inset);332 color: var(--cyan);333 border: 1px solid var(--border);334 border-radius: 10px;335 padding: 14px;336 font-family: var(--mono);337 font-size: 13.5px;338 line-height: 1.6;339 resize: none;340 tab-size: 4;341 }342 textarea:focus { outline: none; border-color: var(--cyan); box-shadow: 0 0 0 3px color-mix(in srgb, var(--cyan) 22%, transparent); }343 textarea::placeholder { color: #5a5a8a; }344 345 /* ---- Editor controls (language + examples) ---- */346 .editor-controls { display: flex; align-items: center; gap: 8px; }347 select.ctrl-select { padding: 7px 28px 7px 10px; font-size: 12.5px; }348 349 /* ---- Code editor (CodeMirror) wrapper ---- */350 .editor-wrap {351 flex: 1;352 min-height: 0;353 display: flex;354 flex-direction: column;355 background: var(--bg-inset);356 border: 1px solid var(--border);357 border-radius: 10px;358 overflow: hidden;359 transition: border-color .15s, box-shadow .15s;360 }361 .editor-wrap[hidden] { display: none; } /* author display:flex would else beat the hidden attr */362 .editor-wrap.focused { border-color: var(--cyan); box-shadow: 0 0 0 3px color-mix(in srgb, var(--cyan) 22%, transparent); }363 #editor { flex: 1; min-height: 0; overflow: hidden; }364 .cm-editor { height: 100%; background: transparent; font-size: 13.5px; }365 .cm-editor.cm-focused { outline: none; }366 .cm-editor .cm-scroller {367 font-family: var(--mono);368 line-height: 1.6;369 overflow: auto;370 scrollbar-width: thin;371 scrollbar-color: var(--scroll) var(--bg-inset);372 }373 .cm-editor .cm-scroller::-webkit-scrollbar { width: 10px; height: 10px; }374 .cm-editor .cm-scroller::-webkit-scrollbar-track { background: var(--bg-inset); border-radius: 999px; }375 .cm-editor .cm-scroller::-webkit-scrollbar-thumb {376 background: var(--scroll); border-radius: 999px; border: 2px solid var(--bg-inset);377 }378 .cm-editor .cm-scroller::-webkit-scrollbar-thumb:hover { background: var(--scroll-hi); }379 380 /* Editor footer — VS Code-style status bar: position (left) + actions (right) */381 .editor-foot {382 display: flex;383 align-items: center;384 justify-content: space-between;385 padding: 6px 10px;386 border-top: 1px solid var(--border);387 background: var(--bg-panel);388 }389 .editor-status { font-family: var(--mono); font-size: 11.5px; color: var(--text-dim); }390 .editor-actions { display: flex; gap: 6px; }391 .icon-btn {392 font-family: var(--sans);393 font-size: 11.5px;394 font-weight: 600;395 color: var(--text-dim);396 background: var(--bg-inset);397 border: 1px solid var(--border);398 border-radius: 7px;399 padding: 5px 11px;400 cursor: pointer;401 transition: color .12s, border-color .12s;402 }403 .icon-btn:hover:not(:disabled) { color: var(--cyan); border-color: var(--cyan); }404 .icon-btn.ok { color: var(--cyan); border-color: var(--cyan); }405 .icon-btn:disabled { opacity: .4; cursor: not-allowed; }406 407 /* ---- Bottom action bar (source actions + Generate) ---- */408 .action-bar {409 display: flex;410 align-items: center;411 justify-content: space-between;412 gap: 10px;413 margin-top: 14px;414 }415 416 /* ---- Generate button (compact primary, aligned with the controls) ---- */417 .btn-primary {418 font-family: var(--sans);419 font-size: 13.5px;420 font-weight: 600;421 color: var(--on-accent); /* label color picked by accent luminance (AA in every palette/theme) */422 background: var(--cyan); /* flat accent — no gradient */423 border: none;424 padding: 9px 18px;425 cursor: pointer;426 border-radius: 9px;427 transition: filter .15s, transform .05s;428 }429 .btn-primary:hover { filter: brightness(1.08); }430 .btn-primary:active { transform: translateY(1px); }431 .btn-primary:disabled {432 background: var(--border);433 color: var(--text-dim);434 cursor: not-allowed;435 filter: none;436 }437 438 /* ---- Output actions (Copy Mermaid / SVG / PNG) in the diagram header ---- */439 .output-actions { display: flex; gap: 6px; }440 441 /* ---- Progress bar ---- */442 .progress {443 height: 5px;444 background: var(--bg-inset);445 border: 1px solid var(--border);446 border-radius: 999px;447 overflow: hidden;448 margin-bottom: 12px;449 }450 .progress[hidden] { display: none; }451 .progress-fill {452 height: 100%;453 width: 0%;454 background: var(--cyan); /* flat terracotta — no gradient, no glow */455 border-radius: 999px;456 }457 /* Live path: duration unknown → honest indeterminate sweep */458 .progress-fill.indeterminate {459 width: 35%;460 animation: cf-sweep 1.15s ease-in-out infinite;461 }462 @keyframes cf-sweep {463 0% { margin-left: -35%; }464 100% { margin-left: 100%; }465 }466 467 /* ---- Flowchart canvas (bigger) ---- */468 #flowchart-target {469 flex: 1;470 background: var(--bg-inset); /* plain canvas — grid removed */471 border: 1px solid var(--border);472 border-radius: 10px;473 padding: 18px;474 overflow: auto;475 display: flex;476 justify-content: safe center; /* 'safe' → no clipping when zoomed past the edge */477 align-items: flex-start; /* tall charts scroll from the top */478 transition: background .25s ease, border-color .25s ease; /* ease the canvas on theme toggle (#9) */479 }480 /* ---- Custom scrollbar (muted) — shared by the chart box + code input ---- */481 #flowchart-target, textarea {482 scrollbar-width: thin; /* Firefox */483 scrollbar-color: var(--scroll) var(--bg-inset);484 }485 #flowchart-target::-webkit-scrollbar,486 textarea::-webkit-scrollbar { width: 10px; height: 10px; }487 #flowchart-target::-webkit-scrollbar-track,488 textarea::-webkit-scrollbar-track {489 background: var(--bg-inset);490 border-radius: 999px;491 }492 #flowchart-target::-webkit-scrollbar-thumb,493 textarea::-webkit-scrollbar-thumb {494 background: var(--scroll);495 border-radius: 999px;496 border: 2px solid var(--bg-inset);497 }498 #flowchart-target::-webkit-scrollbar-thumb:hover,499 textarea::-webkit-scrollbar-thumb:hover { background: var(--scroll-hi); }500 #flowchart-target::-webkit-scrollbar-corner,501 textarea::-webkit-scrollbar-corner { background: var(--bg-inset); }502 /* Render the SVG a bit under full panel width (centred) so it reads at a503 comfortable size; long charts still grow + scroll instead of shrinking.504 --fit is the default "fit" scale; the zoom control multiplies it. */505 #flowchart-target svg {506 width: calc(72% * var(--zoom, 1)) !important; /* fit-to-width, slightly inset; zoom scales it */507 max-width: none !important; /* override Mermaid's inline max-width so zoom can grow it */508 height: auto !important;509 min-height: 320px;510 flex: 0 0 auto;511 }512 /* Empty state: the lone Start anchor renders small + centred — not stretched513 to fill the canvas. A DEFINITE height makes SVG sizing deterministic (with514 just width:auto an SVG falls back to the ~300px default and scales UP); the515 width then follows the viewBox aspect ratio. */516 #flowchart-target.anchor-only { align-items: center; }517 #flowchart-target.anchor-only svg {518 width: auto !important;519 height: 56px !important;520 min-height: 0 !important;521 max-width: none !important;522 }523 /* Bold + glow the node's cyan edge when hovered */524 #flowchart-target .node rect,525 #flowchart-target .node polygon,526 #flowchart-target .node circle,527 #flowchart-target .node path {528 transition: stroke .12s ease, stroke-width .12s ease, filter .12s ease;529 }530 #flowchart-target .node:hover rect,531 #flowchart-target .node:hover polygon,532 #flowchart-target .node:hover circle,533 #flowchart-target .node:hover path,534 /* Code→node link: emphasise nodes whose source range holds the cursor line. */535 #flowchart-target .node.cf-active rect,536 #flowchart-target .node.cf-active polygon,537 #flowchart-target .node.cf-active circle,538 #flowchart-target .node.cf-active path {539 stroke: var(--cyan) !important;540 stroke-width: 3px !important;541 filter: drop-shadow(0 0 5px color-mix(in srgb, var(--cyan) 40%, transparent));542 }543 544 /* ===== Node restyle — warm "paper card" flowchart aesthetic ===== */545 /* Soft lift on every node so they read as little cards on the canvas */546 #flowchart-target .node { filter: drop-shadow(0 2px 5px rgba(30, 45, 22, .12)); }547 548 /* ===== Diagram-reveal animation (#1) — "Trace the path" ===== */549 /* Scale each node from its own centre (SVG <g> needs an explicit box). */550 #flowchart-target .node { transform-box: fill-box; transform-origin: 50% 50%; }551 /* Anti-flash while a new chart mounts: hide everything WAAPI will animate in,552 except the Start anchor (.cf-start), which stays put as the chart grows out of it. */553 #flowchart-target.revealing .node:not(.cf-start),554 #flowchart-target.revealing g.edgePaths path,555 #flowchart-target.revealing .edgeLabel { opacity: 0; }556 /* Process nodes (rect): rounded warm-white cards, hairline border */557 #flowchart-target .node rect {558 rx: 9px; ry: 9px;559 fill: var(--bg-panel);560 stroke: var(--border);561 stroke-width: 1.5px;562 }563 /* Decision nodes (diamond/polygon): sand fill + accent edge so they pop */564 #flowchart-target .node polygon {565 fill: var(--bg-inset);566 stroke: var(--cyan);567 stroke-width: 1.75px;568 }569 /* Node text: UI font, inked, medium weight */570 #flowchart-target .nodeLabel,571 #flowchart-target .node .label,572 #flowchart-target .node foreignObject div {573 font-family: var(--sans) !important;574 font-weight: 500;575 color: var(--text) !important;576 fill: var(--text);577 }578 /* Edges: warm-brown, a touch thicker; refined arrowheads */579 #flowchart-target .edgePath path.path,580 #flowchart-target .flowchart-link {581 stroke: var(--violet);582 stroke-width: 1.8px;583 }584 #flowchart-target marker path,585 #flowchart-target .arrowMarkerPath,586 #flowchart-target .arrowheadPath { fill: var(--violet) !important; stroke: none; }587 /* Edge labels (Yes / No / True / False) → centered warm pill chips.588 Mermaid sizes the foreignObject to the BARE text, so we let it overflow589 and recenter the padded chip by half its added padding+border (-10,-3). */590 #flowchart-target .edgeLabel foreignObject { overflow: visible; }591 #flowchart-target .edgeLabel foreignObject > div { transform: translate(-10px, -3px); }592 #flowchart-target .edgeLabel span {593 display: inline-block;594 font-family: var(--sans) !important;595 font-size: 11px !important;596 font-weight: 600 !important;597 line-height: 1;598 color: var(--text-dim) !important;599 background: var(--bg-panel) !important;600 border: 1px solid var(--border);601 border-radius: 999px;602 padding: 2px 9px;603 }604 605 #flowchart-target .placeholder { color: var(--text-dim); font-size: 14px; }606 #flowchart-target .err { color: var(--tok-invalid); align-self: flex-start; }607 #flowchart-target .raw {608 color: var(--text-dim); font-family: var(--mono); font-size: 12px;609 white-space: pre-wrap; text-align: left; align-self: flex-start; width: 100%;610 }611 </style>612</head>613<body>614 <header>615 <div class="logo-mark">616 <!-- Decision node (C4): FILLED diamond "decision" forking to two HOLLOW outcome nodes -->617 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">618 <path d="M12 3 L15.4 6.5 L12 10 L8.6 6.5 Z" fill="currentColor"/>619 <path d="M10.3 8.4 L7 15.8"/>620 <path d="M13.7 8.4 L17 15.8"/>621 <circle cx="6" cy="18" r="2.7"/>622 <circle cx="18" cy="18" r="2.7"/>623 </svg>624 </div>625 <div>626 <div class="wordmark">Code<span class="flow">Flow</span></div>627 </div>628 <span class="tagline">Paste your code on the left, hit <b>Generate</b>, and read its logic as a flowchart →</span>629 <button id="theme-toggle" class="theme-toggle" type="button" title="Toggle light / dark" aria-label="Toggle light / dark">630 <svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">631 <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>632 </svg>633 <svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">634 <circle cx="12" cy="12" r="4"/>635 <path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>636 </svg>637 </button>638 </header>639 640 <div class="container">641 <!-- LEFT: input -->642 <div class="panel">643 <div class="panel-head">644 <span class="panel-title">Source Code</span>645 <div class="editor-controls">646 <select id="lang-select" class="ctrl-select" title="Editor language" aria-label="Editor language">647 <option value="python" selected>Python</option>648 <option value="javascript">JavaScript</option>649 <option value="java">Java</option>650 <option value="cpp">C / C++</option>651 </select>652 <select id="examples-select">653 <option value="" disabled selected>Code examples…</option>654 <option value="loop">Sum a list of numbers</option>655 <option value="ifelse">Check status (if / else)</option>656 <option value="func">Find the first even number</option>657 </select>658 </div>659 </div>660 <!-- CodeMirror mounts into #editor and reveals .editor-wrap; the661 textarea below is the always-present fallback if CM can't load. -->662 <div id="editor-wrap" class="editor-wrap" hidden>663 <div id="editor"></div>664 <div class="editor-foot">665 <span id="editor-status" class="editor-status">Ln 1, Col 1 · 0 lines</span>666 </div>667 </div>668 <textarea id="code-fallback" placeholder="Paste your code here, or pick a code example…" spellcheck="false"></textarea>669 <!-- Always-visible (outside editor-wrap, so it survives the textarea fallback) -->670 <div class="action-bar">671 <div class="editor-actions">672 <button id="copy-btn" class="icon-btn" type="button">Copy</button>673 <button id="clear-btn" class="icon-btn" type="button">Clear</button>674 </div>675 <button id="submit-btn" class="btn-primary" type="button">Generate Flowchart</button>676 </div>677 </div>678 679 <!-- Visual cue: code flows left → right into the diagram -->680 <div class="flow-arrow" aria-hidden="true">681 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">682 <path d="M4 12h14M13 6l6 6-6 6"/>683 </svg>684 </div>685 686 <!-- RIGHT: diagram -->687 <div class="panel panel-diagram">688 <div class="panel-head">689 <span class="panel-title">Flowchart</span>690 <div class="output-actions">691 <button id="copy-mmd-btn" class="icon-btn" type="button" disabled title="Copy the Mermaid source">Copy Mermaid</button>692 <button id="svg-btn" class="icon-btn" type="button" disabled title="Download as SVG">SVG</button>693 <button id="png-btn" class="icon-btn" type="button" disabled title="Download as PNG">PNG</button>694 </div>695 </div>696 <div id="progress" class="progress" hidden><div id="progress-fill" class="progress-fill"></div></div>697 <div id="flowchart-target">698 <span class="placeholder">Loading…</span>699 </div>700 <div id="scroll-hint" class="scroll-hint" hidden>701 <svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">702 <path d="M8 7l4-4 4 4M8 17l4 4 4-4"/>703 </svg>704 Scroll to view full chart705 </div>706 <div id="zoom-ctrl" class="zoom-ctrl" hidden>707 <button id="zoom-out" class="zoom-btn" type="button" title="Zoom out" aria-label="Zoom out">−</button>708 <button id="zoom-fit" class="zoom-btn zoom-fit" type="button" title="Fit to width">fit</button>709 <button id="zoom-in" class="zoom-btn" type="button" title="Zoom in" aria-label="Zoom in">+</button>710 </div>711 </div>712 </div>713 714 <!-- Vendored libraries (classic scripts → globals): Mermaid (window.mermaid),715 CodeMirror bundle (window.CM6), Gradio client (window.GradioClient).716 Loaded before the module below so the module can use the globals. -->717 <script src="static/mermaid.min.js"></script>718 <script src="static/cm.bundle.js"></script>719 <script src="static/gradio-client.js"></script>720 721 <script type="module">722 // `mermaid` is the global from static/mermaid.min.js (vendored, no CDN).723 // CodeMirror is read from the window.CM6 global inside initEditor() — kept in724 // a try/catch so any editor failure is contained and falls back to a textarea.725 // take down the rest of the page (examples, Generate). See initEditor().726 727 // Per-palette, per-theme Mermaid palettes. Keyed [PALETTE][theme] so the728 // diagram tracks whichever palette is active (see the PALETTE flag below).729 const MERMAID_THEMES = {730 green: {731 light: { background: '#e2e7d6', primaryColor: '#f9fbf4', primaryTextColor: '#1e251c',732 primaryBorderColor: '#2c6e49', lineColor: '#6b7a55', tertiaryColor: '#eef2e2',733 edgeLabelBackground: '#edf0e6' },734 dark: { background: '#0e130d', primaryColor: '#1a211a', primaryTextColor: '#e7eede',735 primaryBorderColor: '#3f9e63', lineColor: '#8fa274', tertiaryColor: '#1f271d',736 edgeLabelBackground: '#1a211a' },737 },738 rust: {739 light: { background: '#efe2d2', primaryColor: '#fdf6ec', primaryTextColor: '#2a201a',740 primaryBorderColor: '#b0532e', lineColor: '#9c6f4f', tertiaryColor: '#f3e8d6',741 edgeLabelBackground: '#f6ece0' },742 dark: { background: '#16100c', primaryColor: '#231b16', primaryTextColor: '#f0e6d6',743 primaryBorderColor: '#d56a3c', lineColor: '#b3855f', tertiaryColor: '#29201a',744 edgeLabelBackground: '#231b16' },745 },746 };747 function initMermaid(themeName) {748 mermaid.initialize({749 startOnLoad: false,750 theme: 'base',751 // Tighter node padding + spacing so decision diamonds hug their text (#8).752 flowchart: { padding: 6, nodeSpacing: 45, rankSpacing: 45, useMaxWidth: true },753 themeVariables: { fontFamily: "'Hanken Grotesk', sans-serif", ...MERMAID_THEMES[PALETTE][themeName] },754 });755 }756 757 // ── Active palette (quick switch back to the old warm theme) ──────758 // Flip this ONE word to swap the whole UI: 'green' (Pine & Sage, the759 // current default) or 'rust' (Rust & Sienna, the previous warm theme).760 // It just toggles a `rust` body class — every var(--cyan)/etc., the761 // color-mix focus rings, and the matching Mermaid palette all follow.762 const PALETTE = 'green';763 document.body.classList.toggle('rust', PALETTE === 'rust');764 765 // Theme state — default = light; toggle → forest charcoal. Persisted.766 let currentTheme = 'light';767 try { if (localStorage.getItem('cf-theme-warm') === 'dark') currentTheme = 'dark'; } catch {}768 function applyTheme(name) {769 currentTheme = name;770 document.body.classList.toggle('dark', name === 'dark');771 initMermaid(name);772 try { localStorage.setItem('cf-theme-warm', name); } catch {}773 }774 applyTheme(currentTheme); // sets body class + initializes Mermaid775 776 /* ----- Pre-rendered presets -----777 Each preset stores BOTH the Python source and its already-computed778 Mermaid, so selecting one paints the diagram instantly — no model779 call. This is the "perception of fast inference" demo path and is780 also what makes this page fully previewable offline. */781 const PRESETS = {782 loop: {783 code:784`total = 0785for n in numbers:786 total += n787print(total)`,788 mermaid:789`graph TD790 A["Start"] --> B["Initialise total to zero"]791 B --> C{"More numbers to process?"}792 C -- Yes --> D["Add current number to total"]793 D --> C794 C -- No --> E["Print total"]795 E --> F["End"]`,796 linemap: { B: [1, 1], C: [2, 2], D: [3, 3], E: [4, 4] }797 },798 ifelse: {799 code:800`def check_status(val):801 if val > 10:802 return "Active"803 else:804 return "Inactive"`,805 mermaid:806`graph TD807 A["Start: check_status"] --> B{"Value greater than ten?"}808 B -- True --> C["Return Active"]809 B -- False --> D["Return Inactive"]810 C --> E["End"]811 D --> E`,812 linemap: { A: [1, 1], B: [2, 2], C: [3, 3], D: [5, 5] }813 },814 func: {815 code:816`def find_first_even(nums):817 for i in range(len(nums)):818 if nums[i] % 2 == 0:819 return nums[i]820 return None`,821 mermaid:822`graph TD823 A["Start: find_first_even"] --> B{"More elements to check?"}824 B -- Yes --> C{"Element is even?"}825 C -- Yes --> D["Return that element"]826 C -- No --> B827 B -- No --> E["Return None"]828 D --> F["End"]829 E --> F`,830 linemap: { A: [1, 1], B: [2, 2], C: [3, 3], D: [4, 4], E: [5, 5] }831 }832 };833 834 const target = document.getElementById('flowchart-target');835 const submitBtn = document.getElementById('submit-btn');836 const select = document.getElementById('examples-select');837 const progress = document.getElementById('progress');838 const progressFill = document.getElementById('progress-fill');839 const scrollHint = document.getElementById('scroll-hint');840 const langSelect = document.getElementById('lang-select');841 const copyBtn = document.getElementById('copy-btn');842 const clearBtn = document.getElementById('clear-btn');843 const fallback = document.getElementById('code-fallback');844 const editorWrap = document.getElementById('editor-wrap');845 const editorStatus = document.getElementById('editor-status');846 const copyMmdBtn = document.getElementById('copy-mmd-btn');847 const svgBtn = document.getElementById('svg-btn');848 const pngBtn = document.getElementById('png-btn');849 const themeToggle = document.getElementById('theme-toggle');850 const zoomCtrl = document.getElementById('zoom-ctrl');851 const zoomInBtn = document.getElementById('zoom-in');852 const zoomOutBtn = document.getElementById('zoom-out');853 const zoomFitBtn = document.getElementById('zoom-fit');854 855 /* ===== Real code editor (CodeMirror 6) =====856 The plain <textarea> (#code-fallback) renders by default; CodeMirror857 hides it only once it mounts. So a blocked CDN / CM failure degrades to858 a working textarea instead of a broken page. getCode()/setCode() below859 hide which input is live, so the preset + submit logic never branches. */860 861 // CodeMirror is vendored as a single IIFE bundle (static/cm.bundle.js → the862 // CM6 global), so every package shares ONE @codemirror/state/view/@lezer863 // instance by construction — no ?deps= juggling, no CDN. Grammars come from864 // the same bundle (CM6.python / .javascript / .java / .cpp).865 const LANG_LOADERS = {866 python: () => CM6.python(),867 javascript: () => CM6.javascript(),868 java: () => CM6.java(),869 cpp: () => CM6.cpp(),870 };871 async function makeLangExt(name) {872 try { return (LANG_LOADERS[name] || LANG_LOADERS.python)(); } catch { return []; }873 }874 875 let cmView = null;876 let lastLineMap = {}; // nodeId -> [startLine, endLine] for the current chart877 let cfHighlightLines = () => {}; // set once CM mounts: highlight editor lines (node→code hover)878 let cfSelectLines = () => {}; // set once CM mounts: select + focus a line range (node→code edit)879 let langCompartment = null;880 881 function getCode() {882 return cmView ? cmView.state.doc.toString() : fallback.value;883 }884 function setCode(text) {885 if (cmView) cmView.dispatch({ changes: { from: 0, to: cmView.state.doc.length, insert: text } });886 else fallback.value = text;887 updateStatus();888 }889 function focusEditor() { (cmView || fallback).focus(); }890 891 function updateStatus() {892 const doc = getCode();893 const lines = doc === '' ? 0 : doc.split('\n').length;894 let ln = 1, col = 1;895 if (cmView) {896 const pos = cmView.state.selection.main.head;897 const line = cmView.state.doc.lineAt(pos);898 ln = line.number;899 col = pos - line.from + 1;900 }901 editorStatus.textContent = `Ln ${ln}, Col ${col} · ${lines} line${lines === 1 ? '' : 's'}`;902 }903 904 async function setLanguage(name) {905 if (cmView && langCompartment) {906 cmView.dispatch({ effects: langCompartment.reconfigure(await makeLangExt(name)) });907 }908 }909 910 async function initEditor() {911 try {912 // Everything comes from the vendored CM6 global (static/cm.bundle.js).913 // Wrapped in try/catch so any editor failure is contained to the914 // editor and falls back to the plain textarea — the rest of the page915 // (mermaid, examples, Generate) keeps working.916 if (!window.CM6) throw new Error('CodeMirror bundle not loaded');917 const { EditorState, Compartment, StateField, StateEffect } = CM6;918 const { EditorView, keymap, placeholder, lineNumbers, highlightActiveLine,919 highlightActiveLineGutter, drawSelection, dropCursor, Decoration } = CM6;920 const { history, defaultKeymap, historyKeymap, indentWithTab } = CM6;921 const { indentOnInput, bracketMatching, syntaxHighlighting, HighlightStyle } = CM6;922 const tg = CM6.tags;923 924 // Lean "basic setup" — line numbers, active line, history/undo,925 // indent-on-input, bracket matching. (No autocomplete/search/lint;926 // this is a code-input box, not a full IDE.)927 const setup = [928 lineNumbers(), highlightActiveLineGutter(), highlightActiveLine(),929 drawSelection(), dropCursor(), history(), indentOnInput(), bracketMatching(),930 keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),931 ];932 933 // Indigo / cyan / violet token theme so highlighting matches the UI.934 // Token colors reference CSS vars (--tok-*) so they flip with the theme.935 const cfHighlight = HighlightStyle.define([936 { tag: [tg.keyword, tg.controlKeyword, tg.operatorKeyword, tg.modifier,937 tg.definitionKeyword, tg.moduleKeyword], color: 'var(--tok-keyword)' },938 { tag: [tg.function(tg.variableName), tg.function(tg.propertyName)], color: 'var(--tok-fn)' },939 { tag: [tg.className, tg.typeName], color: 'var(--tok-fn)' },940 { tag: [tg.string, tg.special(tg.string)], color: 'var(--tok-string)' },941 { tag: [tg.number, tg.bool, tg.atom], color: 'var(--tok-number)' },942 { tag: [tg.comment, tg.lineComment, tg.blockComment], color: 'var(--tok-comment)', fontStyle: 'italic' },943 { tag: [tg.propertyName], color: 'var(--tok-prop)' },944 { tag: [tg.operator, tg.punctuation, tg.bracket], color: 'var(--tok-punct)' },945 { tag: [tg.variableName], color: 'var(--tok-var)' },946 { tag: [tg.invalid], color: 'var(--tok-invalid)' },947 ]);948 const cfTheme = EditorView.theme({949 '&': { color: 'var(--text)', backgroundColor: 'transparent', height: '100%' },950 '.cm-content': { caretColor: 'var(--cyan)', padding: '12px 0' },951 '.cm-cursor, .cm-dropCursor': { borderLeftColor: 'var(--cyan)' },952 '&.cm-focused .cm-selectionBackground, .cm-selectionBackground': { backgroundColor: 'color-mix(in srgb, var(--cyan) 16%, transparent)' },953 '.cm-gutters': { backgroundColor: 'transparent', color: 'var(--gutter)', border: 'none' },954 '.cm-lineNumbers .cm-gutterElement': { padding: '0 8px 0 14px' },955 '.cm-activeLineGutter': { backgroundColor: 'rgba(150,110,60,.07)', color: 'var(--text-dim)' },956 '.cm-activeLine': { backgroundColor: 'rgba(150,110,60,.05)' },957 '.cm-matchingBracket': { backgroundColor: 'rgba(156,111,79,.28)', outline: '1px solid var(--violet)' },958 '.cm-line.cf-linked': { backgroundColor: 'color-mix(in srgb, var(--cyan) 14%, transparent)' },959 });960 961 // Node→code highlight: a line-decoration field toggled by an effect.962 const setHL = StateEffect.define();963 const lineDeco = Decoration.line({ class: 'cf-linked' });964 const hlField = StateField.define({965 create: () => Decoration.none,966 update(deco, tr) {967 deco = deco.map(tr.changes);968 for (const e of tr.effects) if (e.is(setHL)) {969 if (!e.value) { deco = Decoration.none; continue; }970 const { a, b } = e.value, ranges = [];971 for (let n = a; n <= b; n++)972 if (n >= 1 && n <= tr.state.doc.lines)973 ranges.push(lineDeco.range(tr.state.doc.line(n).from));974 deco = Decoration.set(ranges, true);975 }976 return deco;977 },978 provide: f => EditorView.decorations.from(f),979 });980 981 langCompartment = new Compartment();982 cmView = new EditorView({983 parent: document.getElementById('editor'),984 state: EditorState.create({985 doc: fallback.value || '',986 extensions: [987 setup,988 langCompartment.of(await makeLangExt(langSelect.value)),989 keymap.of([indentWithTab]),990 placeholder("Paste your code here, or pick a code example…"),991 syntaxHighlighting(cfHighlight),992 cfTheme,993 hlField,994 EditorView.updateListener.of(u => {995 if (u.selectionSet || u.docChanged) updateStatus();996 if (u.focusChanged) editorWrap.classList.toggle('focused', u.view.hasFocus);997 if (u.selectionSet || u.docChanged) highlightNodesForCursor(u.view);998 }),999 ],1000 }),1001 });1002 // Expose the highlight/select ops to the node→code handlers.1003 cfHighlightLines = (a, b) => {1004 if (!a) { cmView.dispatch({ effects: setHL.of(null) }); return; }1005 const n = Math.min(Math.max(1, a), cmView.state.doc.lines);1006 cmView.dispatch({ effects: [setHL.of({ a, b }),1007 EditorView.scrollIntoView(cmView.state.doc.line(n).from, { y: 'center' })] });1008 };1009 cfSelectLines = (a, b) => {1010 const doc = cmView.state.doc;1011 a = Math.min(Math.max(1, a), doc.lines); b = Math.min(Math.max(1, b), doc.lines);1012 cmView.dispatch({ selection: { anchor: doc.line(a).from, head: doc.line(b).to }, scrollIntoView: true });1013 cmView.focus();1014 };1015 1016 fallback.hidden = true;1017 editorWrap.hidden = false;1018 updateStatus();1019 } catch (e) {1020 // CM unavailable → leave the (already-visible) textarea as the editor.1021 cmView = null;1022 console.warn('CodeMirror unavailable, using textarea fallback:', e);1023 }1024 }1025 1026 // Brief "Copied"/"Saved" confirmation on a button, then restore its label.1027 function flashOk(btn, msg) {1028 const orig = btn.dataset.orig || (btn.dataset.orig = btn.textContent);1029 btn.textContent = msg;1030 btn.classList.add('ok');1031 setTimeout(() => { btn.textContent = orig; btn.classList.remove('ok'); }, 1200);1032 }1033 1034 langSelect.addEventListener('change', () => setLanguage(langSelect.value));1035 copyBtn.addEventListener('click', async () => {1036 const text = getCode();1037 if (!text) return;1038 try { await navigator.clipboard.writeText(text); flashOk(copyBtn, 'Copied'); } catch {}1039 });1040 clearBtn.addEventListener('click', () => { setCode(''); focusEditor(); });1041 1042 /* ===== Diagram export (Copy Mermaid / SVG / PNG) =====1043 These act on the most recent render. `lastMermaid` holds the source1044 even on a parse error (so it stays copyable); SVG/PNG need a real svg. */1045 let lastMermaid = '';1046 1047 function refreshExportButtons() {1048 // A real generated chart (lastMermaid set), not the empty-state Start anchor.1049 const hasSvg = !!lastMermaid && !!target.querySelector('svg');1050 copyMmdBtn.disabled = !lastMermaid;1051 svgBtn.disabled = !hasSvg;1052 pngBtn.disabled = !hasSvg;1053 zoomCtrl.hidden = !hasSvg; // zoom controls only make sense with a chart1054 }1055 1056 /* ===== Zoom (#3) — scales the SVG width via the --zoom custom property;1057 overflow + 'safe center' let the user scroll/pan a zoomed-in chart. ===== */1058 let zoom = 1;1059 const ZOOM_MIN = 0.4, ZOOM_MAX = 3, ZOOM_STEP = 1.2;1060 function applyZoom() {1061 target.style.setProperty('--zoom', zoom.toFixed(3));1062 updateScrollHint();1063 }1064 function setZoom(z) { zoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z)); applyZoom(); }1065 zoomInBtn.addEventListener('click', () => setZoom(zoom * ZOOM_STEP));1066 zoomOutBtn.addEventListener('click', () => setZoom(zoom / ZOOM_STEP));1067 zoomFitBtn.addEventListener('click', () => setZoom(1));1068 1069 /* ===== Light / dark toggle (#9) — with a diagram crossfade ===== */1070 themeToggle.addEventListener('click', async () => {1071 const next = currentTheme === 'light' ? 'dark' : 'light';1072 const svg = (lastMermaid && target.querySelector('svg')) ? target.querySelector('svg') : null;1073 if (svg && !prefersReduced) {1074 // Fade the current chart out FIRST so the palette swap + re-render1075 // (Mermaid bakes some colors in, so a re-render is required) happen1076 // unseen — then fade the re-themed chart back in. Fade-through, not a1077 // dual-SVG dissolve: our node colors are CSS-var-driven, so a cloned1078 // "old" SVG would instantly re-resolve to the new theme.1079 await svg.animate([{ opacity: 1 }, { opacity: 0 }],1080 { duration: 150, easing: 'ease', fill: 'forwards' }).finished;1081 applyTheme(next); // swap vars + re-init Mermaid while invisible1082 await renderMermaid(lastMermaid); // re-themed chart, mounted at full opacity1083 const fresh = target.querySelector('svg');1084 if (fresh) fresh.animate([{ opacity: 0 }, { opacity: 1 }],1085 { duration: 220, easing: 'ease', fill: 'both' });1086 } else {1087 applyTheme(next);1088 if (lastMermaid && target.querySelector('svg')) await renderMermaid(lastMermaid);1089 }1090 });1091 1092 function download(filename, blob) {1093 const url = URL.createObjectURL(blob);1094 const a = document.createElement('a');1095 a.href = url;1096 a.download = filename;1097 a.click();1098 setTimeout(() => URL.revokeObjectURL(url), 1000);1099 }1100 1101 // Serialize the rendered SVG, namespaced so it opens standalone.1102 function serializedSvg() {1103 const svg = target.querySelector('svg');1104 if (!svg) return null;1105 const clone = svg.cloneNode(true);1106 clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');1107 return { svg, data: new XMLSerializer().serializeToString(clone) };1108 }1109 1110 copyMmdBtn.addEventListener('click', async () => {1111 if (!lastMermaid) return;1112 try { await navigator.clipboard.writeText(lastMermaid); flashOk(copyMmdBtn, 'Copied'); } catch {}1113 });1114 1115 svgBtn.addEventListener('click', () => {1116 const s = serializedSvg();1117 if (!s) return;1118 download('flowchart.svg', new Blob([s.data], { type: 'image/svg+xml;charset=utf-8' }));1119 flashOk(svgBtn, 'Saved');1120 });1121 1122 pngBtn.addEventListener('click', () => {1123 const s = serializedSvg();1124 if (!s) return;1125 // Natural pixel size from the viewBox (the on-screen svg is CSS-stretched1126 // to 100%); render at 2× for a crisp export on a solid canvas bg.1127 const vb = s.svg.viewBox && s.svg.viewBox.baseVal;1128 const w = (vb && vb.width) || s.svg.clientWidth || 800;1129 const h = (vb && vb.height) || s.svg.clientHeight || 600;1130 const scale = 2;1131 const img = new Image();1132 img.onload = () => {1133 const canvas = document.createElement('canvas');1134 canvas.width = Math.round(w * scale);1135 canvas.height = Math.round(h * scale);1136 const ctx = canvas.getContext('2d');1137 // Solid canvas bg = the current theme's --bg-inset, so labels stay readable.1138 ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--bg-inset').trim() || '#e2e7d6';1139 ctx.fillRect(0, 0, canvas.width, canvas.height);1140 ctx.drawImage(img, 0, 0, canvas.width, canvas.height);1141 canvas.toBlob(b => { if (b) { download('flowchart.png', b); flashOk(pngBtn, 'Saved'); } }, 'image/png');1142 };1143 img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(s.data);1144 });1145 1146 // Simulated inference time for the pre-rendered demo path. The bar1147 // tracks this exactly and the chart is revealed at 100% — tune freely.1148 const PRESET_DURATION_MS = 2000;1149 1150 let renderSeq = 0; // guards against out-of-order async renders1151 1152 // Show the "scroll" hint only when the chart overflows its box AND the1153 // user isn't already scrolled to the bottom. Hides at the bottom,1154 // reappears as soon as they scroll back up.1155 function updateScrollHint() {1156 const overflowing = target.scrollHeight > target.clientHeight + 4;1157 const atBottom =1158 target.scrollTop + target.clientHeight >= target.scrollHeight - 4;1159 scrollHint.hidden = !(overflowing && !atBottom);1160 }1161 window.addEventListener('resize', updateScrollHint);1162 target.addEventListener('scroll', updateScrollHint);1163 1164 /* ===== Diagram-reveal animation (#1) — "Trace the path" + "Calm" =====1165 A new chart draws itself in: the topmost node is the persistent anchor1166 (it stays put), and everything else flows OUT of it top-to-bottom — nodes1167 scale in, edges draw on (stroke dash), each arrowhead landing as its line1168 arrives. Runs ONLY on a fresh Generate (not zoom / theme / re-layout) and1169 is skipped under prefers-reduced-motion. */1170 const REVEAL_STEP = 95; // ms between successive elements — "Calm"1171 const prefersReduced = matchMedia('(prefers-reduced-motion: reduce)').matches;1172 let anchorNode = null; // topmost node — never animates1173 let edgeMarkers = new Map();1174 let revealToken = 0; // bumped per reveal so a stale scroll-follow loop self-cancels1175 const yTop = el => el.getBoundingClientRect().top;1176 1177 function animNodeIn(n, delay) {1178 // Animate the INDIVIDUAL scale/translate props (not `transform`) so we1179 // compose with Mermaid's positioning transform="translate(x,y)" attribute1180 // instead of overriding it (which would pile every node in the corner).1181 n.animate(1182 [{ opacity: 0, scale: '.97', translate: '0 6px' }, { opacity: 1, scale: '1', translate: '0 0' }],1183 { duration: 280, delay, easing: 'cubic-bezier(.2,.7,.3,1)', fill: 'both' }1184 );1185 }1186 function animEdgeIn(e, delay) {1187 const len = e.getTotalLength();1188 e.style.strokeDasharray = len;1189 const a = e.animate(1190 [{ strokeDashoffset: len }, { strokeDashoffset: 0 }],1191 { duration: 440, delay, easing: 'ease-in-out', fill: 'both' }1192 );1193 a.onfinish = () => { // land the arrowhead when the line arrives1194 const m = edgeMarkers.get(e);1195 if (m?.end) e.setAttribute('marker-end', m.end);1196 if (m?.start) e.setAttribute('marker-start', m.start);1197 e.style.strokeDashoffset = 0;1198 };1199 }1200 function fadeIn(l, delay) {