Rishi-Jain-27 commited on
Commit
42bbd85
Β·
1 Parent(s): 320c295

Completed HTML frontend

Browse files
Files changed (5) hide show
  1. .gitignore +1 -0
  2. README.md +9 -1
  3. app.py +84 -116
  4. frontend.html +1492 -0
  5. smoke-test.sh +66 -0
.gitignore CHANGED
@@ -4,3 +4,4 @@ notes-for-blog.md
4
  venv
5
  __pycache__
6
  .DS_Store
 
 
4
  venv
5
  __pycache__
6
  .DS_Store
7
+ Screenshot 2026-06-07 at 10.46.25β€―AM.png
README.md CHANGED
@@ -9,7 +9,15 @@ python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  license: mit
12
- short_description: Turn Python code into a readable Mermaid.js flowchart πŸ“Š
13
  ---
14
 
 
 
 
 
 
 
 
 
15
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
9
  app_file: app.py
10
  pinned: false
11
  license: mit
12
+ short_description: Turn code into a readable Mermaid.js flowchart πŸ“Š
13
  ---
14
 
15
+ Add tags for the tracks and badges you want to be considered for to the yaml block at the top of your README, plus a short write-up of the idea and tech.
16
+ Post it
17
+ Create one social-media post showcasing your app, and link to it from your Space README.
18
+
19
+ Record a demo
20
+ Submit a demo video showing your app working β€” so judges can evaluate it even if GPU or API limits stop a live run.
21
+
22
+
23
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
app.py CHANGED
@@ -1,13 +1,4 @@
1
- """
2
- 3. Graph. Capture the resulting mermaid string and visualize it
3
 
4
- To do
5
- - create the custom gradio look
6
- - explore making it look better
7
- - get a better model β€” Qwen 30b coder
8
- - use zerogpu
9
-
10
- """
11
  from huggingface_hub import hf_hub_download
12
  from llama_cpp import Llama
13
  import gradio as gr
@@ -15,9 +6,39 @@ from gradio import Server
15
  from fastapi.responses import HTMLResponse # serve the custom frontend from a route
16
  from typing import Any, cast # to resolve PyLance freaking out over llama-cpp-python in the generate_flowchart function
17
  from textwrap import dedent
18
- import re # remove thinking tag from response
19
-
20
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  out = []
23
  for line in text.split('\n'):
@@ -26,10 +47,31 @@ import re # remove thinking tag from response
26
  out.append(line)
27
  return '\n'.join(out)
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  @app.api(name="generate_flowchart")
30
- def generate_flowchart(src_code: str) -> str:
31
  # check if src_code is empty
32
- if not src_code.strip(): return ""
 
 
 
 
 
33
 
34
  # Set system prompt
35
  system_prompt = dedent("""
@@ -41,10 +83,11 @@ def generate_flowchart(src_code: str) -> str:
41
 
42
  ## Strict Constraints
43
  <constraints>
44
- 1. OUTPUT FORMAT: Output ONLY valid, raw Mermaid.js syntax.
45
  2. NO MARKDOWN FENCING: Do not wrap the output in ```mermaid or ``` blocks. Start directly with the Mermaid graph definition, for example: graph TD.
46
  3. NO PROSE: Do not include introductory text, explanations, or concluding remarks. If the code cannot be parsed, output an isolated error node.
47
  4. NODE NAMING: Paraphrase conditions into plain words β€” never put raw code, operators, quotes, parentheses, or square brackets/subscripts inside labels (write Index in bounds?, not i < len(nums); write Element is even?, not nums[i] % 2 == 0)
 
48
  </constraints>
49
 
50
  <banned_vocabulary>
@@ -67,22 +110,28 @@ def generate_flowchart(src_code: str) -> str:
67
  ## Few-Shot Examples
68
 
69
  Input:
70
- def check_status(val):
71
- if val > 10:
72
- return "Active"
73
- else:
74
- return "Inactive"
75
 
76
  Output:
77
  <thinking>
78
  1. Control structures: One conditional check, two return branches.
79
  2. Nodes: A Start, B Conditional, C Active return, D Inactive return.
80
- 3. Syntax verification: B uses curly braces for decisions. Edges use standard arrows.
81
  </thinking>
82
  graph TD
83
  A[Start: check_status] --> B{val > 10}
84
  B -- True --> C[Return 'Active']
85
  B -- False --> D[Return 'Inactive']
 
 
 
 
 
 
86
  """).strip()
87
 
88
  # Reset the cache per request so no cross-request bleeding
@@ -92,7 +141,7 @@ def generate_flowchart(src_code: str) -> str:
92
  response = cast(Any, llm.create_chat_completion(
93
  messages=[
94
  {"role": "system", "content": system_prompt},
95
- {"role": "user", "content": src_code}
96
  ],
97
  temperature=0.1, # Keep it quite deterministic for now
98
  max_tokens=1024,
@@ -102,105 +151,24 @@ def generate_flowchart(src_code: str) -> str:
102
  content = response["choices"][0]["message"]["content"]
103
 
104
  # remove the thinking tags from the response
105
- cleaned = re.sub(r'<thinking>.*?</thinking>', '', content, flags=re.DOTALL)
 
 
 
 
 
 
 
106
 
107
  # Quote-wrap each node label and escape any leaked code characters
108
- cleaned = quote_labels(cleaned)
109
 
110
- return cleaned.strip() # and remove excess whitespace
111
 
112
  # ----- Custom Frontend ----- #
113
- index_html = """
114
- <!DOCTYPE html>
115
- <html lang="en">
116
- <head>
117
- <meta charset="UTF-8">
118
- <title>Code-to-Flowchart Generator</title>
119
- <style>
120
- body { font-family: sans-serif; background: #111827; color: #f3f4f6; margin: 0; padding: 20px; }
121
- .container { display: flex; gap: 20px; height: 90vh; }
122
- .panel { flex: 1; display: flex; flex-direction: column; background: #1f2937; padding: 15px; border-radius: 8px; }
123
- textarea { flex: 1; background: #111827; color: #34d399; border: 1px solid #374151; padding: 10px; font-family: monospace; resize: none; border-radius: 4px; }
124
- button { background: #059669; color: white; border: none; padding: 12px; margin-top: 10px; cursor: pointer; font-weight: bold; border-radius: 4px; }
125
- button:hover { background: #10b981; }
126
- button:disabled { background: #374151; cursor: not-allowed; }
127
- #flowchart-target { flex: 1; background: #ffffff; padding: 10px; border-radius: 4px; overflow: auto; display: flex; justify-content: center; align-items: start; }
128
- </style>
129
- </head>
130
- <body>
131
- <h2>Flowchart Transpiler</h2>
132
- <div class="container">
133
- <div class="panel">
134
- <h3>Source Code Input</h3>
135
- <textarea id="code-input" placeholder="Paste your code here..." spellcheck="false"></textarea>
136
- <button id="submit-btn">Generate Flowchart</button>
137
- </div>
138
- <div class="panel">
139
- <h3>Mermaid Flowchart Visualizer</h3>
140
- <div id="flowchart-target">
141
- <pre class="mermaid" id="mermaid-string">
142
- graph TD
143
- A[Paste Code] --> B[Click Generate]
144
- </pre>
145
- </div>
146
- </div>
147
- </div>
148
-
149
- <script type="module">
150
- import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client@1/dist/index.min.js";
151
- import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
152
-
153
- mermaid.initialize({ startOnLoad: true, theme: 'neutral' });
154
-
155
- // Instantiate the local Gradio application client dynamically
156
- const client = await Client.connect(window.location.origin);
157
-
158
- document.getElementById('submit-btn').addEventListener('click', async () => {
159
- const codeValue = document.getElementById('code-input').value;
160
- const targetDiv = document.getElementById('flowchart-target');
161
- const submitBtn = document.getElementById('submit-btn');
162
-
163
- if (!codeValue.trim()) {
164
- targetDiv.innerHTML = "<p style='color:red;'>Please input code first.</p>";
165
- return;
166
- }
167
-
168
- // Disable the button while a request is in flight so a slow CPU
169
- // generation can't be double-fired into a concurrent request.
170
- submitBtn.disabled = true;
171
- submitBtn.textContent = "Generating...";
172
- targetDiv.innerHTML = "Generating diagram...";
173
-
174
- let mermaidSyntax = "";
175
- try {
176
- // Call the @app.api function registered in python (name + param must match)
177
- const result = await client.predict("/generate_flowchart", { src_code: codeValue });
178
- mermaidSyntax = result.data[0];
179
-
180
- // Inject the raw string into a clean layout block and re-trigger parsing
181
- targetDiv.innerHTML = `<pre class="mermaid">${mermaidSyntax}</pre>`;
182
- await mermaid.run();
183
-
184
- } catch (error) {
185
- // On failure show the error AND the exact raw Mermaid we tried to render,
186
- // so a parse error can be diagnosed from the real output. textContent is
187
- // used for the raw string so newlines/special chars can't break the page.
188
- targetDiv.innerHTML = "<p style='color:red;'>Error during generation: " + error.message + "</p><p style='color:#111;font-weight:bold;text-align:left;'>Raw Mermaid output:</p>";
189
- const dbg = document.createElement("pre");
190
- dbg.style.color = "#111";
191
- dbg.style.whiteSpace = "pre-wrap";
192
- dbg.style.textAlign = "left";
193
- dbg.textContent = mermaidSyntax;
194
- targetDiv.appendChild(dbg);
195
- } finally {
196
- submitBtn.disabled = false;
197
- submitBtn.textContent = "Generate Flowchart";
198
- }
199
- });
200
- </script>
201
- </body>
202
- </html>
203
- """
204
 
205
  # Load the custom HTML
206
  # / takes precedent over default Blocks UI
 
 
 
1
 
 
 
 
 
 
 
 
2
  from huggingface_hub import hf_hub_download
3
  from llama_cpp import Llama
4
  import gradio as gr
 
6
  from fastapi.responses import HTMLResponse # serve the custom frontend from a route
7
  from typing import Any, cast # to resolve PyLance freaking out over llama-cpp-python in the generate_flowchart function
8
  from textwrap import dedent
9
+ from pathlib import Path # load the custom frontend from disk
10
+ import re # remove thinking tag from response
11
+
12
+ # ----- Get Model ----- #
13
+ # Download Q4_K_M GGUF file from the repo
14
+ model_path = hf_hub_download(
15
+ repo_id="unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF",
16
+ filename="Qwen3-Coder-30B-A3B-Instruct-UD-Q3_K_XL.gguf" # fallback: Q2_K_XL
17
+ )
18
+
19
+ # Initialize llama.cpp with the local cached path
20
+ llm = Llama(
21
+ model_path=model_path,
22
+ n_ctx=4096,
23
+ n_threads=2
24
+ )
25
+
26
+ # ----- Init App ----- #
27
+ app = gr.Server(title="Code-to-Flowchart Generator")
28
+
29
+ # ----- Functions ----- #
30
+
31
+ # This is a cleaning function to resolve common syntax errors.
32
+ def quote_labels(text: str) -> str:
33
+ # Mermaid node labels can't hold raw code characters, so quote-wrap each label body
34
+ # A label's real closing bracket is followed by a Mermaid connector, edge-label, pipe, statement end, or EOL
35
+ # operators after a subscript (== < <= > >= != %) are never mistaken for a close.
36
+ END = r'(?=\s*(?:[-<][-.>xo]|==[>=xo]|\||;|$))'
37
+
38
+ def esc(body: str) -> str:
39
+ return (body.replace('"', "'")
40
+ .replace('[', '&#91;').replace(']', '&#93;')
41
+ .replace('{', '&#123;').replace('}', '&#125;'))
42
 
43
  out = []
44
  for line in text.split('\n'):
 
47
  out.append(line)
48
  return '\n'.join(out)
49
 
50
+ # Parse the model's <linemap> block into {nodeId: [startLine, endLine]}.
51
+ # Tolerant of junk lines; drops any entry whose line(s) fall outside the source.
52
+ def parse_linemap(block: str, num_lines: int) -> dict:
53
+ out: dict = {}
54
+ for raw in block.strip().splitlines():
55
+ m = re.match(r'\s*([A-Za-z]\w*)\s*:\s*(\d+)(?:\s*-\s*(\d+))?\s*$', raw)
56
+ if not m:
57
+ continue
58
+ a = int(m.group(2))
59
+ b = int(m.group(3)) if m.group(3) else a
60
+ if a > b:
61
+ a, b = b, a
62
+ if num_lines and 1 <= a <= num_lines and 1 <= b <= num_lines:
63
+ out[m.group(1)] = [a, b]
64
+ return out
65
+
66
  @app.api(name="generate_flowchart")
67
+ def generate_flowchart(src_code: str) -> dict:
68
  # check if src_code is empty
69
+ if not src_code.strip(): return {"mermaid": "", "linemap": {}}
70
+
71
+ # Number the source lines so the model can cite them in the <linemap> block.
72
+ src_lines = src_code.splitlines()
73
+ num_lines = len(src_lines)
74
+ numbered = "\n".join(f"{i}| {ln}" for i, ln in enumerate(src_lines, 1))
75
 
76
  # Set system prompt
77
  system_prompt = dedent("""
 
83
 
84
  ## Strict Constraints
85
  <constraints>
86
+ 1. OUTPUT FORMAT: Output valid, raw Mermaid.js syntax, immediately followed by the required <linemap> block (constraint 5). Nothing else.
87
  2. NO MARKDOWN FENCING: Do not wrap the output in ```mermaid or ``` blocks. Start directly with the Mermaid graph definition, for example: graph TD.
88
  3. NO PROSE: Do not include introductory text, explanations, or concluding remarks. If the code cannot be parsed, output an isolated error node.
89
  4. NODE NAMING: Paraphrase conditions into plain words β€” never put raw code, operators, quotes, parentheses, or square brackets/subscripts inside labels (write Index in bounds?, not i < len(nums); write Element is even?, not nums[i] % 2 == 0)
90
+ 5. SOURCE MAP: The user's code is prefixed with `N| ` line numbers (these are references, never copy the `N| ` prefix into a label). After the diagram, output a <linemap> block: one `NodeId: N` per node, where N is the 1-based source line that node represents (use `NodeId: start-end` for a multi-line construct). Omit purely structural Start/End nodes that correspond to no source line.
91
  </constraints>
92
 
93
  <banned_vocabulary>
 
110
  ## Few-Shot Examples
111
 
112
  Input:
113
+ 1| def check_status(val):
114
+ 2| if val > 10:
115
+ 3| return "Active"
116
+ 4| else:
117
+ 5| return "Inactive"
118
 
119
  Output:
120
  <thinking>
121
  1. Control structures: One conditional check, two return branches.
122
  2. Nodes: A Start, B Conditional, C Active return, D Inactive return.
123
+ 3. Source lines: def is line 1, the if is line 2, Active return is line 3, Inactive return is line 5.
124
  </thinking>
125
  graph TD
126
  A[Start: check_status] --> B{val > 10}
127
  B -- True --> C[Return 'Active']
128
  B -- False --> D[Return 'Inactive']
129
+ <linemap>
130
+ A: 1
131
+ B: 2
132
+ C: 3
133
+ D: 5
134
+ </linemap>
135
  """).strip()
136
 
137
  # Reset the cache per request so no cross-request bleeding
 
141
  response = cast(Any, llm.create_chat_completion(
142
  messages=[
143
  {"role": "system", "content": system_prompt},
144
+ {"role": "user", "content": numbered}
145
  ],
146
  temperature=0.1, # Keep it quite deterministic for now
147
  max_tokens=1024,
 
151
  content = response["choices"][0]["message"]["content"]
152
 
153
  # remove the thinking tags from the response
154
+ content = re.sub(r'<thinking>.*?</thinking>', '', content, flags=re.DOTALL)
155
+
156
+ # Extract + strip the node→line map, then validate it against the source length
157
+ linemap: dict = {}
158
+ lm = re.search(r'<linemap>(.*?)</linemap>', content, flags=re.DOTALL)
159
+ if lm:
160
+ linemap = parse_linemap(lm.group(1), num_lines)
161
+ content = content[:lm.start()] + content[lm.end():]
162
 
163
  # Quote-wrap each node label and escape any leaked code characters
164
+ mermaid = quote_labels(content).strip() # and remove excess whitespace
165
 
166
+ return {"mermaid": mermaid, "linemap": linemap}
167
 
168
  # ----- Custom Frontend ----- #
169
+ # Served from frontend.html so the same file can be opened directly in a
170
+ # browser (file://) to preview the UI without loading the model.
171
+ index_html = (Path(__file__).parent / "frontend.html").read_text(encoding="utf-8")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
 
173
  # Load the custom HTML
174
  # / takes precedent over default Blocks UI
frontend.html ADDED
@@ -0,0 +1,1492 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>CodeFlow β€” Code to Flowchart</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
10
+ <style>
11
+ /* ============================================================
12
+ PINE & SAGE β€” the CodeFlow theme (green + light).
13
+ Soft sage-paper base, deep pine-green accent (dark enough to clear
14
+ WCAG AA on the light bg), muted moss branch lines. Light by default;
15
+ toggle β†’ forest "charcoal" dark. Colorblind-safe by design: meaning
16
+ rides on text + node shape, never hue β€” green is purely decorative,
17
+ and luminance contrast does the work.
18
+ NOTE: --cyan / --violet are historical var names kept so every
19
+ reference stays valid; here --cyan = the pine accent and
20
+ --violet = moss green. (Safe future cleanup: rename to
21
+ --accent / --accent-2.)
22
+ ============================================================ */
23
+ :root {
24
+ --bg: #edf0e6; /* sage paper */
25
+ --bg-glow: #e8efdd; /* faint header tint */
26
+ --bg-panel: #f9fbf4; /* near-white card */
27
+ --bg-inset: #e2e7d6; /* sage inset (input / canvas) */
28
+ --border: #d3dac3; /* sage hairline */
29
+ --cyan: #2c6e49; /* ACCENT β€” pine green (deep, AA-friendly) */
30
+ --cyan-dim: #245a3b;
31
+ --on-accent: #fbfdf8; /* label on the accent β€” near-white (dark accent β†’ ~6:1 AA) */
32
+ --violet: #6b7a55; /* SECONDARY β€” moss green (branch lines) */
33
+ --text: #1e251c; /* deep green-black ink */
34
+ --text-dim: #5d6b54;
35
+ --label: #5a7048; /* moss-green section labels */
36
+ --scroll: #c8d3b8;
37
+ --scroll-hi: #b2c29c;
38
+ --gutter: #9aa888;
39
+ /* Editor syntax tokens β€” green family (pine / gold / olive / moss) */
40
+ --tok-keyword: #2c6e49; /* pine */
41
+ --tok-fn: #876618; /* dark gold */
42
+ --tok-string: #66722f; /* olive */
43
+ --tok-number: #9e521f; /* burnt orange */
44
+ --tok-comment: #8c9a7e; /* sage gray */
45
+ --tok-prop: #6f7d52; /* moss */
46
+ --tok-punct: #5d6b54;
47
+ --tok-var: #1e251c;
48
+ --tok-invalid: #b5362a;
49
+ --mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
50
+ --sans: 'Hanken Grotesk', system-ui, -apple-system, sans-serif;
51
+ --display: 'Fraunces', Georgia, 'Times New Roman', serif; /* serif for the wordmark */
52
+ }
53
+
54
+ /* ---- Forest charcoal (dark counterpart, via toggle): same green identity ---- */
55
+ body.dark {
56
+ --bg: #121711;
57
+ --bg-glow: #1f271d;
58
+ --bg-panel: #1a211a;
59
+ --bg-inset: #0e130d;
60
+ --border: #2c372a;
61
+ --cyan: #3f9e63; /* pine lifted for dark */
62
+ --cyan-dim: #348352;
63
+ --on-accent: #122012; /* label on the accent β€” dark ink (light accent β†’ ~5:1 AA) */
64
+ --violet: #8fa274; /* moss lifted */
65
+ --text: #e7eede; /* green cream */
66
+ --text-dim: #9cac8e;
67
+ --label: #9fc98a;
68
+ --scroll: #34402f;
69
+ --scroll-hi: #45543d;
70
+ --gutter: #59674a;
71
+ --tok-keyword: #5fb37e;
72
+ --tok-fn: #d6a85e;
73
+ --tok-string: #b8c47a;
74
+ --tok-number: #e0a86a;
75
+ --tok-comment: #7e8b70;
76
+ --tok-prop: #9fb381;
77
+ --tok-punct: #9cac8e;
78
+ --tok-var: #e7eede;
79
+ --tok-invalid: #ff6b6b;
80
+ }
81
+
82
+ /* ============================================================
83
+ RUST & SIENNA β€” fallback palette (the previous warm theme).
84
+ Activated by adding `rust` to <body> (see the PALETTE flag in JS).
85
+ Same var names, so everything that reads var(--cyan)/etc. β€” incl.
86
+ the color-mix focus rings β€” follows automatically; the Mermaid
87
+ diagram is handled by the matching MERMAID_THEMES.rust entry.
88
+ ============================================================ */
89
+ body.rust {
90
+ --bg: #f6ece0;
91
+ --bg-glow: #f0e3d2;
92
+ --bg-panel: #fdf6ec;
93
+ --bg-inset: #efe2d2;
94
+ --border: #e0cdb6;
95
+ --cyan: #b0532e;
96
+ --cyan-dim: #95421f;
97
+ --on-accent: #fffaf0; /* dark accent β†’ near-white label (~4.8 AA) */
98
+ --violet: #9c6f4f;
99
+ --text: #2a201a;
100
+ --text-dim: #75614f;
101
+ --label: #9c5a33;
102
+ --scroll: #d6c4a8;
103
+ --scroll-hi: #c2ab88;
104
+ --gutter: #ad9d7e;
105
+ --tok-keyword: #a8472a;
106
+ --tok-fn: #876618;
107
+ --tok-string: #66722f;
108
+ --tok-number: #9e521f;
109
+ --tok-comment: #a4937b;
110
+ --tok-prop: #92633a;
111
+ --tok-punct: #75614f;
112
+ --tok-var: #2a201a;
113
+ --tok-invalid: #b5362a;
114
+ }
115
+ /* Rust dark β€” full var set so it wins over green's body.dark on toggle */
116
+ body.rust.dark {
117
+ --bg: #1b1512;
118
+ --bg-glow: #29201a;
119
+ --bg-panel: #231b16;
120
+ --bg-inset: #16100c;
121
+ --border: #392d24;
122
+ --cyan: #d56a3c;
123
+ --cyan-dim: #b85730;
124
+ --on-accent: #1e1408; /* light accent β†’ dark ink label (~4.8 AA) */
125
+ --violet: #b3855f;
126
+ --text: #f0e6d6;
127
+ --text-dim: #b3a489;
128
+ --label: #d98a5a;
129
+ --scroll: #443629;
130
+ --scroll-hi: #594636;
131
+ --gutter: #6c5b47;
132
+ --tok-keyword: #e07a52;
133
+ --tok-fn: #d6a85e;
134
+ --tok-string: #b8c47a;
135
+ --tok-number: #e0a86a;
136
+ --tok-comment: #8f8169;
137
+ --tok-prop: #d39a6e;
138
+ --tok-punct: #b3a489;
139
+ --tok-var: #f0e6d6;
140
+ --tok-invalid: #ff6b6b;
141
+ }
142
+
143
+ * { box-sizing: border-box; }
144
+
145
+ body {
146
+ font-family: var(--sans);
147
+ background: radial-gradient(1200px 600px at 50% -10%, var(--bg-glow) 0%, var(--bg) 55%);
148
+ color: var(--text);
149
+ margin: 0;
150
+ padding: 24px 28px 28px;
151
+ min-height: 100vh;
152
+ transition: background .25s, color .25s;
153
+ }
154
+
155
+ /* ---- Header ---- */
156
+ header {
157
+ display: flex;
158
+ align-items: center;
159
+ gap: 14px;
160
+ margin-bottom: 22px;
161
+ }
162
+ /* Standalone "decision node" mark β€” no badge; inherits the accent (so it
163
+ flips to amber in dark mode). The svg uses currentColor. */
164
+ .logo-mark {
165
+ display: grid; place-items: center;
166
+ color: var(--cyan);
167
+ }
168
+ .logo-mark svg { width: 34px; height: 34px; }
169
+ .wordmark { font-family: var(--display); font-size: 25px; font-weight: 600; letter-spacing: -0.2px; }
170
+ .wordmark .flow { color: var(--cyan); }
171
+ /* Boxed instruction callout β€” accent left-bar draws the eye to the "how to" */
172
+ .tagline {
173
+ color: var(--text);
174
+ font-size: 12.5px;
175
+ font-weight: 500;
176
+ margin-left: 10px;
177
+ padding: 8px 14px;
178
+ background: var(--bg-panel);
179
+ border: 1px solid var(--border);
180
+ border-left: 3px solid var(--cyan);
181
+ border-radius: 9px;
182
+ box-shadow: 0 1px 2px rgba(30, 45, 22, .08);
183
+ }
184
+ .tagline b { color: var(--cyan); font-weight: 700; }
185
+
186
+ /* ---- Theme toggle (far right) ---- */
187
+ .theme-toggle {
188
+ margin-left: auto;
189
+ width: 38px; height: 38px;
190
+ display: grid; place-items: center;
191
+ color: var(--text-dim);
192
+ background: var(--bg-panel);
193
+ border: 1px solid var(--border);
194
+ border-radius: 10px;
195
+ cursor: pointer;
196
+ transition: color .15s, border-color .15s;
197
+ }
198
+ .theme-toggle:hover { color: var(--cyan); border-color: var(--cyan); }
199
+ .theme-toggle svg { width: 18px; height: 18px; }
200
+ /* Show moon in dark mode, sun in light mode */
201
+ .theme-toggle .icon-moon { display: none; } /* default = light β†’ show sun */
202
+ body.dark .theme-toggle .icon-sun { display: none; }
203
+ body.dark .theme-toggle .icon-moon { display: block; }
204
+
205
+ /* ---- Flow cue between the panels ---- */
206
+ .flow-arrow {
207
+ flex: 0 0 auto;
208
+ align-self: center;
209
+ display: grid; place-items: center;
210
+ color: var(--cyan);
211
+ opacity: .7;
212
+ }
213
+ .flow-arrow svg { width: 26px; height: 26px; }
214
+
215
+ /* ---- Layout ---- */
216
+ .container {
217
+ display: flex;
218
+ gap: 22px;
219
+ height: calc(100vh - 110px);
220
+ }
221
+ .panel {
222
+ flex: 1;
223
+ min-width: 0;
224
+ display: flex;
225
+ flex-direction: column;
226
+ background: var(--bg-panel);
227
+ border: 1px solid var(--border);
228
+ border-radius: 14px;
229
+ padding: 18px;
230
+ }
231
+ /* Give the diagram more room than the code input so charts read bigger */
232
+ .panel-diagram { flex: 1.5; position: relative; }
233
+ .panel-head {
234
+ display: flex;
235
+ align-items: center;
236
+ justify-content: space-between;
237
+ margin-bottom: 14px;
238
+ gap: 12px;
239
+ }
240
+ .panel-title {
241
+ font-size: 13px;
242
+ font-weight: 600;
243
+ text-transform: uppercase;
244
+ letter-spacing: 1.2px;
245
+ color: var(--label);
246
+ }
247
+
248
+ /* Subtle "scroll" hint β€” only shown when the chart overflows its box */
249
+ .scroll-hint {
250
+ position: absolute;
251
+ bottom: 28px;
252
+ left: 50%;
253
+ transform: translateX(-50%);
254
+ display: flex;
255
+ align-items: center;
256
+ gap: 6px;
257
+ background: var(--bg-panel);
258
+ backdrop-filter: blur(4px);
259
+ border: 1px solid var(--border);
260
+ color: var(--text-dim);
261
+ font-size: 11.5px;
262
+ font-weight: 500;
263
+ padding: 5px 12px;
264
+ border-radius: 999px;
265
+ pointer-events: none;
266
+ box-shadow: 0 4px 14px rgba(0, 0, 0, .3);
267
+ }
268
+ .scroll-hint[hidden] { display: none; }
269
+ .scroll-hint svg { width: 13px; height: 13px; stroke: var(--cyan); }
270
+
271
+ /* ---- Zoom controls (bottom-left of the canvas) ---- */
272
+ .zoom-ctrl {
273
+ position: absolute;
274
+ bottom: 28px;
275
+ left: 28px;
276
+ display: flex;
277
+ align-items: stretch;
278
+ gap: 1px;
279
+ background: var(--bg-panel);
280
+ border: 1px solid var(--border);
281
+ border-radius: 9px;
282
+ overflow: hidden;
283
+ box-shadow: 0 4px 14px rgba(0, 0, 0, .25);
284
+ }
285
+ .zoom-ctrl[hidden] { display: none; }
286
+ .zoom-btn {
287
+ font-family: var(--sans);
288
+ font-size: 14px;
289
+ font-weight: 600;
290
+ color: var(--text-dim);
291
+ background: transparent;
292
+ border: none;
293
+ min-width: 30px;
294
+ padding: 5px 8px;
295
+ cursor: pointer;
296
+ transition: color .12s, background .12s;
297
+ }
298
+ .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); }
299
+ .zoom-btn:hover { color: var(--cyan); background: var(--bg-inset); }
300
+
301
+ /* ---- Examples dropdown ---- */
302
+ .examples {
303
+ position: relative;
304
+ }
305
+ select {
306
+ font-family: var(--sans);
307
+ font-size: 13px;
308
+ font-weight: 500;
309
+ color: var(--text);
310
+ background: var(--bg-inset);
311
+ border: 1px solid var(--border);
312
+ border-radius: 8px;
313
+ padding: 8px 32px 8px 12px;
314
+ cursor: pointer;
315
+ appearance: none;
316
+ -webkit-appearance: none;
317
+ /* Chevron: warm neutral, theme-aware via the body.dark override below */
318
+ 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>");
319
+ background-repeat: no-repeat;
320
+ background-position: right 12px center;
321
+ transition: border-color .15s;
322
+ }
323
+ select:hover, select:focus { border-color: var(--cyan); outline: none; }
324
+ /* Dark-mode chevron (lighter neutral on the charcoal field) */
325
+ body.dark select {
326
+ 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>");
327
+ }
328
+
329
+ /* ---- Code input ---- */
330
+ textarea {
331
+ flex: 1;
332
+ background: var(--bg-inset);
333
+ color: var(--cyan);
334
+ border: 1px solid var(--border);
335
+ border-radius: 10px;
336
+ padding: 14px;
337
+ font-family: var(--mono);
338
+ font-size: 13.5px;
339
+ line-height: 1.6;
340
+ resize: none;
341
+ tab-size: 4;
342
+ }
343
+ textarea:focus { outline: none; border-color: var(--cyan); box-shadow: 0 0 0 3px color-mix(in srgb, var(--cyan) 22%, transparent); }
344
+ textarea::placeholder { color: #5a5a8a; }
345
+
346
+ /* ---- Editor controls (language + examples) ---- */
347
+ .editor-controls { display: flex; align-items: center; gap: 8px; }
348
+ select.ctrl-select { padding: 7px 28px 7px 10px; font-size: 12.5px; }
349
+
350
+ /* ---- Code editor (CodeMirror) wrapper ---- */
351
+ .editor-wrap {
352
+ flex: 1;
353
+ min-height: 0;
354
+ display: flex;
355
+ flex-direction: column;
356
+ background: var(--bg-inset);
357
+ border: 1px solid var(--border);
358
+ border-radius: 10px;
359
+ overflow: hidden;
360
+ transition: border-color .15s, box-shadow .15s;
361
+ }
362
+ .editor-wrap[hidden] { display: none; } /* author display:flex would else beat the hidden attr */
363
+ .editor-wrap.focused { border-color: var(--cyan); box-shadow: 0 0 0 3px color-mix(in srgb, var(--cyan) 22%, transparent); }
364
+ #editor { flex: 1; min-height: 0; overflow: hidden; }
365
+ .cm-editor { height: 100%; background: transparent; font-size: 13.5px; }
366
+ .cm-editor.cm-focused { outline: none; }
367
+ .cm-editor .cm-scroller {
368
+ font-family: var(--mono);
369
+ line-height: 1.6;
370
+ overflow: auto;
371
+ scrollbar-width: thin;
372
+ scrollbar-color: var(--scroll) var(--bg-inset);
373
+ }
374
+ .cm-editor .cm-scroller::-webkit-scrollbar { width: 10px; height: 10px; }
375
+ .cm-editor .cm-scroller::-webkit-scrollbar-track { background: var(--bg-inset); border-radius: 999px; }
376
+ .cm-editor .cm-scroller::-webkit-scrollbar-thumb {
377
+ background: var(--scroll); border-radius: 999px; border: 2px solid var(--bg-inset);
378
+ }
379
+ .cm-editor .cm-scroller::-webkit-scrollbar-thumb:hover { background: var(--scroll-hi); }
380
+
381
+ /* Editor footer β€” VS Code-style status bar: position (left) + actions (right) */
382
+ .editor-foot {
383
+ display: flex;
384
+ align-items: center;
385
+ justify-content: space-between;
386
+ padding: 6px 10px;
387
+ border-top: 1px solid var(--border);
388
+ background: var(--bg-panel);
389
+ }
390
+ .editor-status { font-family: var(--mono); font-size: 11.5px; color: var(--text-dim); }
391
+ .editor-actions { display: flex; gap: 6px; }
392
+ .icon-btn {
393
+ font-family: var(--sans);
394
+ font-size: 11.5px;
395
+ font-weight: 600;
396
+ color: var(--text-dim);
397
+ background: var(--bg-inset);
398
+ border: 1px solid var(--border);
399
+ border-radius: 7px;
400
+ padding: 5px 11px;
401
+ cursor: pointer;
402
+ transition: color .12s, border-color .12s;
403
+ }
404
+ .icon-btn:hover:not(:disabled) { color: var(--cyan); border-color: var(--cyan); }
405
+ .icon-btn.ok { color: var(--cyan); border-color: var(--cyan); }
406
+ .icon-btn:disabled { opacity: .4; cursor: not-allowed; }
407
+
408
+ /* ---- Bottom action bar (source actions + Generate) ---- */
409
+ .action-bar {
410
+ display: flex;
411
+ align-items: center;
412
+ justify-content: space-between;
413
+ gap: 10px;
414
+ margin-top: 14px;
415
+ }
416
+
417
+ /* ---- Generate button (compact primary, aligned with the controls) ---- */
418
+ .btn-primary {
419
+ font-family: var(--sans);
420
+ font-size: 13.5px;
421
+ font-weight: 600;
422
+ color: var(--on-accent); /* label color picked by accent luminance (AA in every palette/theme) */
423
+ background: var(--cyan); /* flat accent β€” no gradient */
424
+ border: none;
425
+ padding: 9px 18px;
426
+ cursor: pointer;
427
+ border-radius: 9px;
428
+ transition: filter .15s, transform .05s;
429
+ }
430
+ .btn-primary:hover { filter: brightness(1.08); }
431
+ .btn-primary:active { transform: translateY(1px); }
432
+ .btn-primary:disabled {
433
+ background: var(--border);
434
+ color: var(--text-dim);
435
+ cursor: not-allowed;
436
+ filter: none;
437
+ }
438
+
439
+ /* ---- Output actions (Copy Mermaid / SVG / PNG) in the diagram header ---- */
440
+ .output-actions { display: flex; gap: 6px; }
441
+
442
+ /* ---- Progress bar ---- */
443
+ .progress {
444
+ height: 5px;
445
+ background: var(--bg-inset);
446
+ border: 1px solid var(--border);
447
+ border-radius: 999px;
448
+ overflow: hidden;
449
+ margin-bottom: 12px;
450
+ }
451
+ .progress[hidden] { display: none; }
452
+ .progress-fill {
453
+ height: 100%;
454
+ width: 0%;
455
+ background: var(--cyan); /* flat terracotta β€” no gradient, no glow */
456
+ border-radius: 999px;
457
+ }
458
+ /* Live path: duration unknown β†’ honest indeterminate sweep */
459
+ .progress-fill.indeterminate {
460
+ width: 35%;
461
+ animation: cf-sweep 1.15s ease-in-out infinite;
462
+ }
463
+ @keyframes cf-sweep {
464
+ 0% { margin-left: -35%; }
465
+ 100% { margin-left: 100%; }
466
+ }
467
+
468
+ /* ---- Flowchart canvas (bigger) ---- */
469
+ #flowchart-target {
470
+ flex: 1;
471
+ background: var(--bg-inset); /* plain canvas β€” grid removed */
472
+ border: 1px solid var(--border);
473
+ border-radius: 10px;
474
+ padding: 18px;
475
+ overflow: auto;
476
+ display: flex;
477
+ justify-content: safe center; /* 'safe' β†’ no clipping when zoomed past the edge */
478
+ align-items: flex-start; /* tall charts scroll from the top */
479
+ transition: background .25s ease, border-color .25s ease; /* ease the canvas on theme toggle (#9) */
480
+ }
481
+ /* ---- Custom scrollbar (muted) β€” shared by the chart box + code input ---- */
482
+ #flowchart-target, textarea {
483
+ scrollbar-width: thin; /* Firefox */
484
+ scrollbar-color: var(--scroll) var(--bg-inset);
485
+ }
486
+ #flowchart-target::-webkit-scrollbar,
487
+ textarea::-webkit-scrollbar { width: 10px; height: 10px; }
488
+ #flowchart-target::-webkit-scrollbar-track,
489
+ textarea::-webkit-scrollbar-track {
490
+ background: var(--bg-inset);
491
+ border-radius: 999px;
492
+ }
493
+ #flowchart-target::-webkit-scrollbar-thumb,
494
+ textarea::-webkit-scrollbar-thumb {
495
+ background: var(--scroll);
496
+ border-radius: 999px;
497
+ border: 2px solid var(--bg-inset);
498
+ }
499
+ #flowchart-target::-webkit-scrollbar-thumb:hover,
500
+ textarea::-webkit-scrollbar-thumb:hover { background: var(--scroll-hi); }
501
+ #flowchart-target::-webkit-scrollbar-corner,
502
+ textarea::-webkit-scrollbar-corner { background: var(--bg-inset); }
503
+ /* Render the SVG at full panel width and let long charts grow + scroll
504
+ instead of being shrunk to fit (which made them hard to read). */
505
+ #flowchart-target svg {
506
+ width: calc(100% * var(--zoom, 1)) !important; /* zoom scales width; height follows */
507
+ max-width: none !important; /* override Mermaid's inline max-width so zoom can grow it */
508
+ height: auto !important;
509
+ min-height: 360px;
510
+ flex: 0 0 auto;
511
+ }
512
+ /* Empty state: the lone Start anchor renders small + centred β€” not stretched
513
+ to fill the canvas. A DEFINITE height makes SVG sizing deterministic (with
514
+ just width:auto an SVG falls back to the ~300px default and scales UP); the
515
+ width then follows the viewBox aspect ratio. */
516
+ #flowchart-target.anchor-only { align-items: center; }
517
+ #flowchart-target.anchor-only svg {
518
+ width: auto !important;
519
+ height: 56px !important;
520
+ min-height: 0 !important;
521
+ max-width: none !important;
522
+ }
523
+ /* Bold + glow the node's cyan edge when hovered */
524
+ #flowchart-target .node rect,
525
+ #flowchart-target .node polygon,
526
+ #flowchart-target .node circle,
527
+ #flowchart-target .node path {
528
+ transition: stroke .12s ease, stroke-width .12s ease, filter .12s ease;
529
+ }
530
+ #flowchart-target .node:hover rect,
531
+ #flowchart-target .node:hover polygon,
532
+ #flowchart-target .node:hover circle,
533
+ #flowchart-target .node:hover path,
534
+ /* Code→node link: emphasise nodes whose source range holds the cursor line. */
535
+ #flowchart-target .node.cf-active rect,
536
+ #flowchart-target .node.cf-active polygon,
537
+ #flowchart-target .node.cf-active circle,
538
+ #flowchart-target .node.cf-active path {
539
+ stroke: var(--cyan) !important;
540
+ stroke-width: 3px !important;
541
+ filter: drop-shadow(0 0 5px color-mix(in srgb, var(--cyan) 40%, transparent));
542
+ }
543
+
544
+ /* ===== Node restyle β€” warm "paper card" flowchart aesthetic ===== */
545
+ /* Soft lift on every node so they read as little cards on the canvas */
546
+ #flowchart-target .node { filter: drop-shadow(0 2px 5px rgba(30, 45, 22, .12)); }
547
+
548
+ /* ===== Diagram-reveal animation (#1) β€” "Trace the path" ===== */
549
+ /* Scale each node from its own centre (SVG <g> needs an explicit box). */
550
+ #flowchart-target .node { transform-box: fill-box; transform-origin: 50% 50%; }
551
+ /* Anti-flash while a new chart mounts: hide everything WAAPI will animate in,
552
+ except the Start anchor (.cf-start), which stays put as the chart grows out of it. */
553
+ #flowchart-target.revealing .node:not(.cf-start),
554
+ #flowchart-target.revealing g.edgePaths path,
555
+ #flowchart-target.revealing .edgeLabel { opacity: 0; }
556
+ /* Process nodes (rect): rounded warm-white cards, hairline border */
557
+ #flowchart-target .node rect {
558
+ rx: 9px; ry: 9px;
559
+ fill: var(--bg-panel);
560
+ stroke: var(--border);
561
+ stroke-width: 1.5px;
562
+ }
563
+ /* Decision nodes (diamond/polygon): sand fill + accent edge so they pop */
564
+ #flowchart-target .node polygon {
565
+ fill: var(--bg-inset);
566
+ stroke: var(--cyan);
567
+ stroke-width: 1.75px;
568
+ }
569
+ /* Node text: UI font, inked, medium weight */
570
+ #flowchart-target .nodeLabel,
571
+ #flowchart-target .node .label,
572
+ #flowchart-target .node foreignObject div {
573
+ font-family: var(--sans) !important;
574
+ font-weight: 500;
575
+ color: var(--text) !important;
576
+ fill: var(--text);
577
+ }
578
+ /* Edges: warm-brown, a touch thicker; refined arrowheads */
579
+ #flowchart-target .edgePath path.path,
580
+ #flowchart-target .flowchart-link {
581
+ stroke: var(--violet);
582
+ stroke-width: 1.8px;
583
+ }
584
+ #flowchart-target marker path,
585
+ #flowchart-target .arrowMarkerPath,
586
+ #flowchart-target .arrowheadPath { fill: var(--violet) !important; stroke: none; }
587
+ /* Edge labels (Yes / No / True / False) β†’ centered warm pill chips.
588
+ Mermaid sizes the foreignObject to the BARE text, so we let it overflow
589
+ and recenter the padded chip by half its added padding+border (-10,-3). */
590
+ #flowchart-target .edgeLabel foreignObject { overflow: visible; }
591
+ #flowchart-target .edgeLabel foreignObject > div { transform: translate(-10px, -3px); }
592
+ #flowchart-target .edgeLabel span {
593
+ display: inline-block;
594
+ font-family: var(--sans) !important;
595
+ font-size: 11px !important;
596
+ font-weight: 600 !important;
597
+ line-height: 1;
598
+ color: var(--text-dim) !important;
599
+ background: var(--bg-panel) !important;
600
+ border: 1px solid var(--border);
601
+ border-radius: 999px;
602
+ padding: 2px 9px;
603
+ }
604
+
605
+ #flowchart-target .placeholder { color: var(--text-dim); font-size: 14px; }
606
+ #flowchart-target .err { color: var(--tok-invalid); align-self: flex-start; }
607
+ #flowchart-target .raw {
608
+ color: var(--text-dim); font-family: var(--mono); font-size: 12px;
609
+ white-space: pre-wrap; text-align: left; align-self: flex-start; width: 100%;
610
+ }
611
+ </style>
612
+ </head>
613
+ <body>
614
+ <header>
615
+ <div class="logo-mark">
616
+ <!-- Decision node (C4): FILLED diamond "decision" forking to two HOLLOW outcome nodes -->
617
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
618
+ <path d="M12 3 L15.4 6.5 L12 10 L8.6 6.5 Z" fill="currentColor"/>
619
+ <path d="M10.3 8.4 L7 15.8"/>
620
+ <path d="M13.7 8.4 L17 15.8"/>
621
+ <circle cx="6" cy="18" r="2.7"/>
622
+ <circle cx="18" cy="18" r="2.7"/>
623
+ </svg>
624
+ </div>
625
+ <div>
626
+ <div class="wordmark">Code<span class="flow">Flow</span></div>
627
+ </div>
628
+ <span class="tagline">Paste your code on the left, hit <b>Generate</b>, and read its logic as a flowchart β†’</span>
629
+ <button id="theme-toggle" class="theme-toggle" type="button" title="Toggle light / dark" aria-label="Toggle light / dark">
630
+ <svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
631
+ <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>
632
+ </svg>
633
+ <svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
634
+ <circle cx="12" cy="12" r="4"/>
635
+ <path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>
636
+ </svg>
637
+ </button>
638
+ </header>
639
+
640
+ <div class="container">
641
+ <!-- LEFT: input -->
642
+ <div class="panel">
643
+ <div class="panel-head">
644
+ <span class="panel-title">Source Code</span>
645
+ <div class="editor-controls">
646
+ <select id="lang-select" class="ctrl-select" title="Editor language" aria-label="Editor language">
647
+ <option value="python" selected>Python</option>
648
+ <option value="javascript">JavaScript</option>
649
+ <option value="java">Java</option>
650
+ <option value="cpp">C / C++</option>
651
+ </select>
652
+ <select id="examples-select">
653
+ <option value="" disabled selected>Code examples…</option>
654
+ <option value="loop">Sum a list of numbers</option>
655
+ <option value="ifelse">Check status (if / else)</option>
656
+ <option value="func">Find the first even number</option>
657
+ </select>
658
+ </div>
659
+ </div>
660
+ <!-- CodeMirror mounts into #editor and reveals .editor-wrap; the
661
+ textarea below is the always-present fallback if CM can't load. -->
662
+ <div id="editor-wrap" class="editor-wrap" hidden>
663
+ <div id="editor"></div>
664
+ <div class="editor-foot">
665
+ <span id="editor-status" class="editor-status">Ln 1, Col 1 Β· 0 lines</span>
666
+ </div>
667
+ </div>
668
+ <textarea id="code-fallback" placeholder="Paste your code here, or pick a code example…" spellcheck="false"></textarea>
669
+ <!-- Always-visible (outside editor-wrap, so it survives the textarea fallback) -->
670
+ <div class="action-bar">
671
+ <div class="editor-actions">
672
+ <button id="copy-btn" class="icon-btn" type="button">Copy</button>
673
+ <button id="clear-btn" class="icon-btn" type="button">Clear</button>
674
+ </div>
675
+ <button id="submit-btn" class="btn-primary" type="button">Generate Flowchart</button>
676
+ </div>
677
+ </div>
678
+
679
+ <!-- Visual cue: code flows left β†’ right into the diagram -->
680
+ <div class="flow-arrow" aria-hidden="true">
681
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
682
+ <path d="M4 12h14M13 6l6 6-6 6"/>
683
+ </svg>
684
+ </div>
685
+
686
+ <!-- RIGHT: diagram -->
687
+ <div class="panel panel-diagram">
688
+ <div class="panel-head">
689
+ <span class="panel-title">Flowchart</span>
690
+ <div class="output-actions">
691
+ <button id="copy-mmd-btn" class="icon-btn" type="button" disabled title="Copy the Mermaid source">Copy Mermaid</button>
692
+ <button id="svg-btn" class="icon-btn" type="button" disabled title="Download as SVG">SVG</button>
693
+ <button id="png-btn" class="icon-btn" type="button" disabled title="Download as PNG">PNG</button>
694
+ </div>
695
+ </div>
696
+ <div id="progress" class="progress" hidden><div id="progress-fill" class="progress-fill"></div></div>
697
+ <div id="flowchart-target">
698
+ <span class="placeholder">Loading…</span>
699
+ </div>
700
+ <div id="scroll-hint" class="scroll-hint" hidden>
701
+ <svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
702
+ <path d="M8 7l4-4 4 4M8 17l4 4 4-4"/>
703
+ </svg>
704
+ Scroll to view full chart
705
+ </div>
706
+ <div id="zoom-ctrl" class="zoom-ctrl" hidden>
707
+ <button id="zoom-out" class="zoom-btn" type="button" title="Zoom out" aria-label="Zoom out">βˆ’</button>
708
+ <button id="zoom-fit" class="zoom-btn zoom-fit" type="button" title="Fit to width">fit</button>
709
+ <button id="zoom-in" class="zoom-btn" type="button" title="Zoom in" aria-label="Zoom in">+</button>
710
+ </div>
711
+ </div>
712
+ </div>
713
+
714
+ <script type="module">
715
+ import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
716
+ // CodeMirror is imported *dynamically* inside initEditor() (not at the top
717
+ // level) so that any CM/CDN failure is contained to the editor and can't
718
+ // take down the rest of the page (examples, Generate). See initEditor().
719
+
720
+ // Per-palette, per-theme Mermaid palettes. Keyed [PALETTE][theme] so the
721
+ // diagram tracks whichever palette is active (see the PALETTE flag below).
722
+ const MERMAID_THEMES = {
723
+ green: {
724
+ light: { background: '#e2e7d6', primaryColor: '#f9fbf4', primaryTextColor: '#1e251c',
725
+ primaryBorderColor: '#2c6e49', lineColor: '#6b7a55', tertiaryColor: '#eef2e2',
726
+ edgeLabelBackground: '#edf0e6' },
727
+ dark: { background: '#0e130d', primaryColor: '#1a211a', primaryTextColor: '#e7eede',
728
+ primaryBorderColor: '#3f9e63', lineColor: '#8fa274', tertiaryColor: '#1f271d',
729
+ edgeLabelBackground: '#1a211a' },
730
+ },
731
+ rust: {
732
+ light: { background: '#efe2d2', primaryColor: '#fdf6ec', primaryTextColor: '#2a201a',
733
+ primaryBorderColor: '#b0532e', lineColor: '#9c6f4f', tertiaryColor: '#f3e8d6',
734
+ edgeLabelBackground: '#f6ece0' },
735
+ dark: { background: '#16100c', primaryColor: '#231b16', primaryTextColor: '#f0e6d6',
736
+ primaryBorderColor: '#d56a3c', lineColor: '#b3855f', tertiaryColor: '#29201a',
737
+ edgeLabelBackground: '#231b16' },
738
+ },
739
+ };
740
+ function initMermaid(themeName) {
741
+ mermaid.initialize({
742
+ startOnLoad: false,
743
+ theme: 'base',
744
+ // Tighter node padding + spacing so decision diamonds hug their text (#8).
745
+ flowchart: { padding: 6, nodeSpacing: 45, rankSpacing: 45, useMaxWidth: true },
746
+ themeVariables: { fontFamily: "'Hanken Grotesk', sans-serif", ...MERMAID_THEMES[PALETTE][themeName] },
747
+ });
748
+ }
749
+
750
+ // ── Active palette (quick switch back to the old warm theme) ──────
751
+ // Flip this ONE word to swap the whole UI: 'green' (Pine & Sage, the
752
+ // current default) or 'rust' (Rust & Sienna, the previous warm theme).
753
+ // It just toggles a `rust` body class β€” every var(--cyan)/etc., the
754
+ // color-mix focus rings, and the matching Mermaid palette all follow.
755
+ const PALETTE = 'green';
756
+ document.body.classList.toggle('rust', PALETTE === 'rust');
757
+
758
+ // Theme state β€” default = light; toggle β†’ forest charcoal. Persisted.
759
+ let currentTheme = 'light';
760
+ try { if (localStorage.getItem('cf-theme-warm') === 'dark') currentTheme = 'dark'; } catch {}
761
+ function applyTheme(name) {
762
+ currentTheme = name;
763
+ document.body.classList.toggle('dark', name === 'dark');
764
+ initMermaid(name);
765
+ try { localStorage.setItem('cf-theme-warm', name); } catch {}
766
+ }
767
+ applyTheme(currentTheme); // sets body class + initializes Mermaid
768
+
769
+ /* ----- Pre-rendered presets -----
770
+ Each preset stores BOTH the Python source and its already-computed
771
+ Mermaid, so selecting one paints the diagram instantly β€” no model
772
+ call. This is the "perception of fast inference" demo path and is
773
+ also what makes this page fully previewable offline. */
774
+ const PRESETS = {
775
+ loop: {
776
+ code:
777
+ `total = 0
778
+ for n in numbers:
779
+ total += n
780
+ print(total)`,
781
+ mermaid:
782
+ `graph TD
783
+ A["Start"] --> B["Initialise total to zero"]
784
+ B --> C{"More numbers to process?"}
785
+ C -- Yes --> D["Add current number to total"]
786
+ D --> C
787
+ C -- No --> E["Print total"]
788
+ E --> F["End"]`,
789
+ linemap: { B: [1, 1], C: [2, 2], D: [3, 3], E: [4, 4] }
790
+ },
791
+ ifelse: {
792
+ code:
793
+ `def check_status(val):
794
+ if val > 10:
795
+ return "Active"
796
+ else:
797
+ return "Inactive"`,
798
+ mermaid:
799
+ `graph TD
800
+ A["Start: check_status"] --> B{"Value greater than ten?"}
801
+ B -- True --> C["Return Active"]
802
+ B -- False --> D["Return Inactive"]
803
+ C --> E["End"]
804
+ D --> E`,
805
+ linemap: { A: [1, 1], B: [2, 2], C: [3, 3], D: [5, 5] }
806
+ },
807
+ func: {
808
+ code:
809
+ `def find_first_even(nums):
810
+ for i in range(len(nums)):
811
+ if nums[i] % 2 == 0:
812
+ return nums[i]
813
+ return None`,
814
+ mermaid:
815
+ `graph TD
816
+ A["Start: find_first_even"] --> B{"More elements to check?"}
817
+ B -- Yes --> C{"Element is even?"}
818
+ C -- Yes --> D["Return that element"]
819
+ C -- No --> B
820
+ B -- No --> E["Return None"]
821
+ D --> F["End"]
822
+ E --> F`,
823
+ linemap: { A: [1, 1], B: [2, 2], C: [3, 3], D: [4, 4], E: [5, 5] }
824
+ }
825
+ };
826
+
827
+ const target = document.getElementById('flowchart-target');
828
+ const submitBtn = document.getElementById('submit-btn');
829
+ const select = document.getElementById('examples-select');
830
+ const progress = document.getElementById('progress');
831
+ const progressFill = document.getElementById('progress-fill');
832
+ const scrollHint = document.getElementById('scroll-hint');
833
+ const langSelect = document.getElementById('lang-select');
834
+ const copyBtn = document.getElementById('copy-btn');
835
+ const clearBtn = document.getElementById('clear-btn');
836
+ const fallback = document.getElementById('code-fallback');
837
+ const editorWrap = document.getElementById('editor-wrap');
838
+ const editorStatus = document.getElementById('editor-status');
839
+ const copyMmdBtn = document.getElementById('copy-mmd-btn');
840
+ const svgBtn = document.getElementById('svg-btn');
841
+ const pngBtn = document.getElementById('png-btn');
842
+ const themeToggle = document.getElementById('theme-toggle');
843
+ const zoomCtrl = document.getElementById('zoom-ctrl');
844
+ const zoomInBtn = document.getElementById('zoom-in');
845
+ const zoomOutBtn = document.getElementById('zoom-out');
846
+ const zoomFitBtn = document.getElementById('zoom-fit');
847
+
848
+ /* ===== Real code editor (CodeMirror 6) =====
849
+ The plain <textarea> (#code-fallback) renders by default; CodeMirror
850
+ hides it only once it mounts. So a blocked CDN / CM failure degrades to
851
+ a working textarea instead of a broken page. getCode()/setCode() below
852
+ hide which input is live, so the preset + submit logic never branches. */
853
+
854
+ // Layered ?deps= chain so every package shares ONE @codemirror/state +
855
+ // @codemirror/view + @lezer/highlight instance β€” otherwise CM's facets
856
+ // don't match across packages and it throws, and token tags from the
857
+ // grammars don't match our HighlightStyle so colors never apply. We pin
858
+ // the @6 / @1 majors (not micro-versions) so esm.sh resolves one mutually
859
+ // compatible current set. (Verified in a headless Chrome harness.)
860
+ const D = {
861
+ state: "@codemirror/state@6",
862
+ view: "@codemirror/state@6,@codemirror/view@6",
863
+ lang: "@codemirror/state@6,@codemirror/view@6,@codemirror/language@6,@lezer/highlight@1",
864
+ };
865
+
866
+ // Lazy grammar loaders β€” only the picked language's parser is fetched.
867
+ const LANG_LOADERS = {
868
+ python: () => import("https://esm.sh/@codemirror/lang-python@6?deps=" + D.lang).then(m => m.python()),
869
+ javascript: () => import("https://esm.sh/@codemirror/lang-javascript@6?deps=" + D.lang).then(m => m.javascript()),
870
+ java: () => import("https://esm.sh/@codemirror/lang-java@6?deps=" + D.lang).then(m => m.java()),
871
+ cpp: () => import("https://esm.sh/@codemirror/lang-cpp@6?deps=" + D.lang).then(m => m.cpp()),
872
+ };
873
+ async function makeLangExt(name) {
874
+ try { return await LANG_LOADERS[name](); } catch { return []; }
875
+ }
876
+
877
+ let cmView = null;
878
+ let lastLineMap = {}; // nodeId -> [startLine, endLine] for the current chart
879
+ let cfHighlightLines = () => {}; // set once CM mounts: highlight editor lines (node→code hover)
880
+ let cfSelectLines = () => {}; // set once CM mounts: select + focus a line range (node→code edit)
881
+ let langCompartment = null;
882
+
883
+ function getCode() {
884
+ return cmView ? cmView.state.doc.toString() : fallback.value;
885
+ }
886
+ function setCode(text) {
887
+ if (cmView) cmView.dispatch({ changes: { from: 0, to: cmView.state.doc.length, insert: text } });
888
+ else fallback.value = text;
889
+ updateStatus();
890
+ }
891
+ function focusEditor() { (cmView || fallback).focus(); }
892
+
893
+ function updateStatus() {
894
+ const doc = getCode();
895
+ const lines = doc === '' ? 0 : doc.split('\n').length;
896
+ let ln = 1, col = 1;
897
+ if (cmView) {
898
+ const pos = cmView.state.selection.main.head;
899
+ const line = cmView.state.doc.lineAt(pos);
900
+ ln = line.number;
901
+ col = pos - line.from + 1;
902
+ }
903
+ editorStatus.textContent = `Ln ${ln}, Col ${col} Β· ${lines} line${lines === 1 ? '' : 's'}`;
904
+ }
905
+
906
+ async function setLanguage(name) {
907
+ if (cmView && langCompartment) {
908
+ cmView.dispatch({ effects: langCompartment.reconfigure(await makeLangExt(name)) });
909
+ }
910
+ }
911
+
912
+ async function initEditor() {
913
+ try {
914
+ // Dynamic imports: contained here so a CM/CDN failure can't kill
915
+ // the rest of the page (mermaid, examples, Generate all still run).
916
+ // We assemble our own editor setup from the individual packages
917
+ // rather than the `codemirror` meta-package, which only exposes a
918
+ // `default` export under ?deps= (so basicSetup comes back undefined).
919
+ const [stateMod, viewMod, commandsMod, languageMod, lezerMod] = await Promise.all([
920
+ import("https://esm.sh/@codemirror/state@6"),
921
+ import("https://esm.sh/@codemirror/view@6?deps=" + D.state),
922
+ import("https://esm.sh/@codemirror/commands@6?deps=" + D.view),
923
+ import("https://esm.sh/@codemirror/language@6?deps=" + D.lang),
924
+ import("https://esm.sh/@lezer/highlight@1"),
925
+ ]);
926
+ const { EditorState, Compartment, StateField, StateEffect } = stateMod;
927
+ const { EditorView, keymap, placeholder, lineNumbers, highlightActiveLine,
928
+ highlightActiveLineGutter, drawSelection, dropCursor, Decoration } = viewMod;
929
+ const { history, defaultKeymap, historyKeymap, indentWithTab } = commandsMod;
930
+ const { indentOnInput, bracketMatching, syntaxHighlighting, HighlightStyle } = languageMod;
931
+ const tg = lezerMod.tags;
932
+
933
+ // Lean "basic setup" β€” line numbers, active line, history/undo,
934
+ // indent-on-input, bracket matching. (No autocomplete/search/lint;
935
+ // this is a code-input box, not a full IDE.)
936
+ const setup = [
937
+ lineNumbers(), highlightActiveLineGutter(), highlightActiveLine(),
938
+ drawSelection(), dropCursor(), history(), indentOnInput(), bracketMatching(),
939
+ keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
940
+ ];
941
+
942
+ // Indigo / cyan / violet token theme so highlighting matches the UI.
943
+ // Token colors reference CSS vars (--tok-*) so they flip with the theme.
944
+ const cfHighlight = HighlightStyle.define([
945
+ { tag: [tg.keyword, tg.controlKeyword, tg.operatorKeyword, tg.modifier,
946
+ tg.definitionKeyword, tg.moduleKeyword], color: 'var(--tok-keyword)' },
947
+ { tag: [tg.function(tg.variableName), tg.function(tg.propertyName)], color: 'var(--tok-fn)' },
948
+ { tag: [tg.className, tg.typeName], color: 'var(--tok-fn)' },
949
+ { tag: [tg.string, tg.special(tg.string)], color: 'var(--tok-string)' },
950
+ { tag: [tg.number, tg.bool, tg.atom], color: 'var(--tok-number)' },
951
+ { tag: [tg.comment, tg.lineComment, tg.blockComment], color: 'var(--tok-comment)', fontStyle: 'italic' },
952
+ { tag: [tg.propertyName], color: 'var(--tok-prop)' },
953
+ { tag: [tg.operator, tg.punctuation, tg.bracket], color: 'var(--tok-punct)' },
954
+ { tag: [tg.variableName], color: 'var(--tok-var)' },
955
+ { tag: [tg.invalid], color: 'var(--tok-invalid)' },
956
+ ]);
957
+ const cfTheme = EditorView.theme({
958
+ '&': { color: 'var(--text)', backgroundColor: 'transparent', height: '100%' },
959
+ '.cm-content': { caretColor: 'var(--cyan)', padding: '12px 0' },
960
+ '.cm-cursor, .cm-dropCursor': { borderLeftColor: 'var(--cyan)' },
961
+ '&.cm-focused .cm-selectionBackground, .cm-selectionBackground': { backgroundColor: 'color-mix(in srgb, var(--cyan) 16%, transparent)' },
962
+ '.cm-gutters': { backgroundColor: 'transparent', color: 'var(--gutter)', border: 'none' },
963
+ '.cm-lineNumbers .cm-gutterElement': { padding: '0 8px 0 14px' },
964
+ '.cm-activeLineGutter': { backgroundColor: 'rgba(150,110,60,.07)', color: 'var(--text-dim)' },
965
+ '.cm-activeLine': { backgroundColor: 'rgba(150,110,60,.05)' },
966
+ '.cm-matchingBracket': { backgroundColor: 'rgba(156,111,79,.28)', outline: '1px solid var(--violet)' },
967
+ '.cm-line.cf-linked': { backgroundColor: 'color-mix(in srgb, var(--cyan) 14%, transparent)' },
968
+ });
969
+
970
+ // Node→code highlight: a line-decoration field toggled by an effect.
971
+ const setHL = StateEffect.define();
972
+ const lineDeco = Decoration.line({ class: 'cf-linked' });
973
+ const hlField = StateField.define({
974
+ create: () => Decoration.none,
975
+ update(deco, tr) {
976
+ deco = deco.map(tr.changes);
977
+ for (const e of tr.effects) if (e.is(setHL)) {
978
+ if (!e.value) { deco = Decoration.none; continue; }
979
+ const { a, b } = e.value, ranges = [];
980
+ for (let n = a; n <= b; n++)
981
+ if (n >= 1 && n <= tr.state.doc.lines)
982
+ ranges.push(lineDeco.range(tr.state.doc.line(n).from));
983
+ deco = Decoration.set(ranges, true);
984
+ }
985
+ return deco;
986
+ },
987
+ provide: f => EditorView.decorations.from(f),
988
+ });
989
+
990
+ langCompartment = new Compartment();
991
+ cmView = new EditorView({
992
+ parent: document.getElementById('editor'),
993
+ state: EditorState.create({
994
+ doc: fallback.value || '',
995
+ extensions: [
996
+ setup,
997
+ langCompartment.of(await makeLangExt(langSelect.value)),
998
+ keymap.of([indentWithTab]),
999
+ placeholder("Paste your code here, or pick a code example…"),
1000
+ syntaxHighlighting(cfHighlight),
1001
+ cfTheme,
1002
+ hlField,
1003
+ EditorView.updateListener.of(u => {
1004
+ if (u.selectionSet || u.docChanged) updateStatus();
1005
+ if (u.focusChanged) editorWrap.classList.toggle('focused', u.view.hasFocus);
1006
+ if (u.selectionSet || u.docChanged) highlightNodesForCursor(u.view);
1007
+ }),
1008
+ ],
1009
+ }),
1010
+ });
1011
+ // Expose the highlight/select ops to the node→code handlers.
1012
+ cfHighlightLines = (a, b) => {
1013
+ if (!a) { cmView.dispatch({ effects: setHL.of(null) }); return; }
1014
+ const n = Math.min(Math.max(1, a), cmView.state.doc.lines);
1015
+ cmView.dispatch({ effects: [setHL.of({ a, b }),
1016
+ EditorView.scrollIntoView(cmView.state.doc.line(n).from, { y: 'center' })] });
1017
+ };
1018
+ cfSelectLines = (a, b) => {
1019
+ const doc = cmView.state.doc;
1020
+ a = Math.min(Math.max(1, a), doc.lines); b = Math.min(Math.max(1, b), doc.lines);
1021
+ cmView.dispatch({ selection: { anchor: doc.line(a).from, head: doc.line(b).to }, scrollIntoView: true });
1022
+ cmView.focus();
1023
+ };
1024
+
1025
+ fallback.hidden = true;
1026
+ editorWrap.hidden = false;
1027
+ updateStatus();
1028
+ } catch (e) {
1029
+ // CM unavailable β†’ leave the (already-visible) textarea as the editor.
1030
+ cmView = null;
1031
+ console.warn('CodeMirror unavailable, using textarea fallback:', e);
1032
+ }
1033
+ }
1034
+
1035
+ // Brief "Copied"/"Saved" confirmation on a button, then restore its label.
1036
+ function flashOk(btn, msg) {
1037
+ const orig = btn.dataset.orig || (btn.dataset.orig = btn.textContent);
1038
+ btn.textContent = msg;
1039
+ btn.classList.add('ok');
1040
+ setTimeout(() => { btn.textContent = orig; btn.classList.remove('ok'); }, 1200);
1041
+ }
1042
+
1043
+ langSelect.addEventListener('change', () => setLanguage(langSelect.value));
1044
+ copyBtn.addEventListener('click', async () => {
1045
+ const text = getCode();
1046
+ if (!text) return;
1047
+ try { await navigator.clipboard.writeText(text); flashOk(copyBtn, 'Copied'); } catch {}
1048
+ });
1049
+ clearBtn.addEventListener('click', () => { setCode(''); focusEditor(); });
1050
+
1051
+ /* ===== Diagram export (Copy Mermaid / SVG / PNG) =====
1052
+ These act on the most recent render. `lastMermaid` holds the source
1053
+ even on a parse error (so it stays copyable); SVG/PNG need a real svg. */
1054
+ let lastMermaid = '';
1055
+
1056
+ function refreshExportButtons() {
1057
+ // A real generated chart (lastMermaid set), not the empty-state Start anchor.
1058
+ const hasSvg = !!lastMermaid && !!target.querySelector('svg');
1059
+ copyMmdBtn.disabled = !lastMermaid;
1060
+ svgBtn.disabled = !hasSvg;
1061
+ pngBtn.disabled = !hasSvg;
1062
+ zoomCtrl.hidden = !hasSvg; // zoom controls only make sense with a chart
1063
+ }
1064
+
1065
+ /* ===== Zoom (#3) β€” scales the SVG width via the --zoom custom property;
1066
+ overflow + 'safe center' let the user scroll/pan a zoomed-in chart. ===== */
1067
+ let zoom = 1;
1068
+ const ZOOM_MIN = 0.4, ZOOM_MAX = 3, ZOOM_STEP = 1.2;
1069
+ function applyZoom() {
1070
+ target.style.setProperty('--zoom', zoom.toFixed(3));
1071
+ updateScrollHint();
1072
+ }
1073
+ function setZoom(z) { zoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z)); applyZoom(); }
1074
+ zoomInBtn.addEventListener('click', () => setZoom(zoom * ZOOM_STEP));
1075
+ zoomOutBtn.addEventListener('click', () => setZoom(zoom / ZOOM_STEP));
1076
+ zoomFitBtn.addEventListener('click', () => setZoom(1));
1077
+
1078
+ /* ===== Light / dark toggle (#9) β€” with a diagram crossfade ===== */
1079
+ themeToggle.addEventListener('click', async () => {
1080
+ const next = currentTheme === 'light' ? 'dark' : 'light';
1081
+ const svg = (lastMermaid && target.querySelector('svg')) ? target.querySelector('svg') : null;
1082
+ if (svg && !prefersReduced) {
1083
+ // Fade the current chart out FIRST so the palette swap + re-render
1084
+ // (Mermaid bakes some colors in, so a re-render is required) happen
1085
+ // unseen β€” then fade the re-themed chart back in. Fade-through, not a
1086
+ // dual-SVG dissolve: our node colors are CSS-var-driven, so a cloned
1087
+ // "old" SVG would instantly re-resolve to the new theme.
1088
+ await svg.animate([{ opacity: 1 }, { opacity: 0 }],
1089
+ { duration: 150, easing: 'ease', fill: 'forwards' }).finished;
1090
+ applyTheme(next); // swap vars + re-init Mermaid while invisible
1091
+ await renderMermaid(lastMermaid); // re-themed chart, mounted at full opacity
1092
+ const fresh = target.querySelector('svg');
1093
+ if (fresh) fresh.animate([{ opacity: 0 }, { opacity: 1 }],
1094
+ { duration: 220, easing: 'ease', fill: 'both' });
1095
+ } else {
1096
+ applyTheme(next);
1097
+ if (lastMermaid && target.querySelector('svg')) await renderMermaid(lastMermaid);
1098
+ }
1099
+ });
1100
+
1101
+ function download(filename, blob) {
1102
+ const url = URL.createObjectURL(blob);
1103
+ const a = document.createElement('a');
1104
+ a.href = url;
1105
+ a.download = filename;
1106
+ a.click();
1107
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
1108
+ }
1109
+
1110
+ // Serialize the rendered SVG, namespaced so it opens standalone.
1111
+ function serializedSvg() {
1112
+ const svg = target.querySelector('svg');
1113
+ if (!svg) return null;
1114
+ const clone = svg.cloneNode(true);
1115
+ clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
1116
+ return { svg, data: new XMLSerializer().serializeToString(clone) };
1117
+ }
1118
+
1119
+ copyMmdBtn.addEventListener('click', async () => {
1120
+ if (!lastMermaid) return;
1121
+ try { await navigator.clipboard.writeText(lastMermaid); flashOk(copyMmdBtn, 'Copied'); } catch {}
1122
+ });
1123
+
1124
+ svgBtn.addEventListener('click', () => {
1125
+ const s = serializedSvg();
1126
+ if (!s) return;
1127
+ download('flowchart.svg', new Blob([s.data], { type: 'image/svg+xml;charset=utf-8' }));
1128
+ flashOk(svgBtn, 'Saved');
1129
+ });
1130
+
1131
+ pngBtn.addEventListener('click', () => {
1132
+ const s = serializedSvg();
1133
+ if (!s) return;
1134
+ // Natural pixel size from the viewBox (the on-screen svg is CSS-stretched
1135
+ // to 100%); render at 2Γ— for a crisp export on a solid canvas bg.
1136
+ const vb = s.svg.viewBox && s.svg.viewBox.baseVal;
1137
+ const w = (vb && vb.width) || s.svg.clientWidth || 800;
1138
+ const h = (vb && vb.height) || s.svg.clientHeight || 600;
1139
+ const scale = 2;
1140
+ const img = new Image();
1141
+ img.onload = () => {
1142
+ const canvas = document.createElement('canvas');
1143
+ canvas.width = Math.round(w * scale);
1144
+ canvas.height = Math.round(h * scale);
1145
+ const ctx = canvas.getContext('2d');
1146
+ // Solid canvas bg = the current theme's --bg-inset, so labels stay readable.
1147
+ ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--bg-inset').trim() || '#e2e7d6';
1148
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
1149
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
1150
+ canvas.toBlob(b => { if (b) { download('flowchart.png', b); flashOk(pngBtn, 'Saved'); } }, 'image/png');
1151
+ };
1152
+ img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(s.data);
1153
+ });
1154
+
1155
+ // Simulated inference time for the pre-rendered demo path. The bar
1156
+ // tracks this exactly and the chart is revealed at 100% β€” tune freely.
1157
+ const PRESET_DURATION_MS = 2000;
1158
+
1159
+ let renderSeq = 0; // guards against out-of-order async renders
1160
+
1161
+ // Show the "scroll" hint only when the chart overflows its box AND the
1162
+ // user isn't already scrolled to the bottom. Hides at the bottom,
1163
+ // reappears as soon as they scroll back up.
1164
+ function updateScrollHint() {
1165
+ const overflowing = target.scrollHeight > target.clientHeight + 4;
1166
+ const atBottom =
1167
+ target.scrollTop + target.clientHeight >= target.scrollHeight - 4;
1168
+ scrollHint.hidden = !(overflowing && !atBottom);
1169
+ }
1170
+ window.addEventListener('resize', updateScrollHint);
1171
+ target.addEventListener('scroll', updateScrollHint);
1172
+
1173
+ /* ===== Diagram-reveal animation (#1) β€” "Trace the path" + "Calm" =====
1174
+ A new chart draws itself in: the topmost node is the persistent anchor
1175
+ (it stays put), and everything else flows OUT of it top-to-bottom β€” nodes
1176
+ scale in, edges draw on (stroke dash), each arrowhead landing as its line
1177
+ arrives. Runs ONLY on a fresh Generate (not zoom / theme / re-layout) and
1178
+ is skipped under prefers-reduced-motion. */
1179
+ const REVEAL_STEP = 95; // ms between successive elements β€” "Calm"
1180
+ const prefersReduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
1181
+ let anchorNode = null; // topmost node β€” never animates
1182
+ let edgeMarkers = new Map();
1183
+ let revealToken = 0; // bumped per reveal so a stale scroll-follow loop self-cancels
1184
+ const yTop = el => el.getBoundingClientRect().top;
1185
+
1186
+ function animNodeIn(n, delay) {
1187
+ // Animate the INDIVIDUAL scale/translate props (not `transform`) so we
1188
+ // compose with Mermaid's positioning transform="translate(x,y)" attribute
1189
+ // instead of overriding it (which would pile every node in the corner).
1190
+ n.animate(
1191
+ [{ opacity: 0, scale: '.97', translate: '0 6px' }, { opacity: 1, scale: '1', translate: '0 0' }],
1192
+ { duration: 280, delay, easing: 'cubic-bezier(.2,.7,.3,1)', fill: 'both' }
1193
+ );
1194
+ }
1195
+ function animEdgeIn(e, delay) {
1196
+ const len = e.getTotalLength();
1197
+ e.style.strokeDasharray = len;
1198
+ const a = e.animate(
1199
+ [{ strokeDashoffset: len }, { strokeDashoffset: 0 }],
1200
+ { duration: 440, delay, easing: 'ease-in-out', fill: 'both' }
1201
+ );
1202
+ a.onfinish = () => { // land the arrowhead when the line arrives
1203
+ const m = edgeMarkers.get(e);
1204
+ if (m?.end) e.setAttribute('marker-end', m.end);
1205
+ if (m?.start) e.setAttribute('marker-start', m.start);
1206
+ e.style.strokeDashoffset = 0;
1207
+ };
1208
+ }
1209
+ function fadeIn(l, delay) {
1210
+ l.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 220, delay, easing: 'ease', fill: 'both' });
1211
+ }
1212
+ // Scroll the canvas to track the reveal front in REAL TIME. Rather than
1213
+ // chasing each node with a (laggy) smooth scrollTo, we run a rAF loop that
1214
+ // mirrors the WAAPI schedule: at elapsed time t the front is item t/STEP, so
1215
+ // we place that item's content-Y at ~50% of the viewport every frame. Because
1216
+ // the front advances smoothly, directly setting scrollTop reads as a smooth
1217
+ // glide that never falls behind. Monotonic-down; no-op when the chart fits.
1218
+ function startScrollFollow(items) {
1219
+ if (prefersReduced || items.length < 2) return;
1220
+ const myToken = ++revealToken;
1221
+ const containerTop = target.getBoundingClientRect().top;
1222
+ const ys = items.map(it => yTop(it.el) - containerTop); // content-Y at scrollTop 0
1223
+ const t0 = performance.now();
1224
+ const FRONT = 0.5; // keep the active element around mid-viewport
1225
+ function frame(now) {
1226
+ if (myToken !== revealToken) return; // superseded by a newer reveal
1227
+ const f = (now - t0) / REVEAL_STEP; // fractional front index
1228
+ const i0 = Math.min(items.length - 1, Math.max(0, Math.floor(f)));
1229
+ const i1 = Math.min(items.length - 1, i0 + 1);
1230
+ const frontY = ys[i0] + (ys[i1] - ys[i0]) * Math.min(1, Math.max(0, f - i0));
1231
+ const maxScroll = Math.max(0, target.scrollHeight - target.clientHeight);
1232
+ const desired = Math.min(maxScroll, Math.max(0, frontY - target.clientHeight * FRONT));
1233
+ if (desired > target.scrollTop) target.scrollTop = desired; // only follow downward
1234
+ if (f < items.length + 1) requestAnimationFrame(frame); // run just past the last item
1235
+ }
1236
+ requestAnimationFrame(frame);
1237
+ }
1238
+
1239
+ // The persistent empty state: a lone Start node, shown when there's no diagram.
1240
+ async function showStartAnchor() {
1241
+ try {
1242
+ const { svg } = await mermaid.render('cf-anchor-' + (++renderSeq), 'flowchart TD\n A([Start])');
1243
+ target.innerHTML = svg;
1244
+ const n = target.querySelector('.node');
1245
+ if (n) n.classList.add('cf-start');
1246
+ target.classList.remove('revealing');
1247
+ target.classList.add('anchor-only'); // render the anchor small, not stretched
1248
+ zoom = 1; applyZoom();
1249
+ } catch {
1250
+ target.innerHTML = '<span class="placeholder">Your flowchart will appear here.</span>';
1251
+ }
1252
+ lastMermaid = '';
1253
+ lastLineMap = {};
1254
+ refreshExportButtons();
1255
+ }
1256
+
1257
+ // After a fresh chart mounts (with the anchor already tagged + others hidden),
1258
+ // schedule the top-to-bottom reveal that flows out of the anchor node.
1259
+ function revealDiagram() {
1260
+ // Capture arrowhead markers before stripping them, so each restores on land
1261
+ // (survives the animation; the map keyed by element is stable).
1262
+ edgeMarkers = new Map();
1263
+ target.querySelectorAll('g.edgePaths path').forEach(e =>
1264
+ edgeMarkers.set(e, { end: e.getAttribute('marker-end'), start: e.getAttribute('marker-start') }));
1265
+
1266
+ const nodes = [...target.querySelectorAll('.node')].filter(n => n !== anchorNode);
1267
+ const edges = [...target.querySelectorAll('g.edgePaths path')];
1268
+ const labels = [...target.querySelectorAll('.edgeLabel')];
1269
+
1270
+ edges.forEach(e => {
1271
+ const len = e.getTotalLength();
1272
+ e.style.strokeDasharray = len;
1273
+ e.style.strokeDashoffset = len;
1274
+ e.removeAttribute('marker-end'); // hide arrowhead until the line lands
1275
+ e.removeAttribute('marker-start');
1276
+ });
1277
+ void target.getBoundingClientRect();
1278
+
1279
+ if (prefersReduced) { // accessibility: jump to final frame
1280
+ edges.forEach(e => {
1281
+ const m = edgeMarkers.get(e);
1282
+ if (m?.end) e.setAttribute('marker-end', m.end);
1283
+ if (m?.start) e.setAttribute('marker-start', m.start);
1284
+ e.style.strokeDashoffset = 0; e.style.strokeDasharray = 'none';
1285
+ });
1286
+ target.classList.remove('revealing');
1287
+ return;
1288
+ }
1289
+
1290
+ // One continuous top-to-bottom sweep flowing out of the anchor.
1291
+ target.scrollTop = 0; // start the trace from the top
1292
+ const items = [
1293
+ ...nodes.map(el => ({ el, t: 'n', y: yTop(el) })),
1294
+ ...edges.map(el => ({ el, t: 'e', y: yTop(el) })),
1295
+ ...labels.map(el => ({ el, t: 'l', y: yTop(el) })),
1296
+ ].sort((a, b) => a.y - b.y);
1297
+ items.forEach((it, i) => {
1298
+ const d = i * REVEAL_STEP;
1299
+ if (it.t === 'n') animNodeIn(it.el, d);
1300
+ else if (it.t === 'e') animEdgeIn(it.el, d);
1301
+ else fadeIn(it.el, d);
1302
+ });
1303
+
1304
+ // Animations now hold each element's hidden initial frame via fill:'both'
1305
+ // backwards-fill, so the anti-flash class is redundant β€” drop it (else its
1306
+ // opacity:0 keeps the EDGES invisible: they only animate the dash, not opacity).
1307
+ target.classList.remove('revealing');
1308
+
1309
+ // Scroll in lock-step with the reveal so the user watches it being drawn.
1310
+ startScrollFollow(items);
1311
+ }
1312
+
1313
+ /* ===== Node ↔ code linking (#1) β€” driven by lastLineMap (nodeId β†’ [a,b]) ===== */
1314
+ // Code β†’ node: emphasise the node(s) whose source range contains the cursor line.
1315
+ function highlightNodesForCursor(view) {
1316
+ target.querySelectorAll('.node.cf-active').forEach(n => n.classList.remove('cf-active'));
1317
+ const ids = Object.keys(lastLineMap);
1318
+ if (!ids.length) return;
1319
+ const ln = view.state.doc.lineAt(view.state.selection.main.head).number;
1320
+ for (const id of ids) {
1321
+ const [a, b] = lastLineMap[id];
1322
+ if (ln >= a && ln <= b) {
1323
+ const g = target.querySelector('.node[data-id="' + id + '"]');
1324
+ if (g) g.classList.add('cf-active');
1325
+ }
1326
+ }
1327
+ }
1328
+ // Node β†’ code: hover highlights the source lines; click selects them for editing.
1329
+ function wireNodeLinks() {
1330
+ if (!Object.keys(lastLineMap).length) return;
1331
+ target.querySelectorAll('.node[data-id]').forEach(g => {
1332
+ const range = lastLineMap[g.getAttribute('data-id')];
1333
+ if (!range) return;
1334
+ g.style.cursor = 'pointer';
1335
+ g.addEventListener('mouseenter', () => cfHighlightLines(range[0], range[1]));
1336
+ g.addEventListener('mouseleave', () => cfHighlightLines(0));
1337
+ g.addEventListener('click', () => cfSelectLines(range[0], range[1]));
1338
+ });
1339
+ }
1340
+
1341
+ async function renderMermaid(syntax, animate = false, linemap = undefined) {
1342
+ const id = 'cf-' + (++renderSeq);
1343
+ lastMermaid = syntax; // copyable even if the parse below fails
1344
+ if (linemap !== undefined) lastLineMap = linemap || {}; // undefined = keep (theme re-render)
1345
+ try {
1346
+ const { svg } = await mermaid.render(id, syntax);
1347
+ target.innerHTML = svg;
1348
+ target.classList.remove('anchor-only'); // a real chart fills the canvas
1349
+ wireNodeLinks(); // attach node→code hover/click (no-op without a map)
1350
+ zoom = 1; applyZoom(); // each fresh chart starts fit-to-width
1351
+ updateScrollHint();
1352
+ if (animate && !prefersReduced) {
1353
+ // Tag the anchor + hide the rest synchronously (no flash), then reveal
1354
+ // next frame once layout is ready for getTotalLength/getBBox.
1355
+ anchorNode = [...target.querySelectorAll('.node')].sort((a, b) => yTop(a) - yTop(b))[0] || null;
1356
+ if (anchorNode) anchorNode.classList.add('cf-start');
1357
+ target.classList.add('revealing');
1358
+ requestAnimationFrame(revealDiagram);
1359
+ } else {
1360
+ target.classList.remove('revealing'); // theme/zoom re-render: no animation
1361
+ }
1362
+ } catch (err) {
1363
+ target.classList.remove('revealing');
1364
+ lastLineMap = {}; // no chart β†’ no links
1365
+ scrollHint.hidden = true;
1366
+ target.innerHTML =
1367
+ '<div style="width:100%"><p class="err">Diagram failed to parse: ' +
1368
+ (err && err.message ? err.message : err) +
1369
+ '</p><p class="panel-title" style="text-align:left">Raw Mermaid output:</p></div>';
1370
+ const pre = document.createElement('pre');
1371
+ pre.className = 'raw';
1372
+ pre.textContent = syntax; // textContent: newline- and injection-safe
1373
+ target.appendChild(pre);
1374
+ }
1375
+ refreshExportButtons();
1376
+ }
1377
+
1378
+ // Example dropdown β†’ only fill the code box. The diagram waits for
1379
+ // the user to click Generate (so the chart appears with the bar).
1380
+ select.addEventListener('change', () => {
1381
+ const preset = PRESETS[select.value];
1382
+ if (!preset) return;
1383
+ setCode(preset.code);
1384
+ langSelect.value = 'python'; // all presets are Python
1385
+ setLanguage('python');
1386
+ showStartAnchor(); // keep the Start node on the canvas, waiting for Generate
1387
+ scrollHint.hidden = true;
1388
+ focusEditor();
1389
+ });
1390
+
1391
+ // Match the current code to a preset (so Generate can use its
1392
+ // pre-rendered Mermaid instead of calling the model).
1393
+ function findPreset(code) {
1394
+ const norm = s => s.trim();
1395
+ return Object.values(PRESETS).find(p => norm(p.code) === norm(code)) || null;
1396
+ }
1397
+
1398
+ // ----- Progress bar helpers -----
1399
+ // Determinate, accurate fill over `duration` ms; resolves at 100%.
1400
+ function runProgress(duration) {
1401
+ return new Promise(resolve => {
1402
+ progressFill.classList.remove('indeterminate');
1403
+ progressFill.style.marginLeft = '0';
1404
+ const start = performance.now();
1405
+ function tick(now) {
1406
+ const t = Math.min(1, (now - start) / duration);
1407
+ progressFill.style.width = (t * 100) + '%';
1408
+ if (t < 1) requestAnimationFrame(tick);
1409
+ else resolve();
1410
+ }
1411
+ requestAnimationFrame(tick);
1412
+ });
1413
+ }
1414
+ function startIndeterminate() {
1415
+ progressFill.style.width = '';
1416
+ progressFill.style.marginLeft = '';
1417
+ progressFill.classList.add('indeterminate');
1418
+ }
1419
+ function hideProgress() {
1420
+ progress.hidden = true;
1421
+ progressFill.classList.remove('indeterminate');
1422
+ progressFill.style.width = '0%';
1423
+ progressFill.style.marginLeft = '0';
1424
+ }
1425
+
1426
+ /* ----- Live generation (server only) -----
1427
+ The Gradio client is imported lazily on first click so this page
1428
+ still loads and the presets still work when opened directly as a
1429
+ file:// preview with no backend running. */
1430
+ let clientPromise = null;
1431
+ async function getClient() {
1432
+ if (!clientPromise) {
1433
+ const { Client } = await import("https://cdn.jsdelivr.net/npm/@gradio/client@1/dist/index.min.js");
1434
+ clientPromise = Client.connect(window.location.origin);
1435
+ }
1436
+ return clientPromise;
1437
+ }
1438
+
1439
+ submitBtn.addEventListener('click', async () => {
1440
+ const code = getCode();
1441
+ if (!code.trim()) {
1442
+ target.innerHTML = '<p class="err">Please input code first.</p>';
1443
+ scrollHint.hidden = true;
1444
+ return;
1445
+ }
1446
+
1447
+ const preset = findPreset(code);
1448
+
1449
+ submitBtn.disabled = true;
1450
+ submitBtn.textContent = "Generating…";
1451
+ await showStartAnchor(); // reset to the lone Start node β€” the chart will flow out of it
1452
+ scrollHint.hidden = true;
1453
+ progress.hidden = false;
1454
+ refreshExportButtons();
1455
+
1456
+ try {
1457
+ if (preset) {
1458
+ // Pre-rendered demo path: the bar accurately tracks the
1459
+ // (fixed) time until the chart is revealed at 100%.
1460
+ await runProgress(PRESET_DURATION_MS);
1461
+ await renderMermaid(preset.mermaid, true, preset.linemap || {});
1462
+ } else {
1463
+ // Live model path: duration is unknown, so show an honest
1464
+ // indeterminate sweep rather than a fake percentage.
1465
+ startIndeterminate();
1466
+ const client = await getClient();
1467
+ const result = await client.predict("/generate_flowchart", { src_code: code });
1468
+ // Backend returns { mermaid, linemap }; tolerate a bare string too.
1469
+ const out = result.data[0];
1470
+ const isObj = out && typeof out === 'object';
1471
+ await renderMermaid(isObj ? out.mermaid : out, true, (isObj && out.linemap) || {});
1472
+ }
1473
+ } catch (error) {
1474
+ target.innerHTML =
1475
+ '<p class="err">Live generation unavailable: ' +
1476
+ (error && error.message ? error.message : error) +
1477
+ '</p><p class="placeholder">Tip: pick a code example above to preview a diagram without the model.</p>';
1478
+ } finally {
1479
+ hideProgress();
1480
+ submitBtn.disabled = false;
1481
+ submitBtn.textContent = "Generate Flowchart";
1482
+ refreshExportButtons();
1483
+ }
1484
+ });
1485
+
1486
+ // Mount the real editor (falls back to the textarea on failure).
1487
+ initEditor();
1488
+ // Show the persistent Start node on the canvas (the empty state).
1489
+ showStartAnchor();
1490
+ </script>
1491
+ </body>
1492
+ </html>
smoke-test.sh ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Headless-Chrome smoke test for frontend.html.
3
+ #
4
+ # Two kinds of checks:
5
+ # β€’ STATIC β€” markup that's in the file regardless of rendering (buttons,
6
+ # controls). Grepped straight from frontend.html β†’ 100% reliable.
7
+ # β€’ DYNAMIC β€” proof the page actually rendered: CodeMirror mounted (its CDN/ESM
8
+ # imports ran), the editor wrap was revealed and the textarea fallback hidden.
9
+ # These need a real browser, so we render with headless Chrome.
10
+ #
11
+ # Only the dynamic half can flake (CDN timing), so the headless dump is retried
12
+ # until it's complete. Static checks never touch the browser.
13
+ #
14
+ # Usage: ./smoke-test.sh (exit 0 = pass, 1 = fail)
15
+ set -euo pipefail
16
+ cd "$(dirname "$0")"
17
+
18
+ FILE="frontend.html"
19
+ src="$(cat "$FILE")"
20
+
21
+ CHROME="${CHROME:-/Applications/Google Chrome.app/Contents/MacOS/Google Chrome}"
22
+ [ -x "$CHROME" ] || { echo "βœ— Chrome not found at: $CHROME (set \$CHROME)"; exit 1; }
23
+
24
+ # Retry the dump until it's a COMPLETE render (has </html> AND the CodeMirror
25
+ # mount), up to 5 attempts β€” guards against partial/pre-render dumps.
26
+ dom=""
27
+ for attempt in 1 2 3 4 5; do
28
+ dom="$("$CHROME" --headless --disable-gpu --no-sandbox \
29
+ --virtual-time-budget=25000 \
30
+ --dump-dom "file://$PWD/$FILE" 2>/dev/null)"
31
+ if grep -q '</html>' <<<"$dom" && grep -q 'class="cm-editor' <<<"$dom"; then
32
+ break
33
+ fi
34
+ [ "$attempt" -lt 5 ] && { echo "… incomplete render (attempt $attempt), retrying"; sleep 1; }
35
+ done
36
+
37
+ fail=0
38
+ chk() { # chk "<haystack>" "<label>" "<grep -E pattern>" "<want: yes|no>"
39
+ # here-string (not echo|grep): with `set -o pipefail`, grep -q matching closes
40
+ # the pipe early β†’ echo gets SIGPIPE β†’ the pipeline falsely reports failure.
41
+ if grep -qE "$3" <<<"$1"; then found=yes; else found=no; fi
42
+ if [ "$found" = "$4" ]; then echo "βœ“ $2"; else echo "βœ— $2 (wanted match=$4, got=$found)"; fail=1; fi
43
+ }
44
+ static() { chk "$src" "$1" "$2" "$3"; } # grep the source file
45
+ dynamic(){ chk "$dom" "$1" "$2" "$3"; } # grep the rendered DOM
46
+
47
+ echo "β€” dynamic (needs a real render) β€”"
48
+ dynamic "editor mounted (.cm-editor present)" 'class="cm-editor' yes
49
+ dynamic "line-number gutter rendered" 'class="cm-gutterElement' yes
50
+ dynamic "editor-wrap revealed by JS (not hidden)" '<div id="editor-wrap" class="editor-wrap">' yes
51
+ dynamic "textarea fallback hidden by JS" '<textarea id="code-fallback"[^>]*hidden' yes
52
+
53
+ echo "β€” static (markup in the file) β€”"
54
+ static "language selector present" 'id="lang-select"' yes
55
+ static "Copy / Clear buttons present" 'id="copy-btn".*|id="clear-btn"' yes
56
+ static "Generate is compact (in action bar)" '<button id="submit-btn" class="btn-primary"' yes
57
+ static "output toolbar present (Copy Mermaid)" 'id="copy-mmd-btn"' yes
58
+ static "SVG / PNG export buttons present" 'id="svg-btn".*|id="png-btn"' yes
59
+ static "export buttons disabled until a render" '<button id="svg-btn"[^>]*disabled' yes
60
+ static "theme toggle present" 'id="theme-toggle"' yes
61
+ static "zoom controls present" 'id="zoom-ctrl"' yes
62
+ static "flow-arrow cue present" 'class="flow-arrow"' yes
63
+
64
+ echo
65
+ [ "$fail" -eq 0 ] && echo "PASS" || echo "FAIL"
66
+ exit "$fail"