CodeFlow / frontend.html
Rishi-Jain-27's picture
Updated README, added tracing capabilities, changed size of flowchart, vendored the animation assets so this meets off the grid.
1433b16
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CodeFlow — Code to Flowchart</title>
<!-- All assets are vendored locally (see static/) — no external CDN/API at runtime. -->
<link rel="stylesheet" href="static/fonts.css">
<style>
/* ============================================================
PINE & SAGE — the CodeFlow theme (green + light).
Soft sage-paper base, deep pine-green accent (dark enough to clear
WCAG AA on the light bg), muted moss branch lines. Light by default;
toggle → forest "charcoal" dark. Colorblind-safe by design: meaning
rides on text + node shape, never hue — green is purely decorative,
and luminance contrast does the work.
NOTE: --cyan / --violet are historical var names kept so every
reference stays valid; here --cyan = the pine accent and
--violet = moss green. (Safe future cleanup: rename to
--accent / --accent-2.)
============================================================ */
:root {
--bg: #edf0e6; /* sage paper */
--bg-glow: #e8efdd; /* faint header tint */
--bg-panel: #f9fbf4; /* near-white card */
--bg-inset: #e2e7d6; /* sage inset (input / canvas) */
--border: #d3dac3; /* sage hairline */
--cyan: #2c6e49; /* ACCENT — pine green (deep, AA-friendly) */
--cyan-dim: #245a3b;
--on-accent: #fbfdf8; /* label on the accent — near-white (dark accent → ~6:1 AA) */
--violet: #6b7a55; /* SECONDARY — moss green (branch lines) */
--text: #1e251c; /* deep green-black ink */
--text-dim: #5d6b54;
--label: #5a7048; /* moss-green section labels */
--scroll: #c8d3b8;
--scroll-hi: #b2c29c;
--gutter: #9aa888;
/* Editor syntax tokens — green family (pine / gold / olive / moss) */
--tok-keyword: #2c6e49; /* pine */
--tok-fn: #876618; /* dark gold */
--tok-string: #66722f; /* olive */
--tok-number: #9e521f; /* burnt orange */
--tok-comment: #8c9a7e; /* sage gray */
--tok-prop: #6f7d52; /* moss */
--tok-punct: #5d6b54;
--tok-var: #1e251c;
--tok-invalid: #b5362a;
--mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--sans: 'Hanken Grotesk', system-ui, -apple-system, sans-serif;
--display: 'Fraunces', Georgia, 'Times New Roman', serif; /* serif for the wordmark */
}
/* ---- Forest charcoal (dark counterpart, via toggle): same green identity ---- */
body.dark {
--bg: #121711;
--bg-glow: #1f271d;
--bg-panel: #1a211a;
--bg-inset: #0e130d;
--border: #2c372a;
--cyan: #3f9e63; /* pine lifted for dark */
--cyan-dim: #348352;
--on-accent: #122012; /* label on the accent — dark ink (light accent → ~5:1 AA) */
--violet: #8fa274; /* moss lifted */
--text: #e7eede; /* green cream */
--text-dim: #9cac8e;
--label: #9fc98a;
--scroll: #34402f;
--scroll-hi: #45543d;
--gutter: #59674a;
--tok-keyword: #5fb37e;
--tok-fn: #d6a85e;
--tok-string: #b8c47a;
--tok-number: #e0a86a;
--tok-comment: #7e8b70;
--tok-prop: #9fb381;
--tok-punct: #9cac8e;
--tok-var: #e7eede;
--tok-invalid: #ff6b6b;
}
/* ============================================================
RUST & SIENNA — fallback palette (the previous warm theme).
Activated by adding `rust` to <body> (see the PALETTE flag in JS).
Same var names, so everything that reads var(--cyan)/etc. — incl.
the color-mix focus rings — follows automatically; the Mermaid
diagram is handled by the matching MERMAID_THEMES.rust entry.
============================================================ */
body.rust {
--bg: #f6ece0;
--bg-glow: #f0e3d2;
--bg-panel: #fdf6ec;
--bg-inset: #efe2d2;
--border: #e0cdb6;
--cyan: #b0532e;
--cyan-dim: #95421f;
--on-accent: #fffaf0; /* dark accent → near-white label (~4.8 AA) */
--violet: #9c6f4f;
--text: #2a201a;
--text-dim: #75614f;
--label: #9c5a33;
--scroll: #d6c4a8;
--scroll-hi: #c2ab88;
--gutter: #ad9d7e;
--tok-keyword: #a8472a;
--tok-fn: #876618;
--tok-string: #66722f;
--tok-number: #9e521f;
--tok-comment: #a4937b;
--tok-prop: #92633a;
--tok-punct: #75614f;
--tok-var: #2a201a;
--tok-invalid: #b5362a;
}
/* Rust dark — full var set so it wins over green's body.dark on toggle */
body.rust.dark {
--bg: #1b1512;
--bg-glow: #29201a;
--bg-panel: #231b16;
--bg-inset: #16100c;
--border: #392d24;
--cyan: #d56a3c;
--cyan-dim: #b85730;
--on-accent: #1e1408; /* light accent → dark ink label (~4.8 AA) */
--violet: #b3855f;
--text: #f0e6d6;
--text-dim: #b3a489;
--label: #d98a5a;
--scroll: #443629;
--scroll-hi: #594636;
--gutter: #6c5b47;
--tok-keyword: #e07a52;
--tok-fn: #d6a85e;
--tok-string: #b8c47a;
--tok-number: #e0a86a;
--tok-comment: #8f8169;
--tok-prop: #d39a6e;
--tok-punct: #b3a489;
--tok-var: #f0e6d6;
--tok-invalid: #ff6b6b;
}
* { box-sizing: border-box; }
body {
font-family: var(--sans);
background: radial-gradient(1200px 600px at 50% -10%, var(--bg-glow) 0%, var(--bg) 55%);
color: var(--text);
margin: 0;
padding: 24px 28px 28px;
min-height: 100vh;
transition: background .25s, color .25s;
}
/* ---- Header ---- */
header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 22px;
}
/* Standalone "decision node" mark — no badge; inherits the accent (so it
flips to amber in dark mode). The svg uses currentColor. */
.logo-mark {
display: grid; place-items: center;
color: var(--cyan);
}
.logo-mark svg { width: 34px; height: 34px; }
.wordmark { font-family: var(--display); font-size: 25px; font-weight: 600; letter-spacing: -0.2px; }
.wordmark .flow { color: var(--cyan); }
/* Boxed instruction callout — accent left-bar draws the eye to the "how to" */
.tagline {
color: var(--text);
font-size: 12.5px;
font-weight: 500;
margin-left: 10px;
padding: 8px 14px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-left: 3px solid var(--cyan);
border-radius: 9px;
box-shadow: 0 1px 2px rgba(30, 45, 22, .08);
}
.tagline b { color: var(--cyan); font-weight: 700; }
/* ---- Theme toggle (far right) ---- */
.theme-toggle {
margin-left: auto;
width: 38px; height: 38px;
display: grid; place-items: center;
color: var(--text-dim);
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
transition: color .15s, border-color .15s;
}
.theme-toggle:hover { color: var(--cyan); border-color: var(--cyan); }
.theme-toggle svg { width: 18px; height: 18px; }
/* Show moon in dark mode, sun in light mode */
.theme-toggle .icon-moon { display: none; } /* default = light → show sun */
body.dark .theme-toggle .icon-sun { display: none; }
body.dark .theme-toggle .icon-moon { display: block; }
/* ---- Flow cue between the panels ---- */
.flow-arrow {
flex: 0 0 auto;
align-self: center;
display: grid; place-items: center;
color: var(--cyan);
opacity: .7;
}
.flow-arrow svg { width: 26px; height: 26px; }
/* ---- Layout ---- */
.container {
display: flex;
gap: 22px;
height: calc(100vh - 110px);
}
.panel {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 18px;
}
/* Give the diagram more room than the code input so charts read bigger */
.panel-diagram { flex: 1.5; position: relative; }
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
gap: 12px;
}
.panel-title {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.2px;
color: var(--label);
}
/* Subtle "scroll" hint — only shown when the chart overflows its box */
.scroll-hint {
position: absolute;
bottom: 28px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 6px;
background: var(--bg-panel);
backdrop-filter: blur(4px);
border: 1px solid var(--border);
color: var(--text-dim);
font-size: 11.5px;
font-weight: 500;
padding: 5px 12px;
border-radius: 999px;
pointer-events: none;
box-shadow: 0 4px 14px rgba(0, 0, 0, .3);
}
.scroll-hint[hidden] { display: none; }
.scroll-hint svg { width: 13px; height: 13px; stroke: var(--cyan); }
/* ---- Zoom controls (bottom-left of the canvas) ---- */
.zoom-ctrl {
position: absolute;
bottom: 28px;
left: 28px;
display: flex;
align-items: stretch;
gap: 1px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 9px;
overflow: hidden;
box-shadow: 0 4px 14px rgba(0, 0, 0, .25);
}
.zoom-ctrl[hidden] { display: none; }
.zoom-btn {
font-family: var(--sans);
font-size: 14px;
font-weight: 600;
color: var(--text-dim);
background: transparent;
border: none;
min-width: 30px;
padding: 5px 8px;
cursor: pointer;
transition: color .12s, background .12s;
}
.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); }
.zoom-btn:hover { color: var(--cyan); background: var(--bg-inset); }
/* ---- Examples dropdown ---- */
.examples {
position: relative;
}
select {
font-family: var(--sans);
font-size: 13px;
font-weight: 500;
color: var(--text);
background: var(--bg-inset);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 32px 8px 12px;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
/* Chevron: warm neutral, theme-aware via the body.dark override below */
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>");
background-repeat: no-repeat;
background-position: right 12px center;
transition: border-color .15s;
}
select:hover, select:focus { border-color: var(--cyan); outline: none; }
/* Dark-mode chevron (lighter neutral on the charcoal field) */
body.dark select {
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>");
}
/* ---- Code input ---- */
textarea {
flex: 1;
background: var(--bg-inset);
color: var(--cyan);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px;
font-family: var(--mono);
font-size: 13.5px;
line-height: 1.6;
resize: none;
tab-size: 4;
}
textarea:focus { outline: none; border-color: var(--cyan); box-shadow: 0 0 0 3px color-mix(in srgb, var(--cyan) 22%, transparent); }
textarea::placeholder { color: #5a5a8a; }
/* ---- Editor controls (language + examples) ---- */
.editor-controls { display: flex; align-items: center; gap: 8px; }
select.ctrl-select { padding: 7px 28px 7px 10px; font-size: 12.5px; }
/* ---- Code editor (CodeMirror) wrapper ---- */
.editor-wrap {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--bg-inset);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
transition: border-color .15s, box-shadow .15s;
}
.editor-wrap[hidden] { display: none; } /* author display:flex would else beat the hidden attr */
.editor-wrap.focused { border-color: var(--cyan); box-shadow: 0 0 0 3px color-mix(in srgb, var(--cyan) 22%, transparent); }
#editor { flex: 1; min-height: 0; overflow: hidden; }
.cm-editor { height: 100%; background: transparent; font-size: 13.5px; }
.cm-editor.cm-focused { outline: none; }
.cm-editor .cm-scroller {
font-family: var(--mono);
line-height: 1.6;
overflow: auto;
scrollbar-width: thin;
scrollbar-color: var(--scroll) var(--bg-inset);
}
.cm-editor .cm-scroller::-webkit-scrollbar { width: 10px; height: 10px; }
.cm-editor .cm-scroller::-webkit-scrollbar-track { background: var(--bg-inset); border-radius: 999px; }
.cm-editor .cm-scroller::-webkit-scrollbar-thumb {
background: var(--scroll); border-radius: 999px; border: 2px solid var(--bg-inset);
}
.cm-editor .cm-scroller::-webkit-scrollbar-thumb:hover { background: var(--scroll-hi); }
/* Editor footer — VS Code-style status bar: position (left) + actions (right) */
.editor-foot {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
border-top: 1px solid var(--border);
background: var(--bg-panel);
}
.editor-status { font-family: var(--mono); font-size: 11.5px; color: var(--text-dim); }
.editor-actions { display: flex; gap: 6px; }
.icon-btn {
font-family: var(--sans);
font-size: 11.5px;
font-weight: 600;
color: var(--text-dim);
background: var(--bg-inset);
border: 1px solid var(--border);
border-radius: 7px;
padding: 5px 11px;
cursor: pointer;
transition: color .12s, border-color .12s;
}
.icon-btn:hover:not(:disabled) { color: var(--cyan); border-color: var(--cyan); }
.icon-btn.ok { color: var(--cyan); border-color: var(--cyan); }
.icon-btn:disabled { opacity: .4; cursor: not-allowed; }
/* ---- Bottom action bar (source actions + Generate) ---- */
.action-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 14px;
}
/* ---- Generate button (compact primary, aligned with the controls) ---- */
.btn-primary {
font-family: var(--sans);
font-size: 13.5px;
font-weight: 600;
color: var(--on-accent); /* label color picked by accent luminance (AA in every palette/theme) */
background: var(--cyan); /* flat accent — no gradient */
border: none;
padding: 9px 18px;
cursor: pointer;
border-radius: 9px;
transition: filter .15s, transform .05s;
}
.btn-primary:hover { filter: brightness(1.08); }
.btn-primary:active { transform: translateY(1px); }
.btn-primary:disabled {
background: var(--border);
color: var(--text-dim);
cursor: not-allowed;
filter: none;
}
/* ---- Output actions (Copy Mermaid / SVG / PNG) in the diagram header ---- */
.output-actions { display: flex; gap: 6px; }
/* ---- Progress bar ---- */
.progress {
height: 5px;
background: var(--bg-inset);
border: 1px solid var(--border);
border-radius: 999px;
overflow: hidden;
margin-bottom: 12px;
}
.progress[hidden] { display: none; }
.progress-fill {
height: 100%;
width: 0%;
background: var(--cyan); /* flat terracotta — no gradient, no glow */
border-radius: 999px;
}
/* Live path: duration unknown → honest indeterminate sweep */
.progress-fill.indeterminate {
width: 35%;
animation: cf-sweep 1.15s ease-in-out infinite;
}
@keyframes cf-sweep {
0% { margin-left: -35%; }
100% { margin-left: 100%; }
}
/* ---- Flowchart canvas (bigger) ---- */
#flowchart-target {
flex: 1;
background: var(--bg-inset); /* plain canvas — grid removed */
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px;
overflow: auto;
display: flex;
justify-content: safe center; /* 'safe' → no clipping when zoomed past the edge */
align-items: flex-start; /* tall charts scroll from the top */
transition: background .25s ease, border-color .25s ease; /* ease the canvas on theme toggle (#9) */
}
/* ---- Custom scrollbar (muted) — shared by the chart box + code input ---- */
#flowchart-target, textarea {
scrollbar-width: thin; /* Firefox */
scrollbar-color: var(--scroll) var(--bg-inset);
}
#flowchart-target::-webkit-scrollbar,
textarea::-webkit-scrollbar { width: 10px; height: 10px; }
#flowchart-target::-webkit-scrollbar-track,
textarea::-webkit-scrollbar-track {
background: var(--bg-inset);
border-radius: 999px;
}
#flowchart-target::-webkit-scrollbar-thumb,
textarea::-webkit-scrollbar-thumb {
background: var(--scroll);
border-radius: 999px;
border: 2px solid var(--bg-inset);
}
#flowchart-target::-webkit-scrollbar-thumb:hover,
textarea::-webkit-scrollbar-thumb:hover { background: var(--scroll-hi); }
#flowchart-target::-webkit-scrollbar-corner,
textarea::-webkit-scrollbar-corner { background: var(--bg-inset); }
/* Render the SVG a bit under full panel width (centred) so it reads at a
comfortable size; long charts still grow + scroll instead of shrinking.
--fit is the default "fit" scale; the zoom control multiplies it. */
#flowchart-target svg {
width: calc(72% * var(--zoom, 1)) !important; /* fit-to-width, slightly inset; zoom scales it */
max-width: none !important; /* override Mermaid's inline max-width so zoom can grow it */
height: auto !important;
min-height: 320px;
flex: 0 0 auto;
}
/* Empty state: the lone Start anchor renders small + centred — not stretched
to fill the canvas. A DEFINITE height makes SVG sizing deterministic (with
just width:auto an SVG falls back to the ~300px default and scales UP); the
width then follows the viewBox aspect ratio. */
#flowchart-target.anchor-only { align-items: center; }
#flowchart-target.anchor-only svg {
width: auto !important;
height: 56px !important;
min-height: 0 !important;
max-width: none !important;
}
/* Bold + glow the node's cyan edge when hovered */
#flowchart-target .node rect,
#flowchart-target .node polygon,
#flowchart-target .node circle,
#flowchart-target .node path {
transition: stroke .12s ease, stroke-width .12s ease, filter .12s ease;
}
#flowchart-target .node:hover rect,
#flowchart-target .node:hover polygon,
#flowchart-target .node:hover circle,
#flowchart-target .node:hover path,
/* Code→node link: emphasise nodes whose source range holds the cursor line. */
#flowchart-target .node.cf-active rect,
#flowchart-target .node.cf-active polygon,
#flowchart-target .node.cf-active circle,
#flowchart-target .node.cf-active path {
stroke: var(--cyan) !important;
stroke-width: 3px !important;
filter: drop-shadow(0 0 5px color-mix(in srgb, var(--cyan) 40%, transparent));
}
/* ===== Node restyle — warm "paper card" flowchart aesthetic ===== */
/* Soft lift on every node so they read as little cards on the canvas */
#flowchart-target .node { filter: drop-shadow(0 2px 5px rgba(30, 45, 22, .12)); }
/* ===== Diagram-reveal animation (#1) — "Trace the path" ===== */
/* Scale each node from its own centre (SVG <g> needs an explicit box). */
#flowchart-target .node { transform-box: fill-box; transform-origin: 50% 50%; }
/* Anti-flash while a new chart mounts: hide everything WAAPI will animate in,
except the Start anchor (.cf-start), which stays put as the chart grows out of it. */
#flowchart-target.revealing .node:not(.cf-start),
#flowchart-target.revealing g.edgePaths path,
#flowchart-target.revealing .edgeLabel { opacity: 0; }
/* Process nodes (rect): rounded warm-white cards, hairline border */
#flowchart-target .node rect {
rx: 9px; ry: 9px;
fill: var(--bg-panel);
stroke: var(--border);
stroke-width: 1.5px;
}
/* Decision nodes (diamond/polygon): sand fill + accent edge so they pop */
#flowchart-target .node polygon {
fill: var(--bg-inset);
stroke: var(--cyan);
stroke-width: 1.75px;
}
/* Node text: UI font, inked, medium weight */
#flowchart-target .nodeLabel,
#flowchart-target .node .label,
#flowchart-target .node foreignObject div {
font-family: var(--sans) !important;
font-weight: 500;
color: var(--text) !important;
fill: var(--text);
}
/* Edges: warm-brown, a touch thicker; refined arrowheads */
#flowchart-target .edgePath path.path,
#flowchart-target .flowchart-link {
stroke: var(--violet);
stroke-width: 1.8px;
}
#flowchart-target marker path,
#flowchart-target .arrowMarkerPath,
#flowchart-target .arrowheadPath { fill: var(--violet) !important; stroke: none; }
/* Edge labels (Yes / No / True / False) → centered warm pill chips.
Mermaid sizes the foreignObject to the BARE text, so we let it overflow
and recenter the padded chip by half its added padding+border (-10,-3). */
#flowchart-target .edgeLabel foreignObject { overflow: visible; }
#flowchart-target .edgeLabel foreignObject > div { transform: translate(-10px, -3px); }
#flowchart-target .edgeLabel span {
display: inline-block;
font-family: var(--sans) !important;
font-size: 11px !important;
font-weight: 600 !important;
line-height: 1;
color: var(--text-dim) !important;
background: var(--bg-panel) !important;
border: 1px solid var(--border);
border-radius: 999px;
padding: 2px 9px;
}
#flowchart-target .placeholder { color: var(--text-dim); font-size: 14px; }
#flowchart-target .err { color: var(--tok-invalid); align-self: flex-start; }
#flowchart-target .raw {
color: var(--text-dim); font-family: var(--mono); font-size: 12px;
white-space: pre-wrap; text-align: left; align-self: flex-start; width: 100%;
}
</style>
</head>
<body>
<header>
<div class="logo-mark">
<!-- Decision node (C4): FILLED diamond "decision" forking to two HOLLOW outcome nodes -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3 L15.4 6.5 L12 10 L8.6 6.5 Z" fill="currentColor"/>
<path d="M10.3 8.4 L7 15.8"/>
<path d="M13.7 8.4 L17 15.8"/>
<circle cx="6" cy="18" r="2.7"/>
<circle cx="18" cy="18" r="2.7"/>
</svg>
</div>
<div>
<div class="wordmark">Code<span class="flow">Flow</span></div>
</div>
<span class="tagline">Paste your code on the left, hit <b>Generate</b>, and read its logic as a flowchart →</span>
<button id="theme-toggle" class="theme-toggle" type="button" title="Toggle light / dark" aria-label="Toggle light / dark">
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>
</svg>
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"/>
<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"/>
</svg>
</button>
</header>
<div class="container">
<!-- LEFT: input -->
<div class="panel">
<div class="panel-head">
<span class="panel-title">Source Code</span>
<div class="editor-controls">
<select id="lang-select" class="ctrl-select" title="Editor language" aria-label="Editor language">
<option value="python" selected>Python</option>
<option value="javascript">JavaScript</option>
<option value="java">Java</option>
<option value="cpp">C / C++</option>
</select>
<select id="examples-select">
<option value="" disabled selected>Code examples…</option>
<option value="loop">Sum a list of numbers</option>
<option value="ifelse">Check status (if / else)</option>
<option value="func">Find the first even number</option>
</select>
</div>
</div>
<!-- CodeMirror mounts into #editor and reveals .editor-wrap; the
textarea below is the always-present fallback if CM can't load. -->
<div id="editor-wrap" class="editor-wrap" hidden>
<div id="editor"></div>
<div class="editor-foot">
<span id="editor-status" class="editor-status">Ln 1, Col 1 · 0 lines</span>
</div>
</div>
<textarea id="code-fallback" placeholder="Paste your code here, or pick a code example…" spellcheck="false"></textarea>
<!-- Always-visible (outside editor-wrap, so it survives the textarea fallback) -->
<div class="action-bar">
<div class="editor-actions">
<button id="copy-btn" class="icon-btn" type="button">Copy</button>
<button id="clear-btn" class="icon-btn" type="button">Clear</button>
</div>
<button id="submit-btn" class="btn-primary" type="button">Generate Flowchart</button>
</div>
</div>
<!-- Visual cue: code flows left → right into the diagram -->
<div class="flow-arrow" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 12h14M13 6l6 6-6 6"/>
</svg>
</div>
<!-- RIGHT: diagram -->
<div class="panel panel-diagram">
<div class="panel-head">
<span class="panel-title">Flowchart</span>
<div class="output-actions">
<button id="copy-mmd-btn" class="icon-btn" type="button" disabled title="Copy the Mermaid source">Copy Mermaid</button>
<button id="svg-btn" class="icon-btn" type="button" disabled title="Download as SVG">SVG</button>
<button id="png-btn" class="icon-btn" type="button" disabled title="Download as PNG">PNG</button>
</div>
</div>
<div id="progress" class="progress" hidden><div id="progress-fill" class="progress-fill"></div></div>
<div id="flowchart-target">
<span class="placeholder">Loading…</span>
</div>
<div id="scroll-hint" class="scroll-hint" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 7l4-4 4 4M8 17l4 4 4-4"/>
</svg>
Scroll to view full chart
</div>
<div id="zoom-ctrl" class="zoom-ctrl" hidden>
<button id="zoom-out" class="zoom-btn" type="button" title="Zoom out" aria-label="Zoom out"></button>
<button id="zoom-fit" class="zoom-btn zoom-fit" type="button" title="Fit to width">fit</button>
<button id="zoom-in" class="zoom-btn" type="button" title="Zoom in" aria-label="Zoom in">+</button>
</div>
</div>
</div>
<!-- Vendored libraries (classic scripts → globals): Mermaid (window.mermaid),
CodeMirror bundle (window.CM6), Gradio client (window.GradioClient).
Loaded before the module below so the module can use the globals. -->
<script src="static/mermaid.min.js"></script>
<script src="static/cm.bundle.js"></script>
<script src="static/gradio-client.js"></script>
<script type="module">
// `mermaid` is the global from static/mermaid.min.js (vendored, no CDN).
// CodeMirror is read from the window.CM6 global inside initEditor() — kept in
// a try/catch so any editor failure is contained and falls back to a textarea.
// take down the rest of the page (examples, Generate). See initEditor().
// Per-palette, per-theme Mermaid palettes. Keyed [PALETTE][theme] so the
// diagram tracks whichever palette is active (see the PALETTE flag below).
const MERMAID_THEMES = {
green: {
light: { background: '#e2e7d6', primaryColor: '#f9fbf4', primaryTextColor: '#1e251c',
primaryBorderColor: '#2c6e49', lineColor: '#6b7a55', tertiaryColor: '#eef2e2',
edgeLabelBackground: '#edf0e6' },
dark: { background: '#0e130d', primaryColor: '#1a211a', primaryTextColor: '#e7eede',
primaryBorderColor: '#3f9e63', lineColor: '#8fa274', tertiaryColor: '#1f271d',
edgeLabelBackground: '#1a211a' },
},
rust: {
light: { background: '#efe2d2', primaryColor: '#fdf6ec', primaryTextColor: '#2a201a',
primaryBorderColor: '#b0532e', lineColor: '#9c6f4f', tertiaryColor: '#f3e8d6',
edgeLabelBackground: '#f6ece0' },
dark: { background: '#16100c', primaryColor: '#231b16', primaryTextColor: '#f0e6d6',
primaryBorderColor: '#d56a3c', lineColor: '#b3855f', tertiaryColor: '#29201a',
edgeLabelBackground: '#231b16' },
},
};
function initMermaid(themeName) {
mermaid.initialize({
startOnLoad: false,
theme: 'base',
// Tighter node padding + spacing so decision diamonds hug their text (#8).
flowchart: { padding: 6, nodeSpacing: 45, rankSpacing: 45, useMaxWidth: true },
themeVariables: { fontFamily: "'Hanken Grotesk', sans-serif", ...MERMAID_THEMES[PALETTE][themeName] },
});
}
// ── Active palette (quick switch back to the old warm theme) ──────
// Flip this ONE word to swap the whole UI: 'green' (Pine & Sage, the
// current default) or 'rust' (Rust & Sienna, the previous warm theme).
// It just toggles a `rust` body class — every var(--cyan)/etc., the
// color-mix focus rings, and the matching Mermaid palette all follow.
const PALETTE = 'green';
document.body.classList.toggle('rust', PALETTE === 'rust');
// Theme state — default = light; toggle → forest charcoal. Persisted.
let currentTheme = 'light';
try { if (localStorage.getItem('cf-theme-warm') === 'dark') currentTheme = 'dark'; } catch {}
function applyTheme(name) {
currentTheme = name;
document.body.classList.toggle('dark', name === 'dark');
initMermaid(name);
try { localStorage.setItem('cf-theme-warm', name); } catch {}
}
applyTheme(currentTheme); // sets body class + initializes Mermaid
/* ----- Pre-rendered presets -----
Each preset stores BOTH the Python source and its already-computed
Mermaid, so selecting one paints the diagram instantly — no model
call. This is the "perception of fast inference" demo path and is
also what makes this page fully previewable offline. */
const PRESETS = {
loop: {
code:
`total = 0
for n in numbers:
total += n
print(total)`,
mermaid:
`graph TD
A["Start"] --> B["Initialise total to zero"]
B --> C{"More numbers to process?"}
C -- Yes --> D["Add current number to total"]
D --> C
C -- No --> E["Print total"]
E --> F["End"]`,
linemap: { B: [1, 1], C: [2, 2], D: [3, 3], E: [4, 4] }
},
ifelse: {
code:
`def check_status(val):
if val > 10:
return "Active"
else:
return "Inactive"`,
mermaid:
`graph TD
A["Start: check_status"] --> B{"Value greater than ten?"}
B -- True --> C["Return Active"]
B -- False --> D["Return Inactive"]
C --> E["End"]
D --> E`,
linemap: { A: [1, 1], B: [2, 2], C: [3, 3], D: [5, 5] }
},
func: {
code:
`def find_first_even(nums):
for i in range(len(nums)):
if nums[i] % 2 == 0:
return nums[i]
return None`,
mermaid:
`graph TD
A["Start: find_first_even"] --> B{"More elements to check?"}
B -- Yes --> C{"Element is even?"}
C -- Yes --> D["Return that element"]
C -- No --> B
B -- No --> E["Return None"]
D --> F["End"]
E --> F`,
linemap: { A: [1, 1], B: [2, 2], C: [3, 3], D: [4, 4], E: [5, 5] }
}
};
const target = document.getElementById('flowchart-target');
const submitBtn = document.getElementById('submit-btn');
const select = document.getElementById('examples-select');
const progress = document.getElementById('progress');
const progressFill = document.getElementById('progress-fill');
const scrollHint = document.getElementById('scroll-hint');
const langSelect = document.getElementById('lang-select');
const copyBtn = document.getElementById('copy-btn');
const clearBtn = document.getElementById('clear-btn');
const fallback = document.getElementById('code-fallback');
const editorWrap = document.getElementById('editor-wrap');
const editorStatus = document.getElementById('editor-status');
const copyMmdBtn = document.getElementById('copy-mmd-btn');
const svgBtn = document.getElementById('svg-btn');
const pngBtn = document.getElementById('png-btn');
const themeToggle = document.getElementById('theme-toggle');
const zoomCtrl = document.getElementById('zoom-ctrl');
const zoomInBtn = document.getElementById('zoom-in');
const zoomOutBtn = document.getElementById('zoom-out');
const zoomFitBtn = document.getElementById('zoom-fit');
/* ===== Real code editor (CodeMirror 6) =====
The plain <textarea> (#code-fallback) renders by default; CodeMirror
hides it only once it mounts. So a blocked CDN / CM failure degrades to
a working textarea instead of a broken page. getCode()/setCode() below
hide which input is live, so the preset + submit logic never branches. */
// CodeMirror is vendored as a single IIFE bundle (static/cm.bundle.js → the
// CM6 global), so every package shares ONE @codemirror/state/view/@lezer
// instance by construction — no ?deps= juggling, no CDN. Grammars come from
// the same bundle (CM6.python / .javascript / .java / .cpp).
const LANG_LOADERS = {
python: () => CM6.python(),
javascript: () => CM6.javascript(),
java: () => CM6.java(),
cpp: () => CM6.cpp(),
};
async function makeLangExt(name) {
try { return (LANG_LOADERS[name] || LANG_LOADERS.python)(); } catch { return []; }
}
let cmView = null;
let lastLineMap = {}; // nodeId -> [startLine, endLine] for the current chart
let cfHighlightLines = () => {}; // set once CM mounts: highlight editor lines (node→code hover)
let cfSelectLines = () => {}; // set once CM mounts: select + focus a line range (node→code edit)
let langCompartment = null;
function getCode() {
return cmView ? cmView.state.doc.toString() : fallback.value;
}
function setCode(text) {
if (cmView) cmView.dispatch({ changes: { from: 0, to: cmView.state.doc.length, insert: text } });
else fallback.value = text;
updateStatus();
}
function focusEditor() { (cmView || fallback).focus(); }
function updateStatus() {
const doc = getCode();
const lines = doc === '' ? 0 : doc.split('\n').length;
let ln = 1, col = 1;
if (cmView) {
const pos = cmView.state.selection.main.head;
const line = cmView.state.doc.lineAt(pos);
ln = line.number;
col = pos - line.from + 1;
}
editorStatus.textContent = `Ln ${ln}, Col ${col} · ${lines} line${lines === 1 ? '' : 's'}`;
}
async function setLanguage(name) {
if (cmView && langCompartment) {
cmView.dispatch({ effects: langCompartment.reconfigure(await makeLangExt(name)) });
}
}
async function initEditor() {
try {
// Everything comes from the vendored CM6 global (static/cm.bundle.js).
// Wrapped in try/catch so any editor failure is contained to the
// editor and falls back to the plain textarea — the rest of the page
// (mermaid, examples, Generate) keeps working.
if (!window.CM6) throw new Error('CodeMirror bundle not loaded');
const { EditorState, Compartment, StateField, StateEffect } = CM6;
const { EditorView, keymap, placeholder, lineNumbers, highlightActiveLine,
highlightActiveLineGutter, drawSelection, dropCursor, Decoration } = CM6;
const { history, defaultKeymap, historyKeymap, indentWithTab } = CM6;
const { indentOnInput, bracketMatching, syntaxHighlighting, HighlightStyle } = CM6;
const tg = CM6.tags;
// Lean "basic setup" — line numbers, active line, history/undo,
// indent-on-input, bracket matching. (No autocomplete/search/lint;
// this is a code-input box, not a full IDE.)
const setup = [
lineNumbers(), highlightActiveLineGutter(), highlightActiveLine(),
drawSelection(), dropCursor(), history(), indentOnInput(), bracketMatching(),
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
];
// Indigo / cyan / violet token theme so highlighting matches the UI.
// Token colors reference CSS vars (--tok-*) so they flip with the theme.
const cfHighlight = HighlightStyle.define([
{ tag: [tg.keyword, tg.controlKeyword, tg.operatorKeyword, tg.modifier,
tg.definitionKeyword, tg.moduleKeyword], color: 'var(--tok-keyword)' },
{ tag: [tg.function(tg.variableName), tg.function(tg.propertyName)], color: 'var(--tok-fn)' },
{ tag: [tg.className, tg.typeName], color: 'var(--tok-fn)' },
{ tag: [tg.string, tg.special(tg.string)], color: 'var(--tok-string)' },
{ tag: [tg.number, tg.bool, tg.atom], color: 'var(--tok-number)' },
{ tag: [tg.comment, tg.lineComment, tg.blockComment], color: 'var(--tok-comment)', fontStyle: 'italic' },
{ tag: [tg.propertyName], color: 'var(--tok-prop)' },
{ tag: [tg.operator, tg.punctuation, tg.bracket], color: 'var(--tok-punct)' },
{ tag: [tg.variableName], color: 'var(--tok-var)' },
{ tag: [tg.invalid], color: 'var(--tok-invalid)' },
]);
const cfTheme = EditorView.theme({
'&': { color: 'var(--text)', backgroundColor: 'transparent', height: '100%' },
'.cm-content': { caretColor: 'var(--cyan)', padding: '12px 0' },
'.cm-cursor, .cm-dropCursor': { borderLeftColor: 'var(--cyan)' },
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground': { backgroundColor: 'color-mix(in srgb, var(--cyan) 16%, transparent)' },
'.cm-gutters': { backgroundColor: 'transparent', color: 'var(--gutter)', border: 'none' },
'.cm-lineNumbers .cm-gutterElement': { padding: '0 8px 0 14px' },
'.cm-activeLineGutter': { backgroundColor: 'rgba(150,110,60,.07)', color: 'var(--text-dim)' },
'.cm-activeLine': { backgroundColor: 'rgba(150,110,60,.05)' },
'.cm-matchingBracket': { backgroundColor: 'rgba(156,111,79,.28)', outline: '1px solid var(--violet)' },
'.cm-line.cf-linked': { backgroundColor: 'color-mix(in srgb, var(--cyan) 14%, transparent)' },
});
// Node→code highlight: a line-decoration field toggled by an effect.
const setHL = StateEffect.define();
const lineDeco = Decoration.line({ class: 'cf-linked' });
const hlField = StateField.define({
create: () => Decoration.none,
update(deco, tr) {
deco = deco.map(tr.changes);
for (const e of tr.effects) if (e.is(setHL)) {
if (!e.value) { deco = Decoration.none; continue; }
const { a, b } = e.value, ranges = [];
for (let n = a; n <= b; n++)
if (n >= 1 && n <= tr.state.doc.lines)
ranges.push(lineDeco.range(tr.state.doc.line(n).from));
deco = Decoration.set(ranges, true);
}
return deco;
},
provide: f => EditorView.decorations.from(f),
});
langCompartment = new Compartment();
cmView = new EditorView({
parent: document.getElementById('editor'),
state: EditorState.create({
doc: fallback.value || '',
extensions: [
setup,
langCompartment.of(await makeLangExt(langSelect.value)),
keymap.of([indentWithTab]),
placeholder("Paste your code here, or pick a code example…"),
syntaxHighlighting(cfHighlight),
cfTheme,
hlField,
EditorView.updateListener.of(u => {
if (u.selectionSet || u.docChanged) updateStatus();
if (u.focusChanged) editorWrap.classList.toggle('focused', u.view.hasFocus);
if (u.selectionSet || u.docChanged) highlightNodesForCursor(u.view);
}),
],
}),
});
// Expose the highlight/select ops to the node→code handlers.
cfHighlightLines = (a, b) => {
if (!a) { cmView.dispatch({ effects: setHL.of(null) }); return; }
const n = Math.min(Math.max(1, a), cmView.state.doc.lines);
cmView.dispatch({ effects: [setHL.of({ a, b }),
EditorView.scrollIntoView(cmView.state.doc.line(n).from, { y: 'center' })] });
};
cfSelectLines = (a, b) => {
const doc = cmView.state.doc;
a = Math.min(Math.max(1, a), doc.lines); b = Math.min(Math.max(1, b), doc.lines);
cmView.dispatch({ selection: { anchor: doc.line(a).from, head: doc.line(b).to }, scrollIntoView: true });
cmView.focus();
};
fallback.hidden = true;
editorWrap.hidden = false;
updateStatus();
} catch (e) {
// CM unavailable → leave the (already-visible) textarea as the editor.
cmView = null;
console.warn('CodeMirror unavailable, using textarea fallback:', e);
}
}
// Brief "Copied"/"Saved" confirmation on a button, then restore its label.
function flashOk(btn, msg) {
const orig = btn.dataset.orig || (btn.dataset.orig = btn.textContent);
btn.textContent = msg;
btn.classList.add('ok');
setTimeout(() => { btn.textContent = orig; btn.classList.remove('ok'); }, 1200);
}
langSelect.addEventListener('change', () => setLanguage(langSelect.value));
copyBtn.addEventListener('click', async () => {
const text = getCode();
if (!text) return;
try { await navigator.clipboard.writeText(text); flashOk(copyBtn, 'Copied'); } catch {}
});
clearBtn.addEventListener('click', () => { setCode(''); focusEditor(); });
/* ===== Diagram export (Copy Mermaid / SVG / PNG) =====
These act on the most recent render. `lastMermaid` holds the source
even on a parse error (so it stays copyable); SVG/PNG need a real svg. */
let lastMermaid = '';
function refreshExportButtons() {
// A real generated chart (lastMermaid set), not the empty-state Start anchor.
const hasSvg = !!lastMermaid && !!target.querySelector('svg');
copyMmdBtn.disabled = !lastMermaid;
svgBtn.disabled = !hasSvg;
pngBtn.disabled = !hasSvg;
zoomCtrl.hidden = !hasSvg; // zoom controls only make sense with a chart
}
/* ===== Zoom (#3) — scales the SVG width via the --zoom custom property;
overflow + 'safe center' let the user scroll/pan a zoomed-in chart. ===== */
let zoom = 1;
const ZOOM_MIN = 0.4, ZOOM_MAX = 3, ZOOM_STEP = 1.2;
function applyZoom() {
target.style.setProperty('--zoom', zoom.toFixed(3));
updateScrollHint();
}
function setZoom(z) { zoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z)); applyZoom(); }
zoomInBtn.addEventListener('click', () => setZoom(zoom * ZOOM_STEP));
zoomOutBtn.addEventListener('click', () => setZoom(zoom / ZOOM_STEP));
zoomFitBtn.addEventListener('click', () => setZoom(1));
/* ===== Light / dark toggle (#9) — with a diagram crossfade ===== */
themeToggle.addEventListener('click', async () => {
const next = currentTheme === 'light' ? 'dark' : 'light';
const svg = (lastMermaid && target.querySelector('svg')) ? target.querySelector('svg') : null;
if (svg && !prefersReduced) {
// Fade the current chart out FIRST so the palette swap + re-render
// (Mermaid bakes some colors in, so a re-render is required) happen
// unseen — then fade the re-themed chart back in. Fade-through, not a
// dual-SVG dissolve: our node colors are CSS-var-driven, so a cloned
// "old" SVG would instantly re-resolve to the new theme.
await svg.animate([{ opacity: 1 }, { opacity: 0 }],
{ duration: 150, easing: 'ease', fill: 'forwards' }).finished;
applyTheme(next); // swap vars + re-init Mermaid while invisible
await renderMermaid(lastMermaid); // re-themed chart, mounted at full opacity
const fresh = target.querySelector('svg');
if (fresh) fresh.animate([{ opacity: 0 }, { opacity: 1 }],
{ duration: 220, easing: 'ease', fill: 'both' });
} else {
applyTheme(next);
if (lastMermaid && target.querySelector('svg')) await renderMermaid(lastMermaid);
}
});
function download(filename, blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// Serialize the rendered SVG, namespaced so it opens standalone.
function serializedSvg() {
const svg = target.querySelector('svg');
if (!svg) return null;
const clone = svg.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
return { svg, data: new XMLSerializer().serializeToString(clone) };
}
copyMmdBtn.addEventListener('click', async () => {
if (!lastMermaid) return;
try { await navigator.clipboard.writeText(lastMermaid); flashOk(copyMmdBtn, 'Copied'); } catch {}
});
svgBtn.addEventListener('click', () => {
const s = serializedSvg();
if (!s) return;
download('flowchart.svg', new Blob([s.data], { type: 'image/svg+xml;charset=utf-8' }));
flashOk(svgBtn, 'Saved');
});
pngBtn.addEventListener('click', () => {
const s = serializedSvg();
if (!s) return;
// Natural pixel size from the viewBox (the on-screen svg is CSS-stretched
// to 100%); render at 2× for a crisp export on a solid canvas bg.
const vb = s.svg.viewBox && s.svg.viewBox.baseVal;
const w = (vb && vb.width) || s.svg.clientWidth || 800;
const h = (vb && vb.height) || s.svg.clientHeight || 600;
const scale = 2;
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = Math.round(w * scale);
canvas.height = Math.round(h * scale);
const ctx = canvas.getContext('2d');
// Solid canvas bg = the current theme's --bg-inset, so labels stay readable.
ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--bg-inset').trim() || '#e2e7d6';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob(b => { if (b) { download('flowchart.png', b); flashOk(pngBtn, 'Saved'); } }, 'image/png');
};
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(s.data);
});
// Simulated inference time for the pre-rendered demo path. The bar
// tracks this exactly and the chart is revealed at 100% — tune freely.
const PRESET_DURATION_MS = 2000;
let renderSeq = 0; // guards against out-of-order async renders
// Show the "scroll" hint only when the chart overflows its box AND the
// user isn't already scrolled to the bottom. Hides at the bottom,
// reappears as soon as they scroll back up.
function updateScrollHint() {
const overflowing = target.scrollHeight > target.clientHeight + 4;
const atBottom =
target.scrollTop + target.clientHeight >= target.scrollHeight - 4;
scrollHint.hidden = !(overflowing && !atBottom);
}
window.addEventListener('resize', updateScrollHint);
target.addEventListener('scroll', updateScrollHint);
/* ===== Diagram-reveal animation (#1) — "Trace the path" + "Calm" =====
A new chart draws itself in: the topmost node is the persistent anchor
(it stays put), and everything else flows OUT of it top-to-bottom — nodes
scale in, edges draw on (stroke dash), each arrowhead landing as its line
arrives. Runs ONLY on a fresh Generate (not zoom / theme / re-layout) and
is skipped under prefers-reduced-motion. */
const REVEAL_STEP = 95; // ms between successive elements — "Calm"
const prefersReduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
let anchorNode = null; // topmost node — never animates
let edgeMarkers = new Map();
let revealToken = 0; // bumped per reveal so a stale scroll-follow loop self-cancels
const yTop = el => el.getBoundingClientRect().top;
function animNodeIn(n, delay) {
// Animate the INDIVIDUAL scale/translate props (not `transform`) so we
// compose with Mermaid's positioning transform="translate(x,y)" attribute
// instead of overriding it (which would pile every node in the corner).
n.animate(
[{ opacity: 0, scale: '.97', translate: '0 6px' }, { opacity: 1, scale: '1', translate: '0 0' }],
{ duration: 280, delay, easing: 'cubic-bezier(.2,.7,.3,1)', fill: 'both' }
);
}
function animEdgeIn(e, delay) {
const len = e.getTotalLength();
e.style.strokeDasharray = len;
const a = e.animate(
[{ strokeDashoffset: len }, { strokeDashoffset: 0 }],
{ duration: 440, delay, easing: 'ease-in-out', fill: 'both' }
);
a.onfinish = () => { // land the arrowhead when the line arrives
const m = edgeMarkers.get(e);
if (m?.end) e.setAttribute('marker-end', m.end);
if (m?.start) e.setAttribute('marker-start', m.start);
e.style.strokeDashoffset = 0;
};
}
function fadeIn(l, delay) {
l.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 220, delay, easing: 'ease', fill: 'both' });
}
// Scroll the canvas to track the reveal front in REAL TIME. Rather than
// chasing each node with a (laggy) smooth scrollTo, we run a rAF loop that
// mirrors the WAAPI schedule: at elapsed time t the front is item t/STEP, so
// we place that item's content-Y at ~50% of the viewport every frame. Because
// the front advances smoothly, directly setting scrollTop reads as a smooth
// glide that never falls behind. Monotonic-down; no-op when the chart fits.
function startScrollFollow(items) {
if (prefersReduced || items.length < 2) return;
const myToken = ++revealToken;
const containerTop = target.getBoundingClientRect().top;
const ys = items.map(it => yTop(it.el) - containerTop); // content-Y at scrollTop 0
const t0 = performance.now();
const FRONT = 0.5; // keep the active element around mid-viewport
function frame(now) {
if (myToken !== revealToken) return; // superseded by a newer reveal
const f = (now - t0) / REVEAL_STEP; // fractional front index
const i0 = Math.min(items.length - 1, Math.max(0, Math.floor(f)));
const i1 = Math.min(items.length - 1, i0 + 1);
const frontY = ys[i0] + (ys[i1] - ys[i0]) * Math.min(1, Math.max(0, f - i0));
const maxScroll = Math.max(0, target.scrollHeight - target.clientHeight);
const desired = Math.min(maxScroll, Math.max(0, frontY - target.clientHeight * FRONT));
if (desired > target.scrollTop) target.scrollTop = desired; // only follow downward
if (f < items.length + 1) requestAnimationFrame(frame); // run just past the last item
}
requestAnimationFrame(frame);
}
// The persistent empty state: a lone Start node, shown when there's no diagram.
async function showStartAnchor() {
try {
const { svg } = await mermaid.render('cf-anchor-' + (++renderSeq), 'flowchart TD\n A([Start])');
target.innerHTML = svg;
const n = target.querySelector('.node');
if (n) n.classList.add('cf-start');
target.classList.remove('revealing');
target.classList.add('anchor-only'); // render the anchor small, not stretched
zoom = 1; applyZoom();
} catch {
target.innerHTML = '<span class="placeholder">Your flowchart will appear here.</span>';
}
lastMermaid = '';
lastLineMap = {};
refreshExportButtons();
}
// After a fresh chart mounts (with the anchor already tagged + others hidden),
// schedule the top-to-bottom reveal that flows out of the anchor node.
function revealDiagram() {
// Capture arrowhead markers before stripping them, so each restores on land
// (survives the animation; the map keyed by element is stable).
edgeMarkers = new Map();
target.querySelectorAll('g.edgePaths path').forEach(e =>
edgeMarkers.set(e, { end: e.getAttribute('marker-end'), start: e.getAttribute('marker-start') }));
const nodes = [...target.querySelectorAll('.node')].filter(n => n !== anchorNode);
const edges = [...target.querySelectorAll('g.edgePaths path')];
const labels = [...target.querySelectorAll('.edgeLabel')];
edges.forEach(e => {
const len = e.getTotalLength();
e.style.strokeDasharray = len;
e.style.strokeDashoffset = len;
e.removeAttribute('marker-end'); // hide arrowhead until the line lands
e.removeAttribute('marker-start');
});
void target.getBoundingClientRect();
if (prefersReduced) { // accessibility: jump to final frame
edges.forEach(e => {
const m = edgeMarkers.get(e);
if (m?.end) e.setAttribute('marker-end', m.end);
if (m?.start) e.setAttribute('marker-start', m.start);
e.style.strokeDashoffset = 0; e.style.strokeDasharray = 'none';
});
target.classList.remove('revealing');
return;
}
// One continuous top-to-bottom sweep flowing out of the anchor.
target.scrollTop = 0; // start the trace from the top
const items = [
...nodes.map(el => ({ el, t: 'n', y: yTop(el) })),
...edges.map(el => ({ el, t: 'e', y: yTop(el) })),
...labels.map(el => ({ el, t: 'l', y: yTop(el) })),
].sort((a, b) => a.y - b.y);
items.forEach((it, i) => {
const d = i * REVEAL_STEP;
if (it.t === 'n') animNodeIn(it.el, d);
else if (it.t === 'e') animEdgeIn(it.el, d);
else fadeIn(it.el, d);
});
// Animations now hold each element's hidden initial frame via fill:'both'
// backwards-fill, so the anti-flash class is redundant — drop it (else its
// opacity:0 keeps the EDGES invisible: they only animate the dash, not opacity).
target.classList.remove('revealing');
// Scroll in lock-step with the reveal so the user watches it being drawn.
startScrollFollow(items);
}
/* ===== Node ↔ code linking (#1) — driven by lastLineMap (nodeId → [a,b]) ===== */
// Code → node: emphasise the node(s) whose source range contains the cursor line.
function highlightNodesForCursor(view) {
target.querySelectorAll('.node.cf-active').forEach(n => n.classList.remove('cf-active'));
const ids = Object.keys(lastLineMap);
if (!ids.length) return;
const ln = view.state.doc.lineAt(view.state.selection.main.head).number;
for (const id of ids) {
const [a, b] = lastLineMap[id];
if (ln >= a && ln <= b) {
const g = target.querySelector('.node[data-id="' + id + '"]');
if (g) g.classList.add('cf-active');
}
}
}
// Node → code: hover highlights the source lines; click selects them for editing.
function wireNodeLinks() {
if (!Object.keys(lastLineMap).length) return;
target.querySelectorAll('.node[data-id]').forEach(g => {
const range = lastLineMap[g.getAttribute('data-id')];
if (!range) return;
g.style.cursor = 'pointer';
g.addEventListener('mouseenter', () => cfHighlightLines(range[0], range[1]));
g.addEventListener('mouseleave', () => cfHighlightLines(0));
g.addEventListener('click', () => cfSelectLines(range[0], range[1]));
});
}
async function renderMermaid(syntax, animate = false, linemap = undefined) {
const id = 'cf-' + (++renderSeq);
lastMermaid = syntax; // copyable even if the parse below fails
if (linemap !== undefined) lastLineMap = linemap || {}; // undefined = keep (theme re-render)
try {
const { svg } = await mermaid.render(id, syntax);
target.innerHTML = svg;
target.classList.remove('anchor-only'); // a real chart fills the canvas
wireNodeLinks(); // attach node→code hover/click (no-op without a map)
zoom = 1; applyZoom(); // each fresh chart starts fit-to-width
updateScrollHint();
if (animate && !prefersReduced) {
// Tag the anchor + hide the rest synchronously (no flash), then reveal
// next frame once layout is ready for getTotalLength/getBBox.
anchorNode = [...target.querySelectorAll('.node')].sort((a, b) => yTop(a) - yTop(b))[0] || null;
if (anchorNode) anchorNode.classList.add('cf-start');
target.classList.add('revealing');
requestAnimationFrame(revealDiagram);
} else {
target.classList.remove('revealing'); // theme/zoom re-render: no animation
}
} catch (err) {
target.classList.remove('revealing');
lastLineMap = {}; // no chart → no links
scrollHint.hidden = true;
target.innerHTML =
'<div style="width:100%"><p class="err">Diagram failed to parse: ' +
(err && err.message ? err.message : err) +
'</p><p class="panel-title" style="text-align:left">Raw Mermaid output:</p></div>';
const pre = document.createElement('pre');
pre.className = 'raw';
pre.textContent = syntax; // textContent: newline- and injection-safe
target.appendChild(pre);
}
refreshExportButtons();
}
// Example dropdown → only fill the code box. The diagram waits for
// the user to click Generate (so the chart appears with the bar).
select.addEventListener('change', () => {
const preset = PRESETS[select.value];
if (!preset) return;
setCode(preset.code);
langSelect.value = 'python'; // all presets are Python
setLanguage('python');
showStartAnchor(); // keep the Start node on the canvas, waiting for Generate
scrollHint.hidden = true;
focusEditor();
});
// Match the current code to a preset (so Generate can use its
// pre-rendered Mermaid instead of calling the model).
function findPreset(code) {
const norm = s => s.trim();
return Object.values(PRESETS).find(p => norm(p.code) === norm(code)) || null;
}
// ----- Progress bar helpers -----
// Determinate, accurate fill over `duration` ms; resolves at 100%.
function runProgress(duration) {
return new Promise(resolve => {
progressFill.classList.remove('indeterminate');
progressFill.style.marginLeft = '0';
const start = performance.now();
function tick(now) {
const t = Math.min(1, (now - start) / duration);
progressFill.style.width = (t * 100) + '%';
if (t < 1) requestAnimationFrame(tick);
else resolve();
}
requestAnimationFrame(tick);
});
}
function startIndeterminate() {
progressFill.style.width = '';
progressFill.style.marginLeft = '';
progressFill.classList.add('indeterminate');
}
function hideProgress() {
progress.hidden = true;
progressFill.classList.remove('indeterminate');
progressFill.style.width = '0%';
progressFill.style.marginLeft = '0';
}
/* ----- Live generation (server only) -----
Uses the vendored Gradio client (static/gradio-client.js → the
GradioClient global). Connected lazily on first Generate; presets still
render with no backend. */
let clientPromise = null;
async function getClient() {
if (!clientPromise) {
const { Client } = window.GradioClient;
clientPromise = Client.connect(window.location.origin);
}
return clientPromise;
}
submitBtn.addEventListener('click', async () => {
const code = getCode();
if (!code.trim()) {
target.innerHTML = '<p class="err">Please input code first.</p>';
scrollHint.hidden = true;
return;
}
const preset = findPreset(code);
submitBtn.disabled = true;
submitBtn.textContent = "Generating…";
await showStartAnchor(); // reset to the lone Start node — the chart will flow out of it
scrollHint.hidden = true;
progress.hidden = false;
refreshExportButtons();
try {
if (preset) {
// Pre-rendered demo path: the bar accurately tracks the
// (fixed) time until the chart is revealed at 100%.
await runProgress(PRESET_DURATION_MS);
await renderMermaid(preset.mermaid, true, preset.linemap || {});
} else {
// Live model path: duration is unknown, so show an honest
// indeterminate sweep rather than a fake percentage.
startIndeterminate();
const client = await getClient();
const result = await client.predict("/generate_flowchart", { src_code: code });
// Backend returns { mermaid, linemap }; tolerate a bare string too.
const out = result.data[0];
const isObj = out && typeof out === 'object';
await renderMermaid(isObj ? out.mermaid : out, true, (isObj && out.linemap) || {});
}
} catch (error) {
target.innerHTML =
'<p class="err">Live generation unavailable: ' +
(error && error.message ? error.message : error) +
'</p><p class="placeholder">Tip: pick a code example above to preview a diagram without the model.</p>';
} finally {
hideProgress();
submitBtn.disabled = false;
submitBtn.textContent = "Generate Flowchart";
refreshExportButtons();
}
});
// Mount the real editor (falls back to the textarea on failure).
initEditor();
// Show the persistent Start node on the canvas (the empty state).
showStartAnchor();
</script>
</body>
</html>