Spaces:
Running
Running
Commit Β·
42bbd85
1
Parent(s): 320c295
Completed HTML frontend
Browse files- .gitignore +1 -0
- README.md +9 -1
- app.py +84 -116
- frontend.html +1492 -0
- 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
|
| 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
|
| 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) ->
|
| 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
|
| 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 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 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.
|
| 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":
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
# Quote-wrap each node label and escape any leaked code characters
|
| 108 |
-
|
| 109 |
|
| 110 |
-
return
|
| 111 |
|
| 112 |
# ----- Custom Frontend ----- #
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 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('[', '[').replace(']', ']')
|
| 41 |
+
.replace('{', '{').replace('}', '}'))
|
| 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"
|