Spaces:
Running
Running
Updated README, added tracing capabilities, changed size of flowchart, vendored the animation assets so this meets off the grid.
1433b16 | <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)) ; /* fit-to-width, slightly inset; zoom scales it */ | |
| max-width: none ; /* override Mermaid's inline max-width so zoom can grow it */ | |
| height: auto ; | |
| 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 ; | |
| height: 56px ; | |
| min-height: 0 ; | |
| max-width: none ; | |
| } | |
| /* 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) ; | |
| stroke-width: 3px ; | |
| 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) ; | |
| font-weight: 500; | |
| color: var(--text) ; | |
| 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) ; 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) ; | |
| font-size: 11px ; | |
| font-weight: 600 ; | |
| line-height: 1; | |
| color: var(--text-dim) ; | |
| background: var(--bg-panel) ; | |
| 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> | |