bedrock-flows 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/auth-schema.sql +8 -0
  2. package/bin/bedrock-flows.mjs +127 -0
  3. package/lib/setup.mjs +262 -0
  4. package/package.json +11 -0
  5. package/template/.storybook/main.js +46 -0
  6. package/template/.storybook/manager-head.html +963 -0
  7. package/template/.storybook/preview-head.html +35 -0
  8. package/template/.storybook/preview.js +23 -0
  9. package/template/CHANGELOG.md +236 -0
  10. package/template/README.md +26 -0
  11. package/template/apps/dashboard/index.html +15 -0
  12. package/template/apps/dashboard/package.json +22 -0
  13. package/template/apps/dashboard/src/App.module.css +1318 -0
  14. package/template/apps/dashboard/src/App.tsx +2716 -0
  15. package/template/apps/dashboard/src/auth-client.ts +17 -0
  16. package/template/apps/dashboard/src/changelog.tsx +92 -0
  17. package/template/apps/dashboard/src/index.css +86 -0
  18. package/template/apps/dashboard/src/main.tsx +15 -0
  19. package/template/apps/dashboard/src/theme.ts +31 -0
  20. package/template/apps/dashboard/src/vite-env.d.ts +4 -0
  21. package/template/apps/dashboard/vite.config.ts +48 -0
  22. package/template/apps/worker/.dev.vars.example +50 -0
  23. package/template/apps/worker/package.json +19 -0
  24. package/template/apps/worker/src/index.ts +295 -0
  25. package/template/apps/worker/tsconfig.json +11 -0
  26. package/template/apps/worker/wrangler.jsonc +29 -0
  27. package/template/bedrock.config.ts +16 -0
  28. package/template/design-system/README.md +97 -0
  29. package/template/design-system/starter-v1/components/button/component.css +42 -0
  30. package/template/design-system/starter-v1/components/button/danger.html +21 -0
  31. package/template/design-system/starter-v1/components/button/default.html +21 -0
  32. package/template/design-system/starter-v1/components/button/disabled.html +21 -0
  33. package/template/design-system/starter-v1/components/button/ghost.html +21 -0
  34. package/template/design-system/starter-v1/components/button/macro.njk +14 -0
  35. package/template/design-system/starter-v1/components/button/primary.html +21 -0
  36. package/template/design-system/starter-v1/components/button/variants.json +30 -0
  37. package/template/design-system/starter-v1/ds.json +3 -0
  38. package/template/design-system/starter-v1/global.css +52 -0
  39. package/template/design-system/starter-v1/style.css +107 -0
  40. package/template/gitignore +8 -0
  41. package/template/package.json +41 -0
  42. package/template/prototypes/F-001-hello/1-welcome.njk +30 -0
  43. package/template/prototypes/F-001-hello/2-form.njk +46 -0
  44. package/template/prototypes/F-001-hello/3-done.njk +29 -0
  45. package/template/prototypes/F-001-hello/meta.json +6 -0
  46. package/template/prototypes/_shared/_auth-gate.njk +54 -0
  47. package/template/prototypes/_shared/delivery.njk +43 -0
  48. package/template/prototypes/_shared/layout.njk +15 -0
  49. package/template/prototypes/_shared/screen.njk +1818 -0
  50. package/template/prototypes/_shared/wireflow.njk +4731 -0
  51. package/template/public/auth-gate.css +150 -0
  52. package/template/public/bedrock/color-inspector.js +284 -0
  53. package/template/public/bedrock/component-overlay.js +219 -0
  54. package/template/public/bedrock/data/bedrock-config.js +45 -0
  55. package/template/public/bedrock/font-size-overlay.js +590 -0
  56. package/template/public/bedrock/grid-overlay.js +379 -0
  57. package/template/public/bedrock/prototype-navigation.js +974 -0
  58. package/template/public/cmdk.js +146 -0
  59. package/template/public/ds-xray.css +112 -0
  60. package/template/public/ds-xray.js +271 -0
  61. package/template/public/favicon.svg +4 -0
  62. package/template/public/icons/bolt-fill.svg +3 -0
  63. package/template/public/icons/bolt.svg +3 -0
  64. package/template/public/icons/caret-down-fill.svg +3 -0
  65. package/template/public/icons/check-double.svg +4 -0
  66. package/template/public/icons/check.svg +3 -0
  67. package/template/public/icons/chevron-left.svg +3 -0
  68. package/template/public/icons/chevron-right.svg +3 -0
  69. package/template/public/icons/circle-info.svg +6 -0
  70. package/template/public/icons/grid.svg +6 -0
  71. package/template/public/icons/message-square-1.svg +3 -0
  72. package/template/public/icons/message-square.svg +3 -0
  73. package/template/public/icons/messages.svg +4 -0
  74. package/template/public/icons/options-horizontal.svg +5 -0
  75. package/template/public/icons/swatches.svg +6 -0
  76. package/template/public/icons/workflow.svg +6 -0
  77. package/template/public/lightbox.js +87 -0
  78. package/template/public/proto-chrome.css +596 -0
  79. package/template/public/screen-comments.css +723 -0
  80. package/template/public/wireflow-client.js +26 -0
  81. package/template/scripts/build-storybooks.mjs +8 -0
  82. package/template/scripts/dev-setup.mjs +15 -0
  83. package/template/scripts/generate-stories.mjs +12 -0
  84. package/template/scripts/generate-variants.mjs +22 -0
  85. package/template/tsconfig.base.json +19 -0
@@ -0,0 +1,4731 @@
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" />
6
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
+ <title>{{ meta.name }} — wireflow</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;900&display=swap" />
11
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Sharp:opsz,wght,FILL,GRAD@24,400,0..1,0&display=swap" />
12
+ {# Shared auth-gate card styling — single source across dashboard/wireflow/screen. #}
13
+ <link rel="stylesheet" href="/auth-gate.css" />
14
+ <script>
15
+ // Apply the saved theme before first paint (shared key with the
16
+ // dashboard) so there's no flash of the system theme on override.
17
+ try {
18
+ var t = localStorage.getItem('bedrockTheme');
19
+ if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
20
+ } catch (e) {}
21
+ // `d` toggles the chrome between light and dark (same persisted
22
+ // override the user menu writes). Skipped in editable contexts.
23
+ document.addEventListener('keydown', function (e) {
24
+ if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
25
+ var el = e.target;
26
+ if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable)) return;
27
+ if (e.key !== 'd' && e.key !== 'D') return;
28
+ e.preventDefault();
29
+ var html = document.documentElement;
30
+ var cur = html.getAttribute('data-theme') ||
31
+ (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
32
+ var next = cur === 'dark' ? 'light' : 'dark';
33
+ html.setAttribute('data-theme', next);
34
+ try { localStorage.setItem('bedrockTheme', next); } catch (err) {}
35
+ });
36
+ </script>
37
+ <style>
38
+ :root {
39
+ --slate-50: #f8fafc;
40
+ --slate-100: #f1f5f9;
41
+ --slate-200: #e2e8f0;
42
+ --slate-300: #cbd5e1;
43
+ --slate-500: #64748b;
44
+ --slate-600: #475569;
45
+ --slate-700: #334155;
46
+ --slate-900: #0f172a;
47
+ --brand: #2563eb;
48
+ --success: #10b981;
49
+
50
+ /* Wireflow chrome tokens — re-mapped under dark mode below. The actual
51
+ prototype iframes (the "content") never read these, so they stay in
52
+ their own light DS palette regardless of the user's system theme. */
53
+ --wf-page-bg: var(--slate-100);
54
+ --wf-text: var(--slate-700);
55
+ --wf-text-strong: var(--slate-900);
56
+ --wf-text-muted: var(--slate-500);
57
+ --wf-surface: #fff;
58
+ --wf-surface-alt: var(--slate-50);
59
+ --wf-border: var(--slate-200);
60
+ --wf-border-strong: var(--slate-300);
61
+ --wf-canvas-bg: #E7EDF3;
62
+ --wf-canvas-dot: var(--slate-300);
63
+ --wf-frame-border: var(--slate-300);
64
+ }
65
+ /* Dark tokens apply when the OS prefers dark (unless the user forced
66
+ light), OR when the user explicitly forced dark from the user menu.
67
+ data-theme is set on <html> by the early inline script + the topbar
68
+ toggle below; the localStorage key is shared with the dashboard. */
69
+ @media (prefers-color-scheme: dark) {
70
+ :root:not([data-theme='light']) {
71
+ --wf-page-bg: #0b1220;
72
+ --wf-text: #cbd5e1;
73
+ --wf-text-strong: #f1f5f9;
74
+ --wf-text-muted: #94a3b8;
75
+ --wf-surface: #1e293b;
76
+ --wf-surface-alt: #0f172a;
77
+ --wf-border: #334155;
78
+ --wf-border-strong: #475569;
79
+ --wf-canvas-bg: #0f172a;
80
+ --wf-canvas-dot: #334155;
81
+ --wf-frame-border: #475569;
82
+ /* Subtle, low-contrast toolbar surfaces for dark mode — the regular
83
+ --wf-surface (#1e293b) reads as a bright cream against the very
84
+ dark #0f172a canvas. Translucent off-white blends them in. */
85
+ --wf-toolbar-bg: rgba(255, 255, 255, 0.04);
86
+ --wf-toolbar-bg-hover: rgba(255, 255, 255, 0.08);
87
+ --wf-toolbar-border: rgba(255, 255, 255, 0.10);
88
+ }
89
+ }
90
+ :root {
91
+ --wf-toolbar-bg: var(--wf-surface);
92
+ --wf-toolbar-bg-hover: var(--wf-surface-alt);
93
+ --wf-toolbar-border: var(--wf-border-strong);
94
+ }
95
+ :root[data-theme='dark'] {
96
+ --wf-page-bg: #0b1220;
97
+ --wf-text: #cbd5e1;
98
+ --wf-text-strong: #f1f5f9;
99
+ --wf-text-muted: #94a3b8;
100
+ --wf-surface: #1e293b;
101
+ --wf-surface-alt: #0f172a;
102
+ --wf-border: #334155;
103
+ --wf-border-strong: #475569;
104
+ --wf-canvas-bg: #0f172a;
105
+ --wf-canvas-dot: #334155;
106
+ --wf-frame-border: #475569;
107
+ --wf-toolbar-bg: rgba(255, 255, 255, 0.04);
108
+ --wf-toolbar-bg-hover: rgba(255, 255, 255, 0.08);
109
+ --wf-toolbar-border: rgba(255, 255, 255, 0.10);
110
+ }
111
+ * { box-sizing: border-box; }
112
+ html, body { margin: 0; padding: 0; height: 100%; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: var(--wf-text); background: var(--wf-page-bg); }
113
+ .topbar {
114
+ display: flex; align-items: center; justify-content: space-between;
115
+ padding: 10px 20px; background: var(--wf-surface); border-bottom: 1px solid var(--wf-border);
116
+ /* Topbar establishes its own stacking context so the user-menu
117
+ dropdown paints above the comments sidebar (z-index 50) and any
118
+ other in-canvas overlay below the auth gate. */
119
+ position: relative; z-index: 60;
120
+ }
121
+ .topbar__left { display: flex; align-items: center; gap: 16px; min-width: 0; }
122
+ .topbar__actions { display: flex; align-items: center; gap: 12px; }
123
+ .topbar__back {
124
+ display: inline-flex; align-items: center;
125
+ font-size: 13px; color: var(--wf-text-muted); text-decoration: none;
126
+ white-space: nowrap;
127
+ }
128
+ .topbar__back:hover { color: var(--wf-text-strong); }
129
+ .topbar__ds {
130
+ display: inline-flex; align-items: center; gap: 6px;
131
+ padding: 4px 10px; border-radius: 999px;
132
+ background: var(--wf-surface-alt); border: 1px solid var(--wf-border);
133
+ color: var(--wf-text); text-decoration: none;
134
+ font-size: 12px; font-weight: 500;
135
+ }
136
+ .topbar__ds:hover { background: var(--wf-surface); color: var(--wf-text-strong); border-color: var(--wf-border-strong); }
137
+ .topbar__ds-label { color: var(--wf-text-muted); font-weight: 400; }
138
+ .topbar__ds-version { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
139
+ .topbar__ds-arrow { color: var(--wf-text-muted); font-size: 11px; }
140
+ /* References dropdown — what this flow/set is built from. Native
141
+ <details> so no JS; summary reuses the .topbar__ds pill look. */
142
+ .topbar__refs { position: relative; }
143
+ .topbar__refs > summary { list-style: none; cursor: pointer; }
144
+ .topbar__refs > summary::-webkit-details-marker { display: none; }
145
+ .topbar__refs[open] > summary { background: var(--wf-surface); border-color: var(--wf-border-strong); }
146
+ .topbar__refs-menu {
147
+ position: absolute; top: calc(100% + 6px); right: 0; z-index: 50;
148
+ min-width: 240px; padding: 6px;
149
+ background: var(--wf-surface); border: 1px solid var(--wf-border);
150
+ border-radius: 10px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
151
+ }
152
+ .topbar__refs-item {
153
+ display: flex; align-items: baseline; justify-content: space-between;
154
+ gap: 16px; padding: 8px 10px; border-radius: 6px;
155
+ font-size: 12px; color: var(--wf-text); text-decoration: none;
156
+ }
157
+ a.topbar__refs-item:hover { background: var(--wf-toolbar-bg); color: var(--wf-text-strong); }
158
+ .topbar__refs-k { color: var(--wf-text-muted); }
159
+ .topbar__refs-v { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
160
+ .topbar__refs-item--muted .topbar__refs-v { color: var(--wf-text-muted); font-family: inherit; }
161
+ .topbar h1 { margin: 0; font-size: 14px; font-weight: 600; color: var(--wf-text-strong); letter-spacing: -0.01em; }
162
+ .topbar h1 small { color: var(--wf-text-muted); font-weight: 500; margin-left: 8px; }
163
+ /* Title is a switcher: click to jump to another wireflow without
164
+ going back to the dashboard list. */
165
+ .topbar__title { position: relative; display: flex; align-items: center; }
166
+ /* Wireflow status pill — mirrors the dashboard statusBadge tones so a
167
+ reviewer scrubbing through wireflows sees the current state without
168
+ jumping back to the index. Hidden on mobile to keep the topbar
169
+ slim; reveals at ≥720px. */
170
+ .topbar__status {
171
+ display: none;
172
+ margin-left: 10px;
173
+ padding: 2px 9px;
174
+ font-size: 11px; font-weight: 600; line-height: 1.4;
175
+ border-radius: 999px;
176
+ background: var(--wf-surface-alt); color: var(--wf-text-muted);
177
+ letter-spacing: 0.01em;
178
+ white-space: nowrap;
179
+ }
180
+ @media (min-width: 720px) { .topbar__status { display: inline-flex; align-items: center; } }
181
+ .topbar__status--to-validate { background: #fef3c7; color: #78350f; }
182
+ .topbar__status--work-in-progress { background: #dbeafe; color: #1e3a8a; }
183
+ .topbar__status--ready-for-design,
184
+ .topbar__status--ready-for-engineering { background: #dcfce7; color: #166534; }
185
+ .topbar__status--waiting-for-spec-change { background: #f1f5f9; color: #475569; }
186
+ .topbar__status--to-do { background: #f1f5f9; color: #64748b; }
187
+ /* Dark-mode tones — match the dashboard statusBadge palette so a
188
+ reviewer sees the same "To validate" amber on the dashboard list
189
+ and on the wireflow's detail topbar (previously the wireflow
190
+ kept its light tones under dark and read as a different colour). */
191
+ @media (prefers-color-scheme: dark) {
192
+ html:not([data-theme='light']) .topbar__status--to-validate { background: #422006; color: #fcd34d; }
193
+ html:not([data-theme='light']) .topbar__status--work-in-progress { background: #1e3a8a; color: #93c5fd; }
194
+ html:not([data-theme='light']) .topbar__status--ready-for-design,
195
+ html:not([data-theme='light']) .topbar__status--ready-for-engineering { background: #14532d; color: #86efac; }
196
+ html:not([data-theme='light']) .topbar__status--waiting-for-spec-change,
197
+ html:not([data-theme='light']) .topbar__status--to-do { background: #1e293b; color: #94a3b8; }
198
+ }
199
+ html[data-theme='dark'] .topbar__status--to-validate { background: #422006; color: #fcd34d; }
200
+ html[data-theme='dark'] .topbar__status--work-in-progress { background: #1e3a8a; color: #93c5fd; }
201
+ html[data-theme='dark'] .topbar__status--ready-for-design,
202
+ html[data-theme='dark'] .topbar__status--ready-for-engineering { background: #14532d; color: #86efac; }
203
+ html[data-theme='dark'] .topbar__status--waiting-for-spec-change,
204
+ html[data-theme='dark'] .topbar__status--to-do { background: #1e293b; color: #94a3b8; }
205
+ .topbar__switcher {
206
+ display: inline-flex; align-items: center; gap: 8px;
207
+ background: none; border: 0; padding: 4px 8px; margin: -4px 0 -4px -8px;
208
+ border-radius: 6px; cursor: pointer; font: inherit;
209
+ font-size: 14px; font-weight: 600; color: var(--wf-text-strong); letter-spacing: -0.01em;
210
+ transition: background 120ms ease, color 120ms ease;
211
+ }
212
+ .topbar__switcher:hover,
213
+ .topbar__switcher[aria-expanded="true"] { background: var(--wf-surface-alt); }
214
+ .topbar__switcher:hover .topbar__switcher-caret { color: var(--wf-text-strong); }
215
+ .topbar__switcher small { color: var(--wf-text-muted); font-weight: 500; }
216
+ .topbar__switcher-caret { color: var(--wf-text-muted); font-size: 11px; }
217
+ .topbar__switcher-menu {
218
+ position: absolute; top: calc(100% + 8px); left: 0; z-index: 50;
219
+ min-width: 300px; max-height: 64vh; overflow-y: auto;
220
+ background: var(--wf-surface); border: 1px solid var(--wf-border);
221
+ border-radius: 10px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); padding: 6px;
222
+ }
223
+ .topbar__switcher-group {
224
+ font-size: 10.5px; font-weight: 600; letter-spacing: 0.06em;
225
+ text-transform: uppercase; color: var(--wf-text-muted);
226
+ padding: 8px 10px 4px;
227
+ }
228
+ .topbar__switcher-item {
229
+ display: flex; align-items: baseline; gap: 8px; width: 100%;
230
+ padding: 8px 10px; border: 0; background: none; border-radius: 6px;
231
+ font: inherit; font-size: 13px; color: var(--wf-text); text-align: left;
232
+ cursor: pointer; text-decoration: none;
233
+ }
234
+ .topbar__switcher-item:hover { background: var(--wf-toolbar-bg); color: var(--wf-text-strong); }
235
+ .topbar__switcher-item.is-current {
236
+ color: var(--wf-text-strong);
237
+ background: var(--wf-toolbar-bg);
238
+ font-weight: 600;
239
+ cursor: default;
240
+ }
241
+ .topbar__switcher-item.is-current::after {
242
+ content: '✓';
243
+ margin-left: auto;
244
+ padding-left: 12px;
245
+ color: var(--wf-accent, #2563eb);
246
+ font-weight: 700;
247
+ }
248
+ .topbar__switcher-item code {
249
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
250
+ font-size: 11px; color: var(--wf-text-muted); flex: none;
251
+ }
252
+ .topbar a { color: var(--wf-text-muted); font-size: 13px; text-decoration: none; }
253
+ .topbar a:hover { color: var(--wf-text-strong); }
254
+
255
+ .wireflow-canvas {
256
+ position: relative; overflow: hidden;
257
+ background: var(--wf-canvas-bg);
258
+ background-image: radial-gradient(circle, var(--wf-canvas-dot) 1px, transparent 1px);
259
+ background-size: 20px 20px;
260
+ height: calc(100vh - 45px); cursor: grab;
261
+ /* `touch-action: none` blocks the browser's native panning + pinch
262
+ on the canvas so our pan/pinch handlers can take over. The
263
+ Mermaid + Spec overlays re-enable native scroll via `auto`
264
+ below so long markdown / large diagrams stay scrollable. */
265
+ touch-action: none;
266
+ }
267
+ .wireflow-canvas:active { cursor: grabbing; }
268
+ .wireflow-mermaid, .wireflow-spec, .wf-comments-panel { touch-action: auto; }
269
+
270
+ /*
271
+ * Persistent toolbar — pinned to the top of the canvas, visible in every
272
+ * view. Two slots:
273
+ * .wf-tabs left · always visible · Wireflow | Mermaid | Spec
274
+ * .wf-actions right · view-specific action buttons that swap based on
275
+ * the canvas's is-mermaid / is-spec class
276
+ *
277
+ * The container is pointer-events:none so the underlying canvas keeps
278
+ * receiving pan/zoom events except where children opt back in.
279
+ */
280
+ .wf-toolbar {
281
+ position: absolute; top: 12px; left: 12px; right: 12px;
282
+ display: flex; justify-content: space-between; align-items: center;
283
+ z-index: 10; pointer-events: none;
284
+ }
285
+ .wf-toolbar > * { pointer-events: auto; }
286
+ /* When the comments sidebar is open it covers the right 320px of the
287
+ canvas. Inset the toolbar's right edge and the comments FAB so they
288
+ sit on the canvas, not behind the sidebar. */
289
+ .wireflow-canvas.is-commenting .wf-toolbar { right: 332px; }
290
+ .wireflow-canvas.is-commenting .wf-comments-fab { right: 336px; }
291
+ @media (max-width: 800px) {
292
+ /* On narrow viewports the sidebar docks to the bottom (60vh), so
293
+ horizontal layout is fine — leave toolbar/FAB at default right. */
294
+ .wireflow-canvas.is-commenting .wf-toolbar { right: 12px; }
295
+ .wireflow-canvas.is-commenting .wf-comments-fab { right: 16px; bottom: calc(60vh + 16px); }
296
+ }
297
+
298
+ /* === Dropdown menus ===
299
+ Used for the view switcher (Wireflow / Mermaid / Spec) and per-view
300
+ overflow actions. A `.wf-menu` is a positioning anchor; clicking
301
+ its `.wf-menu__trigger` toggles `.is-open` on the wrapper, which
302
+ reveals the `.wf-menu__panel` below. JS handles outside-click
303
+ dismissal so the markup stays simple. */
304
+ .wf-menu { position: relative; display: inline-flex; }
305
+ .wf-menu__trigger {
306
+ display: inline-flex; align-items: center; gap: 4px;
307
+ padding: 5px 10px;
308
+ border: 1px solid var(--wf-toolbar-border);
309
+ background: var(--wf-toolbar-bg);
310
+ color: var(--wf-text);
311
+ border-radius: 6px;
312
+ font: inherit; font-size: 12px; font-weight: 500;
313
+ cursor: pointer;
314
+ }
315
+ .wf-menu__trigger:hover { background: var(--wf-toolbar-bg-hover); color: var(--wf-text-strong); }
316
+ .wf-menu.is-open .wf-menu__trigger {
317
+ background: var(--wf-text-strong); color: var(--wf-surface);
318
+ border-color: var(--wf-text-strong);
319
+ }
320
+ .wf-menu__caret { font-size: 10px; opacity: 0.7; }
321
+ .wf-menu__panel {
322
+ position: absolute; top: calc(100% + 4px); left: 0; min-width: 200px;
323
+ display: none; flex-direction: column;
324
+ /* Vertical-only padding so menu items can run edge-to-edge of the
325
+ panel — their hover background then reads as a full-width row,
326
+ matching how native dropdowns + macOS menus behave. */
327
+ padding: 4px 0;
328
+ background: var(--wf-surface);
329
+ border: 1px solid var(--wf-border-strong);
330
+ border-radius: 6px;
331
+ box-shadow: 0 8px 24px rgba(15,23,42,0.18);
332
+ z-index: 50;
333
+ }
334
+ /* The view switcher sits on the left side of the toolbar; its menu
335
+ opens flush-left. The right-side action menus open right-aligned
336
+ so they don't bleed off-screen. */
337
+ [data-menu="mermaid"] .wf-menu__panel,
338
+ [data-menu="spec"] .wf-menu__panel,
339
+ [data-menu="figma"] .wf-menu__panel,
340
+ [data-menu="figma-copy"] .wf-menu__panel { left: auto; right: 0; }
341
+ .wf-menu.is-open .wf-menu__panel { display: flex; }
342
+ /* Menu items read like rows in a native dropdown: full-width, no
343
+ per-item border or border-radius (the panel owns the rounded
344
+ corners), hover paints the entire row. The explicit
345
+ `appearance: none` + `outline: 0` zap the default user-agent
346
+ button chrome (the thin slate outline that was making them look
347
+ like outlined buttons instead of menu items). Keyboard focus
348
+ is restored via a dedicated `:focus-visible` rule below. */
349
+ .wf-menu__panel button,
350
+ .wf-menu__panel a {
351
+ display: flex; align-items: center; gap: 6px;
352
+ width: 100%; box-sizing: border-box;
353
+ padding: 7px 12px;
354
+ -webkit-appearance: none; appearance: none;
355
+ border: 0; outline: 0; background: transparent;
356
+ color: var(--wf-text);
357
+ font: inherit; font-size: 12px; font-weight: 500;
358
+ text-align: left; text-decoration: none;
359
+ border-radius: 0;
360
+ cursor: pointer;
361
+ }
362
+ .wf-menu__panel button:hover,
363
+ .wf-menu__panel a:hover { background: var(--wf-surface-alt); color: var(--wf-text-strong); }
364
+ .wf-menu__panel button:focus-visible,
365
+ .wf-menu__panel a:focus-visible {
366
+ background: var(--wf-surface-alt); color: var(--wf-text-strong);
367
+ box-shadow: inset 2px 0 0 var(--brand);
368
+ }
369
+ .wf-menu__panel button[aria-checked="true"]::before,
370
+ .wf-menu__panel button[aria-pressed="true"]::before {
371
+ content: '✓ '; color: var(--wf-text-muted); font-weight: 700;
372
+ }
373
+
374
+ /* Desktop: collapse `wf-menu` dropdowns into inline button strips. The
375
+ trigger hides, the panel goes static + horizontal. Same treatment for:
376
+ - the view switcher (`views`) — aria-checked drives the active fill
377
+ - the mermaid actions (`mermaid`) — Copy / Download / ASCII toggle
378
+ - the spec actions (`spec`) — Copy / Download / Open original
379
+ Mobile (<720px) keeps the dropdown form. */
380
+ @media (min-width: 720px) {
381
+ /* The view switcher (Wireflow / Mermaid / Spec) stays a segmented
382
+ inline group on desktop — it's a primary toggle. The Mermaid +
383
+ Spec "Actions" overflow menus stay as dropdowns at every width
384
+ (per feedback): the four side-by-side buttons crowded the bar. */
385
+ [data-menu="views"] .wf-menu__trigger { display: none; }
386
+ [data-menu="views"] .wf-menu__panel {
387
+ position: static; display: inline-flex; flex-direction: row;
388
+ padding: 2px; gap: 0; min-width: 0;
389
+ border: 1px solid var(--wf-toolbar-border);
390
+ background: var(--wf-toolbar-bg);
391
+ box-shadow: none;
392
+ }
393
+ [data-menu="views"] .wf-menu__panel button {
394
+ padding: 4px 12px; border-radius: 4px; font-size: 12px;
395
+ color: var(--wf-text-muted);
396
+ white-space: nowrap;
397
+ }
398
+ [data-menu="views"] .wf-menu__panel button[aria-checked="true"] {
399
+ background: var(--wf-surface);
400
+ color: var(--wf-text-strong);
401
+ box-shadow: 0 1px 2px rgba(15,23,42,0.06);
402
+ }
403
+ [data-menu="views"] .wf-menu__panel button[aria-checked="true"]::before,
404
+ [data-menu="mermaid"] .wf-menu__panel button[aria-checked="true"]::before,
405
+ [data-menu="mermaid"] .wf-menu__panel button[aria-pressed="true"]::before {
406
+ content: none;
407
+ }
408
+ /* The mermaid ASCII toggle is a menuitemcheckbox — when active it
409
+ should read like a pressed segment, not just a hover state. */
410
+ [data-menu="mermaid"] .wf-menu__panel button[aria-checked="true"] {
411
+ background: var(--wf-surface);
412
+ color: var(--wf-text-strong);
413
+ box-shadow: 0 1px 2px rgba(15,23,42,0.06);
414
+ }
415
+ [data-menu="mermaid"] .wf-menu__panel button:hover,
416
+ [data-menu="mermaid"] .wf-menu__panel a:hover,
417
+ [data-menu="spec"] .wf-menu__panel button:hover,
418
+ [data-menu="spec"] .wf-menu__panel a:hover,
419
+ [data-menu="figma"] .wf-menu__panel button:hover,
420
+ [data-menu="figma"] .wf-menu__panel a:hover {
421
+ background: var(--wf-surface);
422
+ color: var(--wf-text-strong);
423
+ }
424
+ }
425
+
426
+ .wf-actions { display: inline-flex; gap: 4px; align-items: center; }
427
+ .wf-actions__group { display: none; gap: 4px; align-items: center; }
428
+ .wireflow-canvas:not(.is-mermaid):not(.is-spec):not(.is-figma) .wf-actions__group--wireflow { display: inline-flex; }
429
+ .wireflow-canvas.is-mermaid .wf-actions__group--mermaid { display: inline-flex; }
430
+ .wireflow-canvas.is-spec .wf-actions__group--spec { display: inline-flex; }
431
+ .wireflow-canvas.is-figma .wf-actions__group--figma { display: inline-flex; }
432
+
433
+ /* Toolbar controls match the .wf-tabs button sizing on the left side
434
+ (padding 5px 12px, font 12px) so the toolbar reads as one consistent
435
+ row. Each control gets the same vertical metrics; the zoom-% adds a
436
+ tabular-numeric font feature so its width doesn't twitch as digits
437
+ change. */
438
+ .wireflow-dir-toggle, .wireflow-ctrl-btn, .wireflow-zoom-pct {
439
+ border: 1px solid var(--wf-toolbar-border); background: var(--wf-toolbar-bg);
440
+ cursor: pointer; font-family: inherit; color: var(--wf-text);
441
+ padding: 5px 12px;
442
+ border-radius: 6px;
443
+ font-size: 12px; font-weight: 500; line-height: 1.35;
444
+ display: inline-flex; align-items: center; justify-content: center;
445
+ }
446
+ .wireflow-ctrl-btn { min-width: 28px; padding: 5px 10px; font-size: 14px; }
447
+ .wireflow-zoom-pct { min-width: 50px; font-variant-numeric: tabular-nums; }
448
+ .wireflow-dir-toggle:hover,
449
+ .wireflow-ctrl-btn:hover,
450
+ .wireflow-zoom-pct:hover { background: var(--wf-toolbar-bg-hover); color: var(--wf-text-strong); }
451
+ .wireflow-zoom-controls { display: inline-flex; gap: 4px; }
452
+ /* Viewport breakpoint buttons — same surface vocabulary as the other
453
+ toolbar controls; icon-only, grouped tight like the zoom cluster.
454
+ Selectors are scoped under .wf-actions so they out-specify the generic
455
+ `.wf-actions button` rule (which otherwise forces 4px 10px padding and
456
+ crushes the icon-only SVG). */
457
+ .wireflow-viewport { position: relative; display: inline-flex; align-items: stretch; }
458
+ /* Main half — quick desktop/mobile toggle. */
459
+ .wf-actions .wireflow-viewport-main {
460
+ border: 1px solid var(--wf-toolbar-border); border-right: none;
461
+ background: var(--wf-toolbar-bg); color: var(--wf-text);
462
+ width: 30px; height: 24px; padding: 0; border-radius: 6px 0 0 6px;
463
+ flex: 0 0 auto; cursor: pointer;
464
+ display: inline-flex; align-items: center; justify-content: center;
465
+ }
466
+ .wf-actions .wireflow-viewport-main svg { width: 16px; height: 16px; display: block; }
467
+ .wf-actions .wireflow-viewport-main:hover { background: var(--wf-toolbar-bg-hover); color: var(--wf-text-strong); }
468
+ .wf-actions .wireflow-viewport-main.is-active {
469
+ background: var(--wf-text-strong); color: var(--wf-page-bg); border-color: var(--wf-text-strong);
470
+ }
471
+ /* Caret half — a <summary> that toggles the breakpoint menu. */
472
+ .wireflow-viewport-menu { position: relative; display: inline-flex; }
473
+ .wireflow-viewport-caret {
474
+ list-style: none; cursor: pointer; user-select: none;
475
+ border: 1px solid var(--wf-toolbar-border);
476
+ background: var(--wf-toolbar-bg); color: var(--wf-text-muted);
477
+ width: 18px; height: 24px; padding: 0; border-radius: 0 6px 6px 0;
478
+ display: inline-flex; align-items: center; justify-content: center;
479
+ }
480
+ .wireflow-viewport-caret::-webkit-details-marker { display: none; }
481
+ .wireflow-viewport-caret::marker { content: ''; }
482
+ .wireflow-viewport-caret:hover,
483
+ .wireflow-viewport-menu[open] .wireflow-viewport-caret { background: var(--wf-toolbar-bg-hover); color: var(--wf-text-strong); }
484
+ /* Breakpoint dropdown. */
485
+ .wireflow-viewport-panel {
486
+ position: absolute; top: calc(100% + 6px); right: 0; z-index: 50;
487
+ min-width: 190px; padding: 4px;
488
+ background: var(--wf-surface); border: 1px solid var(--wf-border-strong);
489
+ border-radius: 8px; box-shadow: 0 8px 24px rgba(15,23,42,0.18);
490
+ display: flex; flex-direction: column; gap: 1px;
491
+ }
492
+ .wf-actions .wireflow-viewport-item {
493
+ display: flex; align-items: center; gap: 8px; width: 100%;
494
+ padding: 6px 8px; border: 0; background: none; cursor: pointer; appearance: none;
495
+ border-radius: 6px; color: var(--wf-text); font: inherit; font-size: 12px; text-align: left;
496
+ }
497
+ .wf-actions .wireflow-viewport-item:hover { background: var(--wf-toolbar-bg-hover); color: var(--wf-text-strong); }
498
+ .wf-actions .wireflow-viewport-item.is-active { background: var(--wf-text-strong); color: var(--wf-page-bg); }
499
+ .wireflow-viewport-item__icon { display: inline-flex; flex: 0 0 auto; }
500
+ .wireflow-viewport-item__icon svg { width: 16px; height: 16px; display: block; }
501
+ .wireflow-viewport-item__label { flex: 1; }
502
+ .wireflow-viewport-item__w { color: var(--wf-text-muted); font-variant-numeric: tabular-nums; }
503
+ .wf-actions .wireflow-viewport-item.is-active .wireflow-viewport-item__w { color: inherit; opacity: 0.75; }
504
+ /* Breakpoints outside the flow's intended viewport range — still clickable
505
+ (the range is advisory), just visibly demoted. Mirrors screen.njk. */
506
+ .wireflow-viewport-item.is-out-of-range > * { opacity: 0.4; }
507
+ .wireflow-viewport-item.is-out-of-range:hover > * { opacity: 0.7; }
508
+ /* Intended-range footer under the breakpoint list. */
509
+ .wireflow-viewport-range {
510
+ margin-top: 3px;
511
+ padding: 6px 8px 4px;
512
+ border-top: 1px solid var(--wf-border-strong);
513
+ font-size: 10.5px;
514
+ color: var(--wf-text-muted);
515
+ white-space: nowrap;
516
+ }
517
+ .wireflow-viewport-range span { opacity: 0.7; font-variant-numeric: tabular-nums; }
518
+ @media (max-width: 800px), (pointer: coarse) {
519
+ .wireflow-viewport { display: none; }
520
+ }
521
+ /* Copy-to-Figma button is wider than the icon-only ctrl-btns (carries a
522
+ label); reset the min-width so its padding actually shows. */
523
+ .wireflow-figma-btn { min-width: 0; padding: 5px 10px; font-size: 12px; gap: 0; }
524
+ .wireflow-figma-btn[disabled] { opacity: 0.7; cursor: progress; }
525
+ .wireflow-figma-btn.is-done { background: var(--wf-text-strong); color: var(--wf-page-bg); border-color: var(--wf-text-strong); }
526
+ .wireflow-figma-btn.is-error { border-color: #dc2626; color: #dc2626; }
527
+ /* On touch / narrow viewports the user pinch-zooms — these buttons
528
+ are redundant chrome and steal toolbar space. */
529
+ @media (max-width: 800px), (pointer: coarse) {
530
+ .wireflow-zoom-controls { display: none; }
531
+ }
532
+
533
+ .wireflow-canvas__inner { position: relative; }
534
+ /* SVG hints — keep arrows/labels crisp under canvas transform. These
535
+ are cheap and don't promote layers, so they don't trigger the
536
+ cache-then-upscale blur that `will-change: transform` introduced. */
537
+ .wireflow-arrows, .wireflow-labels {
538
+ shape-rendering: geometricPrecision;
539
+ text-rendering: geometricPrecision;
540
+ }
541
+ .wf-node__title, .wf-node__label, .wf-node__header {
542
+ -webkit-font-smoothing: antialiased;
543
+ text-rendering: geometricPrecision;
544
+ }
545
+
546
+ .wireflow-arrows,
547
+ .wireflow-labels {
548
+ position: absolute; top: 0; left: 0;
549
+ width: 100%; height: 100%; pointer-events: none;
550
+ }
551
+ /* `vector-effect: non-scaling-stroke` keeps arrow stroke width
552
+ constant on screen regardless of canvas zoom — without it, fully
553
+ zooming in turns the lines into thick slabs. */
554
+ /* NOTE: stroke-width is intentionally NOT set in CSS. It's written as an
555
+ attribute in applyTransform() (1.5 * inv-scale) so the rendered stroke
556
+ stays at 1.5 CSS-px at any canvas zoom. CSS stroke-width would shadow
557
+ the attribute (presentation attrs lose to CSS), making counter-scaling
558
+ a no-op — that's exactly the bug this comment exists to prevent. */
559
+ .wireflow-arrows path { stroke: var(--slate-500); fill: none; }
560
+ /* Loop edges (intentional cycles — Resend, retry) read in the brand
561
+ color so they pop out from the forward flow without needing the
562
+ reviewer to inspect dash patterns. The dashed stroke + brand
563
+ color + brand-coloured label background are the three signals
564
+ the legend in the corner explains. */
565
+ .wireflow-arrows path.wf-edge-path--loop { stroke: var(--brand); }
566
+ /* Arrowhead triangles: filled, no stroke. Counter-scaled per applyTransform()
567
+ via the inner <g class="wf-arrowhead"> so screen size is constant. */
568
+ .wireflow-arrows .wf-arrowhead path { fill: var(--slate-500); stroke: none; }
569
+ .wireflow-arrows .wf-arrowhead--loop path { fill: var(--brand); }
570
+ .wireflow-labels text {
571
+ font-family: inherit; font-size: 9px;
572
+ fill: #fff; font-weight: 500;
573
+ }
574
+ /* Dot stroke-width and radius are both written by applyTransform() — see
575
+ the comment on `.wireflow-arrows path` above for why CSS stroke-width
576
+ must not be set here. */
577
+ .wireflow-arrows circle.wf-dot { fill: white; stroke: var(--slate-500); }
578
+ /* Inverted badge so it reads as part of the arrow itself (white text on
579
+ the same slate-900 fill as the path stroke). */
580
+ .wireflow-labels .wf-label-bg {
581
+ fill: #1e293b;
582
+ stroke: none;
583
+ }
584
+ /* Loop edge labels carry the brand fill so the badge matches the
585
+ arrow it sits on — same brand cue the legend uses. White text
586
+ stays high-contrast on either color. */
587
+ .wireflow-labels .wf-edge-label--loop .wf-label-bg { fill: var(--brand); }
588
+ /* `.wf-edge-label` is scaled via the SVG `transform` attribute set in
589
+ applyTransform() — CSS `transform-box: fill-box` on SVG <g> doesn't
590
+ behave consistently across browsers, which made the labels appear
591
+ to drift / stick on zoom. Direct attribute scaling is universally
592
+ supported. The bg+text are centered at (0,0) so scaling around the
593
+ SVG default origin keeps the label visually centered. */
594
+ /* Labels live in a separate SVG layer that paints AFTER the frames so
595
+ text never gets clipped by an iframe sitting at the edge midpoint.
596
+ Edges stay in .wireflow-arrows below the frames so they don't draw
597
+ across the previewed screens. */
598
+ .wireflow-labels { z-index: 5; }
599
+
600
+ .wf-node { position: absolute; display: flex; flex-direction: column; gap: 4px; text-decoration: none; }
601
+ /* Counter-scale the header so it stays readable at every zoom level.
602
+ transform-origin bottom-left keeps it pinned to the frame's top-left. */
603
+ .wf-node__header {
604
+ display: flex; align-items: center; gap: 6px;
605
+ transform: scale(var(--inv-scale, 1));
606
+ transform-origin: 0 100%;
607
+ white-space: nowrap;
608
+ }
609
+ /* Diamond text counter-scale (forward compatible: no diamonds in current
610
+ graphs, but spec-listed as constant-size). */
611
+ .wf-diamond__text {
612
+ transform: scale(var(--inv-scale, 1));
613
+ transform-origin: center center;
614
+ }
615
+ .wf-node__label {
616
+ font-size: 10px; font-weight: 600; color: white;
617
+ background: var(--slate-700); padding: 1px 5px; border-radius: 3px;
618
+ }
619
+ .wf-node__label--success { background: var(--success); }
620
+ .wf-node__label--dead-end { background: #ff4b26; }
621
+ .wf-node__title {
622
+ font-size: 10px; font-weight: 500; color: var(--slate-500);
623
+ background: white; padding: 0 3px; border-radius: 2px;
624
+ }
625
+ /* Dead-end title pill: orange to flag the "user gives up here / can't
626
+ reach happy ending" branches at a glance. Same brand orange the
627
+ Mermaid view uses for symmetry. */
628
+ .wf-node__title--dead-end {
629
+ background: #ff4b26; color: white;
630
+ }
631
+ /* Open-comment count pill on a node header — makes it obvious which
632
+ screens have unresolved feedback without opening the panel. */
633
+ .wf-node__comments {
634
+ display: inline-flex; align-items: center; gap: 4px;
635
+ font-size: 10px; font-weight: 700; line-height: 1;
636
+ color: white; background: var(--brand, #ff4b26);
637
+ padding: 2px 7px 2px 6px; border-radius: 999px;
638
+ }
639
+ .wf-node__comments-icon { display: block; flex: 0 0 auto; }
640
+ .wf-node__comments[hidden] { display: none; }
641
+ /* "N extra states" badge — a screen has 2+ alternative state pages
642
+ (e.g. 2-otp-request also has 2-otp-request--invalid). Sits next to
643
+ the comments badge in the node header. Subtle slate (not brand) so
644
+ it reads as informational, not actionable like a comment. */
645
+ .wf-node__states {
646
+ display: inline-flex; align-items: center; gap: 3px;
647
+ font-size: 10px; font-weight: 600; line-height: 1;
648
+ color: var(--slate-700); background: var(--slate-200, #e2e8f0);
649
+ padding: 2px 6px; border-radius: 999px;
650
+ }
651
+ .wf-node__states[hidden] { display: none; }
652
+ /* Frame chrome counter-scales with --inv-scale so border thickness, corner
653
+ radius, and shadow stay constant in screen pixels at any zoom. The
654
+ border is rendered as `outline` (with a matching negative outline-offset)
655
+ instead of `border` so it doesn't eat into the frame's box dimensions —
656
+ the iframe scaling math at the inline `width:Npx; height:Npx` relies on
657
+ those staying fixed. */
658
+ .wf-node__frame {
659
+ border-radius: calc(6px * var(--inv-scale, 1));
660
+ outline: calc(1.5px * var(--inv-scale, 1)) solid var(--slate-300);
661
+ outline-offset: calc(-1.5px * var(--inv-scale, 1));
662
+ overflow: hidden; background: white;
663
+ box-shadow: 0 calc(1px * var(--inv-scale, 1)) calc(3px * var(--inv-scale, 1)) rgba(0,0,0,0.06);
664
+ }
665
+ /* Subtle hover affordance — bumps the outline one shade darker
666
+ (slate-400 vs the resting slate-300) so the user sees "yes this
667
+ is clickable" without the previous brand-orange shout that
668
+ turned every hover into a billboard. The link itself is the
669
+ wrapping <a>; only the outline-color changes. */
670
+ .wf-node__frame:hover { outline-color: var(--slate-400); }
671
+ .wf-node__frame iframe { border: none; pointer-events: none; transform-origin: 0 0; display: block; }
672
+
673
+ /* Note node — represents off-system steps (the user reads an SMS, takes a
674
+ phone call, gets paged). No iframe; just an icon + label inside a card
675
+ sized similarly to a phone-frame so the diagram stays balanced. */
676
+ .wf-node--note .wf-node__frame {
677
+ width: 180px; height: 110px;
678
+ display: flex; flex-direction: column; align-items: center;
679
+ justify-content: center; gap: 8px; padding: 12px;
680
+ background: #fffbeb;
681
+ outline-color: #f59e0b;
682
+ text-align: center;
683
+ }
684
+ /* Notes get the equivalent subtle hover bump — slightly deeper
685
+ amber rather than the previous brand-orange flip. */
686
+ .wf-node--note .wf-node__frame:hover { outline-color: #d97706; }
687
+ .wf-node--note .wf-note__icon {
688
+ font-size: 28px; line-height: 1;
689
+ transform: scale(var(--inv-scale, 1));
690
+ transform-origin: center;
691
+ color: #b45309;
692
+ }
693
+ .wf-node--note .wf-note__label {
694
+ font-size: 13px; line-height: 1.3;
695
+ color: #78350f; font-weight: 500;
696
+ transform: scale(var(--inv-scale, 1));
697
+ transform-origin: center;
698
+ }
699
+
700
+ .wireflow-mermaid {
701
+ /* Top padding clears the absolutely-positioned .wireflow-controls
702
+ (top: 12px, height ~32px) so the toolbar buttons don't slide
703
+ under them. Pan/zoom is handled by the JS at the bottom of this
704
+ file — overflow stays hidden so the transformed diagram doesn't
705
+ spawn native scrollbars when zoomed past the panel. ASCII view
706
+ re-enables overflow via the `.is-ascii` selector below. */
707
+ position: absolute; inset: 0; overflow: hidden;
708
+ padding: 60px 32px 32px; background: var(--wf-surface); color: var(--wf-text);
709
+ display: none;
710
+ flex-direction: column;
711
+ align-items: center;
712
+ justify-content: center;
713
+ cursor: grab;
714
+ touch-action: none;
715
+ }
716
+ .wireflow-mermaid.is-panning { cursor: grabbing; }
717
+ .wireflow-canvas.is-mermaid .wireflow-mermaid { display: flex; }
718
+ .wireflow-canvas.is-mermaid .wireflow-canvas__inner,
719
+ .wireflow-canvas.is-mermaid .wireflow-arrows,
720
+ .wireflow-canvas.is-mermaid .wireflow-figma { display: none; }
721
+ .wireflow-canvas.is-mermaid { cursor: default; }
722
+ /* Inner Mermaid block — given an explicit width so the flex parent's
723
+ align-items:center doesn't collapse it. Transform-origin is the
724
+ block's top-left so the JS zoom math (anchor around cursor) is
725
+ straightforward — same model as the wireflow canvas. The diagram's
726
+ own SVG has its viewBox-driven aspect ratio; max-width: 100% keeps
727
+ it inside the panel before the user starts zooming. */
728
+ .wireflow-mermaid .mermaid {
729
+ width: 100%;
730
+ max-width: 1200px;
731
+ text-align: center;
732
+ transform-origin: 0 0;
733
+ will-change: transform;
734
+ }
735
+ .wireflow-mermaid .mermaid svg {
736
+ max-width: 100%;
737
+ height: auto;
738
+ }
739
+ /* Hover affordance for the clickable diagram nodes: lighten the box
740
+ itself (a brightness bump works on any node fill — light grey
741
+ screens, green End, orange dead-end — in both light and dark
742
+ mode) rather than showing a browser tooltip. The tooltip arg was
743
+ dropped from the `click` directive in vite.config.ts. */
744
+ .wireflow-mermaid .mermaid .node.clickable { cursor: pointer; }
745
+ .wireflow-mermaid .mermaid .node.clickable > * {
746
+ transition: filter 100ms ease;
747
+ }
748
+ .wireflow-mermaid .mermaid .node.clickable:hover > * {
749
+ filter: brightness(1.07);
750
+ }
751
+ /* ASCII source view re-enables scrolling since it's a long <pre>
752
+ block that doesn't pan/zoom. */
753
+ .wireflow-mermaid.is-ascii { overflow: auto; cursor: default; touch-action: auto; }
754
+ .wireflow-mermaid.is-ascii .mermaid { transform: none !important; }
755
+
756
+ /* === Legend ===
757
+ Small overlay in the bottom-right corner of the canvas explaining
758
+ the wireflow's visual vocabulary (frame vs note, solid vs dashed
759
+ edge, success vs dead-end pill). Hidden in Mermaid/Spec views.
760
+ Collapsible — state persisted in localStorage keyed per-prototype
761
+ so the user's "I've seen it, hide it" sticks. */
762
+ /* Legend is a popup, opened from a tiny button in the toolbar (next
763
+ to the zoom controls) — not an always-on card. */
764
+ .wf-legend {
765
+ position: absolute; top: 52px; right: 12px;
766
+ display: none; flex-direction: column; align-items: stretch;
767
+ min-width: 178px;
768
+ background: var(--wf-surface);
769
+ border: 1px solid var(--wf-toolbar-border);
770
+ border-radius: 8px;
771
+ box-shadow: 0 8px 24px rgba(15,23,42,0.16);
772
+ z-index: 20;
773
+ color: var(--wf-text);
774
+ font-size: 11px;
775
+ }
776
+ .wf-legend[data-open="true"] { display: flex; }
777
+ .wireflow-canvas.is-mermaid .wf-legend,
778
+ .wireflow-canvas.is-spec .wf-legend,
779
+ .wireflow-canvas.is-figma .wf-legend { display: none !important; }
780
+ /* Clear the open comments sidebar (right 320px). */
781
+ .wireflow-canvas.is-commenting .wf-legend { right: 332px; }
782
+ .wf-legend__head {
783
+ padding: 9px 12px 0;
784
+ font-size: 10px; font-weight: 700; letter-spacing: 0.04em;
785
+ text-transform: uppercase;
786
+ color: var(--wf-text-muted);
787
+ }
788
+ .wf-legend__body {
789
+ display: flex; flex-direction: column; gap: 7px;
790
+ padding: 8px 12px 12px;
791
+ }
792
+ /* The toolbar button that opens the legend popup. */
793
+ .wireflow-ctrl-btn.is-active {
794
+ color: var(--wf-text-strong);
795
+ background: var(--wf-toolbar-active, rgba(148,163,184,0.18));
796
+ }
797
+ .wireflow-legend-btn svg { display: block; }
798
+ .wf-legend__row { display: flex; align-items: center; gap: 8px; }
799
+ .wf-legend__swatch {
800
+ flex: 0 0 24px; width: 24px; height: 14px;
801
+ border-radius: 2px;
802
+ background: #fff;
803
+ border: 1px solid var(--slate-300);
804
+ box-shadow: 0 1px 2px rgba(15,23,42,0.05);
805
+ }
806
+ .wf-legend__swatch--note {
807
+ background: #fffbeb;
808
+ border-color: #f59e0b;
809
+ }
810
+ .wf-legend__edge { flex: 0 0 44px; color: var(--slate-500); }
811
+ .wf-legend__edge--loop { color: var(--brand); }
812
+ .wf-legend__pill {
813
+ flex: 0 0 28px; text-align: center;
814
+ font-size: 9px; font-weight: 700; letter-spacing: 0.02em;
815
+ text-transform: uppercase;
816
+ padding: 2px 0; border-radius: 4px;
817
+ color: #fff;
818
+ }
819
+ .wf-legend__pill--success { background: var(--success); }
820
+ .wf-legend__pill--dead-end { background: #ff4b26; }
821
+
822
+ /* Buttons that live in the persistent toolbar (mermaid + spec actions).
823
+ Same surface tokens as the wireflow-side controls so dark mode is
824
+ consistent across the whole toolbar. */
825
+ .wf-actions button,
826
+ .wf-actions a.wf-spec-link {
827
+ padding: 4px 10px; border-radius: 6px; border: 1px solid var(--wf-toolbar-border);
828
+ background: var(--wf-toolbar-bg); cursor: pointer; font: inherit; font-size: 12px;
829
+ font-weight: 500; color: var(--wf-text); text-decoration: none;
830
+ display: inline-flex; align-items: center; gap: 4px;
831
+ }
832
+ .wf-actions button:hover,
833
+ .wf-actions a.wf-spec-link:hover { background: var(--wf-toolbar-bg-hover); color: var(--wf-text-strong); }
834
+ .wf-actions button[aria-pressed="true"] { background: var(--wf-text-strong); color: var(--wf-surface); border-color: var(--wf-text-strong); }
835
+ .wf-mermaid-source {
836
+ display: none; margin-top: 16px;
837
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
838
+ font-size: 13px; line-height: 1.5; color: var(--wf-text);
839
+ background: var(--wf-surface-alt); border: 1px solid var(--wf-border);
840
+ border-radius: 6px; padding: 16px; white-space: pre; overflow: auto;
841
+ }
842
+ .wireflow-mermaid.is-ascii .mermaid { display: none; }
843
+ .wireflow-mermaid.is-ascii .wf-mermaid-source { display: block; }
844
+
845
+ /* Spec view: third overlay panel parallel to .wireflow-mermaid. Only one
846
+ view is active at a time (is-mermaid OR is-spec, never both). */
847
+ .wireflow-spec {
848
+ /* Same reason as .wireflow-mermaid — clear the controls. */
849
+ position: absolute; inset: 0; overflow: auto;
850
+ padding: 60px 32px 32px; background: var(--wf-surface); color: var(--wf-text); display: none;
851
+ }
852
+ .wireflow-canvas.is-spec .wireflow-spec { display: block; }
853
+ .wireflow-canvas.is-spec .wireflow-canvas__inner,
854
+ .wireflow-canvas.is-spec .wireflow-arrows,
855
+ .wireflow-canvas.is-spec .wireflow-mermaid,
856
+ .wireflow-canvas.is-spec .wireflow-figma { display: none; }
857
+ .wireflow-canvas.is-spec { cursor: default; }
858
+
859
+ /* Figma design-reference view: full-bleed iframe pointing at the
860
+ prototype's public Figma file. Behaves like the Mermaid / Spec
861
+ overlays — exclusive with them via is-figma. The iframe has no
862
+ border + a slate background so the file's own canvas chrome blends
863
+ into the surrounding page. */
864
+ .wireflow-figma {
865
+ position: absolute; inset: 0; display: none;
866
+ background: var(--wf-surface);
867
+ /* Top padding clears the absolutely-positioned toolbar (same as
868
+ the Mermaid/Spec panels above). */
869
+ padding: 52px 0 0;
870
+ }
871
+ .wireflow-figma iframe {
872
+ width: 100%; height: 100%;
873
+ border: 0; display: block;
874
+ background: var(--wf-canvas-bg);
875
+ }
876
+ .wireflow-canvas.is-figma .wireflow-figma { display: block; }
877
+ .wireflow-canvas.is-figma .wireflow-canvas__inner,
878
+ .wireflow-canvas.is-figma .wireflow-arrows,
879
+ .wireflow-canvas.is-figma .wireflow-mermaid,
880
+ .wireflow-canvas.is-figma .wireflow-spec { display: none; }
881
+ .wireflow-canvas.is-figma { cursor: default; }
882
+ /* Hide the comments FAB in the design-reference view — comments are
883
+ anchored to the prototype, not the Figma file. */
884
+ .wireflow-canvas.is-figma .wf-comments-fab { display: none; }
885
+
886
+ /* Author edit affordances for the spec view. */
887
+ .wf-spec-prose[contenteditable="true"] {
888
+ outline: 2px solid var(--brand, #ff4b26); outline-offset: 8px;
889
+ border-radius: 4px; min-height: 200px;
890
+ }
891
+ .spec-edit-toolbar {
892
+ position: sticky; top: 0; z-index: 6;
893
+ display: flex; flex-wrap: wrap; gap: 2px;
894
+ margin: -60px -32px 16px; padding: 8px 32px;
895
+ background: var(--wf-surface-alt);
896
+ border-bottom: 1px solid var(--wf-border);
897
+ }
898
+ .spec-edit-toolbar[hidden] { display: none; }
899
+ .spec-edit-toolbar button {
900
+ padding: 4px 9px; font: inherit; font-size: 12px; font-weight: 600;
901
+ background: var(--wf-surface); color: var(--wf-text-muted);
902
+ border: 1px solid var(--wf-border); border-radius: 5px; cursor: pointer;
903
+ }
904
+ .spec-edit-toolbar button:hover { color: var(--wf-text-strong); }
905
+ .spec-edit-toolbar .spec-edit-save { margin-left: auto; background: var(--brand, #ff4b26); color: #fff; border-color: transparent; }
906
+ .spec-edit-btn {
907
+ padding: 5px 12px; font: inherit; font-size: 12px;
908
+ background: var(--wf-toolbar-bg); color: var(--wf-text-muted);
909
+ border: 1px solid var(--wf-toolbar-border); border-radius: 6px; cursor: pointer;
910
+ }
911
+ .spec-edit-btn[hidden] { display: none; }
912
+ .spec-edit-btn:hover { color: var(--wf-text-strong); }
913
+
914
+ .wf-spec-meta {
915
+ max-width: 760px; margin: 0 auto 16px;
916
+ font-size: 12px; color: var(--wf-text-muted);
917
+ }
918
+ .wf-spec-meta__label { color: var(--wf-text-muted); opacity: 0.8; }
919
+
920
+ .wf-spec-prose {
921
+ max-width: 760px; margin: 0 auto;
922
+ color: var(--wf-text);
923
+ font-size: 15px; line-height: 1.65;
924
+ }
925
+ .wf-spec-prose h1 {
926
+ font-size: 28px; font-weight: 700; color: var(--wf-text-strong);
927
+ letter-spacing: -0.02em; line-height: 1.2;
928
+ margin: 0 0 24px;
929
+ }
930
+ .wf-spec-prose h2 {
931
+ font-size: 18px; font-weight: 600; color: var(--wf-text-strong);
932
+ letter-spacing: -0.01em; line-height: 1.3;
933
+ margin: 32px 0 12px;
934
+ padding-top: 16px; border-top: 1px solid var(--wf-border);
935
+ }
936
+ .wf-spec-prose h2:first-of-type { padding-top: 0; border-top: none; }
937
+ .wf-spec-prose h3 {
938
+ font-size: 15px; font-weight: 600; color: var(--wf-text-strong);
939
+ margin: 20px 0 8px;
940
+ }
941
+ .wf-spec-prose p { margin: 0 0 12px; }
942
+ .wf-spec-prose ul, .wf-spec-prose ol {
943
+ margin: 0 0 12px; padding-left: 24px;
944
+ }
945
+ .wf-spec-prose li { margin: 4px 0; }
946
+ .wf-spec-prose li > p { margin: 0 0 6px; }
947
+ .wf-spec-prose a {
948
+ color: var(--brand); text-decoration: none;
949
+ border-bottom: 1px solid currentColor;
950
+ }
951
+ .wf-spec-prose a:hover { color: var(--wf-text-strong); }
952
+ .wf-spec-prose strong { color: var(--wf-text-strong); font-weight: 600; }
953
+ .wf-spec-prose em { font-style: italic; color: var(--wf-text-muted); }
954
+ .wf-spec-prose code {
955
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
956
+ font-size: 13px; background: var(--wf-surface-alt);
957
+ padding: 1px 5px; border-radius: 3px; color: var(--wf-text-strong);
958
+ }
959
+ .wf-spec-prose pre {
960
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
961
+ font-size: 13px; line-height: 1.5; color: var(--wf-text);
962
+ background: var(--wf-surface-alt); border: 1px solid var(--wf-border);
963
+ border-radius: 6px; padding: 16px; overflow: auto;
964
+ margin: 0 0 16px;
965
+ }
966
+ .wf-spec-prose pre code {
967
+ background: transparent; padding: 0; border-radius: 0;
968
+ color: inherit; font-size: inherit;
969
+ }
970
+ .wf-spec-prose blockquote {
971
+ margin: 0 0 16px; padding: 4px 0 4px 16px;
972
+ border-left: 3px solid var(--wf-border-strong); color: var(--wf-text-muted);
973
+ }
974
+ .wf-spec-prose hr {
975
+ border: none; border-top: 1px solid var(--wf-border);
976
+ margin: 24px 0;
977
+ }
978
+ .wf-spec-prose img { max-width: 100%; height: auto; }
979
+ .wf-spec-prose table {
980
+ border-collapse: collapse; margin: 0 0 16px; font-size: 14px;
981
+ }
982
+ .wf-spec-prose th, .wf-spec-prose td {
983
+ border: 1px solid var(--wf-border); padding: 6px 10px; text-align: left;
984
+ }
985
+ .wf-spec-prose th { background: var(--wf-surface-alt); font-weight: 600; color: var(--wf-text-strong); }
986
+
987
+ /* === Wireflow comments — global panel ===
988
+ A right-side panel toggled from the toolbar. Comments live in
989
+ `prototypes/<slug>/wf-comments.json` and ride along with each
990
+ deploy. In dev a vite middleware persists edits; in prod the JSON
991
+ file is static so the panel is read-only. Per-pin (XY) comments
992
+ were considered but discarded — node positions can change when
993
+ pages are added/renamed, so keeping comments anchored to layout
994
+ coordinates would lose meaning over time. Global per-wireflow is
995
+ the simpler shape that stays meaningful. */
996
+ /* Comments sidebar — pinned to the right edge of the canvas, sits
997
+ above the wireflow nodes (z-index) instead of pushing them. Hard
998
+ border, no shadow — reads as chrome rather than a floating panel. */
999
+ .wf-comments-panel {
1000
+ position: absolute; top: 0; right: 0; bottom: 0;
1001
+ width: 320px;
1002
+ background: var(--wf-surface);
1003
+ border-left: 1px solid var(--wf-border);
1004
+ display: none; flex-direction: column;
1005
+ z-index: 50;
1006
+ color: var(--wf-text);
1007
+ }
1008
+ .wireflow-canvas.is-commenting .wf-comments-panel { display: flex; }
1009
+ @media (max-width: 800px) {
1010
+ .wf-comments-panel {
1011
+ top: auto; left: 0; right: 0; bottom: 0;
1012
+ width: 100%; height: 60vh;
1013
+ border-left: 0; border-top: 1px solid var(--wf-border);
1014
+ }
1015
+ }
1016
+ .wf-comments-panel__header {
1017
+ display: flex; align-items: center; justify-content: space-between;
1018
+ padding: 10px 12px;
1019
+ border-bottom: 1px solid var(--wf-border);
1020
+ }
1021
+ .wf-comments-panel__title {
1022
+ font-size: 13px; font-weight: 600; color: var(--wf-text-strong);
1023
+ margin: 0;
1024
+ }
1025
+ .wf-comments-panel__close {
1026
+ padding: 6px; border: 0;
1027
+ background: transparent; color: var(--wf-text-muted);
1028
+ border-radius: 6px; font: inherit;
1029
+ cursor: pointer;
1030
+ display: inline-flex; align-items: center; justify-content: center;
1031
+ width: 28px; height: 28px;
1032
+ }
1033
+ .wf-comments-panel__close:hover { background: var(--wf-surface-alt); color: var(--wf-text-strong); }
1034
+ .wf-comments-panel__list {
1035
+ flex: 1 1 auto; min-height: 0; overflow-y: auto;
1036
+ padding: 0 14px;
1037
+ display: flex; flex-direction: column;
1038
+ }
1039
+ .wf-comments-panel__empty {
1040
+ color: var(--wf-text-muted); font-size: 13px;
1041
+ text-align: center; padding: 24px 8px;
1042
+ }
1043
+ /* Flat comment rows — no per-card border or fill (that read as
1044
+ "boxes inside the panel box"). A hairline divider between rows
1045
+ plus generous vertical padding does the separation work; the
1046
+ panel itself is the only box. */
1047
+ .wf-comment-card {
1048
+ background: transparent;
1049
+ border: 0;
1050
+ border-bottom: 1px solid var(--wf-border);
1051
+ border-radius: 0;
1052
+ padding: 12px 2px;
1053
+ display: flex; flex-direction: column; gap: 6px;
1054
+ }
1055
+ .wf-comment-card:last-child { border-bottom: 0; }
1056
+ .wf-comment-card--orphaned {
1057
+ cursor: default;
1058
+ }
1059
+ .wf-comment-card.is-resolved {
1060
+ opacity: 0.62;
1061
+ }
1062
+ .wf-comment-card__chips {
1063
+ display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
1064
+ margin-top: 8px;
1065
+ }
1066
+ .wf-comment-card__byline { color: var(--wf-text-faint, #94a3b8); font-size: 11px; }
1067
+ .wf-comment-card__link {
1068
+ color: var(--brand, #2563eb);
1069
+ text-decoration: underline;
1070
+ text-underline-offset: 2px;
1071
+ word-break: break-all;
1072
+ }
1073
+ .wf-comment-card__resolved {
1074
+ display: inline-flex; align-items: center; gap: 4px;
1075
+ align-self: flex-start;
1076
+ padding: 1px 7px; border-radius: 999px;
1077
+ font-size: 10.5px; font-weight: 600;
1078
+ background: #dcfce7; color: #166534;
1079
+ }
1080
+ .wf-comment-card__resolve {
1081
+ padding: 2px 8px; border: 1px solid transparent;
1082
+ background: none; color: var(--wf-text-muted);
1083
+ border-radius: 4px; font: inherit; font-size: 11px;
1084
+ cursor: pointer;
1085
+ }
1086
+ .wf-comment-card__resolve:hover {
1087
+ background: #dcfce7; color: #166534; border-color: #86efac;
1088
+ }
1089
+ /* The 4-letter reference code reads as a quiet monospace tag — no
1090
+ box, just letter-spaced muted text — so it stops competing with
1091
+ the comment body for attention. */
1092
+ .wf-comment-card__code {
1093
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1094
+ font-size: 10px; font-weight: 600; letter-spacing: 0.09em;
1095
+ color: var(--wf-text-muted);
1096
+ margin-right: 6px;
1097
+ }
1098
+ .wf-comment-card__state {
1099
+ align-self: flex-start;
1100
+ font-size: 10.5px; font-weight: 600;
1101
+ color: var(--brand, #ff4b26);
1102
+ background: color-mix(in srgb, var(--brand, #ff4b26) 12%, transparent);
1103
+ padding: 1px 7px; border-radius: 999px;
1104
+ }
1105
+ .wf-comments-panel__filter {
1106
+ appearance: none; background: none; border: 0;
1107
+ color: var(--wf-text-muted); font: inherit; font-size: 11px;
1108
+ cursor: pointer; padding: 4px 6px; border-radius: 4px;
1109
+ }
1110
+ .wf-comments-panel__filter:hover { color: var(--wf-text-strong); }
1111
+ .wf-comment-card__text {
1112
+ font-size: 13px; line-height: 1.45; color: var(--wf-text);
1113
+ white-space: pre-wrap; word-break: break-word;
1114
+ }
1115
+ .wf-comment-card__meta {
1116
+ display: flex; flex-direction: column; align-items: stretch;
1117
+ gap: 6px; font-size: 11px; color: var(--wf-text-muted);
1118
+ }
1119
+ .wf-comment-card__delete,
1120
+ .wf-comment-card__edit {
1121
+ padding: 2px 8px; border: 1px solid transparent;
1122
+ background: none; color: var(--wf-text-muted);
1123
+ border-radius: 4px; font: inherit; font-size: 11px;
1124
+ cursor: pointer;
1125
+ }
1126
+ .wf-comment-card__edit:hover {
1127
+ background: var(--wf-surface); color: var(--wf-text-strong);
1128
+ border-color: var(--wf-border-strong);
1129
+ }
1130
+ .wf-comment-card__delete:hover {
1131
+ background: #fee2e2; color: #b91c1c; border-color: #fca5a5;
1132
+ }
1133
+ .wf-comment-card__actions {
1134
+ display: flex; justify-content: flex-end; align-items: center;
1135
+ gap: 2px; white-space: nowrap;
1136
+ }
1137
+ .wf-comment-card__delete-confirm {
1138
+ display: flex; align-items: center; gap: 6px;
1139
+ margin-top: 4px; padding: 5px 8px;
1140
+ background: var(--wf-surface-alt, #f8fafc);
1141
+ border: 1px solid var(--wf-border);
1142
+ border-radius: 6px;
1143
+ }
1144
+ .wf-comment-card__delete-confirm-msg {
1145
+ flex: 1; font-size: 11px; font-weight: 500;
1146
+ color: var(--wf-text-muted);
1147
+ }
1148
+ .wf-comment-card__editor {
1149
+ width: 100%; padding: 8px; box-sizing: border-box;
1150
+ border: 1px solid var(--wf-border-strong);
1151
+ border-radius: 6px; font: inherit; font-size: 13px;
1152
+ background: var(--wf-surface); color: var(--wf-text);
1153
+ resize: vertical;
1154
+ }
1155
+ .wf-comment-card__edit-row {
1156
+ display: flex; gap: 4px; justify-content: flex-end;
1157
+ }
1158
+ .wf-comment-card__edit-row .wf-comments-panel__post {
1159
+ padding: 2px 10px; font-size: 11px;
1160
+ }
1161
+ .wf-comments-panel__compose {
1162
+ padding: 10px 12px;
1163
+ border-top: 1px solid var(--wf-border);
1164
+ display: flex; flex-direction: column; gap: 6px;
1165
+ }
1166
+ .wf-mention-wrap { position: relative; }
1167
+ .wf-mention {
1168
+ display: inline;
1169
+ padding: 1px 6px; border-radius: 999px;
1170
+ background: rgba(59, 130, 246, 0.32);
1171
+ color: var(--wf-text-strong); font-weight: 600;
1172
+ }
1173
+ .wf-mention-menu {
1174
+ position: absolute; left: 0; right: 0; bottom: calc(100% + 4px);
1175
+ background: var(--wf-surface); border: 1px solid var(--wf-border-strong);
1176
+ border-radius: 8px; box-shadow: 0 8px 24px rgba(15,23,42,0.22);
1177
+ padding: 4px; z-index: 60; max-height: 180px; overflow-y: auto;
1178
+ }
1179
+ .wf-mention-menu[hidden] { display: none; }
1180
+ .wf-mention-menu__item {
1181
+ display: flex; align-items: center; gap: 8px;
1182
+ padding: 6px 8px; border-radius: 6px; cursor: pointer;
1183
+ font-size: 13px; color: var(--wf-text);
1184
+ }
1185
+ .wf-mention-menu__item:hover,
1186
+ .wf-mention-menu__item.is-active {
1187
+ background: var(--wf-surface-alt); color: var(--wf-text-strong);
1188
+ }
1189
+ .wf-mention-menu__handle { color: var(--wf-text-muted); font-size: 12px; }
1190
+ .wf-comments-panel__readonly {
1191
+ padding: 10px 12px;
1192
+ border-top: 1px solid var(--wf-border);
1193
+ font-size: 12px; color: var(--wf-text-muted);
1194
+ }
1195
+ .wf-comments-panel__readonly[hidden] { display: none; }
1196
+ .wf-comments-panel__readonly a { color: var(--wf-text-strong); }
1197
+ .wf-comments-panel__compose textarea,
1198
+ .wf-comments-panel__compose input[type="text"] {
1199
+ width: 100%; padding: 10px 12px; box-sizing: border-box;
1200
+ border: 1px solid var(--wf-border-strong);
1201
+ border-radius: 6px; font: inherit; font-size: 13px; line-height: 1.5;
1202
+ background: var(--wf-surface-alt); color: var(--wf-text);
1203
+ }
1204
+ .wf-comments-panel__compose textarea {
1205
+ min-height: 70px; resize: vertical;
1206
+ }
1207
+ .wf-comments-panel__post {
1208
+ padding: 6px 12px; border: 0; border-radius: 6px;
1209
+ background: var(--wf-text-strong); color: var(--wf-surface);
1210
+ font: inherit; font-size: 12px; font-weight: 500;
1211
+ cursor: pointer; align-self: flex-end;
1212
+ }
1213
+ .wf-comments-panel__post:disabled { opacity: 0.5; cursor: not-allowed; }
1214
+ /* Compose footer: attach-image affordance left, Post right. */
1215
+ .wf-comments-panel__compose-row {
1216
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
1217
+ }
1218
+ .wf-comments-panel__compose-row .wf-comments-panel__post { align-self: auto; }
1219
+ .wf-comments-panel__attach {
1220
+ display: inline-flex; align-items: center; justify-content: center;
1221
+ width: 26px; height: 26px;
1222
+ padding: 0; border: 1px solid transparent; border-radius: 5px;
1223
+ background: none; color: var(--wf-text-muted);
1224
+ cursor: pointer;
1225
+ }
1226
+ .wf-comments-panel__attach:hover {
1227
+ background: var(--wf-surface); color: var(--wf-text-strong);
1228
+ border-color: var(--wf-border-strong);
1229
+ }
1230
+ /* Staged (not yet posted) image thumbnails with a remove control. */
1231
+ .wf-comments-panel__attachments {
1232
+ display: flex; flex-wrap: wrap; gap: 6px;
1233
+ }
1234
+ .wf-comments-panel__attachments[hidden] { display: none !important; }
1235
+ .wf-comments-panel__attach-thumb { position: relative; display: inline-flex; }
1236
+ .wf-comments-panel__attach-thumb img {
1237
+ display: block;
1238
+ max-height: 56px; max-width: 96px;
1239
+ border: 1px solid var(--wf-border-strong);
1240
+ border-radius: 6px;
1241
+ object-fit: cover;
1242
+ }
1243
+ .wf-comments-panel__attach-remove {
1244
+ position: absolute; top: -6px; right: -6px;
1245
+ display: inline-flex; align-items: center; justify-content: center;
1246
+ width: 16px; height: 16px;
1247
+ padding: 0; border: 1px solid var(--wf-border-strong);
1248
+ border-radius: 999px;
1249
+ background: var(--wf-surface);
1250
+ color: var(--wf-text-muted);
1251
+ font-size: 11px; line-height: 1;
1252
+ cursor: pointer;
1253
+ }
1254
+ .wf-comments-panel__attach-remove:hover { color: #b91c1c; }
1255
+ .wf-comments-panel__attach-error {
1256
+ margin: 0;
1257
+ font-size: 11px; font-weight: 500;
1258
+ color: #b91c1c;
1259
+ }
1260
+ .wf-comments-panel__attach-error[hidden] { display: none !important; }
1261
+ /* Image attachments on a posted comment — thumbnails, click = full image. */
1262
+ .wf-comment-card__attachments {
1263
+ display: flex; flex-wrap: wrap; gap: 6px;
1264
+ }
1265
+ .wf-comment-card__attachments img {
1266
+ display: block;
1267
+ max-height: 120px; max-width: 100%;
1268
+ border: 1px solid var(--wf-border);
1269
+ border-radius: 6px;
1270
+ }
1271
+ /* Compose: the side-note opt-out (reference-only — agents skip it). */
1272
+ .wf-comments-panel__note-toggle {
1273
+ display: flex; align-items: center; gap: 6px;
1274
+ font-size: 11.5px; color: var(--wf-text-muted);
1275
+ cursor: pointer; user-select: none;
1276
+ }
1277
+ .wf-comments-panel__note-toggle input { margin: 0; accent-color: var(--wf-text-strong); }
1278
+ /* Manager compose hint — replaces the side-note toggle for managers,
1279
+ whose comments always wait on designer approval. */
1280
+ .wf-comments-panel__manager-hint {
1281
+ margin: 0; font-size: 11.5px; color: var(--wf-text-muted);
1282
+ }
1283
+ /* Designer approval of a manager comment: context textarea + buttons. */
1284
+ .wf-comment-card__approve-row {
1285
+ display: flex; flex-direction: column; gap: 6px; margin-top: 6px;
1286
+ }
1287
+ /* Designer-added context on an approved manager comment — what the
1288
+ agent should actually do with the feedback. */
1289
+ .wf-comment-card__designer-note {
1290
+ margin-top: 6px; padding: 6px 8px;
1291
+ border-left: 3px solid var(--wf-border-strong);
1292
+ background: var(--wf-surface-alt);
1293
+ border-radius: 0 6px 6px 0;
1294
+ font-size: 12px; color: var(--wf-text);
1295
+ white-space: pre-wrap;
1296
+ }
1297
+ .wf-comment-card__designer-note b { font-weight: 600; }
1298
+ /* Manager comment still waiting on a designer — amber chip. */
1299
+ .wf-comment-card__chip--pending { border-color: #f59e0b; color: #b45309; }
1300
+ html[data-theme='dark'] .wf-comment-card__chip--pending { color: #fcd34d; border-color: #b45309; }
1301
+ @media (prefers-color-scheme: dark) {
1302
+ html:not([data-theme='light']) .wf-comment-card__chip--pending { color: #fcd34d; border-color: #b45309; }
1303
+ }
1304
+ /* Agent considered this but it needs a human decision — a soft-blue pill
1305
+ in the same treatment as the green Resolved badge (an attention badge,
1306
+ not a quiet metadata chip). Mirrors .wf-comment-card__resolved. */
1307
+ .wf-comment-card__needs-human {
1308
+ display: inline-flex; align-items: center; gap: 4px;
1309
+ align-self: flex-start;
1310
+ padding: 1px 7px; border-radius: 999px;
1311
+ font-size: 10.5px; font-weight: 600;
1312
+ background: #dbeafe; color: #1e40af;
1313
+ }
1314
+ /* Small metadata chips on a comment card (side-note marker, viewport). */
1315
+ .wf-comment-card__chip {
1316
+ display: inline-block; margin-left: 6px; padding: 1px 6px;
1317
+ border: 1px solid var(--wf-border-strong); border-radius: 999px;
1318
+ font-size: 10px; line-height: 1.5; color: var(--wf-text-muted);
1319
+ white-space: nowrap;
1320
+ }
1321
+ .wf-comments-panel__scope {
1322
+ display: flex; align-items: center; gap: 8px;
1323
+ font-size: 11px; color: var(--wf-text-muted);
1324
+ }
1325
+ .wf-comments-panel__scope-select {
1326
+ flex: 1;
1327
+ padding: 6px 8px;
1328
+ border: 1px solid var(--wf-border-strong);
1329
+ border-radius: 6px;
1330
+ background: var(--wf-surface-alt);
1331
+ color: var(--wf-text);
1332
+ font: inherit; font-size: 12px;
1333
+ }
1334
+ /* Section heading inside the comment list — separates Flow comments
1335
+ from per-screen threads. */
1336
+ .wf-comments-section {
1337
+ font-size: 10px;
1338
+ text-transform: uppercase;
1339
+ letter-spacing: 0.05em;
1340
+ color: var(--wf-text-muted);
1341
+ padding: 8px 4px 4px 4px;
1342
+ border-bottom: 1px solid var(--wf-border);
1343
+ margin-bottom: 4px;
1344
+ }
1345
+ .wf-comments-section:not(:first-child) { margin-top: 12px; }
1346
+ .wf-comment-card__scope {
1347
+ display: inline-flex; align-items: center;
1348
+ padding: 1px 6px;
1349
+ border-radius: 4px;
1350
+ background: var(--wf-surface-alt);
1351
+ color: var(--wf-text-muted);
1352
+ font-size: 10px; font-weight: 500;
1353
+ margin-right: 6px;
1354
+ }
1355
+
1356
+ /* Auth UI inside the comments panel. Sign-in/up form when no session;
1357
+ a small "signed in as X · Sign out" strip in the header otherwise. */
1358
+ .wf-auth-status {
1359
+ display: flex; align-items: center; gap: 8px;
1360
+ margin-left: auto; margin-right: 8px;
1361
+ font-size: 12px; color: var(--wf-text-muted);
1362
+ }
1363
+ .wf-auth-status__label { font-weight: 500; color: var(--wf-text); }
1364
+ .wf-auth-status__signout {
1365
+ background: transparent; border: 1px solid var(--wf-toolbar-border);
1366
+ color: var(--wf-text-muted); padding: 3px 8px;
1367
+ border-radius: 4px; font-size: 11px; cursor: pointer;
1368
+ }
1369
+ .wf-auth-status__signout:hover { color: var(--wf-text-strong); }
1370
+ /* Gate card/form styling lives in the shared /auth-gate.css (bf-auth-*).
1371
+ `display: flex` on `.wf-comments-panel__compose` overrides the UA
1372
+ `[hidden] { display: none }` rule, so an explicit override is needed
1373
+ for the JS toggle to actually hide it. */
1374
+ .wf-comments-panel__compose[hidden] { display: none !important; }
1375
+
1376
+ /* Topbar user menu — pill that opens a dropdown with sign-out. */
1377
+ .topbar__user-menu { position: relative; display: inline-flex; }
1378
+ .topbar__user-menu[hidden] { display: none; }
1379
+ .topbar__user {
1380
+ display: inline-flex; align-items: center; gap: 8px;
1381
+ padding: 4px 12px 4px 4px;
1382
+ border: 1px solid var(--wf-toolbar-border);
1383
+ border-radius: 999px;
1384
+ font: inherit; font-size: 12px; color: var(--wf-text);
1385
+ background: transparent;
1386
+ cursor: pointer;
1387
+ }
1388
+ .topbar__user:hover { background: var(--wf-toolbar-bg-hover); }
1389
+ .topbar__user-avatar,
1390
+ .topbar__user-avatar-fallback {
1391
+ width: 22px; height: 22px;
1392
+ border-radius: 999px;
1393
+ display: inline-flex; align-items: center; justify-content: center;
1394
+ background: var(--brand, #f97316);
1395
+ color: white;
1396
+ font-size: 10px; font-weight: 600;
1397
+ object-fit: cover;
1398
+ }
1399
+ .topbar__user-avatar[hidden],
1400
+ .topbar__user-avatar-fallback[hidden] { display: none; }
1401
+ .topbar__user-name { display: none; }
1402
+ .topbar__user-caret { font-size: 9px; opacity: 0.6; }
1403
+ .topbar__user-panel {
1404
+ position: absolute; top: calc(100% + 6px); right: 0;
1405
+ min-width: 200px;
1406
+ padding: 6px;
1407
+ background: var(--wf-surface);
1408
+ border: 1px solid var(--wf-border-strong);
1409
+ border-radius: 8px;
1410
+ box-shadow: 0 12px 32px rgba(15, 23, 42, 0.28);
1411
+ z-index: 50;
1412
+ display: flex; flex-direction: column; gap: 2px;
1413
+ }
1414
+ .topbar__user-panel[hidden] { display: none; }
1415
+ .topbar__user-panel-meta {
1416
+ padding: 6px 10px;
1417
+ font-size: 11px;
1418
+ color: var(--wf-text-muted);
1419
+ border-bottom: 1px solid var(--wf-border);
1420
+ margin-bottom: 4px;
1421
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1422
+ }
1423
+ .topbar__user-panel-item {
1424
+ display: flex; align-items: center; gap: 6px;
1425
+ padding: 7px 10px;
1426
+ background: transparent; border: 0;
1427
+ color: var(--wf-text);
1428
+ font: inherit; font-size: 12px;
1429
+ text-align: left;
1430
+ border-radius: 6px;
1431
+ cursor: pointer;
1432
+ }
1433
+ .topbar__user-panel-item:hover { background: var(--wf-surface-alt); color: var(--wf-text-strong); }
1434
+ .topbar__user-panel-label {
1435
+ padding: 6px 10px 2px;
1436
+ font-size: 10px; font-weight: 600; letter-spacing: 0.04em;
1437
+ text-transform: uppercase; color: var(--wf-text-muted);
1438
+ }
1439
+ .topbar__theme-toggle {
1440
+ display: flex; gap: 2px;
1441
+ margin: 0 8px 4px; padding: 2px;
1442
+ background: var(--wf-surface-alt);
1443
+ border: 1px solid var(--wf-border);
1444
+ border-radius: 7px;
1445
+ }
1446
+ .topbar__theme-option {
1447
+ flex: 1; padding: 5px 6px;
1448
+ background: transparent; border: 0; border-radius: 5px;
1449
+ color: var(--wf-text-muted);
1450
+ font: inherit; font-size: 11px; cursor: pointer;
1451
+ }
1452
+ .topbar__theme-option:hover { color: var(--wf-text-strong); }
1453
+ .topbar__theme-option[aria-pressed='true'] {
1454
+ background: var(--wf-surface);
1455
+ color: var(--wf-text-strong);
1456
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.18);
1457
+ }
1458
+ .topbar__localport {
1459
+ display: flex; align-items: center;
1460
+ margin: 0 8px 6px; padding: 5px 8px;
1461
+ background: var(--wf-surface-alt);
1462
+ border: 1px solid var(--wf-border); border-radius: 7px;
1463
+ font-size: 12px; color: var(--wf-text-muted);
1464
+ }
1465
+ .topbar__localport-input {
1466
+ flex: 1; min-width: 0; width: 100%;
1467
+ background: none; border: 0; outline: none;
1468
+ color: var(--wf-text-strong);
1469
+ font: inherit; font-size: 12px;
1470
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1471
+ }
1472
+ /* "View local" is a desktop-only convenience (you're not running a
1473
+ dev server on a phone) and only meaningful on the deployed site. */
1474
+ .topbar__viewlocal[hidden] { display: none; }
1475
+ @media (max-width: 720px) {
1476
+ .topbar__viewlocal { display: none !important; }
1477
+ }
1478
+
1479
+ /* Auth gate — full-page overlay shown until the user has a session.
1480
+ Markup from _auth-gate.njk; styling from the shared /auth-gate.css. */
1481
+ .wf-comments-panel__readonly-note {
1482
+ font-size: 11px; color: var(--wf-text-muted);
1483
+ padding: 8px 12px;
1484
+ border-top: 1px solid var(--wf-border);
1485
+ }
1486
+
1487
+ /* Floating comment-toggle FAB — pinned to the bottom-right of the
1488
+ canvas. Reachable on mobile without competing with the toolbar. */
1489
+ .wf-comments-fab {
1490
+ position: absolute;
1491
+ right: 16px; bottom: 16px;
1492
+ width: 44px; height: 44px;
1493
+ border-radius: 999px;
1494
+ border: 1px solid var(--wf-toolbar-border);
1495
+ background: var(--wf-surface);
1496
+ color: var(--wf-text);
1497
+ cursor: pointer;
1498
+ display: flex; align-items: center; justify-content: center;
1499
+ box-shadow: 0 6px 18px rgba(15,23,42,0.18);
1500
+ z-index: 35;
1501
+ }
1502
+ .wf-comments-fab:hover { background: var(--wf-surface-alt); color: var(--wf-text-strong); }
1503
+ .wf-comments-fab.is-active {
1504
+ background: var(--wf-text-strong); color: var(--wf-surface);
1505
+ border-color: var(--wf-text-strong);
1506
+ }
1507
+ .wf-comments-fab .material-symbols-sharp { font-size: 22px; }
1508
+ .wireflow-comment-toggle__count {
1509
+ position: absolute; top: -4px; right: -4px;
1510
+ min-width: 18px; height: 18px;
1511
+ /* Zero horizontal padding so single- and double-digit counts stay
1512
+ square (≤ 9 fits inside 18px; the badge renders as a circle).
1513
+ Three+ digits will pill out the width — acceptable for an edge
1514
+ case where a screen has >99 comments. */
1515
+ padding: 0;
1516
+ border-radius: 999px;
1517
+ background: #ff4b26; color: #fff;
1518
+ font-size: 10px; font-weight: 600;
1519
+ display: flex; align-items: center; justify-content: center;
1520
+ border: 2px solid var(--wf-surface);
1521
+ box-sizing: content-box;
1522
+ }
1523
+
1524
+ /* Hide the (i) info pill on narrow viewports — it competes with the
1525
+ prototype title for space and isn't critical on mobile. */
1526
+ @media (max-width: 720px) {
1527
+ .wf-info { display: none; }
1528
+ .topbar { padding: 8px 12px; }
1529
+ .topbar h1 { font-size: 13px; }
1530
+ .topbar h1 small { display: none; }
1531
+ .wf-toolbar { top: 8px; left: 8px; right: 8px; }
1532
+ .wf-comments-panel { left: 8px; right: 8px; width: auto; }
1533
+ }
1534
+
1535
+ /* Info tooltip — small (i) button next to the prototype title that
1536
+ reveals a short usage cheatsheet on hover/focus. Pure CSS so there's
1537
+ no JS handler to wire up; `tabindex=0` makes it keyboard-reachable. */
1538
+ .wf-info {
1539
+ position: relative;
1540
+ display: inline-flex; align-items: center; justify-content: center;
1541
+ margin-left: 8px;
1542
+ color: var(--wf-text-muted);
1543
+ cursor: help; user-select: none; line-height: 1;
1544
+ vertical-align: middle;
1545
+ }
1546
+ .wf-info > svg { display: block; }
1547
+ .wf-info:hover, .wf-info:focus { color: var(--wf-text-strong); outline: none; }
1548
+ .wf-info__panel {
1549
+ position: absolute; top: calc(100% + 8px); left: 0;
1550
+ width: 320px; padding: 12px 14px;
1551
+ background: var(--wf-surface); border: 1px solid var(--wf-border-strong);
1552
+ border-radius: 8px; box-shadow: 0 4px 18px rgba(0,0,0,0.18);
1553
+ color: var(--wf-text); font-weight: 400; font-style: normal;
1554
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1555
+ font-size: 12px; line-height: 1.5;
1556
+ opacity: 0; pointer-events: none; transform: translateY(-4px);
1557
+ transition: opacity 120ms ease, transform 120ms ease;
1558
+ z-index: 100; text-align: left;
1559
+ }
1560
+ .wf-info:hover .wf-info__panel,
1561
+ .wf-info:focus .wf-info__panel,
1562
+ .wf-info__panel:hover { opacity: 1; pointer-events: auto; transform: translateY(0); }
1563
+ .wf-info__panel h3 {
1564
+ margin: 0 0 6px; font-size: 12px; font-weight: 600;
1565
+ color: var(--wf-text-strong);
1566
+ }
1567
+ .wf-info__panel ul { margin: 0; padding-left: 18px; }
1568
+ .wf-info__panel li { margin: 2px 0; }
1569
+ .wf-info__panel kbd {
1570
+ display: inline-block; padding: 0 5px; font-size: 11px;
1571
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1572
+ background: var(--wf-surface-alt); border: 1px solid var(--wf-border-strong);
1573
+ border-radius: 3px; color: var(--wf-text-strong);
1574
+ }
1575
+ </style>
1576
+ {# Shared breakpoint config — single source of truth for the Viewport
1577
+ control (also used by the delivery-site nav and detail screens). #}
1578
+ <script src="/bedrock/data/bedrock-config.js"></script>
1579
+ {# ⌘K command palette — search the DS, open a component in Storybook. #}
1580
+ <script src="/cmdk.js" defer></script>
1581
+ {# Shared image lightbox — comment attachment thumbnails open in it. #}
1582
+ <script src="/lightbox.js" defer></script>
1583
+ </head>
1584
+ <body>
1585
+ <header class="topbar">
1586
+ <div class="topbar__left">
1587
+ <a class="topbar__back" href="/" title="Back to home" aria-label="Back to home"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 18L8 12L14 6"/></svg></a>
1588
+ <h1 class="topbar__title">
1589
+ <button type="button" class="topbar__switcher" id="wfSwitcherTrigger" aria-haspopup="menu" aria-expanded="false" title="Switch wireflow">
1590
+ {{ meta.name }} <small>{{ meta.id }} · Flow</small>
1591
+ <span class="topbar__switcher-caret" aria-hidden="true"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M15.9325 10C16.3564 10 16.588 10.4944 16.3166 10.8201L12.3841 15.5391C12.1842 15.7789 11.8158 15.7789 11.6159 15.5391L7.68341 10.8201C7.41202 10.4944 7.6436 10 8.06752 10H15.9325Z"/></svg></span>
1592
+ </button>
1593
+ <div class="topbar__switcher-menu" id="wfSwitcherMenu" role="menu" hidden></div>
1594
+ <span class="wf-info" tabindex="0" aria-label="How to use this prototype"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21Z"/><path d="M11.9999 16.7122V10.6733" stroke-linecap="round"/><path d="M12.0029 7.32996H12.0149" stroke-linecap="round"/></svg><span class="wf-info__panel" role="tooltip">
1595
+ <h3>Using this prototype</h3>
1596
+ <ul>
1597
+ <li>Click any frame to open the live screen.</li>
1598
+ <li>Drag to pan, scroll-wheel to zoom, or use the <kbd>+</kbd> / <kbd>−</kbd> buttons. Click the zoom % to refit.</li>
1599
+ <li>Press <kbd>.</kbd> on any rendered screen to toggle the prototype chrome (back / continue bar). Press <kbd>Esc</kbd> to hide it again.</li>
1600
+ <li>Switch between <strong>Wireflow</strong>, <strong>Mermaid</strong>, and <strong>Spec</strong> using the tabs on the left. Mermaid + Spec offer copy/download from the right toolbar.</li>
1601
+ <li>Toggle <strong>LR</strong> / <strong>TB</strong> to flip the diagram orientation; the choice is remembered.</li>
1602
+ </ul>
1603
+ </span></span></h1>
1604
+ </div>
1605
+ <div class="topbar__actions">
1606
+ {% if meta.status %}
1607
+ {%- set _statusSlug = (meta.status | lower) | replace(' ', '-') -%}
1608
+ <span class="topbar__status topbar__status--{{ _statusSlug }}" title="Status from {{ meta.id }} meta.json">{{ meta.status }}</span>
1609
+ {% endif %}
1610
+ <a class="topbar__ds topbar__viewlocal" id="wfViewLocal" href="#" title="Open this page on your local dev server" hidden>
1611
+ <span class="topbar__ds-label">View local</span>
1612
+ <span class="topbar__ds-arrow" aria-hidden="true">↗</span>
1613
+ </a>
1614
+ <details class="topbar__refs">
1615
+ <summary class="topbar__ds" title="What this flow is built from">
1616
+ <span class="topbar__ds-label">References</span>
1617
+ <span class="topbar__ds-arrow" aria-hidden="true"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M15.9325 10C16.3564 10 16.588 10.4944 16.3166 10.8201L12.3841 15.5391C12.1842 15.7789 11.8158 15.7789 11.6159 15.5391L7.68341 10.8201C7.41202 10.4944 7.6436 10 8.06752 10H15.9325Z"/></svg></span>
1618
+ </summary>
1619
+ <div class="topbar__refs-menu" role="menu">
1620
+ <a class="topbar__refs-item" href="/ds/{{ meta.dsVersion }}/storybook/" title="Open {{ meta.dsVersion }} storybook">
1621
+ <span class="topbar__refs-k">Design system</span>
1622
+ <span class="topbar__refs-v">{{ meta.dsVersion }} <span aria-hidden="true">↗</span></span>
1623
+ </a>
1624
+ </div>
1625
+ </details>
1626
+ <div class="topbar__user-menu" id="wfTopbarUser" hidden>
1627
+ <button type="button" class="topbar__user" id="wfTopbarUserTrigger" aria-haspopup="menu" aria-expanded="false">
1628
+ <img class="topbar__user-avatar" id="wfTopbarUserAvatar" alt="" referrerpolicy="no-referrer" hidden />
1629
+ <span class="topbar__user-avatar-fallback" id="wfTopbarUserInitials" hidden></span>
1630
+ <span class="topbar__user-name" id="wfTopbarUserName"></span>
1631
+ <span class="topbar__user-caret" aria-hidden="true"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M15.9325 10C16.3564 10 16.588 10.4944 16.3166 10.8201L12.3841 15.5391C12.1842 15.7789 11.8158 15.7789 11.6159 15.5391L7.68341 10.8201C7.41202 10.4944 7.6436 10 8.06752 10H15.9325Z"/></svg></span>
1632
+ </button>
1633
+ <div class="topbar__user-panel" id="wfTopbarUserPanel" role="menu" hidden>
1634
+ <div class="topbar__user-panel-meta" id="wfTopbarUserEmail"></div>
1635
+ <div class="topbar__user-panel-label">Appearance</div>
1636
+ <div class="topbar__theme-toggle" role="group" aria-label="Appearance">
1637
+ <button type="button" class="topbar__theme-option" data-theme-set="system">System</button>
1638
+ <button type="button" class="topbar__theme-option" data-theme-set="light">Light</button>
1639
+ <button type="button" class="topbar__theme-option" data-theme-set="dark">Dark</button>
1640
+ </div>
1641
+ <div class="topbar__user-panel-label">Local server</div>
1642
+ <div class="topbar__localport">
1643
+ <span class="topbar__localport-prefix">localhost:</span>
1644
+ <input type="text" inputmode="numeric" id="wfLocalPort" class="topbar__localport-input" value="5173" aria-label="Local dev server port" />
1645
+ </div>
1646
+ <button type="button" class="topbar__user-panel-item" id="wfTopbarSignout">Sign out</button>
1647
+ </div>
1648
+ </div>
1649
+ </div>
1650
+ </header>
1651
+ <div class="wireflow-canvas" id="wfCanvas">
1652
+ <div class="wf-toolbar">
1653
+ {# Single dropdown listing all available views (Wireflow / Mermaid /
1654
+ Spec) instead of three side-by-side tabs. The button shows the
1655
+ currently active view; clicking opens the menu. Aria-pressed
1656
+ attributes are still maintained on the menu items so the
1657
+ existing setView() logic keeps working unchanged. #}
1658
+ <div class="wf-menu" data-menu="views">
1659
+ <button class="wf-menu__trigger" id="wfViewMenuBtn" type="button" aria-haspopup="menu" aria-expanded="false">
1660
+ <span id="wfViewMenuLabel">Flow</span>
1661
+ <span class="wf-menu__caret" aria-hidden="true"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M15.9325 10C16.3564 10 16.588 10.4944 16.3166 10.8201L12.3841 15.5391C12.1842 15.7789 11.8158 15.7789 11.6159 15.5391L7.68341 10.8201C7.41202 10.4944 7.6436 10 8.06752 10H15.9325Z"/></svg></span>
1662
+ </button>
1663
+ <div class="wf-menu__panel" role="menu">
1664
+ <button id="wfTabWireflow" role="menuitemradio" aria-checked="true">Flow</button>
1665
+ <button id="wfTabMermaid" role="menuitemradio" aria-checked="false">Mermaid</button>
1666
+ {% if specHtml %}<button id="wfTabSpec" role="menuitemradio" aria-checked="false">Spec</button>{% endif %}
1667
+ {% if figmaEmbedUrl %}<button id="wfTabFigma" role="menuitemradio" aria-checked="false">Figma Wireframe</button>{% endif %}
1668
+ </div>
1669
+ </div>
1670
+ <div class="wf-actions">
1671
+ <div class="wf-actions__group wf-actions__group--wireflow">
1672
+ <button class="wireflow-dir-toggle" id="wfDirToggle">LR</button>
1673
+ {# Viewport breakpoint control — reflows every node to the chosen
1674
+ breakpoint width (full = each node's design-default size).
1675
+ Buttons are injected by initViewport() from BEDROCK_CONFIG.breakpoints. #}
1676
+ <span class="wireflow-viewport" id="wfViewport" role="group" aria-label="Viewport"></span>
1677
+ <span class="wireflow-zoom-controls">
1678
+ <button class="wireflow-ctrl-btn" data-action="zoom-out" title="Zoom out">−</button>
1679
+ <button class="wireflow-zoom-pct" id="wfZoomPct" title="Click to reset to fit" type="button">100%</button>
1680
+ <button class="wireflow-ctrl-btn" data-action="zoom-in" title="Zoom in">+</button>
1681
+ </span>
1682
+ {# Copy the entire wireflow's screens to the clipboard as Figma
1683
+ layers. Iterates every .wf-node iframe, clones each .proto-content
1684
+ into an offscreen wrapper, pre-rasterizes Material Symbols ligature
1685
+ spans, then hands the wrapper to Figit (lazy-loaded). The dropdown
1686
+ chooses how to lay the screens out:
1687
+ • Linearly — screens in a single row (no arrows). Convenient
1688
+ when you want to wire FigJam/Figma connectors yourself.
1689
+ • With arrows — elk positions + the wireflow's own arrow SVG
1690
+ baked in so the journey reads the same in Figma. #}
1691
+ <div class="wf-menu" data-menu="figma-copy">
1692
+ <button class="wireflow-ctrl-btn wireflow-figma-btn wf-menu__trigger" id="wfFigmaCopyBtn" type="button"
1693
+ title="Copy all screens to the clipboard as Figma layers" aria-haspopup="menu" aria-expanded="false">
1694
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1695
+ <path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
1696
+ <rect x="8" y="2" width="8" height="4" rx="1"/>
1697
+ </svg>
1698
+ <span id="wfFigmaCopyLabel" style="margin-left:6px">Copy to Figma</span>
1699
+ <span class="wf-menu__caret" aria-hidden="true" style="margin-left:4px"><svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M15.9325 10C16.3564 10 16.588 10.4944 16.3166 10.8201L12.3841 15.5391C12.1842 15.7789 11.8158 15.7789 11.6159 15.5391L7.68341 10.8201C7.41202 10.4944 7.6436 10 8.06752 10H15.9325Z"/></svg></span>
1700
+ </button>
1701
+ <div class="wf-menu__panel" role="menu">
1702
+ <button id="wfFigmaCopyLinear" type="button" role="menuitem" data-mode="linear">Screens in a row</button>
1703
+ <button id="wfFigmaCopyArrows" type="button" role="menuitem" data-mode="arrows">Screens with arrows</button>
1704
+ </div>
1705
+ </div>
1706
+ <button class="wireflow-ctrl-btn wireflow-legend-btn" id="wfLegendBtn" type="button"
1707
+ title="Legend" aria-expanded="false" aria-controls="wfLegend">
1708
+ <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
1709
+ <rect x="1" y="2" width="4" height="4" rx="1" fill="currentColor"/>
1710
+ <rect x="6.5" y="3" width="6.5" height="2" rx="1" fill="currentColor"/>
1711
+ <rect x="1" y="8" width="4" height="4" rx="1" fill="currentColor"/>
1712
+ <rect x="6.5" y="9" width="6.5" height="2" rx="1" fill="currentColor"/>
1713
+ </svg>
1714
+ </button>
1715
+ </div>
1716
+ {# Mermaid sub-actions in a single overflow menu instead of four
1717
+ side-by-side buttons. Same underlying button ids so the JS
1718
+ handlers below don't need to change. #}
1719
+ <div class="wf-actions__group wf-actions__group--mermaid">
1720
+ <div class="wf-menu" data-menu="mermaid">
1721
+ <button class="wf-menu__trigger" type="button" aria-haspopup="menu" aria-expanded="false">
1722
+ Actions <span class="wf-menu__caret" aria-hidden="true"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M15.9325 10C16.3564 10 16.588 10.4944 16.3166 10.8201L12.3841 15.5391C12.1842 15.7789 11.8158 15.7789 11.6159 15.5391L7.68341 10.8201C7.41202 10.4944 7.6436 10 8.06752 10H15.9325Z"/></svg></span>
1723
+ </button>
1724
+ <div class="wf-menu__panel" role="menu">
1725
+ <button id="wfMermaidAscii" type="button" role="menuitemcheckbox" aria-checked="false">Show as ASCII source</button>
1726
+ <button id="wfMermaidCopy" type="button" role="menuitem">Copy Mermaid source</button>
1727
+ <button id="wfMermaidDownload" type="button" role="menuitem">Download .mmd</button>
1728
+ <button id="wfMermaidDownloadSvg" type="button" role="menuitem">Download SVG</button>
1729
+ </div>
1730
+ </div>
1731
+ </div>
1732
+ {% if specHtml %}
1733
+ {# Spec sub-actions in a single overflow menu. The "Open original"
1734
+ link stays accessible inside the menu via a real <a>. #}
1735
+ <div class="wf-actions__group wf-actions__group--spec">
1736
+ <div class="wf-menu" data-menu="spec">
1737
+ <button class="wf-menu__trigger" type="button" aria-haspopup="menu" aria-expanded="false">
1738
+ Actions <span class="wf-menu__caret" aria-hidden="true"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M15.9325 10C16.3564 10 16.588 10.4944 16.3166 10.8201L12.3841 15.5391C12.1842 15.7789 11.8158 15.7789 11.6159 15.5391L7.68341 10.8201C7.41202 10.4944 7.6436 10 8.06752 10H15.9325Z"/></svg></span>
1739
+ </button>
1740
+ <div class="wf-menu__panel" role="menu">
1741
+ <button id="wfSpecCopy" type="button" role="menuitem">Copy Markdown</button>
1742
+ <button id="wfSpecDownload" type="button" role="menuitem">Download .md</button>
1743
+ {% if meta.briefUrl %}<a class="wf-spec-link" href="{{ meta.briefUrl }}" target="_blank" rel="noopener noreferrer" role="menuitem">Open original ↗</a>{% endif %}
1744
+ </div>
1745
+ </div>
1746
+ </div>
1747
+ {% endif %}
1748
+ {% if figmaEmbedUrl %}
1749
+ {# Figma view actions — a single "Edit in Figma" button. Only one
1750
+ thing makes sense here so we skip the Actions dropdown and put
1751
+ the link directly in the toolbar. #}
1752
+ <div class="wf-actions__group wf-actions__group--figma">
1753
+ <a class="wf-spec-link wf-figma-edit" href="{{ meta.figmaUrl }}" target="_blank" rel="noopener noreferrer">
1754
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="margin-right:4px">
1755
+ <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
1756
+ <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
1757
+ </svg>
1758
+ Edit in Figma ↗
1759
+ </a>
1760
+ </div>
1761
+ {% endif %}
1762
+ </div>
1763
+ </div>
1764
+ <div class="wireflow-canvas__inner" id="wfInner"></div>
1765
+ {# Legend popup — opened from the tiny button in the toolbar. Explains
1766
+ the wireflow's visual vocabulary (frames vs notes, solid vs dashed
1767
+ arrows, success vs dead-end pills). Wireflow view only. #}
1768
+ <aside class="wf-legend" id="wfLegend" data-open="false" aria-label="Wireflow legend" hidden>
1769
+ <div class="wf-legend__head">Legend</div>
1770
+ <div class="wf-legend__body" id="wfLegendBody">
1771
+ <div class="wf-legend__row">
1772
+ <span class="wf-legend__swatch wf-legend__swatch--screen"></span>
1773
+ <span>Screen</span>
1774
+ </div>
1775
+ <div class="wf-legend__row">
1776
+ <span class="wf-legend__swatch wf-legend__swatch--note"></span>
1777
+ <span>Off-system note</span>
1778
+ </div>
1779
+ <div class="wf-legend__row">
1780
+ <svg class="wf-legend__edge" width="44" height="10" viewBox="0 0 44 10" aria-hidden="true">
1781
+ <line x1="2" y1="5" x2="36" y2="5" stroke="currentColor" stroke-width="1.5"/>
1782
+ <path d="M36 1 L42 5 L36 9 Z" fill="currentColor"/>
1783
+ </svg>
1784
+ <span>Next step</span>
1785
+ </div>
1786
+ <div class="wf-legend__row">
1787
+ <svg class="wf-legend__edge wf-legend__edge--loop" width="44" height="10" viewBox="0 0 44 10" aria-hidden="true">
1788
+ <line x1="2" y1="5" x2="36" y2="5" stroke="currentColor" stroke-width="1.5" stroke-dasharray="4 3"/>
1789
+ <path d="M36 1 L42 5 L36 9 Z" fill="currentColor"/>
1790
+ </svg>
1791
+ <span>Loop / retry</span>
1792
+ </div>
1793
+ <div class="wf-legend__row">
1794
+ <span class="wf-legend__pill wf-legend__pill--success">End</span>
1795
+ <span>Happy ending</span>
1796
+ </div>
1797
+ <div class="wf-legend__row">
1798
+ <span class="wf-legend__pill wf-legend__pill--dead-end">End</span>
1799
+ <span>Dead-end / error</span>
1800
+ </div>
1801
+ </div>
1802
+ </aside>
1803
+ <div class="wireflow-mermaid" id="wfMermaid">
1804
+ <pre class="mermaid" id="wfMermaidDiagram">{{ mermaid | safe }}</pre>
1805
+ <pre class="wf-mermaid-source" id="wfMermaidSource">{{ mermaid | safe }}</pre>
1806
+ </div>
1807
+ {% if specHtml %}
1808
+ <div class="wireflow-spec" id="wfSpec">
1809
+ {% if specSyncedAt %}
1810
+ <p class="wf-spec-meta"><span class="wf-spec-meta__label">Last synced:</span> {{ specSyncedAt }}</p>
1811
+ {% endif %}
1812
+ <article class="wf-spec-prose" id="wfSpecBody">{{ specHtml | safe }}</article>
1813
+ <script id="wf-spec-md" type="application/json">{{ specMarkdownJson | safe }}</script>
1814
+ </div>
1815
+ {% endif %}
1816
+ {% if figmaEmbedUrl %}
1817
+ {# Design-reference panel — embeds the public Figma file as an alternative
1818
+ view of the same flow. The iframe is left empty until the user
1819
+ activates the view (lazy mount via data-src → src) so the Figma
1820
+ embed's network cost only hits the people who ask for it. #}
1821
+ <div class="wireflow-figma" id="wfFigma">
1822
+ <iframe
1823
+ id="wfFigmaFrame"
1824
+ title="Figma design reference for {{ meta.id }}"
1825
+ data-src="{{ figmaEmbedUrl }}"
1826
+ allowfullscreen
1827
+ loading="lazy"
1828
+ referrerpolicy="no-referrer-when-downgrade"></iframe>
1829
+ </div>
1830
+ {% endif %}
1831
+
1832
+ {# Floating comment-toggle FAB. Pinned bottom-right of the canvas so
1833
+ it's always reachable without crowding the toolbar. The unread
1834
+ count badge sits in the corner of the FAB. #}
1835
+ <button type="button" class="wf-comments-fab" id="wfCommentToggle" title="Comments" aria-label="Open comments">
1836
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1837
+ <path d="M21 15C21 15.5304 20.7893 16.0391 20.4142 16.4142C20.0391 16.7893 19.5304 17 19 17H7L3 21V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V15Z"/>
1838
+ </svg>
1839
+ <span class="wireflow-comment-toggle__count" id="wfCommentCount" hidden>0</span>
1840
+ </button>
1841
+
1842
+ <aside class="wf-comments-panel" id="wfCommentsPanel" aria-label="Comments">
1843
+ <div class="wf-comments-panel__header">
1844
+ <h2 class="wf-comments-panel__title">Comments</h2>
1845
+ <button type="button" class="wf-comments-panel__filter" id="wfCommentsFilter" title="Toggle resolved comments">Hide resolved</button>
1846
+ <button type="button" class="wf-comments-panel__close" id="wfCommentsClose" title="Close" aria-label="Close">
1847
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" aria-hidden="true"><path d="M3 3l8 8M11 3l-8 8"/></svg>
1848
+ </button>
1849
+ </div>
1850
+ <div class="wf-comments-panel__list" id="wfCommentsList"></div>
1851
+
1852
+ <div class="wf-comments-panel__compose" id="wfCommentsCompose" hidden>
1853
+ <div class="wf-mention-wrap">
1854
+ <textarea id="wfCommentsInput" placeholder="Post a thought about this flow… use @ to mention" rows="3"></textarea>
1855
+ <div class="wf-mention-menu" id="wfMentionMenu" role="listbox" hidden></div>
1856
+ </div>
1857
+ {# Pending image attachments — removable thumbnails, filled by JS. #}
1858
+ <div class="wf-comments-panel__attachments" id="wfCommentsAttachRow" hidden></div>
1859
+ <p class="wf-comments-panel__attach-error" id="wfCommentsAttachError" hidden></p>
1860
+ {# Comments are agent-actionable by default; this opt-out marks one as
1861
+ a side note / reference so automated agents leave it alone.
1862
+ Managers don't get the choice — their comments always await a
1863
+ designer's approval, so the toggle swaps for a hint. #}
1864
+ <label class="wf-comments-panel__note-toggle" id="wfCommentsNoteLabel">
1865
+ <input type="checkbox" id="wfCommentsNoteToggle">
1866
+ <span>Side note — agents won't act on this</span>
1867
+ </label>
1868
+ <p class="wf-comments-panel__manager-hint" id="wfCommentsManagerHint" hidden>
1869
+ A designer reviews your comments before anything is changed.
1870
+ </p>
1871
+ <div class="wf-comments-panel__compose-row">
1872
+ <button type="button" class="wf-comments-panel__attach" id="wfCommentsAttach" title="Attach image" aria-label="Attach image">
1873
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
1874
+ </button>
1875
+ <input type="file" id="wfCommentsFile" accept="image/*" multiple hidden>
1876
+ <button type="button" class="wf-comments-panel__post" id="wfCommentsPost" disabled>Post</button>
1877
+ </div>
1878
+ </div>
1879
+ <div class="wf-comments-panel__readonly" id="wfCommentsReadonly" hidden>
1880
+ Read-only in local dev. <a id="wfCommentsLiveLink" target="_blank" rel="noreferrer">Open the live site</a> to post or edit.
1881
+ </div>
1882
+ </aside>
1883
+
1884
+ {# Full-page auth gate. Hidden by default to avoid a flash on refresh
1885
+ for already-signed-in users; the JS session check unhides it only
1886
+ when there's no user. The worker still gates the comments PUT
1887
+ endpoint, so anonymous users that bypass the gate still can't
1888
+ write — they just see the page until they reload. #}
1889
+ {# Shared gate markup (_auth-gate.njk) + styling (/auth-gate.css) — the
1890
+ same card the screen pages and the dashboard render. #}
1891
+ {% import "_auth-gate.njk" as authUI %}
1892
+ {{ authUI.gate('wf', 'Sign in to continue', 'This prototype review tool is restricted to invited reviewers.', signup=true) }}
1893
+ </div>
1894
+
1895
+ <script id="wf-data" type="application/json">{ "nodes": {{ nodesJson | safe }}, "edges": {{ edgesJson | safe }} }</script>
1896
+ <script type="module">
1897
+ // Bundled @bedrock-flows/wireflow/client (elkjs + the layered-layout driver),
1898
+ // built to /wireflow-client.js — replaces the previous elkjs CDN import.
1899
+ import { layoutWireflow, sizes } from '/wireflow-client.js';
1900
+
1901
+ const data = JSON.parse(document.getElementById('wf-data').textContent);
1902
+ const nodeDefs = data.nodes;
1903
+ const edgeDefs = data.edges;
1904
+
1905
+ // ─── crash-trace beacon ────────────────────────────────────────────────
1906
+ // iOS Safari kills the tab before any in-page console log can be read on
1907
+ // a remote machine. Ship events out via sendBeacon so they survive the
1908
+ // tab dying. View live with `wrangler tail` — events appear as
1909
+ // `[wf-log] {...}`. Toggle on with ?trace=1 in the URL or by setting
1910
+ // localStorage.wfTrace = '1'.
1911
+ // Default ON while we're hunting the iOS OOM crash. Disable with
1912
+ // ?trace=0 or localStorage.wfTrace='0'.
1913
+ const traceOn = (() => {
1914
+ try {
1915
+ const q = new URLSearchParams(location.search).get('trace');
1916
+ if (q === '0') return false;
1917
+ if (q === '1') return true;
1918
+ if (localStorage.getItem('wfTrace') === '0') return false;
1919
+ return true;
1920
+ } catch { return true; }
1921
+ })();
1922
+ const sessionId = Math.random().toString(36).slice(2, 10);
1923
+ const protoId = '{{ meta.id }}';
1924
+ const t0 = performance.now();
1925
+ function wfLog(event, extra) {
1926
+ if (!traceOn) return;
1927
+ const payload = {
1928
+ proto: protoId,
1929
+ sid: sessionId,
1930
+ t: Math.round(performance.now() - t0),
1931
+ event,
1932
+ ...(extra || {}),
1933
+ };
1934
+ try {
1935
+ const mem = performance.memory;
1936
+ if (mem) {
1937
+ payload.heapUsedMB = Math.round(mem.usedJSHeapSize / 1048576);
1938
+ payload.heapLimitMB = Math.round(mem.jsHeapSizeLimit / 1048576);
1939
+ }
1940
+ } catch {}
1941
+ try {
1942
+ const body = JSON.stringify(payload);
1943
+ const blob = new Blob([body], { type: 'application/json' });
1944
+ const sent = navigator.sendBeacon && navigator.sendBeacon('/__wf-log', blob);
1945
+ if (!sent) {
1946
+ fetch('/__wf-log', { method: 'POST', body, keepalive: true, headers: { 'Content-Type': 'application/json' } }).catch(() => {});
1947
+ }
1948
+ } catch {}
1949
+ }
1950
+ if (traceOn) {
1951
+ wfLog('boot', {
1952
+ ua: navigator.userAgent,
1953
+ dpr: window.devicePixelRatio,
1954
+ vw: window.innerWidth,
1955
+ vh: window.innerHeight,
1956
+ nodeCount: Object.keys(nodeDefs || {}).length,
1957
+ edgeCount: (edgeDefs || []).length,
1958
+ });
1959
+ window.addEventListener('error', e => wfLog('error', { msg: String(e.message), src: e.filename, ln: e.lineno }));
1960
+ window.addEventListener('unhandledrejection', e => wfLog('reject', { msg: String((e.reason && e.reason.message) || e.reason) }));
1961
+ window.addEventListener('pagehide', e => wfLog('pagehide', { persisted: e.persisted }));
1962
+ document.addEventListener('visibilitychange', () => wfLog('vis', { state: document.visibilityState }));
1963
+ // Periodic memory heartbeat — captures growth even when no interaction.
1964
+ setInterval(() => wfLog('tick'), 5000);
1965
+ }
1966
+ // ────────────────────────────────────────────────────────────────────────
1967
+
1968
+ // `sizes` (card + iframe framing per node size) is imported from the
1969
+ // bundled client — sm = 1280×800 desktop; phone = 402×874 mobile.
1970
+
1971
+ const canvas = document.getElementById('wfCanvas');
1972
+ const inner = document.getElementById('wfInner');
1973
+ let direction = localStorage.getItem('wfDir-{{ meta.id }}') || 'LR';
1974
+ // Initial values are placeholders — `fitToScreen()` recomputes scale/tx/ty
1975
+ // after the first ELK layout so the whole graph is visible and centered
1976
+ // in the canvas (matching designer expectation when first opening the
1977
+ // wireflow page). Subsequent zoom/pan/direction-change reuse the values.
1978
+ let scale = 0.55, tx = 30, ty = 30, panning = false, sx, sy;
1979
+
1980
+ // ── Viewport breakpoints ────────────────────────────────────────────────
1981
+ // Single source of truth with the delivery-site Viewport control: read
1982
+ // BEDROCK_CONFIG.breakpoints, fall back to the standard set. Selecting a
1983
+ // breakpoint reflows every screen node to that width (full = each node's
1984
+ // design-default size). All breakpoint nodes render at one fixed scale so
1985
+ // mobile vs desktop are directly comparable on the canvas.
1986
+ const WF_BREAKPOINTS = (window.BEDROCK_CONFIG && window.BEDROCK_CONFIG.breakpoints) || [
1987
+ { id: 'full', label: 'Full width', width: null },
1988
+ { id: 'desktop', label: 'Desktop', width: 1280 },
1989
+ { id: 'tablet-lg', label: 'Large Tablet', width: 960 },
1990
+ { id: 'tablet', label: 'Tablet', width: 720 },
1991
+ { id: 'mobile', label: 'Mobile', width: 375, height: 812 },
1992
+ ];
1993
+ // Device glyphs as a descending width staircase (monitor → large tablet →
1994
+ // tablet → phone) so the breakpoints read by silhouette width.
1995
+ const WF_VP_ICONS = {
1996
+ 'full': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9V5a1 1 0 0 1 1-1h4M20 9V5a1 1 0 0 0-1-1h-4M4 15v4a1 1 0 0 0 1 1h4M20 15v4a1 1 0 0 1-1 1h-4"/></svg>',
1997
+ 'desktop': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="4.5" width="19" height="12" rx="1.5"/><path d="M9 20.5h6M12 16.5v4"/></svg>',
1998
+ 'tablet-lg': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4.5" y="3" width="15" height="18" rx="2"/><path d="M10 18h4"/></svg>',
1999
+ 'tablet': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6.5" y="3.5" width="11" height="17" rx="2"/><path d="M10 17.5h4"/></svg>',
2000
+ 'mobile': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="3" width="8" height="18" rx="2"/><path d="M10.5 18h3"/></svg>',
2001
+ };
2002
+ const WF_NODE_SCALE = 0.1875; // 1280px → 240px card, matching the `sm` default
2003
+ // Intended viewport range, resolved server-side (meta.json `viewport` wins
2004
+ // over the DS version's ds.json `intendedViewport`). Advisory — breakpoints
2005
+ // outside it stay clickable; the control just marks them and footnotes the
2006
+ // range. Mirrors screen.njk's detail-screen Viewport control.
2007
+ const WF_RANGE = {{ viewportRange | dump | safe if viewportRange else 'null' }};
2008
+ function wfOutOfRange(w) { return !!(WF_RANGE && w && (w < WF_RANGE.min.w || w > WF_RANGE.max.w)); }
2009
+ // Per-flow canvas MAX zoom, derived from the allowed viewport range.
2010
+ // Theory: a screen is shrunk into its node; the useful max zoom is roughly
2011
+ // what shows the largest allowed screen at ~life-size + a little headroom —
2012
+ // so larger allowed widths earn more zoom. A mobile flow (range max ≈ 440px)
2013
+ // tops out around 4× (≈400%); a laptop flow (≈1440px) earns the full 8×
2014
+ // (≈800%). Linear map calibrated to those two points, clamped 3–8.
2015
+ const WF_ZOOM_BASE = 2.24, WF_ZOOM_SLOPE = 0.004; // 440→4.0, 1440→8.0
2016
+ const WF_ZOOM_MIN = 0.15; // zoom-out floor — unchanged from the old fixed clamp
2017
+ const WF_ZOOM_CAP = 8; // fallback max when there's no resolved range
2018
+ const WF_MAX_ZOOM = WF_RANGE
2019
+ ? Math.max(3.0, Math.min(8.0, WF_ZOOM_BASE + WF_ZOOM_SLOPE * WF_RANGE.max.w))
2020
+ : WF_ZOOM_CAP;
2021
+ // Per-flow key (includes meta.id) so a breakpoint chosen on one flow doesn't
2022
+ // bleed onto a flow with a different range. (Was a shared global before.)
2023
+ const WF_VIEWPORT_KEY = 'protoNavViewport:{{ meta.id }}';
2024
+ // Largest in-range breakpoint as the default when there's no stored choice
2025
+ // (else 'full') — so a flow opens inside its intended range.
2026
+ function wfDefaultViewport() {
2027
+ if (!WF_RANGE) return 'full';
2028
+ const inRange = WF_BREAKPOINTS
2029
+ .filter(b => b.width && !wfOutOfRange(b.width))
2030
+ .sort((a, b) => b.width - a.width);
2031
+ return inRange.length ? inRange[0].id : 'full';
2032
+ }
2033
+ let viewport = localStorage.getItem(WF_VIEWPORT_KEY) || wfDefaultViewport();
2034
+
2035
+ function bpById(id) { return WF_BREAKPOINTS.find(b => b.id === id); }
2036
+
2037
+ // Frame + iframe sizing for a breakpoint (null when full / unconstrained).
2038
+ function viewportSize(bp) {
2039
+ if (!bp || !bp.width) return null;
2040
+ const iw = bp.width;
2041
+ const ih = bp.height || Math.round(iw * 0.625); // 16:10-ish, matches sm
2042
+ return { w: Math.round(iw * WF_NODE_SCALE), h: Math.round(ih * WF_NODE_SCALE), iw, ih, scale: WF_NODE_SCALE };
2043
+ }
2044
+
2045
+ // Render size for a node under the active viewport. Notes never resize
2046
+ // (no iframe); 'full' keeps each node's design-default size.
2047
+ function sizeForNode(def, isNote) {
2048
+ if (isNote) return sizes.note;
2049
+ const vp = viewportSize(bpById(viewport));
2050
+ if (vp) return vp;
2051
+ return sizes[def.size] || sizes.sm;
2052
+ }
2053
+
2054
+ // Split-button control: the main button quick-toggles desktop ↔ mobile
2055
+ // (the two breakpoints you flip between most); the caret opens a dropdown
2056
+ // listing every breakpoint. Keeps the toolbar compact.
2057
+ function initViewport() {
2058
+ const host = document.getElementById('wfViewport');
2059
+ if (!host) return;
2060
+ const CARET = '<svg viewBox="0 0 24 24" width="11" height="11" fill="currentColor" aria-hidden="true"><path d="M7 10l5 5 5-5z"/></svg>';
2061
+ // Out-of-range breakpoints are hidden entirely (not just greyed) — the
2062
+ // control only offers widths the flow's range actually allows. Full
2063
+ // width (no fixed width) is always offered.
2064
+ const items = WF_BREAKPOINTS
2065
+ .filter(bp => !bp.width || !wfOutOfRange(bp.width))
2066
+ .map(bp =>
2067
+ `<button type="button" role="menuitemradio" class="wireflow-viewport-item" data-vp="${bp.id}">`
2068
+ + `<span class="wireflow-viewport-item__icon">${WF_VP_ICONS[bp.id] || ''}</span>`
2069
+ + `<span class="wireflow-viewport-item__label">${bp.label}</span>`
2070
+ + `<span class="wireflow-viewport-item__w">${bp.width ? bp.width + 'px' : ''}</span>`
2071
+ + `</button>`
2072
+ ).join('');
2073
+ // Footer stating the intended range and which level declared it.
2074
+ let rangeHTML = '';
2075
+ if (WF_RANGE) {
2076
+ const rangeSrc = WF_RANGE.source === 'prototype' ? 'set by this flow' : 'from the design system';
2077
+ rangeHTML = '<div class="wireflow-viewport-range" title="Intended viewport range, ' + rangeSrc + '">'
2078
+ + 'Intended: ' + WF_RANGE.min.label + ' – ' + WF_RANGE.max.label
2079
+ + ' <span>(' + WF_RANGE.min.w + '–' + WF_RANGE.max.w + 'px)</span></div>';
2080
+ }
2081
+ host.innerHTML =
2082
+ '<button type="button" class="wireflow-viewport-main" data-vp-toggle title="Toggle desktop / mobile" aria-label="Toggle desktop / mobile"></button>'
2083
+ + '<details class="wireflow-viewport-menu">'
2084
+ + '<summary class="wireflow-viewport-caret" title="All breakpoints" aria-label="Choose breakpoint">' + CARET + '</summary>'
2085
+ + '<div class="wireflow-viewport-panel" role="menu">' + items + rangeHTML + '</div>'
2086
+ + '</details>';
2087
+
2088
+ const mainBtn = host.querySelector('.wireflow-viewport-main');
2089
+ const details = host.querySelector('.wireflow-viewport-menu');
2090
+
2091
+ function sync() {
2092
+ const bp = bpById(viewport) || WF_BREAKPOINTS[0];
2093
+ mainBtn.innerHTML = WF_VP_ICONS[bp.id] || '';
2094
+ mainBtn.classList.toggle('is-active', !!bp.width);
2095
+ host.querySelectorAll('.wireflow-viewport-item').forEach(it => {
2096
+ const on = it.dataset.vp === viewport;
2097
+ it.classList.toggle('is-active', on);
2098
+ it.setAttribute('aria-checked', on ? 'true' : 'false');
2099
+ });
2100
+ }
2101
+ function choose(id) {
2102
+ viewport = id;
2103
+ try { localStorage.setItem(WF_VIEWPORT_KEY, viewport); } catch (e) {}
2104
+ sync();
2105
+ render();
2106
+ }
2107
+
2108
+ mainBtn.addEventListener('click', () => choose(viewport === 'mobile' ? 'desktop' : 'mobile'));
2109
+ host.querySelectorAll('.wireflow-viewport-item').forEach(it =>
2110
+ it.addEventListener('click', () => { choose(it.dataset.vp); details.removeAttribute('open'); }));
2111
+ // Native <details> doesn't close on outside click — wire that up.
2112
+ document.addEventListener('click', e => {
2113
+ if (details.hasAttribute('open') && !host.contains(e.target)) details.removeAttribute('open');
2114
+ });
2115
+ sync();
2116
+ }
2117
+
2118
+ async function render() {
2119
+ // ELK layered layout — the graph construction + layout options now live in
2120
+ // @bedrock-flows/wireflow/client (layoutWireflow). Spacing overrides come from
2121
+ // the prototype's meta.json.
2122
+ // Stamp each node's layout dimensions for the active viewport so ELK
2123
+ // reflows to the chosen breakpoint (full = design-default per-node size).
2124
+ nodeDefs.forEach(n => { const sz = sizeForNode(n, n.type === 'note'); n.w = sz.w; n.h = sz.h; });
2125
+ const laid = await layoutWireflow(nodeDefs, edgeDefs, {
2126
+ direction,
2127
+ nodeNode: {{ meta.spacing.nodeNode if meta.spacing and meta.spacing.nodeNode else 120 }},
2128
+ betweenLayers: {{ meta.spacing.betweenLayers if meta.spacing and meta.spacing.betweenLayers else 180 }},
2129
+ });
2130
+
2131
+ inner.innerHTML = '';
2132
+ const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
2133
+ svgEl.classList.add('wireflow-arrows');
2134
+ // No SVG <marker> — markers don't behave well under canvas zoom (the
2135
+ // interaction between markerUnits, refX, and non-scaling-stroke makes
2136
+ // the head visibly drift away from the path endpoint at high zoom).
2137
+ // Instead, drawEdges() draws an explicit <path> arrowhead at each
2138
+ // edge's terminal point and applyTransform() counter-scales every
2139
+ // arrowhead via SVG `transform` so the head stays a constant on-screen
2140
+ // size at any zoom level.
2141
+ svgEl.innerHTML = '';
2142
+ inner.appendChild(svgEl);
2143
+
2144
+ inner.style.width = (laid.width + 100) + 'px';
2145
+ inner.style.height = (laid.height + 100) + 'px';
2146
+
2147
+ laid.children.forEach(elkNode => {
2148
+ const def = nodeDefs.find(n => n.id === elkNode.id);
2149
+ const isNote = def.type === 'note';
2150
+ const s = sizeForNode(def, isNote);
2151
+
2152
+ const el = document.createElement(isNote ? 'div' : 'a');
2153
+ el.className = 'wf-node' + (isNote ? ' wf-node--note' : '');
2154
+ el.dataset.screenId = def.id;
2155
+ if (!isNote) el.href = def.url;
2156
+ el.style.left = elkNode.x + 'px';
2157
+ el.style.top = elkNode.y + 'px';
2158
+ // Pin the anchor's layout box to the frame size: the node is a flex
2159
+ // column that would otherwise hug its widest child — the nowrap
2160
+ // (counter-scaled) header — making the link's hit area much wider
2161
+ // than the visible card, especially at narrow breakpoints.
2162
+ el.style.width = s.w + 'px';
2163
+ const labelMod = def.labelType === 'success' ? ' wf-node__label--success'
2164
+ : def.labelType === 'dead-end' ? ' wf-node__label--dead-end'
2165
+ : '';
2166
+ const titleMod = def.labelType === 'dead-end' ? ' wf-node__title--dead-end' : '';
2167
+ let lbl = '';
2168
+ if (def.label) lbl = '<span class="wf-node__label' + labelMod + '">' + def.label + '</span>';
2169
+ if (isNote) {
2170
+ const icon = def.icon || 'sms';
2171
+ el.innerHTML = `<div class="wf-node__header">${lbl}<span class="wf-node__title${titleMod}">${def.title}</span></div>
2172
+ <div class="wf-node__frame" style="width:${s.w}px;height:${s.h}px;">
2173
+ <span class="material-symbols-sharp wf-note__icon">${icon}</span>
2174
+ <span class="wf-note__label">${def.noteLabel || def.title}</span>
2175
+ </div>`;
2176
+ } else {
2177
+ // Both src and data-src: src loads the iframe immediately (the
2178
+ // desktop default and pre-capture mobile state); data-src is the
2179
+ // canonical URL the mobile gate restores after a zoom-in.
2180
+ // No loading="lazy" — the canvas transform-scale fooled the
2181
+ // browser's lazy-load heuristic into never resolving the iframes.
2182
+ el.innerHTML = `<div class="wf-node__header">${lbl}<span class="wf-node__title${titleMod}">${def.title}</span><span class="wf-node__states" data-states-badge hidden title="States" aria-label="Extra states"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="8" height="8" rx="1.5"/><rect x="13" y="3" width="8" height="8" rx="1.5"/><rect x="3" y="13" width="8" height="8" rx="1.5"/></svg><span data-states-count></span></span><span class="wf-node__comments" data-comment-badge hidden><svg class="wf-node__comments-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15C21 15.5304 20.7893 16.0391 20.4142 16.4142C20.0391 16.7893 19.5304 17 19 17H7L3 21V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V15Z"/></svg><span data-comment-count></span></span></div>
2183
+ <div class="wf-node__frame" style="width:${s.w}px;height:${s.h}px;">
2184
+ <iframe src="${def.url}" loading="eager" style="width:${s.iw}px;height:${s.ih}px;transform:scale(${s.scale});background:white"></iframe>
2185
+ </div>`;
2186
+ }
2187
+ inner.appendChild(el);
2188
+ });
2189
+
2190
+ // Labels go in a second SVG appended AFTER the frames so the text
2191
+ // paints above any iframe it crosses. Edges/dots stay on svgEl below.
2192
+ const labelsSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
2193
+ labelsSvg.classList.add('wireflow-labels');
2194
+ inner.appendChild(labelsSvg);
2195
+
2196
+ drawEdges(svgEl, labelsSvg, laid);
2197
+ // First render: fit the whole graph into the visible canvas, centered.
2198
+ // Re-renders triggered by direction toggle (LR ↔ TB) recompute size, so
2199
+ // we re-fit there too — the layout dimensions change and the previous
2200
+ // scale/translate would clip or strand the graph off-axis.
2201
+ fitToScreen(laid.width + 100, laid.height + 100);
2202
+ applyTransform();
2203
+ updateNodeCommentBadges();
2204
+ updateNodeStatesBadges();
2205
+ }
2206
+
2207
+ // Per-node "extra states" badge: shown when a screen has 2+ states
2208
+ // declared as sibling pages (e.g. 2-otp-request also has
2209
+ // 2-otp-request--invalid). Counts the --state siblings only; the
2210
+ // base itself doesn't count toward the badge.
2211
+ function updateNodeStatesBadges() {
2212
+ const nodes = document.querySelectorAll('.wf-node[data-screen-id]');
2213
+ nodes.forEach(function (node) {
2214
+ const badge = node.querySelector('[data-states-badge]');
2215
+ if (!badge) return;
2216
+ const countEl = badge.querySelector('[data-states-count]');
2217
+ const n = STATE_COUNTS[node.dataset.screenId] || 0;
2218
+ if (n > 0) {
2219
+ badge.hidden = false;
2220
+ if (countEl) countEl.textContent = '+' + String(n);
2221
+ } else {
2222
+ badge.hidden = true;
2223
+ if (countEl) countEl.textContent = '';
2224
+ }
2225
+ });
2226
+ }
2227
+
2228
+ // Per-node "open comments" badge so it's clear at a glance which
2229
+ // screens have feedback. Counts unresolved comments only (resolved
2230
+ // ones aren't actionable); state pages (foo--bar) roll up to their
2231
+ // base node, mirroring the side-panel grouping.
2232
+ function updateNodeCommentBadges() {
2233
+ // `comments` is declared further down; render() only calls this
2234
+ // after an await so it's initialized by then, but stay defensive —
2235
+ // a thrown ReferenceError here would abort the diagram render.
2236
+ let list;
2237
+ try { list = comments; } catch { return; }
2238
+ const nodes = document.querySelectorAll('.wf-node[data-screen-id]');
2239
+ if (!nodes.length) return;
2240
+ const baseSet = new Set((nodeDefs || []).filter(n => n.type !== 'note').map(n => n.id));
2241
+ const baseOf = (sid) => {
2242
+ // A `?param` URL-state isn't its own node — roll it to its page.
2243
+ const page = sid.split('?')[0];
2244
+ if (baseSet.has(page)) return page;
2245
+ const i = page.indexOf('--');
2246
+ if (i > 0 && baseSet.has(page.slice(0, i))) return page.slice(0, i);
2247
+ return null;
2248
+ };
2249
+ const counts = {};
2250
+ (list || []).forEach((c) => {
2251
+ if (!c.screenId || c.resolved) return;
2252
+ const b = baseOf(c.screenId);
2253
+ if (b) counts[b] = (counts[b] || 0) + 1;
2254
+ });
2255
+ nodes.forEach((node) => {
2256
+ const badge = node.querySelector('[data-comment-badge]');
2257
+ if (!badge) return;
2258
+ const countEl = badge.querySelector('[data-comment-count]');
2259
+ const n = counts[node.dataset.screenId] || 0;
2260
+ // When a screen has open comments, make the node itself deep-link
2261
+ // with the panel open (?comments=1) so clicking a flagged screen
2262
+ // lands directly in its thread. Plain URL when there's nothing.
2263
+ if (node.tagName === 'A' && node.dataset.baseHref === undefined) {
2264
+ node.dataset.baseHref = node.getAttribute('href') || '';
2265
+ }
2266
+ if (n > 0) {
2267
+ badge.hidden = false;
2268
+ if (countEl) countEl.textContent = String(n);
2269
+ badge.title = n + ' open comment' + (n === 1 ? '' : 's');
2270
+ if (node.tagName === 'A') {
2271
+ const base = node.dataset.baseHref || '';
2272
+ node.setAttribute('href', base + (base.indexOf('?') >= 0 ? '&' : '?') + 'comments=1');
2273
+ }
2274
+ } else {
2275
+ badge.hidden = true;
2276
+ if (countEl) countEl.textContent = '';
2277
+ if (node.tagName === 'A' && node.dataset.baseHref !== undefined) {
2278
+ node.setAttribute('href', node.dataset.baseHref);
2279
+ }
2280
+ }
2281
+ });
2282
+ }
2283
+
2284
+ // True midpoint of a polyline by arc-length. Sums segment lengths,
2285
+ // finds the segment containing half-length, lerps to the exact point.
2286
+ function polylineMidpoint(points) {
2287
+ if (points.length < 2) return points[0] || { x: 0, y: 0 };
2288
+ const segLens = [];
2289
+ let total = 0;
2290
+ for (let i = 1; i < points.length; i++) {
2291
+ const dx = points[i].x - points[i - 1].x;
2292
+ const dy = points[i].y - points[i - 1].y;
2293
+ const len = Math.hypot(dx, dy);
2294
+ segLens.push(len);
2295
+ total += len;
2296
+ }
2297
+ let half = total / 2;
2298
+ for (let i = 0; i < segLens.length; i++) {
2299
+ if (half <= segLens[i] || i === segLens.length - 1) {
2300
+ const t = segLens[i] === 0 ? 0 : half / segLens[i];
2301
+ return {
2302
+ x: points[i].x + (points[i + 1].x - points[i].x) * t,
2303
+ y: points[i].y + (points[i + 1].y - points[i].y) * t,
2304
+ };
2305
+ }
2306
+ half -= segLens[i];
2307
+ }
2308
+ return points[points.length - 1];
2309
+ }
2310
+
2311
+ // Greedy word-wrap a label string into N lines of at most maxChars.
2312
+ // Single-word labels longer than maxChars stay on one line — better to
2313
+ // overflow once than mid-word break.
2314
+ function wrapLabel(text, maxChars) {
2315
+ const words = String(text).split(/\s+/).filter(Boolean);
2316
+ if (words.length === 0) return [String(text)];
2317
+ const lines = [];
2318
+ let cur = words[0];
2319
+ for (let i = 1; i < words.length; i++) {
2320
+ const next = cur + ' ' + words[i];
2321
+ if (next.length <= maxChars) {
2322
+ cur = next;
2323
+ } else {
2324
+ lines.push(cur);
2325
+ cur = words[i];
2326
+ }
2327
+ }
2328
+ lines.push(cur);
2329
+ return lines;
2330
+ }
2331
+
2332
+ // Compute scale + translate so the graph fully fits inside the canvas
2333
+ // with a small margin, then center it on both axes.
2334
+ function fitToScreen(graphW, graphH) {
2335
+ const r = canvas.getBoundingClientRect();
2336
+ const margin = 40;
2337
+ const availW = Math.max(100, r.width - margin * 2);
2338
+ const availH = Math.max(100, r.height - margin * 2);
2339
+ const fit = Math.min(availW / graphW, availH / graphH);
2340
+ // Clamp to the same min/max as wheel/zoom buttons enforce.
2341
+ scale = Math.max(WF_ZOOM_MIN, Math.min(WF_MAX_ZOOM, fit));
2342
+ tx = (r.width - graphW * scale) / 2;
2343
+ ty = (r.height - graphH * scale) / 2;
2344
+ }
2345
+
2346
+ function drawEdges(svgEl, labelsSvg, laid) {
2347
+ Array.from(svgEl.children).forEach(ch => { if (ch.tagName !== 'defs') svgEl.removeChild(ch); });
2348
+ Array.from(labelsSvg.children).forEach(ch => labelsSvg.removeChild(ch));
2349
+ laid.edges.forEach((elkEdge, i) => {
2350
+ const edgeDef = edgeDefs[i];
2351
+ if (!elkEdge.sections) return;
2352
+ elkEdge.sections.forEach(section => {
2353
+ const points = [section.startPoint, ...(section.bendPoints || []), section.endPoint];
2354
+ let d = `M ${points[0].x} ${points[0].y}`;
2355
+ for (let j = 1; j < points.length; j++) d += ` L ${points[j].x} ${points[j].y}`;
2356
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
2357
+ path.setAttribute('d', d);
2358
+ path.classList.add('wf-edge-path');
2359
+ // Loop edges (intentional cycles back to an earlier node — retry,
2360
+ // resend cooldown) are drawn dashed AND in the brand color so
2361
+ // they read as "the user can come back here" rather than "this
2362
+ // is the next step". The class also reaches the arrowhead +
2363
+ // label background below so the whole edge reads as one
2364
+ // distinct visual unit, not a recoloured stub.
2365
+ if (edgeDef && edgeDef.loop) {
2366
+ path.setAttribute('stroke-dasharray', '6 4');
2367
+ path.classList.add('wf-edge-path--loop');
2368
+ }
2369
+ svgEl.appendChild(path);
2370
+
2371
+ // Explicit arrowhead at the terminal point. Anchored at the end
2372
+ // and rotated to match the last segment's direction. The visible
2373
+ // size is set in applyTransform() via SVG `transform="scale(inv)"`,
2374
+ // so it stays constant on screen no matter how the canvas zooms.
2375
+ const end = points[points.length - 1];
2376
+ const prev = points[points.length - 2] || points[0];
2377
+ const angle = Math.atan2(end.y - prev.y, end.x - prev.x) * 180 / Math.PI;
2378
+ const headAnchor = document.createElementNS('http://www.w3.org/2000/svg', 'g');
2379
+ headAnchor.setAttribute('transform', `translate(${end.x},${end.y}) rotate(${angle})`);
2380
+ const headInner = document.createElementNS('http://www.w3.org/2000/svg', 'g');
2381
+ headInner.classList.add('wf-arrowhead');
2382
+ if (edgeDef && edgeDef.loop) headInner.classList.add('wf-arrowhead--loop');
2383
+ const headPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
2384
+ // Triangle pointing right (the rotation above orients it along the
2385
+ // path direction). Anchor at (0,0) = path end; tip is at x=0 so the
2386
+ // head sits flush with the endpoint instead of overshooting it.
2387
+ headPath.setAttribute('d', 'M-8,-4 L0,0 L-8,4 Z');
2388
+ headInner.appendChild(headPath);
2389
+ headAnchor.appendChild(headInner);
2390
+ svgEl.appendChild(headAnchor);
2391
+ [points[0], points[points.length - 1]].forEach(p => {
2392
+ const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
2393
+ c.setAttribute('cx', p.x); c.setAttribute('cy', p.y); c.setAttribute('r', 3);
2394
+ c.classList.add('wf-dot');
2395
+ svgEl.appendChild(c);
2396
+ });
2397
+ if (edgeDef && edgeDef.label) {
2398
+ // True polyline midpoint by length, not the bend index — labels
2399
+ // sit on the visual middle of the arrow regardless of how many
2400
+ // bends ELK chose.
2401
+ const mid = polylineMidpoint(points);
2402
+ // Word-wrap into <=2 lines; longer labels stay narrow so they
2403
+ // don't sprawl across multiple frames.
2404
+ const lines = wrapLabel(edgeDef.label, 16);
2405
+ // Tuned for 9px font: ~5.5px avg per char, 11px line height,
2406
+ // 8px horizontal pad, 4px vertical pad. Slight headroom on the
2407
+ // width estimate so descenders / wider letterforms never clip.
2408
+ const lineH = 11;
2409
+ const padX = 8, padY = 4;
2410
+ const maxLineLen = Math.max.apply(null, lines.map(l => l.length));
2411
+ const rectW = Math.max(28, Math.round(maxLineLen * 5.8 + padX * 2));
2412
+ const rectH = lines.length * lineH + padY * 2;
2413
+ // Group positioned at midpoint; inner .wf-edge-label gets a
2414
+ // dynamic SVG transform attribute (set in applyTransform) so it
2415
+ // counter-scales the canvas zoom and stays constant size.
2416
+ const anchor = document.createElementNS('http://www.w3.org/2000/svg', 'g');
2417
+ anchor.setAttribute('transform', `translate(${mid.x},${mid.y})`);
2418
+ const lbl = document.createElementNS('http://www.w3.org/2000/svg', 'g');
2419
+ lbl.classList.add('wf-edge-label');
2420
+ if (edgeDef.loop) lbl.classList.add('wf-edge-label--loop');
2421
+ const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
2422
+ bg.setAttribute('x', -rectW / 2);
2423
+ bg.setAttribute('y', -rectH / 2);
2424
+ bg.setAttribute('width', rectW);
2425
+ bg.setAttribute('height', rectH);
2426
+ bg.setAttribute('rx', 3);
2427
+ bg.classList.add('wf-label-bg');
2428
+ lbl.appendChild(bg);
2429
+ const t = document.createElementNS('http://www.w3.org/2000/svg', 'text');
2430
+ t.setAttribute('text-anchor', 'middle');
2431
+ const firstY = -((lines.length - 1) * lineH) / 2 + 3.5;
2432
+ lines.forEach((line, i) => {
2433
+ const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
2434
+ tspan.setAttribute('x', '0');
2435
+ tspan.setAttribute('y', String(firstY + i * lineH));
2436
+ tspan.textContent = line;
2437
+ t.appendChild(tspan);
2438
+ });
2439
+ lbl.appendChild(t);
2440
+ anchor.appendChild(lbl);
2441
+ labelsSvg.appendChild(anchor);
2442
+ }
2443
+ });
2444
+ });
2445
+ }
2446
+
2447
+ let applyCount = 0;
2448
+ function applyTransform() {
2449
+ // Crash trace: when pinching, beacon BEFORE we write the transform so
2450
+ // the last server-side line tells us the scale we were trying to apply
2451
+ // when the tab died. Sample every 3 applies to balance fidelity vs.
2452
+ // beacon cost.
2453
+ if (canvas.classList.contains('is-pinching')) {
2454
+ applyCount++;
2455
+ if (applyCount % 3 === 0) wfLog('applyXform', { scale: Number(scale.toFixed(3)), tx: Math.round(tx), ty: Math.round(ty), c: applyCount });
2456
+ }
2457
+ inner.style.transform = `translate(${tx}px,${ty}px) scale(${scale})`;
2458
+ inner.style.transformOrigin = '0 0';
2459
+ // CSS variable drives all the counter-scaled visual chrome — node
2460
+ // headers, diamond text, frame borders/radius/shadow, note icon+label.
2461
+ const inv = 1 / scale;
2462
+ inner.style.setProperty('--inv-scale', String(inv));
2463
+ // SVG <g> transform-box: fill-box doesn't behave consistently across
2464
+ // browsers, so we set the scale via the SVG transform attribute on
2465
+ // each edge label directly. This is the bit that keeps labels
2466
+ // constant-size and properly anchored as the canvas zooms.
2467
+ const labels = inner.querySelectorAll('.wf-edge-label');
2468
+ for (let i = 0; i < labels.length; i++) {
2469
+ labels[i].setAttribute('transform', `scale(${inv})`);
2470
+ }
2471
+ // Dot radius is in user space — counter-scale per dot so they stay 3px
2472
+ // on screen at every zoom level (pairs with non-scaling-stroke).
2473
+ const dots = inner.querySelectorAll('circle.wf-dot');
2474
+ for (let i = 0; i < dots.length; i++) {
2475
+ dots[i].setAttribute('r', String(3 * inv));
2476
+ }
2477
+ // Arrowheads are explicit <g class="wf-arrowhead"> nodes positioned at
2478
+ // the path endpoint and oriented along the last segment. Counter-scale
2479
+ // each one so the head's on-screen size stays constant regardless of
2480
+ // canvas zoom.
2481
+ const heads = inner.querySelectorAll('.wf-arrowhead');
2482
+ for (let i = 0; i < heads.length; i++) {
2483
+ heads[i].setAttribute('transform', `scale(${inv})`);
2484
+ }
2485
+ // CSS `vector-effect: non-scaling-stroke` only neutralises the SVG's
2486
+ // own transform — it doesn't account for ancestor CSS transforms, so
2487
+ // a CSS-scaled .inner makes strokes thicken with zoom anyway. Counter-
2488
+ // scale stroke-width via attribute so the visible line stays at 1.5px
2489
+ // regardless of canvas zoom. Same trick keeps dot outlines crisp.
2490
+ const strokePaths = inner.querySelectorAll('.wireflow-arrows path.wf-edge-path');
2491
+ for (let i = 0; i < strokePaths.length; i++) {
2492
+ strokePaths[i].setAttribute('stroke-width', String(1.5 * inv));
2493
+ }
2494
+ const dotsForStroke = inner.querySelectorAll('circle.wf-dot');
2495
+ for (let i = 0; i < dotsForStroke.length; i++) {
2496
+ dotsForStroke[i].setAttribute('stroke-width', String(1.5 * inv));
2497
+ }
2498
+ // Zoom-% badge between the +/- buttons. Click resets the view to fit.
2499
+ const pctEl = document.getElementById('wfZoomPct');
2500
+ if (pctEl) pctEl.textContent = Math.round(scale * 100) + '%';
2501
+ }
2502
+
2503
+ // Iframe load-gating to prevent iOS Safari OOM kills. When the user zooms
2504
+ // out far enough that many iframes become simultaneously visible at small
2505
+ // size, Safari rasterizes ALL of them at full backing-store resolution
2506
+ // (~1280×800 desktop, 402×874 phone) and runs out of memory. We point
2507
+ // each iframe's src to about:blank when zoomed out, restore the real URL
2508
+ // when zoomed back in. The frame chrome (outline + label) stays so the
2509
+ // wireflow shape is still readable.
2510
+ //
2511
+ // Threshold tuned for an 18-node F-001 layout: 0.55 is just below the
2512
+ // fit-to-screen scale on a phone, so the moment the user pinches out
2513
+ // past "fit", iframes blank. They restore as soon as the user zooms in.
2514
+ // Iframe gating + PNG fallback removed — iframes stay live at all
2515
+ // zoom levels. The mobile zoom-out about:blank swap was meant to
2516
+ // prevent iOS Safari OOM, but didn't work well in practice (browsers
2517
+ // throttle iframe paint at small sizes anyway, and the 800px boot
2518
+ // detection misfired with narrow desktop viewports). If iOS OOM
2519
+ // resurfaces we'll need a different approach.
2520
+ canvas.addEventListener('mousedown', e => {
2521
+ if (e.target.closest('.wireflow-controls') || e.target.closest('.wf-node')) return;
2522
+ // Bail out when the user is interacting with the Mermaid or Spec
2523
+ // overlays — those need native scroll, click, and (importantly) text
2524
+ // selection. The pan handler's `preventDefault()` would otherwise kill
2525
+ // selection inside the spec prose. Same applies to the comments
2526
+ // panel — without this guard, clicking the textarea triggered
2527
+ // preventDefault and the field never gained focus, looking disabled.
2528
+ if (e.target.closest('.wireflow-mermaid') || e.target.closest('.wireflow-spec') || e.target.closest('.wf-comments-panel') || e.target.closest('.wf-toolbar')) return;
2529
+ panning = true; sx = e.clientX - tx; sy = e.clientY - ty; e.preventDefault();
2530
+ });
2531
+ window.addEventListener('mousemove', e => { if (!panning) return; tx = e.clientX - sx; ty = e.clientY - sy; applyTransform(); });
2532
+ window.addEventListener('mouseup', () => { panning = false; });
2533
+ // Wheel zoom — registered in the *capture* phase so we beat
2534
+ // macOS Chromium / Safari's page-zoom shortcut on ⌘+scroll (and
2535
+ // ctrl+scroll on Windows / Linux). Without capture, the browser
2536
+ // sees the modifier-wheel first, fires its own page zoom, and the
2537
+ // canvas listener never runs. Plain two-finger trackpad scroll and
2538
+ // pinch (which Chrome reports as wheel + ctrlKey:true) both flow
2539
+ // through the same code path; the modifier just makes the
2540
+ // preventDefault unambiguous.
2541
+ canvas.addEventListener('wheel', e => {
2542
+ // In Mermaid / Spec / Figma views the canvas hosts a scrollable
2543
+ // overlay — bail out so the browser's natural scroll works there.
2544
+ if (canvas.classList.contains('is-mermaid')
2545
+ || canvas.classList.contains('is-spec')
2546
+ || canvas.classList.contains('is-figma')) return;
2547
+ // Same for the comments panel: without this, wheeling inside the
2548
+ // panel zooms the canvas instead of scrolling the comment list.
2549
+ if (e.target.closest && e.target.closest('.wf-comments-panel')) return;
2550
+ e.preventDefault();
2551
+ // ⌘/Ctrl+scroll is an explicit "zoom" gesture — use a bigger step
2552
+ // (×1.25 / ×0.8) so a single notch is felt. Plain wheel keeps the
2553
+ // gentler ×1.1 / ×0.9 step that pairs with trackpad inertia.
2554
+ const isExplicit = e.metaKey || e.ctrlKey;
2555
+ const r = canvas.getBoundingClientRect(), mx = e.clientX - r.left, my = e.clientY - r.top, ps = scale;
2556
+ const up = isExplicit ? 1.25 : 1.1;
2557
+ const down = isExplicit ? 0.8 : 0.9;
2558
+ scale *= e.deltaY > 0 ? down : up;
2559
+ scale = Math.max(WF_ZOOM_MIN, Math.min(WF_MAX_ZOOM, scale));
2560
+ tx = mx - (mx - tx) * (scale / ps); ty = my - (my - ty) * (scale / ps);
2561
+ applyTransform();
2562
+ }, { passive: false, capture: true });
2563
+ // Touch / pinch support — mirrors the mouse pan + wheel zoom for
2564
+ // tablets and phones. One-finger drag pans; two-finger pinch zooms
2565
+ // around the midpoint between the fingers. The same exclusion list
2566
+ // as mousedown applies (toolbar + overlays + nodes don't pan).
2567
+ let touchPanStartX = 0, touchPanStartY = 0, touchPanning = false;
2568
+ let pinchStartDist = 0, pinchStartScale = 1, pinchAnchorX = 0, pinchAnchorY = 0;
2569
+ // rAF-coalesce touchmove → applyTransform. iOS fires touchmove faster than
2570
+ // the compositor can paint; without this, pinch on a graph full of iframes
2571
+ // queues work until the tab is killed.
2572
+ let touchRafPending = false;
2573
+ function scheduleTransform() {
2574
+ if (touchRafPending) return;
2575
+ touchRafPending = true;
2576
+ requestAnimationFrame(() => { touchRafPending = false; applyTransform(); });
2577
+ }
2578
+ function distance(t0, t1) {
2579
+ const dx = t0.clientX - t1.clientX;
2580
+ const dy = t0.clientY - t1.clientY;
2581
+ return Math.hypot(dx, dy);
2582
+ }
2583
+ function midpoint(t0, t1, rect) {
2584
+ return {
2585
+ x: (t0.clientX + t1.clientX) / 2 - rect.left,
2586
+ y: (t0.clientY + t1.clientY) / 2 - rect.top,
2587
+ };
2588
+ }
2589
+ canvas.addEventListener('touchstart', (e) => {
2590
+ // 2-finger pinch must work across the board — including on node frames,
2591
+ // toolbar, overlays — so we skip target-based exclusions for pinch.
2592
+ // For 1-finger touches we still bail on chrome/overlays/nodes so taps
2593
+ // on buttons, scroll inside Mermaid/Spec, and node link clicks keep
2594
+ // working. Pan is only allowed on the empty canvas.
2595
+ if (e.touches.length === 1) {
2596
+ const blocking = e.target.closest && (
2597
+ e.target.closest('.wireflow-controls') ||
2598
+ e.target.closest('.wf-node') ||
2599
+ e.target.closest('.wireflow-mermaid') ||
2600
+ e.target.closest('.wireflow-spec') ||
2601
+ e.target.closest('.wf-comments-panel') ||
2602
+ e.target.closest('.wf-toolbar')
2603
+ );
2604
+ if (blocking) return;
2605
+ }
2606
+ if (e.touches.length === 1) {
2607
+ touchPanning = true;
2608
+ touchPanStartX = e.touches[0].clientX - tx;
2609
+ touchPanStartY = e.touches[0].clientY - ty;
2610
+ } else if (e.touches.length === 2) {
2611
+ // Cancel any 1-finger pan in progress and lock to pinch.
2612
+ touchPanning = false;
2613
+ pinchStartDist = distance(e.touches[0], e.touches[1]);
2614
+ pinchStartScale = scale;
2615
+ const r = canvas.getBoundingClientRect();
2616
+ const mid = midpoint(e.touches[0], e.touches[1], r);
2617
+ pinchAnchorX = mid.x;
2618
+ pinchAnchorY = mid.y;
2619
+ canvas.classList.add('is-pinching');
2620
+ wfLog('pinchStart', { scale, ifr: document.querySelectorAll('.wf-node__frame iframe').length });
2621
+ e.preventDefault();
2622
+ }
2623
+ }, { passive: false });
2624
+ canvas.addEventListener('touchmove', (e) => {
2625
+ if (e.touches.length === 1 && touchPanning) {
2626
+ tx = e.touches[0].clientX - touchPanStartX;
2627
+ ty = e.touches[0].clientY - touchPanStartY;
2628
+ scheduleTransform();
2629
+ e.preventDefault();
2630
+ } else if (e.touches.length === 2 && pinchStartDist > 0) {
2631
+ pinchMoveCount++;
2632
+ // Dense mid-pinch sampling. The last received line is our best clue
2633
+ // about where the tab died — sample every 5 moves.
2634
+ if (pinchMoveCount % 5 === 0) wfLog('pinchMove', { scale: Number(scale.toFixed(3)), n: pinchMoveCount });
2635
+ const d = distance(e.touches[0], e.touches[1]);
2636
+ const ps = scale;
2637
+ scale = Math.max(WF_ZOOM_MIN, Math.min(WF_MAX_ZOOM, pinchStartScale * (d / pinchStartDist)));
2638
+ // Zoom around the original pinch midpoint so the graph appears to
2639
+ // anchor where the fingers started, not drift.
2640
+ tx = pinchAnchorX - (pinchAnchorX - tx) * (scale / ps);
2641
+ ty = pinchAnchorY - (pinchAnchorY - ty) * (scale / ps);
2642
+ scheduleTransform();
2643
+ e.preventDefault();
2644
+ }
2645
+ }, { passive: false });
2646
+ let pinchMoveCount = 0;
2647
+ function endTouch() {
2648
+ const wasPinching = canvas.classList.contains('is-pinching');
2649
+ touchPanning = false;
2650
+ pinchStartDist = 0;
2651
+ canvas.classList.remove('is-pinching');
2652
+ if (wasPinching) {
2653
+ wfLog('pinchEnd', { scale, moves: pinchMoveCount });
2654
+ pinchMoveCount = 0;
2655
+ }
2656
+ }
2657
+ canvas.addEventListener('touchend', endTouch);
2658
+ canvas.addEventListener('touchcancel', endTouch);
2659
+
2660
+ document.querySelectorAll('.wireflow-ctrl-btn').forEach(btn => {
2661
+ btn.addEventListener('click', () => {
2662
+ const a = btn.dataset.action;
2663
+ const ps = scale;
2664
+ scale *= a === 'zoom-in' ? 1.2 : 0.8;
2665
+ scale = Math.max(WF_ZOOM_MIN, Math.min(WF_MAX_ZOOM, scale));
2666
+ const r = canvas.getBoundingClientRect(), cx = r.width / 2, cy = r.height / 2;
2667
+ tx = cx - (cx - tx) * (scale / ps); ty = cy - (cy - ty) * (scale / ps);
2668
+ applyTransform();
2669
+ });
2670
+ });
2671
+ // Click the zoom % to refit the graph to the canvas (handy after panning
2672
+ // way off into empty space).
2673
+ const zoomPctBtn = document.getElementById('wfZoomPct');
2674
+ if (zoomPctBtn) {
2675
+ zoomPctBtn.addEventListener('click', () => {
2676
+ const w = parseFloat(inner.style.width) || 0;
2677
+ const h = parseFloat(inner.style.height) || 0;
2678
+ if (w && h) {
2679
+ fitToScreen(w, h);
2680
+ applyTransform();
2681
+ }
2682
+ });
2683
+ }
2684
+ const dirBtn = document.getElementById('wfDirToggle');
2685
+ dirBtn.textContent = direction;
2686
+ dirBtn.addEventListener('click', () => {
2687
+ direction = direction === 'LR' ? 'TB' : 'LR';
2688
+ dirBtn.textContent = direction;
2689
+ localStorage.setItem('wfDir-{{ meta.id }}', direction);
2690
+ render();
2691
+ });
2692
+
2693
+ // === Legend collapse toggle ===
2694
+ // Persisted per-prototype so the user's "I've seen it, keep it
2695
+ // tucked away" choice survives reloads. Default = expanded
2696
+ // (returning users who've never collapsed it see the full card).
2697
+ (function () {
2698
+ const legend = document.getElementById('wfLegend');
2699
+ const btn = document.getElementById('wfLegendBtn');
2700
+ if (!legend || !btn) return;
2701
+ function setOpen(open) {
2702
+ legend.dataset.open = open ? 'true' : 'false';
2703
+ legend.hidden = !open;
2704
+ btn.setAttribute('aria-expanded', open ? 'true' : 'false');
2705
+ btn.classList.toggle('is-active', open);
2706
+ }
2707
+ btn.addEventListener('click', (e) => {
2708
+ e.stopPropagation();
2709
+ setOpen(legend.dataset.open !== 'true');
2710
+ });
2711
+ // Dismiss on outside-click or Escape — it's a transient popup.
2712
+ document.addEventListener('click', (e) => {
2713
+ if (legend.dataset.open === 'true'
2714
+ && !legend.contains(e.target) && !btn.contains(e.target)) setOpen(false);
2715
+ });
2716
+ document.addEventListener('keydown', (e) => {
2717
+ if (e.key === 'Escape' && legend.dataset.open === 'true') setOpen(false);
2718
+ });
2719
+ })();
2720
+
2721
+ // View tabs. Wireflow / Mermaid / Spec are mutually exclusive — gated by
2722
+ // .is-mermaid / .is-spec classes on the canvas. Each tab button stays
2723
+ // visible at all times; the active one's aria-pressed flips to true.
2724
+ // Mermaid loads lazily on first activation.
2725
+ let mermaidLoaded = false;
2726
+ const mermaidContainer = document.getElementById('wfMermaid');
2727
+ const mermaidSource = document.getElementById('wfMermaidSource').textContent;
2728
+ const tabWireflow = document.getElementById('wfTabWireflow');
2729
+ const tabMermaid = document.getElementById('wfTabMermaid');
2730
+ const tabSpec = document.getElementById('wfTabSpec');
2731
+ const tabFigma = document.getElementById('wfTabFigma');
2732
+ const figmaFrame = document.getElementById('wfFigmaFrame');
2733
+
2734
+ const viewMenuLabel = document.getElementById('wfViewMenuLabel');
2735
+ const VIEW_LABELS = {
2736
+ wireflow: 'Flow',
2737
+ mermaid: 'Mermaid',
2738
+ spec: 'Spec',
2739
+ figma: 'Figma Wireframe',
2740
+ };
2741
+ // Persistent view URLs: /p/<id>/ = wireflow, /p/<id>/mermaid,
2742
+ // /p/<id>/spec, /p/<id>/figma. The view is reflected in the address
2743
+ // bar (pushState) so links are shareable and the back button steps
2744
+ // between views.
2745
+ // Use the template id directly, not the JS PROTO_ID const — that one
2746
+ // is declared later (comments block) and would be in the temporal
2747
+ // dead zone when this runs.
2748
+ const VIEW_BASE = '/p/{{ meta.id }}/';
2749
+ function viewToPath(view) {
2750
+ return view === 'mermaid' ? VIEW_BASE + 'mermaid'
2751
+ : view === 'spec' ? VIEW_BASE + 'spec'
2752
+ : view === 'figma' ? VIEW_BASE + 'figma'
2753
+ : VIEW_BASE;
2754
+ }
2755
+ function pathToView() {
2756
+ const p = location.pathname.replace(/\/+$/, '');
2757
+ if (p.endsWith('/mermaid') && tabMermaid) return 'mermaid';
2758
+ if (p.endsWith('/spec') && tabSpec) return 'spec';
2759
+ if (p.endsWith('/figma') && tabFigma) return 'figma';
2760
+ return 'wireflow';
2761
+ }
2762
+ // Lazy-mount the Figma iframe on first activation. Embedding Figma
2763
+ // is heavy (its bundle + WebGL canvas) and the design-reference view
2764
+ // is opt-in — most visitors never open it.
2765
+ function ensureFigma() {
2766
+ if (!figmaFrame) return;
2767
+ if (figmaFrame.src) return;
2768
+ const src = figmaFrame.getAttribute('data-src');
2769
+ if (src) figmaFrame.src = src;
2770
+ }
2771
+ function setView(view, opts) {
2772
+ // view: 'wireflow' | 'mermaid' | 'spec' | 'figma'
2773
+ canvas.classList.toggle('is-mermaid', view === 'mermaid');
2774
+ canvas.classList.toggle('is-spec', view === 'spec');
2775
+ canvas.classList.toggle('is-figma', view === 'figma');
2776
+ // The menu items are <button role="menuitemradio"> — aria-checked
2777
+ // tracks the active view. Used by the trigger label below + the
2778
+ // ✓ checkmark prefix in the panel CSS, and the selected segment
2779
+ // styling in the desktop button group.
2780
+ tabWireflow.setAttribute('aria-checked', String(view === 'wireflow'));
2781
+ if (tabMermaid) tabMermaid.setAttribute('aria-checked', String(view === 'mermaid'));
2782
+ if (tabSpec) tabSpec.setAttribute('aria-checked', String(view === 'spec'));
2783
+ if (tabFigma) tabFigma.setAttribute('aria-checked', String(view === 'figma'));
2784
+ if (viewMenuLabel) viewMenuLabel.textContent = VIEW_LABELS[view] || VIEW_LABELS.wireflow;
2785
+ if (view === 'figma') ensureFigma();
2786
+ // Sync the URL unless we're responding to a popstate / initial load.
2787
+ if (!opts || opts.push !== false) {
2788
+ const target = viewToPath(view);
2789
+ if (location.pathname.replace(/\/+$/, '') !== target.replace(/\/+$/, '')) {
2790
+ history.pushState({ view }, '', target + location.search + location.hash);
2791
+ }
2792
+ }
2793
+ }
2794
+ window.addEventListener('popstate', () => {
2795
+ const view = pathToView();
2796
+ setView(view, { push: false });
2797
+ if (view === 'mermaid') ensureMermaid();
2798
+ });
2799
+
2800
+ // Generic dropdown-menu toggling for any element with `data-menu`.
2801
+ // Click the trigger → toggle is-open. Click outside → close all.
2802
+ document.querySelectorAll('.wf-menu').forEach((menu) => {
2803
+ const trig = menu.querySelector('.wf-menu__trigger');
2804
+ if (!trig) return;
2805
+ trig.addEventListener('click', (e) => {
2806
+ e.stopPropagation();
2807
+ const opening = !menu.classList.contains('is-open');
2808
+ // Close any other open menu first so only one is visible at a time.
2809
+ document.querySelectorAll('.wf-menu.is-open').forEach((m) => {
2810
+ if (m !== menu) {
2811
+ m.classList.remove('is-open');
2812
+ const t = m.querySelector('.wf-menu__trigger');
2813
+ if (t) t.setAttribute('aria-expanded', 'false');
2814
+ }
2815
+ });
2816
+ menu.classList.toggle('is-open', opening);
2817
+ trig.setAttribute('aria-expanded', String(opening));
2818
+ });
2819
+ // Clicking any panel item dismisses the menu after the click handler
2820
+ // fires (so view switches / actions still run).
2821
+ menu.querySelectorAll('.wf-menu__panel button, .wf-menu__panel a').forEach((item) => {
2822
+ item.addEventListener('click', () => {
2823
+ menu.classList.remove('is-open');
2824
+ trig.setAttribute('aria-expanded', 'false');
2825
+ });
2826
+ });
2827
+ });
2828
+ document.addEventListener('click', (e) => {
2829
+ if (e.target.closest && e.target.closest('.wf-menu')) return;
2830
+ document.querySelectorAll('.wf-menu.is-open').forEach((m) => {
2831
+ m.classList.remove('is-open');
2832
+ const t = m.querySelector('.wf-menu__trigger');
2833
+ if (t) t.setAttribute('aria-expanded', 'false');
2834
+ });
2835
+ });
2836
+
2837
+ async function ensureMermaid() {
2838
+ if (mermaidLoaded) return;
2839
+ const mod = await import('https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs');
2840
+ // Mermaid's default font is "trebuchet ms" — looks dated. Override
2841
+ // with the same system stack the rest of the wireflow chrome uses so
2842
+ // the diagram visually matches the surrounding UI.
2843
+ const sysStack = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif";
2844
+ // Match the surrounding chrome's light/dark theme so the diagram
2845
+ // doesn't look like a bright-white window when the rest of the page
2846
+ // is dark. Mermaid ships a 'dark' built-in; we still re-use the same
2847
+ // font stack and override a few key tokens so node fills/strokes/text
2848
+ // line up with our --wf-* palette.
2849
+ // Effective theme: explicit data-theme override wins; otherwise
2850
+ // fall back to the OS preference.
2851
+ const themeAttr = document.documentElement.getAttribute('data-theme');
2852
+ const isDark = themeAttr === 'dark'
2853
+ || (themeAttr !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
2854
+ mod.default.initialize({
2855
+ startOnLoad: false,
2856
+ theme: isDark ? 'dark' : 'neutral',
2857
+ flowchart: { curve: 'basis' },
2858
+ fontFamily: sysStack,
2859
+ themeVariables: isDark
2860
+ ? {
2861
+ fontFamily: sysStack,
2862
+ background: '#0f172a',
2863
+ primaryColor: '#1e293b',
2864
+ primaryTextColor: '#f1f5f9',
2865
+ primaryBorderColor: '#475569',
2866
+ lineColor: '#94a3b8',
2867
+ secondaryColor: '#334155',
2868
+ tertiaryColor: '#0b1220',
2869
+ mainBkg: '#1e293b',
2870
+ clusterBkg: '#0f172a',
2871
+ clusterBorder: '#475569',
2872
+ edgeLabelBackground: '#1e293b',
2873
+ }
2874
+ : { fontFamily: sysStack },
2875
+ // Default 'strict' sandboxes the SVG and silently drops `click`
2876
+ // directives. We control all node URLs (they're just our own
2877
+ // /p/<id>/<page> paths), so 'loose' is fine here and lets the
2878
+ // diagram nodes act as launchers into each rendered screen.
2879
+ securityLevel: 'loose',
2880
+ });
2881
+ await mod.default.run({ querySelector: '#wfMermaidDiagram' });
2882
+ mermaidLoaded = true;
2883
+ }
2884
+
2885
+ // === Mermaid pan + zoom ===
2886
+ // The mermaid panel was previously a static centered SVG with no
2887
+ // interaction. On larger flows the diagram outgrows the viewport
2888
+ // and the only escape was the browser's native page zoom. We now
2889
+ // give it the same gestures the wireflow canvas has:
2890
+ // - wheel / pinch / ⌘+scroll → zoom around the cursor
2891
+ // - drag (mouse or one-finger touch) → pan
2892
+ // - double-click or "Reset" → recenter at 100 %
2893
+ // Implemented with a CSS transform on the `.mermaid` block so we
2894
+ // don't fight Mermaid's own SVG sizing.
2895
+ const mermaidPanel = document.getElementById('wfMermaid');
2896
+ const mermaidBlock = document.getElementById('wfMermaidDiagram');
2897
+ const mmState = { scale: 1, tx: 0, ty: 0, panning: false, sx: 0, sy: 0 };
2898
+ function mmApply() {
2899
+ mermaidBlock.style.transform =
2900
+ `translate(${mmState.tx}px, ${mmState.ty}px) scale(${mmState.scale})`;
2901
+ }
2902
+ function mmReset() {
2903
+ mmState.scale = 1; mmState.tx = 0; mmState.ty = 0;
2904
+ mmApply();
2905
+ }
2906
+ mermaidPanel.addEventListener('wheel', (e) => {
2907
+ // Same guard as the wireflow wheel handler — ASCII mode is a
2908
+ // scrollable <pre>, leave it to the browser.
2909
+ if (mermaidPanel.classList.contains('is-ascii')) return;
2910
+ e.preventDefault();
2911
+ const r = mermaidPanel.getBoundingClientRect();
2912
+ const mx = e.clientX - r.left, my = e.clientY - r.top;
2913
+ const ps = mmState.scale;
2914
+ const isExplicit = e.metaKey || e.ctrlKey;
2915
+ const up = isExplicit ? 1.25 : 1.1;
2916
+ const down = isExplicit ? 0.8 : 0.9;
2917
+ mmState.scale *= e.deltaY > 0 ? down : up;
2918
+ mmState.scale = Math.max(0.2, Math.min(6, mmState.scale));
2919
+ mmState.tx = mx - (mx - mmState.tx) * (mmState.scale / ps);
2920
+ mmState.ty = my - (my - mmState.ty) * (mmState.scale / ps);
2921
+ mmApply();
2922
+ }, { passive: false, capture: true });
2923
+ mermaidPanel.addEventListener('mousedown', (e) => {
2924
+ if (mermaidPanel.classList.contains('is-ascii')) return;
2925
+ // Don't start a pan if the user is clicking a node link — Mermaid
2926
+ // emits `<a class="clickable">` wrappers (or `.node.clickable`)
2927
+ // for the `click` directives we generate.
2928
+ if (e.target.closest && e.target.closest('a, .clickable')) return;
2929
+ mmState.panning = true;
2930
+ mmState.sx = e.clientX - mmState.tx;
2931
+ mmState.sy = e.clientY - mmState.ty;
2932
+ mermaidPanel.classList.add('is-panning');
2933
+ e.preventDefault();
2934
+ });
2935
+ window.addEventListener('mousemove', (e) => {
2936
+ if (!mmState.panning) return;
2937
+ mmState.tx = e.clientX - mmState.sx;
2938
+ mmState.ty = e.clientY - mmState.sy;
2939
+ mmApply();
2940
+ });
2941
+ window.addEventListener('mouseup', () => {
2942
+ if (!mmState.panning) return;
2943
+ mmState.panning = false;
2944
+ mermaidPanel.classList.remove('is-panning');
2945
+ });
2946
+ mermaidPanel.addEventListener('dblclick', (e) => {
2947
+ if (mermaidPanel.classList.contains('is-ascii')) return;
2948
+ if (e.target.closest && e.target.closest('a, .clickable')) return;
2949
+ mmReset();
2950
+ });
2951
+
2952
+ tabWireflow.addEventListener('click', () => setView('wireflow'));
2953
+ tabMermaid.addEventListener('click', async () => {
2954
+ setView('mermaid');
2955
+ await ensureMermaid();
2956
+ mmReset();
2957
+ });
2958
+ if (tabSpec) tabSpec.addEventListener('click', () => setView('spec'));
2959
+ if (tabFigma) tabFigma.addEventListener('click', () => setView('figma'));
2960
+
2961
+ // Initial view from the URL (/p/<id>/mermaid|spec|figma). Don't push —
2962
+ // we're reflecting the address bar, not changing it. replaceState
2963
+ // seeds a history entry with the view so the first Back works
2964
+ // predictably.
2965
+ (function initViewFromUrl() {
2966
+ const view = pathToView();
2967
+ history.replaceState({ view }, '', viewToPath(view) + location.search + location.hash);
2968
+ if (view !== 'wireflow') {
2969
+ setView(view, { push: false });
2970
+ if (view === 'mermaid') ensureMermaid();
2971
+ }
2972
+ })();
2973
+
2974
+ function downloadBlob(filename, content, mime) {
2975
+ const blob = new Blob([content], { type: mime });
2976
+ const url = URL.createObjectURL(blob);
2977
+ const a = document.createElement('a');
2978
+ a.href = url; a.download = filename;
2979
+ document.body.appendChild(a); a.click();
2980
+ a.remove(); URL.revokeObjectURL(url);
2981
+ }
2982
+
2983
+ document.getElementById('wfMermaidAscii').addEventListener('click', (e) => {
2984
+ const on = mermaidContainer.classList.toggle('is-ascii');
2985
+ e.currentTarget.setAttribute('aria-pressed', String(on));
2986
+ });
2987
+
2988
+ document.getElementById('wfMermaidCopy').addEventListener('click', async (e) => {
2989
+ try {
2990
+ await navigator.clipboard.writeText(mermaidSource);
2991
+ const btn = e.currentTarget;
2992
+ const prev = btn.textContent;
2993
+ btn.textContent = 'Copied';
2994
+ setTimeout(() => { btn.textContent = prev; }, 1200);
2995
+ } catch {
2996
+ // Clipboard blocked (e.g. http) — fall back to selection.
2997
+ const range = document.createRange();
2998
+ range.selectNodeContents(document.getElementById('wfMermaidSource'));
2999
+ const sel = window.getSelection();
3000
+ sel.removeAllRanges(); sel.addRange(range);
3001
+ }
3002
+ });
3003
+
3004
+ document.getElementById('wfMermaidDownload').addEventListener('click', () => {
3005
+ downloadBlob('{{ meta.id }}-wireflow.mmd', mermaidSource, 'text/plain');
3006
+ });
3007
+
3008
+ document.getElementById('wfMermaidDownloadSvg').addEventListener('click', async () => {
3009
+ // Make sure mermaid has rendered to SVG first.
3010
+ if (!mermaidLoaded) tabMermaid.click();
3011
+ // Wait one frame for the swap-in to complete.
3012
+ await new Promise((r) => requestAnimationFrame(r));
3013
+ const svg = document.querySelector('#wfMermaidDiagram svg');
3014
+ if (!svg) return;
3015
+ const xml = '<?xml version="1.0" encoding="UTF-8"?>\n' + svg.outerHTML;
3016
+ downloadBlob('{{ meta.id }}-wireflow.svg', xml, 'image/svg+xml');
3017
+ });
3018
+
3019
+ // Spec toolbar handlers. The raw markdown is stashed as a JSON string
3020
+ // (safely escaped) so Copy/Download work without re-fetching.
3021
+ const specMdEl = document.getElementById('wf-spec-md');
3022
+ if (specMdEl) {
3023
+ const specMarkdown = JSON.parse(specMdEl.textContent);
3024
+ const copyBtn = document.getElementById('wfSpecCopy');
3025
+ const dlBtn = document.getElementById('wfSpecDownload');
3026
+ if (copyBtn) copyBtn.addEventListener('click', async (e) => {
3027
+ try {
3028
+ await navigator.clipboard.writeText(specMarkdown);
3029
+ const btn = e.currentTarget;
3030
+ const prev = btn.textContent;
3031
+ btn.textContent = 'Copied';
3032
+ setTimeout(() => { btn.textContent = prev; }, 1200);
3033
+ } catch {
3034
+ const range = document.createRange();
3035
+ range.selectNodeContents(document.getElementById('wfSpecBody'));
3036
+ const sel = window.getSelection();
3037
+ sel.removeAllRanges(); sel.addRange(range);
3038
+ }
3039
+ });
3040
+ if (dlBtn) dlBtn.addEventListener('click', () => {
3041
+ downloadBlob('{{ meta.id }}-spec.md', specMarkdown, 'text/markdown');
3042
+ });
3043
+ }
3044
+
3045
+ initViewport();
3046
+ render();
3047
+
3048
+ // ============================================================
3049
+ // Comments panel — global notes per wireflow.
3050
+ //
3051
+ // Single source of truth: the deployed Cloudflare worker's KV store
3052
+ // (`/__wf-comments/<id>.json` on the prod origin). When running
3053
+ // locally we hit that prod endpoint directly with credentials so
3054
+ // designers see (and write) the same comments online users see —
3055
+ // no local file shadow, no sync step. Requires a one-time sign-in
3056
+ // on prod so the browser holds a prod-domain session cookie; the
3057
+ // cross-origin fetch attaches it via `credentials: 'include'`.
3058
+ // ============================================================
3059
+ const PROTO_ID = '{{ meta.id }}';
3060
+ // META_PAGES drives the per-node "this page has N sibling states"
3061
+ // badge (e.g. 2-otp-request also has 2-otp-request--invalid → 1
3062
+ // extra state). Filename suffix `<base>--<state>.njk` is the
3063
+ // convention; here we just count siblings per base from the
3064
+ // declared page list.
3065
+ const META_PAGES = {{ (meta.pages or []) | dump | safe }};
3066
+ const STATE_COUNTS = (function () {
3067
+ const out = {};
3068
+ const bases = META_PAGES.filter(function (p) { return p.indexOf('--') < 0; });
3069
+ bases.forEach(function (b) {
3070
+ out[b] = META_PAGES.filter(function (p) { return p.indexOf(b + '--') === 0; }).length;
3071
+ });
3072
+ return out;
3073
+ })();
3074
+ const COMMENTS_PROD_ORIGIN = {{ (bedrock.modules.commenting.prodOrigin or '') | dump | safe }};
3075
+ const COMMENTS_IS_LOCAL = /^(localhost|127\.0\.0\.1)$/.test(window.location.hostname);
3076
+ const COMMENTS_BASE = COMMENTS_IS_LOCAL ? COMMENTS_PROD_ORIGIN : '';
3077
+ const COMMENTS_API = COMMENTS_BASE + '/__wf-comments/' + PROTO_ID + '.json';
3078
+ // Admin emails get a Delete button on every comment so they can tidy
3079
+ // up stale or off-topic notes left by anyone. Everyone else can only
3080
+ // edit/delete their own (matched by userId). Keep lowercase. Empty
3081
+ // by default — fill in for your deploy.
3082
+ const COMMENTS_ADMIN_EMAILS = [];
3083
+ const commentsListEl = document.getElementById('wfCommentsList');
3084
+ const commentsInputEl = document.getElementById('wfCommentsInput');
3085
+ const commentsComposeEl = document.getElementById('wfCommentsCompose');
3086
+ const commentsPostBtn = document.getElementById('wfCommentsPost');
3087
+ const commentsCloseBtn = document.getElementById('wfCommentsClose');
3088
+ const commentsFilterBtn = document.getElementById('wfCommentsFilter');
3089
+ const commentsAttachBtn = document.getElementById('wfCommentsAttach');
3090
+ const commentsAttachInput = document.getElementById('wfCommentsFile');
3091
+ const commentsAttachRowEl = document.getElementById('wfCommentsAttachRow');
3092
+ const commentsAttachErrorEl = document.getElementById('wfCommentsAttachError');
3093
+ const commentsToggleBtn = document.getElementById('wfCommentToggle');
3094
+ const commentsCountBadge = document.getElementById('wfCommentCount');
3095
+ const authGateEl = document.getElementById('wfAuthGate');
3096
+ const topbarUserEl = document.getElementById('wfTopbarUser');
3097
+ const topbarUserNameEl = document.getElementById('wfTopbarUserName');
3098
+ const topbarUserAvatarEl = document.getElementById('wfTopbarUserAvatar');
3099
+ const topbarUserInitialsEl = document.getElementById('wfTopbarUserInitials');
3100
+ const topbarUserTriggerEl = document.getElementById('wfTopbarUserTrigger');
3101
+ const topbarUserPanelEl = document.getElementById('wfTopbarUserPanel');
3102
+ const topbarUserEmailEl = document.getElementById('wfTopbarUserEmail');
3103
+ const topbarSignoutBtn = document.getElementById('wfTopbarSignout');
3104
+
3105
+ // Theme toggle (shared 'bedrockTheme' key with the dashboard). The
3106
+ // early head script already applied it pre-paint; here we just keep
3107
+ // the segmented control in sync and react to clicks.
3108
+ (function initThemeToggle() {
3109
+ const opts = Array.prototype.slice.call(
3110
+ document.querySelectorAll('.topbar__theme-option'));
3111
+ function current() {
3112
+ const v = (function () { try { return localStorage.getItem('bedrockTheme'); } catch (e) { return null; } })();
3113
+ return v === 'light' || v === 'dark' ? v : 'system';
3114
+ }
3115
+ function apply(t) {
3116
+ if (t === 'system') document.documentElement.removeAttribute('data-theme');
3117
+ else document.documentElement.setAttribute('data-theme', t);
3118
+ }
3119
+ function reflect() {
3120
+ const c = current();
3121
+ opts.forEach(function (b) {
3122
+ b.setAttribute('aria-pressed', String(b.dataset.themeSet === c));
3123
+ });
3124
+ }
3125
+ opts.forEach(function (b) {
3126
+ b.addEventListener('click', function () {
3127
+ const t = b.dataset.themeSet;
3128
+ try {
3129
+ if (t === 'system') localStorage.removeItem('bedrockTheme');
3130
+ else localStorage.setItem('bedrockTheme', t);
3131
+ } catch (e) {}
3132
+ apply(t);
3133
+ reflect();
3134
+ });
3135
+ });
3136
+ reflect();
3137
+ })();
3138
+
3139
+ // "View local": on the deployed site, jump to the same path on the
3140
+ // user's dev server. Port is configurable in the user menu (shared
3141
+ // 'bedrockLocalPort' key, default 5173). Hidden on localhost (you're
3142
+ // already local) and on mobile (no dev server there).
3143
+ // Title is a switcher: jump straight to another wireflow without
3144
+ // bouncing back to the dashboard list.
3145
+ (function initWireflowSwitcher() {
3146
+ var trigger = document.getElementById('wfSwitcherTrigger');
3147
+ var menu = document.getElementById('wfSwitcherMenu');
3148
+ if (!trigger || !menu) return;
3149
+ var CURRENT_ID = {{ meta.id | dump | safe }};
3150
+ var loaded = false, open = false;
3151
+ function esc(s) {
3152
+ return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
3153
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c];
3154
+ });
3155
+ }
3156
+ function close() {
3157
+ open = false; menu.hidden = true;
3158
+ trigger.setAttribute('aria-expanded', 'false');
3159
+ document.removeEventListener('click', onDoc, true);
3160
+ document.removeEventListener('keydown', onKey, true);
3161
+ }
3162
+ function onDoc(e) {
3163
+ if (!menu.contains(e.target) && !trigger.contains(e.target)) close();
3164
+ }
3165
+ function onKey(e) {
3166
+ if (e.key === 'Escape') { e.preventDefault(); close(); trigger.focus(); }
3167
+ }
3168
+ async function load() {
3169
+ if (loaded) return;
3170
+ loaded = true;
3171
+ var protos = [];
3172
+ try { var r = await fetch('/__prototypes.json', { cache: 'no-store' }); if (r.ok) protos = await r.json(); } catch (e) {}
3173
+ var html = '';
3174
+ if (Array.isArray(protos) && protos.length) {
3175
+ protos.sort(function (a, b) { return String(a.id).localeCompare(String(b.id)); });
3176
+ html += '<div class="topbar__switcher-group">Flows</div>';
3177
+ protos.forEach(function (p) {
3178
+ var cur = p.id === CURRENT_ID;
3179
+ html += '<a class="topbar__switcher-item' + (cur ? ' is-current' : '') + '" ' +
3180
+ (cur ? 'aria-current="page"' : 'href="/p/' + encodeURIComponent(p.id) + '/"') +
3181
+ '><code>' + esc(p.id) + '</code><span>' + esc(p.name || p.slug) + '</span></a>';
3182
+ });
3183
+ }
3184
+ menu.innerHTML = html || '<div class="topbar__switcher-group">Nothing to switch to</div>';
3185
+ }
3186
+ trigger.addEventListener('click', function (e) {
3187
+ e.preventDefault();
3188
+ if (open) { close(); return; }
3189
+ open = true; menu.hidden = false;
3190
+ trigger.setAttribute('aria-expanded', 'true');
3191
+ load();
3192
+ setTimeout(function () {
3193
+ document.addEventListener('click', onDoc, true);
3194
+ document.addEventListener('keydown', onKey, true);
3195
+ }, 0);
3196
+ });
3197
+ })();
3198
+
3199
+ (function initViewLocal() {
3200
+ const LOCAL_PORT_KEY = 'bedrockLocalPort';
3201
+ const portInput = document.getElementById('wfLocalPort');
3202
+ const viewLocal = document.getElementById('wfViewLocal');
3203
+ function getPort() {
3204
+ let p;
3205
+ try { p = localStorage.getItem(LOCAL_PORT_KEY); } catch (e) {}
3206
+ return (p && /^\d{2,5}$/.test(p)) ? p : '5173';
3207
+ }
3208
+ function syncLink() {
3209
+ if (!viewLocal) return;
3210
+ if (COMMENTS_IS_LOCAL) { viewLocal.hidden = true; return; }
3211
+ viewLocal.hidden = false;
3212
+ viewLocal.href = 'http://localhost:' + getPort()
3213
+ + location.pathname + location.search + location.hash;
3214
+ }
3215
+ if (portInput) {
3216
+ portInput.value = getPort();
3217
+ portInput.addEventListener('input', function () {
3218
+ const v = portInput.value.replace(/\D/g, '').slice(0, 5);
3219
+ portInput.value = v;
3220
+ try {
3221
+ if (v) localStorage.setItem(LOCAL_PORT_KEY, v);
3222
+ else localStorage.removeItem(LOCAL_PORT_KEY);
3223
+ } catch (e) {}
3224
+ syncLink();
3225
+ });
3226
+ // Keep the menu open while editing the port.
3227
+ portInput.addEventListener('click', function (e) { e.stopPropagation(); });
3228
+ }
3229
+ syncLink();
3230
+ })();
3231
+
3232
+ function toggleUserPanel(force) {
3233
+ const next = typeof force === 'boolean' ? force : topbarUserPanelEl.hidden;
3234
+ topbarUserPanelEl.hidden = !next;
3235
+ topbarUserTriggerEl.setAttribute('aria-expanded', String(next));
3236
+ }
3237
+ topbarUserTriggerEl.addEventListener('click', (e) => {
3238
+ e.stopPropagation();
3239
+ toggleUserPanel();
3240
+ });
3241
+ document.addEventListener('mousedown', (e) => {
3242
+ if (topbarUserPanelEl.hidden) return;
3243
+ if (!e.target.closest || !e.target.closest('.topbar__user-menu')) toggleUserPanel(false);
3244
+ });
3245
+ function initialsFor(name, email) {
3246
+ const source = (name || email || '').trim();
3247
+ if (!source) return '?';
3248
+ const tokens = source.split(/[\s@.]+/).filter(Boolean);
3249
+ const a = (tokens[0] || '')[0] || '';
3250
+ const b = (tokens[1] || '')[0] || '';
3251
+ return (a + b).toUpperCase() || source[0].toUpperCase() || '?';
3252
+ }
3253
+ const authFormEl = document.getElementById('wfAuthForm');
3254
+ const authNameEl = document.getElementById('wfAuthName');
3255
+ const authEmailEl = document.getElementById('wfAuthEmail');
3256
+ const authPasswordEl = document.getElementById('wfAuthPassword');
3257
+ const authSubmitBtn = document.getElementById('wfAuthSubmit');
3258
+ const authErrorEl = document.getElementById('wfAuthError');
3259
+ const authTabs = authFormEl.querySelectorAll('.bf-auth-form__tab');
3260
+
3261
+ // Better Auth browser client — always same-origin. (We tried pointing
3262
+ // it at prod from localhost so identities would match the prod KV
3263
+ // comments, but cross-site auth needs SameSite=None;Secure cookies
3264
+ // and localhost-over-http can't satisfy that — it caused OAuth
3265
+ // state_mismatch and bounced sign-in to the prod site.) Local sign-in
3266
+ // therefore stays local; see the admin-delete note in the comments
3267
+ // panel for the identity-matching caveat.
3268
+ const { createAuthClient } = await import('https://esm.sh/better-auth@1.4.21/client');
3269
+ const authClient = createAuthClient();
3270
+ let currentUser = null;
3271
+ let authMode = 'signin';
3272
+ // Role helpers. Designers (and admins) run the agentic loop: their
3273
+ // comments are actionable and they approve manager feedback. Managers
3274
+ // only give comments — never directly actionable (worker-enforced too).
3275
+ function isDesignerUser(u) { return !!u && (u.role === 'designer' || u.role === 'admin'); }
3276
+ function isManagerUser(u) { return !!u && u.role === 'manager'; }
3277
+
3278
+ function showAuthError(msg) {
3279
+ authErrorEl.textContent = msg || '';
3280
+ authErrorEl.hidden = !msg;
3281
+ }
3282
+ function setAuthMode(mode) {
3283
+ authMode = mode;
3284
+ authTabs.forEach(t => t.classList.toggle('is-active', t.dataset.mode === mode));
3285
+ // The hidden toggle lives on the field wrapper so the visible label
3286
+ // hides together with the input.
3287
+ const nameField = document.getElementById('wfAuthNameField');
3288
+ if (nameField) nameField.hidden = mode !== 'signup';
3289
+ authNameEl.required = mode === 'signup';
3290
+ authPasswordEl.autocomplete = mode === 'signup' ? 'new-password' : 'current-password';
3291
+ authSubmitBtn.textContent = mode === 'signup' ? 'Create account' : 'Sign in';
3292
+ showAuthError('');
3293
+ }
3294
+ authTabs.forEach(t => t.addEventListener('click', () => setAuthMode(t.dataset.mode)));
3295
+ setAuthMode('signin');
3296
+
3297
+ const commentsReadonlyEl = document.getElementById('wfCommentsReadonly');
3298
+ const commentsLiveLinkEl = document.getElementById('wfCommentsLiveLink');
3299
+ if (commentsLiveLinkEl) {
3300
+ commentsLiveLinkEl.href = COMMENTS_PROD_ORIGIN + location.pathname + location.search;
3301
+ }
3302
+
3303
+ function applySession(user) {
3304
+ currentUser = user || null;
3305
+ // Local dev is a read-only mirror of the prod comment store —
3306
+ // cross-site authenticated writes from localhost aren't possible,
3307
+ // so we never show the compose box or the sign-in gate here. The
3308
+ // panel just renders live comments; posting/editing happens on the
3309
+ // live site (link in the read-only notice).
3310
+ if (COMMENTS_IS_LOCAL) {
3311
+ authGateEl.hidden = true;
3312
+ topbarUserEl.hidden = true;
3313
+ commentsComposeEl.hidden = true;
3314
+ if (commentsReadonlyEl) commentsReadonlyEl.hidden = false;
3315
+ return;
3316
+ }
3317
+ if (currentUser) {
3318
+ // Signed in: hide the gate, show the user pill, enable composing.
3319
+ authGateEl.hidden = true;
3320
+ topbarUserEl.hidden = false;
3321
+ topbarUserNameEl.textContent = currentUser.name || currentUser.email || 'Signed in';
3322
+ topbarUserEmailEl.textContent = currentUser.email || '';
3323
+ if (currentUser.image) {
3324
+ topbarUserAvatarEl.src = currentUser.image;
3325
+ topbarUserAvatarEl.hidden = false;
3326
+ topbarUserInitialsEl.hidden = true;
3327
+ } else {
3328
+ topbarUserAvatarEl.hidden = true;
3329
+ topbarUserInitialsEl.textContent = initialsFor(currentUser.name, currentUser.email);
3330
+ topbarUserInitialsEl.hidden = false;
3331
+ }
3332
+ commentsComposeEl.hidden = false;
3333
+ // Managers can't choose "side note vs actionable" — their comments
3334
+ // always wait on a designer, so swap the toggle for a hint.
3335
+ const noteLabel = document.getElementById('wfCommentsNoteLabel');
3336
+ const managerHint = document.getElementById('wfCommentsManagerHint');
3337
+ if (noteLabel) noteLabel.hidden = isManagerUser(currentUser);
3338
+ if (managerHint) managerHint.hidden = !isManagerUser(currentUser);
3339
+ // Session cookie is set — pull the user directory so un-commented
3340
+ // accounts are mentionable; re-render so @mentions become pills.
3341
+ loadPeople().then(renderComments);
3342
+ } else {
3343
+ // Signed out: gate covers the page; comment compose hidden.
3344
+ authGateEl.hidden = false;
3345
+ topbarUserEl.hidden = true;
3346
+ commentsComposeEl.hidden = true;
3347
+ }
3348
+ // Re-render so per-card Resolve/Edit/Delete (which depend on
3349
+ // currentUser) appear once the async session resolves — the
3350
+ // initial render runs before getSession() returns.
3351
+ renderComments();
3352
+ if (window.__wfSpecAuth) window.__wfSpecAuth(currentUser);
3353
+ }
3354
+
3355
+ async function refreshSession() {
3356
+ try {
3357
+ const { data } = await authClient.getSession();
3358
+ applySession(data?.user || null);
3359
+ } catch {
3360
+ applySession(null);
3361
+ }
3362
+ }
3363
+
3364
+ authFormEl.addEventListener('submit', async (e) => {
3365
+ e.preventDefault();
3366
+ showAuthError('');
3367
+ authSubmitBtn.disabled = true;
3368
+ try {
3369
+ if (authMode === 'signup') {
3370
+ const { error } = await authClient.signUp.email({
3371
+ email: authEmailEl.value.trim(),
3372
+ password: authPasswordEl.value,
3373
+ name: authNameEl.value.trim() || authEmailEl.value.split('@')[0],
3374
+ });
3375
+ if (error) throw new Error(error.message || 'Sign up failed');
3376
+ } else {
3377
+ const { error } = await authClient.signIn.email({
3378
+ email: authEmailEl.value.trim(),
3379
+ password: authPasswordEl.value,
3380
+ });
3381
+ if (error) throw new Error(error.message || 'Sign in failed');
3382
+ }
3383
+ authPasswordEl.value = '';
3384
+ await refreshSession();
3385
+ } catch (err) {
3386
+ showAuthError(err && err.message ? err.message : 'Something went wrong');
3387
+ } finally {
3388
+ authSubmitBtn.disabled = false;
3389
+ }
3390
+ });
3391
+ topbarSignoutBtn.addEventListener('click', async () => {
3392
+ toggleUserPanel(false);
3393
+ try { await authClient.signOut(); } catch {}
3394
+ await refreshSession();
3395
+ });
3396
+ document.getElementById('wfAuthGoogle').addEventListener('click', async () => {
3397
+ showAuthError('');
3398
+ try {
3399
+ // Better Auth redirects the browser to Google, then back to
3400
+ // /api/auth/callback/google. callbackURL is where to land after.
3401
+ await authClient.signIn.social({
3402
+ provider: 'google',
3403
+ callbackURL: location.pathname,
3404
+ });
3405
+ } catch (err) {
3406
+ showAuthError(err && err.message ? err.message : 'Google sign-in failed');
3407
+ }
3408
+ });
3409
+
3410
+ let comments = [];
3411
+ let directory = [];
3412
+ const PEOPLE_API = COMMENTS_BASE + '/__wf-people.json';
3413
+ async function loadPeople() {
3414
+ try {
3415
+ const r = await fetch(PEOPLE_API, { cache: 'no-store', credentials: 'include' });
3416
+ if (r.ok) {
3417
+ const data = await r.json();
3418
+ directory = Array.isArray(data && data.people) ? data.people : [];
3419
+ }
3420
+ } catch {}
3421
+ }
3422
+
3423
+ // Relative time ("just now", "5m", "3h", "2d") for recent comments —
3424
+ // less visual weight than a full "May 19, 07:43 PM" stamp. Past a
3425
+ // week it falls back to a short absolute date (still no time of day).
3426
+ function fmtDate(iso) {
3427
+ try {
3428
+ const d = new Date(iso);
3429
+ const now = new Date();
3430
+ const sec = Math.round((now - d) / 1000);
3431
+ if (sec < 45) return 'just now';
3432
+ const min = Math.round(sec / 60);
3433
+ if (min < 60) return min + 'm ago';
3434
+ const hr = Math.round(min / 60);
3435
+ if (hr < 24) return hr + 'h ago';
3436
+ const day = Math.round(hr / 24);
3437
+ if (day < 7) return day + 'd ago';
3438
+ const sameYear = d.getFullYear() === now.getFullYear();
3439
+ return d.toLocaleDateString(undefined, {
3440
+ month: 'short', day: 'numeric',
3441
+ ...(sameYear ? {} : { year: 'numeric' }),
3442
+ });
3443
+ } catch { return iso; }
3444
+ }
3445
+ // First name only — the comments panel is a small, familiar group;
3446
+ // surnames just add width. Full name stays available via `title`.
3447
+ function firstName(name) {
3448
+ return String(name || '').trim().split(/\s+/)[0] || '';
3449
+ }
3450
+
3451
+ function screenTitleFor(id) {
3452
+ const n = (nodeDefs || []).find(x => x.id === id);
3453
+ if (n) return n.title || n.id;
3454
+ // `<base>--<state>` or `<base>?<query>`: resolve the base node's
3455
+ // title and append the state so the section header reads cleanly.
3456
+ const sid = String(id || '');
3457
+ const page = sid.split('?')[0];
3458
+ const q = sid.split('?')[1] || '';
3459
+ const i = page.indexOf('--');
3460
+ const baseId = i > 0 ? page.slice(0, i) : page;
3461
+ const bn = (nodeDefs || []).find(x => x.id === baseId);
3462
+ const baseTitle = bn ? (bn.title || bn.id) : baseId;
3463
+ const parts = [];
3464
+ if (i > 0) parts.push(page.slice(i + 2).replace(/-/g, ' '));
3465
+ if (q) parts.push(q.replace(/&/g, ' · '));
3466
+ return parts.length ? baseTitle + ' · ' + parts.join(' · ') : baseTitle;
3467
+ }
3468
+ // True when a comment's screenId still resolves to a real page —
3469
+ // either a wireflow node directly or a `<base>--<state>` page whose
3470
+ // base is a node. Orphaned screenIds (renamed/deleted pages) return
3471
+ // false so the card stays visible but isn't a dead link that 404s.
3472
+ function screenIdResolvable(sid) {
3473
+ if (!sid) return false;
3474
+ const ids = (nodeDefs || []).filter(n => n.type !== 'note').map(n => n.id);
3475
+ const page = sid.split('?')[0];
3476
+ if (ids.indexOf(page) >= 0) return true;
3477
+ const i = page.indexOf('--');
3478
+ return i > 0 && ids.indexOf(page.slice(0, i)) >= 0;
3479
+ }
3480
+ // Deterministic 4-letter code from the comment id. Stable across
3481
+ // reloads and the same on every machine, so reviewers/Claude can
3482
+ // refer to a comment by its code ("address SMTW") without a data
3483
+ // migration — existing UUID-id comments get a code for free.
3484
+ function commentCode(id) {
3485
+ let h = 2166136261 >>> 0;
3486
+ const s = String(id || '');
3487
+ for (let i = 0; i < s.length; i++) {
3488
+ h ^= s.charCodeAt(i);
3489
+ h = Math.imul(h, 16777619) >>> 0;
3490
+ }
3491
+ let out = '';
3492
+ for (let i = 0; i < 4; i++) { out += String.fromCharCode(65 + (h % 26)); h = Math.floor(h / 26) + 131; }
3493
+ return out;
3494
+ }
3495
+ // People who can be @mentioned: anyone who has commented in this
3496
+ // thread. Handle = first word of the display name, lowercased
3497
+ // (so "Steven Naimark" → @steven). First commenter wins on collision.
3498
+ function mentionablePeople() {
3499
+ const seen = new Map();
3500
+ // Registered users first — directory makes an account mentionable
3501
+ // before it has commented. Handle = email local-part so `@alice`
3502
+ // resolves alice@example.com regardless of display name.
3503
+ for (const u of (directory || [])) {
3504
+ const email = (u.email || '').trim().toLowerCase();
3505
+ const local = email.split('@')[0] || '';
3506
+ const handle = local.toLowerCase().replace(/[^a-z0-9]/g, '');
3507
+ if (!handle || seen.has(handle)) continue;
3508
+ seen.set(handle, {
3509
+ handle: handle,
3510
+ name: (u.name || '').trim() || local,
3511
+ userId: u.id || null,
3512
+ });
3513
+ }
3514
+ for (const c of (comments || [])) {
3515
+ const name = (c.author || '').trim();
3516
+ if (!name) continue;
3517
+ const handle = name.split(/\s+/)[0].toLowerCase().replace(/[^a-z0-9]/g, '');
3518
+ if (!handle || seen.has(handle)) continue;
3519
+ seen.set(handle, { handle: handle, name: name, userId: c.userId || null });
3520
+ }
3521
+ return Array.from(seen.values());
3522
+ }
3523
+ // Render comment text into `el`, turning @handle into a pill when it
3524
+ // matches a mentionable person. Plain text node otherwise — never
3525
+ // innerHTML the raw comment (XSS).
3526
+ // Short, friendly label for a long URL (full URL stays the href + title),
3527
+ // e.g. a giant Figma link → "figma.com · 11219-12911".
3528
+ function shortLinkLabel(url) {
3529
+ try {
3530
+ const u = new URL(url);
3531
+ const host = u.hostname.replace(/^www\./, '');
3532
+ if (host.includes('figma.com')) {
3533
+ const node = u.searchParams.get('node-id');
3534
+ return node ? 'figma.com · ' + node : 'figma.com';
3535
+ }
3536
+ let tail = (u.pathname || '').replace(/\/+$/, '');
3537
+ if (tail.length > 22) tail = tail.slice(0, 22) + '…';
3538
+ return host + tail;
3539
+ } catch (e) { return url.length > 42 ? url.slice(0, 42) + '…' : url; }
3540
+ }
3541
+ // Append plain text with http(s) URLs rendered as safe clickable links
3542
+ // (only http/https match, so javascript: URLs can't slip through).
3543
+ function appendTextWithLinks(el, chunk) {
3544
+ chunk.split(/(https?:\/\/[^\s]+)/g).forEach((part) => {
3545
+ if (/^https?:\/\//.test(part)) {
3546
+ const a = document.createElement('a');
3547
+ a.href = part;
3548
+ a.target = '_blank';
3549
+ a.rel = 'noreferrer';
3550
+ a.className = 'wf-comment-card__link';
3551
+ a.title = part;
3552
+ a.textContent = shortLinkLabel(part);
3553
+ el.appendChild(a);
3554
+ } else if (part) {
3555
+ el.appendChild(document.createTextNode(part));
3556
+ }
3557
+ });
3558
+ }
3559
+ function renderTextWithMentions(el, str) {
3560
+ el.textContent = '';
3561
+ const people = mentionablePeople();
3562
+ const byHandle = {};
3563
+ people.forEach((p) => { byHandle[p.handle] = p; });
3564
+ const re = /@([a-z0-9]+)/gi;
3565
+ let last = 0, m;
3566
+ while ((m = re.exec(str)) !== null) {
3567
+ const person = byHandle[m[1].toLowerCase()];
3568
+ if (!person) continue;
3569
+ if (m.index > last) appendTextWithLinks(el, str.slice(last, m.index));
3570
+ const pill = document.createElement('span');
3571
+ pill.className = 'wf-mention';
3572
+ pill.textContent = '@' + person.name;
3573
+ el.appendChild(pill);
3574
+ last = m.index + m[0].length;
3575
+ }
3576
+ if (last < str.length) appendTextWithLinks(el, str.slice(last));
3577
+ }
3578
+ function appendCommentCard(c) {
3579
+ const card = document.createElement('div');
3580
+ card.className = 'wf-comment-card' + (c.resolved ? ' is-resolved' : '');
3581
+ const screenLive = c.screenId && screenIdResolvable(c.screenId);
3582
+ if (c.screenId && !screenLive) {
3583
+ // Orphaned: the page this comment targeted was renamed/deleted.
3584
+ // Keep the card (the feedback still matters) but don't make it a
3585
+ // link — navigating would 404. The section header shows
3586
+ // "(orphaned)" so the reviewer knows why it's inert.
3587
+ card.classList.add('wf-comment-card--orphaned');
3588
+ }
3589
+ if (screenLive) {
3590
+ // Comment is about a specific screen — clicking opens that
3591
+ // screen page so the reviewer can see what's being talked about.
3592
+ // The owner action buttons stop propagation so Edit/Delete don't
3593
+ // also navigate.
3594
+ card.classList.add('wf-comment-card--clickable');
3595
+ card.setAttribute('role', 'link');
3596
+ card.setAttribute('tabindex', '0');
3597
+ // ?comments=1 tells the screen page to auto-open the comments
3598
+ // sidebar so the reviewer lands directly in the conversation.
3599
+ const go = () => {
3600
+ // The screenId may already carry a `?param` state — append the
3601
+ // comments flag with `&` in that case so the URL stays valid.
3602
+ const sep = c.screenId.indexOf('?') >= 0 ? '&' : '?';
3603
+ window.location.href = '/p/' + PROTO_ID + '/' + c.screenId + sep + 'comments=1';
3604
+ };
3605
+ card.addEventListener('click', (e) => {
3606
+ // Links (attachment thumbnails, "Manage online") handle themselves.
3607
+ if (e.target.closest('button, textarea, a')) return;
3608
+ go();
3609
+ });
3610
+ card.addEventListener('keydown', (e) => {
3611
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); }
3612
+ });
3613
+ }
3614
+ const text = document.createElement('div');
3615
+ text.className = 'wf-comment-card__text';
3616
+ renderTextWithMentions(text, c.text || '');
3617
+ card.appendChild(text);
3618
+ // Image attachments — thumbnail row; click opens the full image.
3619
+ if (Array.isArray(c.attachments) && c.attachments.length) {
3620
+ const attRow = document.createElement('div');
3621
+ attRow.className = 'wf-comment-card__attachments';
3622
+ c.attachments.forEach((aid) => {
3623
+ if (typeof aid !== 'string' || !aid) return;
3624
+ const a = document.createElement('a');
3625
+ a.href = attachmentUrl(aid);
3626
+ a.target = '_blank';
3627
+ a.rel = 'noreferrer';
3628
+ // Lightbox when available; the plain link is the fallback.
3629
+ a.addEventListener('click', (ev) => {
3630
+ if (!window.bfLightbox) return;
3631
+ ev.preventDefault();
3632
+ window.bfLightbox.open(a.href, 'Comment attachment', c.text || '');
3633
+ });
3634
+ const img = document.createElement('img');
3635
+ img.loading = 'lazy';
3636
+ img.src = a.href;
3637
+ img.alt = 'Comment attachment';
3638
+ a.appendChild(img);
3639
+ attRow.appendChild(a);
3640
+ });
3641
+ card.appendChild(attRow);
3642
+ }
3643
+ // Designer context added when a manager comment was approved — this is
3644
+ // what the agent is actually asked to do with the feedback.
3645
+ if (c.designerNote) {
3646
+ const dn = document.createElement('div');
3647
+ dn.className = 'wf-comment-card__designer-note';
3648
+ const dnWho = document.createElement('b');
3649
+ dnWho.textContent = (c.designerNoteBy ? firstName(c.designerNoteBy) : 'Designer') + ': ';
3650
+ dn.appendChild(dnWho);
3651
+ dn.appendChild(document.createTextNode(c.designerNote));
3652
+ card.appendChild(dn);
3653
+ }
3654
+ if (c.resolved) {
3655
+ const rb = document.createElement('span');
3656
+ rb.className = 'wf-comment-card__resolved';
3657
+ rb.textContent = '✓ Resolved'
3658
+ + (c.resolvedBy ? ' · ' + c.resolvedBy : '')
3659
+ + (c.resolvedAt ? ' · ' + fmtDate(c.resolvedAt) : '');
3660
+ card.appendChild(rb);
3661
+ }
3662
+ // Agent considered it but it needs a human decision — a soft-blue pill in
3663
+ // the same treatment as the green Resolved badge, at the top of the card.
3664
+ if (!c.resolved && c.status === 'needs-human') {
3665
+ const nh = document.createElement('span');
3666
+ nh.className = 'wf-comment-card__needs-human';
3667
+ nh.textContent = '⚑ Needs human';
3668
+ card.appendChild(nh);
3669
+ }
3670
+ const meta = document.createElement('div');
3671
+ meta.className = 'wf-comment-card__meta';
3672
+ const stamp = document.createElement('span');
3673
+ const code = document.createElement('span');
3674
+ code.className = 'wf-comment-card__code';
3675
+ code.textContent = commentCode(c.id);
3676
+ code.title = 'Comment code — refer to this comment by "' + code.textContent + '"';
3677
+ const who = c.author ? firstName(c.author) + ' · ' : '';
3678
+ const edited = c.editedAt ? ' · edited' : '';
3679
+ stamp.textContent = who + (c.createdAt ? fmtDate(c.createdAt) : '') + edited;
3680
+ if (c.author) stamp.title = c.author;
3681
+ // Chips on their own wrapping row; byline gets its own line (sharing
3682
+ // one row cramped both once viewport/anchor chips arrived).
3683
+ const chipsRow = document.createElement('div');
3684
+ chipsRow.className = 'wf-comment-card__chips';
3685
+ chipsRow.appendChild(code);
3686
+ if (c.kind === 'note') {
3687
+ const noteChip = document.createElement('span');
3688
+ noteChip.className = 'wf-comment-card__chip';
3689
+ noteChip.textContent = 'note';
3690
+ noteChip.title = 'Side note — agents won’t act on this comment';
3691
+ chipsRow.appendChild(noteChip);
3692
+ }
3693
+ if (c.status === 'pending-review') {
3694
+ const prChip = document.createElement('span');
3695
+ prChip.className = 'wf-comment-card__chip wf-comment-card__chip--pending';
3696
+ prChip.textContent = 'awaiting review';
3697
+ prChip.title = 'Manager comment — a designer must approve it before agents act on it';
3698
+ chipsRow.appendChild(prChip);
3699
+ }
3700
+ if (c.viewport && (c.viewport.bp || c.viewport.w)) {
3701
+ const vpChip = document.createElement('span');
3702
+ vpChip.className = 'wf-comment-card__chip';
3703
+ vpChip.textContent = c.viewport.bp || (c.viewport.w + (c.viewport.h ? '×' + c.viewport.h : ''));
3704
+ vpChip.title = 'Viewport this comment was made against';
3705
+ chipsRow.appendChild(vpChip);
3706
+ }
3707
+ card.appendChild(chipsRow);
3708
+ stamp.className = 'wf-comment-card__byline';
3709
+ meta.appendChild(stamp);
3710
+ // State badge: when a comment lives on a `<base>--<state>` page or
3711
+ // a `?param` URL-state it's grouped/counted under the base, so flag
3712
+ // which exact state it's on. Clicking the card still navigates to
3713
+ // the precise c.screenId.
3714
+ const stateLabel = (function () {
3715
+ const sid = c.screenId || '';
3716
+ const page = sid.split('?')[0];
3717
+ const q = sid.split('?')[1] || '';
3718
+ const parts = [];
3719
+ const i = page.indexOf('--');
3720
+ if (i > 0) parts.push(page.slice(i + 2).replace(/-/g, ' '));
3721
+ if (q) parts.push(q.replace(/&/g, ' · '));
3722
+ return parts.join(' · ');
3723
+ })();
3724
+ if (stateLabel) {
3725
+ const stateBadge = document.createElement('span');
3726
+ stateBadge.className = 'wf-comment-card__state';
3727
+ stateBadge.textContent = '↳ ' + stateLabel;
3728
+ stateBadge.title = 'On page state: ' + c.screenId;
3729
+ card.insertBefore(stateBadge, card.querySelector('.wf-comment-card__meta'));
3730
+ }
3731
+ // Edit is author-only (the author check uses userId so an old
3732
+ // comment posted under a different display name still belongs to
3733
+ // its account). Delete is also available to admins so they can
3734
+ // tidy up stray or stale comments left by anyone.
3735
+ const isOwn = currentUser && c.userId && c.userId === currentUser.id;
3736
+ const isAdmin = currentUser && COMMENTS_ADMIN_EMAILS.includes((currentUser.email || '').toLowerCase());
3737
+ // No write actions in local read-only mode (writes can't auth
3738
+ // cross-site anyway — they'd 401 on prod). Resolve is open to any
3739
+ // signed-in reviewer (triage is collaborative); Edit stays
3740
+ // author-only; Delete is author or admin.
3741
+ if (!COMMENTS_IS_LOCAL && currentUser) {
3742
+ const actions = document.createElement('span');
3743
+ actions.className = 'wf-comment-card__actions';
3744
+ // A pending manager comment needs a designer verdict: Approve opens
3745
+ // an inline row to add context for the agent, then promotes the
3746
+ // comment to 'open' (the agentic loop's actionable status).
3747
+ if (c.status === 'pending-review' && isDesignerUser(currentUser)) {
3748
+ const approve = document.createElement('button');
3749
+ approve.type = 'button';
3750
+ approve.className = 'wf-comment-card__resolve';
3751
+ approve.textContent = 'Approve';
3752
+ approve.title = 'Approve for the agent queue — optionally add context first';
3753
+ approve.addEventListener('click', () => beginApproveComment(c, card));
3754
+ actions.appendChild(approve);
3755
+ }
3756
+ // Managers may only close their OWN comments (retract feedback);
3757
+ // resolving other people's threads is designer/reviewer triage.
3758
+ // The worker reverts manager status changes server-side anyway.
3759
+ if (!isManagerUser(currentUser) || isOwn) {
3760
+ const resolve = document.createElement('button');
3761
+ resolve.type = 'button';
3762
+ resolve.className = 'wf-comment-card__resolve';
3763
+ resolve.textContent = c.resolved ? 'Reopen' : 'Resolve';
3764
+ // A manager reopening their own resolved comment would re-enter
3765
+ // the actionable flow — hide Reopen from managers entirely.
3766
+ if (!(isManagerUser(currentUser) && c.resolved)) {
3767
+ resolve.addEventListener('click', () => toggleResolved(c.id));
3768
+ actions.appendChild(resolve);
3769
+ }
3770
+ }
3771
+ if (isOwn) {
3772
+ const edit = document.createElement('button');
3773
+ edit.type = 'button';
3774
+ edit.className = 'wf-comment-card__edit';
3775
+ edit.textContent = 'Edit';
3776
+ edit.addEventListener('click', () => beginEditComment(c, card, text));
3777
+ actions.appendChild(edit);
3778
+ }
3779
+ if (isOwn || isAdmin) {
3780
+ const del = document.createElement('button');
3781
+ del.type = 'button';
3782
+ del.className = 'wf-comment-card__delete';
3783
+ del.textContent = 'Delete';
3784
+ del.addEventListener('click', () => showDeleteConfirm(c.id, card));
3785
+ actions.appendChild(del);
3786
+ }
3787
+ meta.appendChild(actions);
3788
+ } else if (COMMENTS_IS_LOCAL) {
3789
+ // Local dev is read-only — no in-place controls. Offer a direct
3790
+ // link to the live site to manage (resolve/edit/delete) this
3791
+ // exact comment's screen.
3792
+ const actions = document.createElement('span');
3793
+ actions.className = 'wf-comment-card__actions';
3794
+ const manage = document.createElement('a');
3795
+ manage.className = 'wf-comment-card__manage';
3796
+ manage.target = '_blank';
3797
+ manage.rel = 'noreferrer';
3798
+ manage.href = screenLive
3799
+ ? COMMENTS_PROD_ORIGIN + '/p/' + PROTO_ID + '/' + c.screenId
3800
+ + (c.screenId.indexOf('?') >= 0 ? '&' : '?') + 'comments=1'
3801
+ : COMMENTS_PROD_ORIGIN + location.pathname;
3802
+ manage.textContent = 'Manage online ↗';
3803
+ actions.appendChild(manage);
3804
+ meta.appendChild(actions);
3805
+ }
3806
+ card.appendChild(meta);
3807
+ commentsListEl.appendChild(card);
3808
+ }
3809
+ // Designer approval of a manager comment: inline context textarea +
3810
+ // Approve/Cancel. Approving promotes the comment to 'open' (actionable to
3811
+ // the agentic loop) and stores the context as designerNote — the manager's
3812
+ // words stay untouched; the designer's interpretation rides along.
3813
+ function beginApproveComment(c, card) {
3814
+ if (card.querySelector('.wf-comment-card__approve-row')) return;
3815
+ const row = document.createElement('div');
3816
+ row.className = 'wf-comment-card__approve-row';
3817
+ const ta = document.createElement('textarea');
3818
+ ta.className = 'wf-comment-card__editor';
3819
+ ta.placeholder = 'Add context for the agent (optional) — what should be done with this?';
3820
+ ta.rows = 2;
3821
+ const btns = document.createElement('div');
3822
+ btns.className = 'wf-comment-card__edit-row';
3823
+ const ok = document.createElement('button');
3824
+ ok.type = 'button';
3825
+ ok.className = 'wf-comments-panel__post';
3826
+ ok.textContent = 'Approve for agent';
3827
+ const cancel = document.createElement('button');
3828
+ cancel.type = 'button';
3829
+ cancel.className = 'wf-comment-card__delete';
3830
+ cancel.textContent = 'Cancel';
3831
+ ok.addEventListener('click', async () => {
3832
+ const idx = comments.findIndex((x) => x.id === c.id);
3833
+ if (idx < 0 || !currentUser) return;
3834
+ const now = new Date().toISOString();
3835
+ const by = currentUser.name || currentUser.email || 'Designer';
3836
+ const note = ta.value.trim();
3837
+ comments[idx] = {
3838
+ ...comments[idx],
3839
+ status: 'open',
3840
+ resolved: false,
3841
+ statusAt: now,
3842
+ statusBy: by,
3843
+ ...(note ? { designerNote: note, designerNoteBy: by, designerNoteAt: now } : {}),
3844
+ };
3845
+ renderComments();
3846
+ await saveComments();
3847
+ });
3848
+ cancel.addEventListener('click', () => row.remove());
3849
+ btns.appendChild(ok);
3850
+ btns.appendChild(cancel);
3851
+ row.appendChild(ta);
3852
+ row.appendChild(btns);
3853
+ card.insertBefore(row, card.querySelector('.wf-comment-card__meta'));
3854
+ ta.focus();
3855
+ }
3856
+ function beginEditComment(c, card, textEl) {
3857
+ // Inline edit: replace the text node with a textarea + Save/Cancel
3858
+ // buttons. On save we patch the comment in `comments` and persist.
3859
+ const ta = document.createElement('textarea');
3860
+ ta.className = 'wf-comment-card__editor';
3861
+ ta.value = c.text;
3862
+ ta.rows = Math.min(6, Math.max(2, c.text.split('\n').length + 1));
3863
+ const row = document.createElement('div');
3864
+ row.className = 'wf-comment-card__edit-row';
3865
+ const save = document.createElement('button');
3866
+ save.type = 'button';
3867
+ save.className = 'wf-comments-panel__post';
3868
+ save.textContent = 'Save';
3869
+ const cancel = document.createElement('button');
3870
+ cancel.type = 'button';
3871
+ cancel.className = 'wf-comment-card__delete';
3872
+ cancel.textContent = 'Cancel';
3873
+ row.appendChild(save);
3874
+ row.appendChild(cancel);
3875
+ textEl.replaceWith(ta);
3876
+ card.insertBefore(row, card.querySelector('.wf-comment-card__meta'));
3877
+ ta.focus();
3878
+ ta.setSelectionRange(ta.value.length, ta.value.length);
3879
+ save.addEventListener('click', async () => {
3880
+ const next = ta.value.trim();
3881
+ if (!next || next === c.text) { renderComments(); return; }
3882
+ const idx = comments.findIndex(x => x.id === c.id);
3883
+ if (idx >= 0) {
3884
+ comments[idx] = { ...comments[idx], text: next, editedAt: new Date().toISOString() };
3885
+ }
3886
+ renderComments();
3887
+ await saveComments();
3888
+ });
3889
+ cancel.addEventListener('click', () => renderComments());
3890
+ ta.addEventListener('keydown', (e) => {
3891
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); save.click(); }
3892
+ if (e.key === 'Escape') { e.preventDefault(); renderComments(); }
3893
+ });
3894
+ }
3895
+ let hideResolved = localStorage.getItem('wfHideResolved') === '1';
3896
+ function renderComments() {
3897
+ commentsListEl.innerHTML = '';
3898
+ const resolvedCount = comments.filter((c) => c.resolved).length;
3899
+ if (commentsFilterBtn) {
3900
+ commentsFilterBtn.textContent = hideResolved
3901
+ ? 'Show resolved' + (resolvedCount ? ' (' + resolvedCount + ')' : '')
3902
+ : 'Hide resolved';
3903
+ commentsFilterBtn.hidden = resolvedCount === 0;
3904
+ }
3905
+ const visible = hideResolved ? comments.filter((c) => !c.resolved) : comments;
3906
+ if (visible.length === 0) {
3907
+ const empty = document.createElement('div');
3908
+ empty.className = 'wf-comments-panel__empty';
3909
+ empty.textContent = comments.length
3910
+ ? 'All comments resolved.'
3911
+ : 'No comments yet. Post a thought about this flow.';
3912
+ commentsListEl.appendChild(empty);
3913
+ } else {
3914
+ // Group: flow-level first, then per-screen sections in node order.
3915
+ // Within each group, unresolved newest-first, resolved last.
3916
+ const flow = [];
3917
+ const byScreen = new Map();
3918
+ for (const c of visible) {
3919
+ if (c.screenId) {
3920
+ if (!byScreen.has(c.screenId)) byScreen.set(c.screenId, []);
3921
+ byScreen.get(c.screenId).push(c);
3922
+ } else {
3923
+ flow.push(c);
3924
+ }
3925
+ }
3926
+ // Resolved sink to the bottom of their section; otherwise newest first.
3927
+ const sortNewest = (a, b) =>
3928
+ (a.resolved ? 1 : 0) - (b.resolved ? 1 : 0)
3929
+ || (b.createdAt || '').localeCompare(a.createdAt || '');
3930
+ if (flow.length) {
3931
+ const h = document.createElement('div');
3932
+ h.className = 'wf-comments-section';
3933
+ h.textContent = 'Flow';
3934
+ commentsListEl.appendChild(h);
3935
+ flow.sort(sortNewest).forEach(appendCommentCard);
3936
+ }
3937
+ // Iterate screens in their canonical order (matches dropdown order)
3938
+ // so per-screen sections appear top-to-bottom of the flow. State
3939
+ // pages (`<base>--<state>.njk`) aren't wireflow nodes themselves
3940
+ // but they ARE real pages — comments on them slot in right after
3941
+ // their base so they never appear "orphaned".
3942
+ const screenOrder = (nodeDefs || []).filter(n => n.type !== 'note').map(n => n.id);
3943
+ const baseSet = new Set(screenOrder);
3944
+ const sidBase = (sid) => {
3945
+ const page = sid.split('?')[0];
3946
+ if (baseSet.has(page)) return page;
3947
+ const i = page.indexOf('--');
3948
+ if (i > 0 && baseSet.has(page.slice(0, i))) return page.slice(0, i);
3949
+ return null;
3950
+ };
3951
+ const renderSection = (sid, list) => {
3952
+ const h = document.createElement('div');
3953
+ h.className = 'wf-comments-section';
3954
+ h.textContent = sidBase(sid) ? screenTitleFor(sid) : screenTitleFor(sid) + ' (orphaned)';
3955
+ commentsListEl.appendChild(h);
3956
+ list.sort(sortNewest).forEach(appendCommentCard);
3957
+ };
3958
+ for (const base of screenOrder) {
3959
+ // Render the base section first, then any state-page sections
3960
+ // that belong to it (e.g. `3-enter-phone--error-phone-in-use`).
3961
+ const baseList = byScreen.get(base);
3962
+ if (baseList && baseList.length) {
3963
+ renderSection(base, baseList);
3964
+ byScreen.delete(base);
3965
+ }
3966
+ for (const sid of Array.from(byScreen.keys())) {
3967
+ if (sidBase(sid) === base) {
3968
+ renderSection(sid, byScreen.get(sid));
3969
+ byScreen.delete(sid);
3970
+ }
3971
+ }
3972
+ }
3973
+ // Truly orphaned screenIds (no matching base in the flow): render
3974
+ // last so comments don't disappear when a node is renamed/removed.
3975
+ for (const [sid, list] of byScreen) {
3976
+ renderSection(sid, list);
3977
+ }
3978
+ }
3979
+ // Toolbar count badge — the actionable number is OPEN (unresolved)
3980
+ // comments. Hidden when there are none open.
3981
+ const openCount = comments.filter((c) => !c.resolved).length;
3982
+ if (openCount > 0) {
3983
+ commentsCountBadge.hidden = false;
3984
+ commentsCountBadge.textContent = String(openCount);
3985
+ commentsCountBadge.title = openCount + ' open'
3986
+ + (resolvedCount ? ', ' + resolvedCount + ' resolved' : '');
3987
+ } else {
3988
+ commentsCountBadge.hidden = true;
3989
+ }
3990
+ // Keep the per-screen diagram badges in sync with the panel.
3991
+ updateNodeCommentBadges();
3992
+ }
3993
+
3994
+ async function loadComments() {
3995
+ // Always reads from the deployed worker (KV-backed). Cross-origin
3996
+ // when running locally — `credentials: 'include'` so the prod
3997
+ // session cookie is sent. Response: `[{ id, text, author, createdAt }, …]`.
3998
+ try {
3999
+ const r = await fetch(COMMENTS_API, { cache: 'no-store', credentials: 'include' });
4000
+ if (r.ok) {
4001
+ comments = await r.json();
4002
+ if (!Array.isArray(comments)) comments = [];
4003
+ }
4004
+ } catch { /* keep empty */ }
4005
+ renderComments();
4006
+ }
4007
+
4008
+ async function saveComments() {
4009
+ try {
4010
+ await fetch(COMMENTS_API, {
4011
+ method: 'PUT',
4012
+ headers: { 'Content-Type': 'application/json' },
4013
+ body: JSON.stringify(comments),
4014
+ credentials: 'include',
4015
+ });
4016
+ } catch (err) {
4017
+ console.error('[comments] save failed', err);
4018
+ }
4019
+ }
4020
+
4021
+ // ── Image attachments on comments ───────────────────────────────────
4022
+ // Selected images are downscaled client-side (longest edge ≤ 1200px),
4023
+ // staged as removable thumbnails, uploaded on Post, and referenced by id
4024
+ // from the comment's `attachments` array.
4025
+ const ATTACH_API = COMMENTS_BASE + '/__wf-comments/' + PROTO_ID + '/attachments';
4026
+ const ATTACH_MAX_BYTES = 10 * 1024 * 1024;
4027
+ let pendingAttachments = []; // [{ blob, url }]
4028
+ function attachmentUrl(id) {
4029
+ return ATTACH_API + '/' + encodeURIComponent(id);
4030
+ }
4031
+ function showAttachError(msg) {
4032
+ commentsAttachErrorEl.textContent = msg || '';
4033
+ commentsAttachErrorEl.hidden = !msg;
4034
+ }
4035
+ function syncPostDisabled() {
4036
+ commentsPostBtn.disabled =
4037
+ commentsInputEl.value.trim().length === 0 && pendingAttachments.length === 0;
4038
+ }
4039
+ function loadImageFromFile(file) {
4040
+ return new Promise((resolve, reject) => {
4041
+ const url = URL.createObjectURL(file);
4042
+ const img = new Image();
4043
+ img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
4044
+ img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('not a readable image')); };
4045
+ img.src = url;
4046
+ });
4047
+ }
4048
+ function canvasToBlob(canvas, type, quality) {
4049
+ return new Promise((resolve, reject) =>
4050
+ canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('encode failed'))), type, quality));
4051
+ }
4052
+ // Design evidence needs its detail: keep images up to a 4K-class edge
4053
+ // (≤ 4096px) and only re-encode when the upload cap demands it. PNG
4054
+ // inputs stay PNG (transparency/crisp UI) unless too large, then JPEG.
4055
+ async function downscaleImage(file) {
4056
+ const img = await loadImageFromFile(file);
4057
+ const long = Math.max(img.naturalWidth, img.naturalHeight) || 1;
4058
+ const scale = Math.min(1, 4096 / long);
4059
+ if (scale === 1 && file.size <= ATTACH_MAX_BYTES) return file; // keep original bytes
4060
+ const w = Math.max(1, Math.round(img.naturalWidth * scale));
4061
+ const h = Math.max(1, Math.round(img.naturalHeight * scale));
4062
+ const canvas = document.createElement('canvas');
4063
+ canvas.width = w; canvas.height = h;
4064
+ canvas.getContext('2d').drawImage(img, 0, 0, w, h);
4065
+ if (file.type === 'image/png') {
4066
+ const png = await canvasToBlob(canvas, 'image/png');
4067
+ if (png.size <= ATTACH_MAX_BYTES) return png;
4068
+ }
4069
+ const jpg = await canvasToBlob(canvas, 'image/jpeg', 0.85);
4070
+ if (jpg.size > ATTACH_MAX_BYTES) throw new Error('image too large');
4071
+ return jpg;
4072
+ }
4073
+ function renderPendingAttachments() {
4074
+ commentsAttachRowEl.innerHTML = '';
4075
+ commentsAttachRowEl.hidden = pendingAttachments.length === 0;
4076
+ pendingAttachments.forEach((att) => {
4077
+ const wrap = document.createElement('span');
4078
+ wrap.className = 'wf-comments-panel__attach-thumb';
4079
+ const img = document.createElement('img');
4080
+ img.src = att.url;
4081
+ img.alt = 'Pending image attachment';
4082
+ const rm = document.createElement('button');
4083
+ rm.type = 'button';
4084
+ rm.className = 'wf-comments-panel__attach-remove';
4085
+ rm.title = 'Remove image';
4086
+ rm.setAttribute('aria-label', 'Remove image');
4087
+ rm.textContent = '×';
4088
+ rm.addEventListener('click', () => {
4089
+ URL.revokeObjectURL(att.url);
4090
+ pendingAttachments = pendingAttachments.filter((x) => x !== att);
4091
+ renderPendingAttachments();
4092
+ syncPostDisabled();
4093
+ });
4094
+ wrap.appendChild(img);
4095
+ wrap.appendChild(rm);
4096
+ commentsAttachRowEl.appendChild(wrap);
4097
+ });
4098
+ }
4099
+ function clearPendingAttachments() {
4100
+ pendingAttachments.forEach((att) => URL.revokeObjectURL(att.url));
4101
+ pendingAttachments = [];
4102
+ renderPendingAttachments();
4103
+ }
4104
+ // POST each staged image; returns the stored ids. Throws on the first
4105
+ // failure so the caller can keep the compose state intact.
4106
+ async function uploadPendingAttachments() {
4107
+ const ids = [];
4108
+ for (const att of pendingAttachments) {
4109
+ const r = await fetch(ATTACH_API, {
4110
+ method: 'POST',
4111
+ headers: { 'Content-Type': att.blob.type },
4112
+ body: att.blob,
4113
+ credentials: 'include',
4114
+ });
4115
+ if (!r.ok) throw new Error('upload failed (' + r.status + ')');
4116
+ const data = await r.json();
4117
+ if (!data || typeof data.id !== 'string') throw new Error('upload failed (bad response)');
4118
+ ids.push(data.id);
4119
+ }
4120
+ return ids;
4121
+ }
4122
+ async function stageFiles(files) {
4123
+ showAttachError('');
4124
+ for (const f of files) {
4125
+ try {
4126
+ const blob = await downscaleImage(f);
4127
+ pendingAttachments.push({ blob, url: URL.createObjectURL(blob) });
4128
+ } catch (err) {
4129
+ showAttachError('Couldn’t process ' + (f.name || 'image') + ' — try a smaller image.');
4130
+ }
4131
+ }
4132
+ renderPendingAttachments();
4133
+ syncPostDisabled();
4134
+ }
4135
+ commentsAttachBtn.addEventListener('click', () => commentsAttachInput.click());
4136
+ commentsAttachInput.addEventListener('change', async () => {
4137
+ const files = Array.from(commentsAttachInput.files || []);
4138
+ commentsAttachInput.value = '';
4139
+ await stageFiles(files);
4140
+ });
4141
+ // Paste an image straight from the clipboard into the compose box —
4142
+ // screenshots land as staged attachments, text pastes pass through.
4143
+ commentsInputEl.addEventListener('paste', (e) => {
4144
+ const items = Array.from((e.clipboardData && e.clipboardData.items) || []);
4145
+ const files = items
4146
+ .filter((it) => it.kind === 'file' && it.type && it.type.indexOf('image/') === 0)
4147
+ .map((it) => it.getAsFile())
4148
+ .filter(Boolean);
4149
+ if (!files.length) return;
4150
+ e.preventDefault();
4151
+ stageFiles(files);
4152
+ });
4153
+
4154
+ async function postComment() {
4155
+ const text = commentsInputEl.value.trim();
4156
+ if ((!text && !pendingAttachments.length) || !currentUser) return;
4157
+ const author = currentUser.name || currentUser.email || 'Signed in user';
4158
+ const id = (crypto.randomUUID && crypto.randomUUID()) || String(Date.now()) + '-' + Math.random().toString(16).slice(2);
4159
+ // Managers only give comments: theirs start in 'pending-review' and a
4160
+ // designer must approve (and contextualize) them before agents act.
4161
+ // The worker enforces the same rule server-side.
4162
+ const entry = {
4163
+ id, text, author,
4164
+ createdAt: new Date().toISOString(),
4165
+ userId: currentUser.id,
4166
+ status: isManagerUser(currentUser) ? 'pending-review' : 'open',
4167
+ };
4168
+ // Upload staged images FIRST — on failure keep the compose state
4169
+ // (text + thumbnails) so nothing is lost, and surface the error inline.
4170
+ if (pendingAttachments.length) {
4171
+ showAttachError('');
4172
+ commentsPostBtn.disabled = true;
4173
+ try {
4174
+ entry.attachments = await uploadPendingAttachments();
4175
+ } catch (err) {
4176
+ showAttachError('Image upload failed — your comment was not posted. Try again.');
4177
+ syncPostDisabled();
4178
+ return;
4179
+ }
4180
+ }
4181
+ // Opt-out: a side note is reference-only — automated agents skip it.
4182
+ // (Not for managers — their comments are never directly actionable,
4183
+ // so the side-note distinction doesn't apply.)
4184
+ const noteToggle = document.getElementById('wfCommentsNoteToggle');
4185
+ if (!isManagerUser(currentUser) && noteToggle && noteToggle.checked) entry.kind = 'note';
4186
+ // Record the wireflow breakpoint the comment was made against so a
4187
+ // reviewer can re-create the same canvas rendering.
4188
+ try { if (viewport && viewport !== 'full') entry.viewport = { bp: viewport }; } catch (err) {}
4189
+ comments.push(entry);
4190
+ commentsInputEl.value = '';
4191
+ clearPendingAttachments();
4192
+ showAttachError('');
4193
+ if (noteToggle) noteToggle.checked = false;
4194
+ commentsPostBtn.disabled = true;
4195
+ renderComments();
4196
+ await saveComments();
4197
+ }
4198
+
4199
+ function showDeleteConfirm(id, card) {
4200
+ if (card.querySelector('.wf-comment-card__delete-confirm')) return;
4201
+ const row = document.createElement('div');
4202
+ row.className = 'wf-comment-card__delete-confirm';
4203
+ const msg = document.createElement('span');
4204
+ msg.className = 'wf-comment-card__delete-confirm-msg';
4205
+ msg.textContent = 'Delete this comment?';
4206
+ const cancelBtn = document.createElement('button');
4207
+ cancelBtn.type = 'button';
4208
+ cancelBtn.className = 'wf-comment-card__edit';
4209
+ cancelBtn.textContent = 'Cancel';
4210
+ cancelBtn.addEventListener('click', () => row.remove());
4211
+ const confirmBtn = document.createElement('button');
4212
+ confirmBtn.type = 'button';
4213
+ confirmBtn.className = 'wf-comment-card__delete';
4214
+ confirmBtn.textContent = 'Delete';
4215
+ confirmBtn.addEventListener('click', () => deleteComment(id));
4216
+ row.appendChild(msg);
4217
+ row.appendChild(cancelBtn);
4218
+ row.appendChild(confirmBtn);
4219
+ card.appendChild(row);
4220
+ }
4221
+ async function deleteComment(id) {
4222
+ comments = comments.filter((c) => c.id !== id);
4223
+ renderComments();
4224
+ await saveComments();
4225
+ }
4226
+
4227
+ async function toggleResolved(id) {
4228
+ const idx = comments.findIndex((c) => c.id === id);
4229
+ if (idx < 0 || !currentUser) return;
4230
+ const next = !comments[idx].resolved;
4231
+ comments[idx] = {
4232
+ ...comments[idx],
4233
+ // `status` is canonical (open → designed-by-agent → approved);
4234
+ // a human resolving here jumps straight to approved.
4235
+ status: next ? 'approved' : 'open',
4236
+ resolved: next,
4237
+ resolvedAt: next ? new Date().toISOString() : undefined,
4238
+ resolvedBy: next ? (currentUser.name || currentUser.email || 'Signed in user') : undefined,
4239
+ resolvedById: next ? currentUser.id : undefined,
4240
+ };
4241
+ renderComments();
4242
+ await saveComments();
4243
+ }
4244
+
4245
+ commentsToggleBtn.addEventListener('click', () => {
4246
+ const on = canvas.classList.toggle('is-commenting');
4247
+ commentsToggleBtn.classList.toggle('is-active', on);
4248
+ });
4249
+ commentsCloseBtn.addEventListener('click', () => {
4250
+ canvas.classList.remove('is-commenting');
4251
+ commentsToggleBtn.classList.remove('is-active');
4252
+ });
4253
+ // Auto-open the comments panel when the URL contains ?comments=1.
4254
+ if (new URLSearchParams(location.search).get('comments') === '1') {
4255
+ canvas.classList.add('is-commenting');
4256
+ commentsToggleBtn.classList.add('is-active');
4257
+ }
4258
+ if (commentsFilterBtn) {
4259
+ commentsFilterBtn.addEventListener('click', () => {
4260
+ hideResolved = !hideResolved;
4261
+ localStorage.setItem('wfHideResolved', hideResolved ? '1' : '0');
4262
+ renderComments();
4263
+ });
4264
+ }
4265
+ // @mention autocomplete on the compose box. Detects the @word the
4266
+ // caret is in, lists matching thread commenters, inserts @handle.
4267
+ const mentionMenuEl = document.getElementById('wfMentionMenu');
4268
+ let mentionMatches = [], mentionActive = -1, mentionTokenStart = -1;
4269
+ function closeMentionMenu() {
4270
+ mentionMenuEl.hidden = true;
4271
+ mentionMatches = []; mentionActive = -1; mentionTokenStart = -1;
4272
+ }
4273
+ function openMentionMenu() {
4274
+ const val = commentsInputEl.value;
4275
+ const caret = commentsInputEl.selectionStart;
4276
+ const upto = val.slice(0, caret);
4277
+ const m = /(^|\s)@([a-z0-9]*)$/i.exec(upto);
4278
+ if (!m) { closeMentionMenu(); return; }
4279
+ mentionTokenStart = caret - m[2].length - 1; // include the '@'
4280
+ const q = m[2].toLowerCase();
4281
+ mentionMatches = mentionablePeople().filter((p) =>
4282
+ !currentUser || p.userId !== currentUser.id
4283
+ ).filter((p) => p.handle.indexOf(q) === 0 || p.name.toLowerCase().indexOf(q) === 0);
4284
+ if (!mentionMatches.length) { closeMentionMenu(); return; }
4285
+ mentionActive = 0;
4286
+ mentionMenuEl.innerHTML = '';
4287
+ mentionMatches.forEach((p, i) => {
4288
+ const item = document.createElement('div');
4289
+ item.className = 'wf-mention-menu__item' + (i === 0 ? ' is-active' : '');
4290
+ item.setAttribute('role', 'option');
4291
+ item.innerHTML = '';
4292
+ const nm = document.createElement('span');
4293
+ nm.textContent = p.name;
4294
+ const hd = document.createElement('span');
4295
+ hd.className = 'wf-mention-menu__handle';
4296
+ hd.textContent = '@' + p.handle;
4297
+ item.appendChild(nm); item.appendChild(hd);
4298
+ item.addEventListener('mousedown', (ev) => { ev.preventDefault(); pickMention(i); });
4299
+ mentionMenuEl.appendChild(item);
4300
+ });
4301
+ mentionMenuEl.hidden = false;
4302
+ }
4303
+ function pickMention(i) {
4304
+ const p = mentionMatches[i];
4305
+ if (!p || mentionTokenStart < 0) return;
4306
+ const val = commentsInputEl.value;
4307
+ const caret = commentsInputEl.selectionStart;
4308
+ const next = val.slice(0, mentionTokenStart) + '@' + p.handle + ' ' + val.slice(caret);
4309
+ commentsInputEl.value = next;
4310
+ const pos = mentionTokenStart + p.handle.length + 2;
4311
+ commentsInputEl.setSelectionRange(pos, pos);
4312
+ syncPostDisabled();
4313
+ closeMentionMenu();
4314
+ commentsInputEl.focus();
4315
+ }
4316
+ commentsInputEl.addEventListener('input', () => {
4317
+ syncPostDisabled();
4318
+ openMentionMenu();
4319
+ });
4320
+ commentsInputEl.addEventListener('blur', () => setTimeout(closeMentionMenu, 120));
4321
+ commentsInputEl.addEventListener('keydown', (e) => {
4322
+ if (!mentionMenuEl.hidden && mentionMatches.length) {
4323
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
4324
+ e.preventDefault();
4325
+ mentionActive = (mentionActive + (e.key === 'ArrowDown' ? 1 : -1) + mentionMatches.length) % mentionMatches.length;
4326
+ Array.from(mentionMenuEl.children).forEach((el, idx) =>
4327
+ el.classList.toggle('is-active', idx === mentionActive));
4328
+ return;
4329
+ }
4330
+ if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); pickMention(mentionActive); return; }
4331
+ if (e.key === 'Escape') { e.preventDefault(); closeMentionMenu(); return; }
4332
+ }
4333
+ // ⌘/Ctrl+Enter to post — keeps newlines reachable while still giving
4334
+ // a quick way to ship short notes.
4335
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
4336
+ e.preventDefault();
4337
+ postComment();
4338
+ }
4339
+ });
4340
+ commentsPostBtn.addEventListener('click', postComment);
4341
+
4342
+ // ===== Pillar B: editable spec =====
4343
+ // Spec HTML comes from /__spec/<id> (KV overlay or build seed).
4344
+ // Spec authors get an Edit toggle (contenteditable + toolbar →
4345
+ // PUT /__spec/<id>).
4346
+ (function () {
4347
+ var SPEC_AUTHORS = [];
4348
+ var specBody = document.getElementById('wfSpecBody');
4349
+ var specWrap = document.getElementById('wfSpec');
4350
+ if (!specBody || !specWrap) return;
4351
+ var SPEC_API = '/__spec/' + PROTO_ID;
4352
+ var editing = false;
4353
+ function isAuthor(u) { return !!(u && SPEC_AUTHORS.indexOf((u.email || '').toLowerCase()) >= 0); }
4354
+
4355
+ fetch(SPEC_API + '.json', { cache: 'no-store', credentials: 'include' })
4356
+ .then(function (r) { return r.ok ? r.json() : null; })
4357
+ .then(function (d) { if (d && typeof d.html === 'string' && d.html) specBody.innerHTML = d.html; })
4358
+ .catch(function () {});
4359
+
4360
+ var toolbar = document.createElement('div');
4361
+ toolbar.className = 'spec-edit-toolbar'; toolbar.hidden = true;
4362
+ [['bold', 'B'], ['italic', 'I'], ['formatBlock', 'H2', '<h2>'], ['formatBlock', 'H3', '<h3>'], ['formatBlock', 'P', '<p>'], ['insertUnorderedList', '• List'], ['insertOrderedList', '1. List'], ['createLink', 'Link']].forEach(function (d) {
4363
+ var b = document.createElement('button'); b.type = 'button'; b.textContent = d[1];
4364
+ b.addEventListener('mousedown', function (e) { e.preventDefault(); });
4365
+ b.addEventListener('click', function () {
4366
+ var a = d[2];
4367
+ if (d[0] === 'createLink') a = prompt('Link URL') || undefined;
4368
+ document.execCommand(d[0], false, a); specBody.focus();
4369
+ });
4370
+ toolbar.appendChild(b);
4371
+ });
4372
+ var saveBtn = document.createElement('button');
4373
+ saveBtn.type = 'button'; saveBtn.className = 'spec-edit-save'; saveBtn.textContent = 'Save spec';
4374
+ toolbar.appendChild(saveBtn);
4375
+ specWrap.insertBefore(toolbar, specWrap.firstChild);
4376
+
4377
+ var editBtn = document.createElement('button');
4378
+ editBtn.className = 'spec-edit-btn'; editBtn.type = 'button';
4379
+ editBtn.textContent = 'Edit spec'; editBtn.hidden = true;
4380
+ var specActions = document.querySelector('.wf-actions__group--spec') || document.querySelector('.wf-actions');
4381
+ if (specActions) specActions.insertBefore(editBtn, specActions.firstChild);
4382
+
4383
+ function setEditing(on) {
4384
+ editing = on;
4385
+ specBody.contentEditable = on ? 'true' : 'false';
4386
+ toolbar.hidden = !on;
4387
+ editBtn.textContent = on ? 'Editing…' : 'Edit spec';
4388
+ if (on) specBody.focus();
4389
+ }
4390
+ editBtn.addEventListener('click', function () { if (!editing) setEditing(true); });
4391
+ saveBtn.addEventListener('click', async function () {
4392
+ saveBtn.textContent = 'Saving…';
4393
+ try {
4394
+ var r = await fetch(SPEC_API, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ html: specBody.innerHTML }) });
4395
+ if (!r.ok) { var d = await r.json().catch(function () { return {}; }); throw new Error(d.error || ('Failed ' + r.status)); }
4396
+ setEditing(false);
4397
+ } catch (e) { alert('Save failed: ' + e.message); }
4398
+ finally { saveBtn.textContent = 'Save spec'; }
4399
+ });
4400
+
4401
+ window.__wfSpecAuth = function (u) { editBtn.hidden = !isAuthor(u); };
4402
+ })();
4403
+
4404
+ loadComments();
4405
+ refreshSession();
4406
+ </script>
4407
+ {# === Copy entire wireflow to Figma =====================================
4408
+ Lazy-loads @figit/dom-to-figma from esm.sh on first click, then iterates
4409
+ every `.wf-node iframe`, clones each `.proto-content` into a positioned
4410
+ offscreen wrapper, pre-rasterizes Material Symbols ligature spans (Figma
4411
+ doesn't apply icon-font ligatures), and writes a single Figma-paste to
4412
+ the clipboard. The wireflow's own DS stylesheets are loaded inside the
4413
+ iframes, not the parent; we discover + inject them on demand so cloned
4414
+ screen content actually paints with its DS styles. #}
4415
+ <script type="module">
4416
+ (function () {
4417
+ var btn = document.getElementById('wfFigmaCopyBtn');
4418
+ if (!btn) return;
4419
+ var labelEl = document.getElementById('wfFigmaCopyLabel');
4420
+ var converterPromise = null;
4421
+
4422
+ function setLabel(text, state) {
4423
+ if (labelEl) labelEl.textContent = text;
4424
+ btn.classList.remove('is-done', 'is-error');
4425
+ if (state) btn.classList.add(state);
4426
+ }
4427
+ async function getConverter() {
4428
+ if (!converterPromise) {
4429
+ converterPromise = import('https://esm.sh/@figit/dom-to-figma@0.0.2')
4430
+ .then(function (mod) { return mod.createFigmaConverter(); });
4431
+ }
4432
+ return converterPromise;
4433
+ }
4434
+
4435
+ function injectStylesheet(href) {
4436
+ // Skip if already present (avoid stomping existing wireflow links).
4437
+ var existing = document.head.querySelector('link[href="' + CSS.escape(href) + '"]');
4438
+ if (existing) return Promise.resolve(null);
4439
+ var link = document.createElement('link');
4440
+ link.rel = 'stylesheet';
4441
+ link.href = href;
4442
+ link.dataset.figmaCopyInjected = '1';
4443
+ document.head.appendChild(link);
4444
+ return new Promise(function (resolve) {
4445
+ link.addEventListener('load', function () { resolve(link); });
4446
+ // Resolve even on error — a missing stylesheet shouldn't block the
4447
+ // whole capture (worst case, that screen renders unstyled).
4448
+ link.addEventListener('error', function () { resolve(link); });
4449
+ });
4450
+ }
4451
+
4452
+ // mode: 'linear' (screens in a single row, no arrows) or
4453
+ // 'arrows' (elk positions + cloned wireflow arrow + label SVGs).
4454
+ async function doCopy(mode) {
4455
+ if (btn.disabled) return;
4456
+ var iframes = Array.from(document.querySelectorAll('.wf-node iframe'));
4457
+ if (!iframes.length) {
4458
+ setLabel('No screens to copy', 'is-error');
4459
+ setTimeout(function () { setLabel('Copy to Figma'); }, 2200);
4460
+ return;
4461
+ }
4462
+ btn.disabled = true;
4463
+ setLabel('Preparing…');
4464
+
4465
+ var injected = [];
4466
+ var wrapper = null;
4467
+ try {
4468
+ // 1. Discover every stylesheet the iframes load (DS bundle, Google
4469
+ // Fonts, proto-chrome) and inject any missing ones into the parent
4470
+ // head. Same-origin: iframe.contentDocument is reachable.
4471
+ var hrefs = new Set();
4472
+ iframes.forEach(function (iframe) {
4473
+ var doc = iframe.contentDocument;
4474
+ if (!doc) return;
4475
+ doc.head.querySelectorAll('link[rel="stylesheet"]').forEach(function (l) {
4476
+ var h = l.getAttribute('href') || '';
4477
+ if (!h) return;
4478
+ try { hrefs.add(new URL(h, doc.baseURI).toString()); }
4479
+ catch (e) { hrefs.add(h); }
4480
+ });
4481
+ });
4482
+ await Promise.all(Array.from(hrefs).map(function (h) {
4483
+ return injectStylesheet(h).then(function (l) { if (l) injected.push(l); });
4484
+ }));
4485
+ // The wireflow doc's own `body { font-family / color }` rule
4486
+ // cascades into cloned screens (we're appending them to this doc)
4487
+ // and overrides the DS defaults — the single-screen capture doesn't
4488
+ // suffer this because it stays inside the iframe. Scope a reset to
4489
+ // the wrapper via [data-figma-capture-root]. No background here;
4490
+ // the wrapper itself stays transparent per design.
4491
+ var bodyReset = document.createElement('style');
4492
+ bodyReset.dataset.figmaCopyInjected = '1';
4493
+ bodyReset.textContent =
4494
+ '[data-figma-capture-root] {' +
4495
+ ' font-family: Archivo, system-ui, -apple-system, sans-serif !important;' +
4496
+ ' color: #0f172a !important;' +
4497
+ '}';
4498
+ document.head.appendChild(bodyReset);
4499
+ injected.push(bodyReset);
4500
+
4501
+ try { await document.fonts.ready; } catch (e) {}
4502
+
4503
+ // 2. Build the offscreen wrapper with one positioned cell per iframe.
4504
+ // Cell positioning depends on `mode`:
4505
+ // - 'linear' — screens stacked left-to-right with a fixed gap, no
4506
+ // arrows. Lets the user wire connectors in FigJam.
4507
+ // - 'arrows' — screens at their elk-laid coords, plus the live
4508
+ // arrow + label SVGs cloned from the canvas so the
4509
+ // journey reads the same in Figma.
4510
+ wrapper = document.createElement('div');
4511
+ wrapper.setAttribute('data-figma-capture-root', '1');
4512
+ // Transparent wrapper — only the screen cells are opaque white. Gaps
4513
+ // between screens stay clear so arrows (in 'arrows' mode) + the
4514
+ // Figma canvas background show through.
4515
+ wrapper.style.cssText = 'position:fixed;left:-99999px;top:0;background:transparent;';
4516
+
4517
+ // Use ONE scale factor (from the first iframe) to unscale every
4518
+ // position. With a mixed phone/desktop flow this would distort
4519
+ // spacing, but in practice each wireflow is single-form-factor.
4520
+ var globalM = /scale\(([\d.]+)\)/.exec(iframes[0].style.transform || '');
4521
+ var globalScale = globalM ? parseFloat(globalM[1]) : 1;
4522
+ var unscale = globalScale > 0 ? 1 / globalScale : 1;
4523
+
4524
+ // In 'arrows' mode, native wireflow canvas size = elk's scaled-down
4525
+ // inner dimensions multiplied by unscale. Computed up-front so the
4526
+ // arrow SVG cloning and the final wrapper sizing both see them.
4527
+ var inner = document.getElementById('wfInner');
4528
+ var innerW = inner ? parseFloat(inner.style.width) || inner.offsetWidth : 0;
4529
+ var innerH = inner ? parseFloat(inner.style.height) || inner.offsetHeight : 0;
4530
+ var totalW = Math.ceil(innerW * unscale);
4531
+ var totalH = Math.ceil(innerH * unscale);
4532
+
4533
+ // 2a. ('arrows' only) Clone the elk arrow + label SVGs into the
4534
+ // wrapper. Strip the canvas-zoom counter-scale transforms
4535
+ // applyTransform() writes onto arrowheads / dots / labels — those
4536
+ // exist for live pan/zoom; the static export wants natural scaling.
4537
+ if (mode === 'arrows' && inner) {
4538
+ var arrowsSrc = inner.querySelector('svg.wireflow-arrows');
4539
+ var labelsSrc = inner.querySelector('svg.wireflow-labels');
4540
+ function prepSvg(src) {
4541
+ if (!src) return null;
4542
+ var svg = src.cloneNode(true);
4543
+ svg.setAttribute('viewBox', '0 0 ' + innerW + ' ' + innerH);
4544
+ svg.setAttribute('width', totalW);
4545
+ svg.setAttribute('height', totalH);
4546
+ svg.style.cssText = 'position:absolute;left:0;top:0;pointer-events:none;';
4547
+ svg.querySelectorAll('[transform]').forEach(function (el) {
4548
+ var t = el.getAttribute('transform') || '';
4549
+ if (/^\s*scale\(/.test(t)) el.removeAttribute('transform');
4550
+ });
4551
+ svg.querySelectorAll('path').forEach(function (p) {
4552
+ p.setAttribute('stroke-width', '1.5');
4553
+ p.removeAttribute('vector-effect');
4554
+ p.style.removeProperty('vector-effect');
4555
+ });
4556
+ svg.querySelectorAll('circle.wf-dot').forEach(function (c) {
4557
+ c.setAttribute('stroke-width', '1.5');
4558
+ if (!c.getAttribute('r')) c.setAttribute('r', '2.5');
4559
+ });
4560
+ return svg;
4561
+ }
4562
+ var arrowsClone = prepSvg(arrowsSrc);
4563
+ if (arrowsClone) wrapper.appendChild(arrowsClone);
4564
+ // Stash labels to append AFTER cells so they paint on top.
4565
+ wrapper._figmaLabelsSvg = prepSvg(labelsSrc);
4566
+ }
4567
+
4568
+ // Linear layout running cursor — gap between adjacent screens.
4569
+ var LINEAR_GAP = 80;
4570
+ var linearX = 0;
4571
+
4572
+ // Rewrite top-level `body`, `html`, `:root` selectors in inline page
4573
+ // CSS so they target our cell's body/html proxies. Regex is crude
4574
+ // (won't handle e.g. `body.foo > .x` perfectly) but covers the
4575
+ // common `[block styles]` patterns: `body { ... }`,
4576
+ // `html { ... }`, `.foo, body { ... }`. Anything more sophisticated
4577
+ // is unusual in a page-shell `[block styles]` and survives via
4578
+ // the class-targeting rules already in the DS.
4579
+ function rewriteRootSelectors(css, htmlClass, bodyClass) {
4580
+ return css
4581
+ .replace(/(^|[\s,{}])body(?=[\s,.\[:>~+{])/g, '$1.' + bodyClass)
4582
+ .replace(/(^|[\s,{}])html(?=[\s,.\[:>~+{])/g, '$1.' + htmlClass)
4583
+ .replace(/(^|[\s,{}]):root(?=[\s,.\[:>~+{])/g, '$1.' + htmlClass);
4584
+ }
4585
+
4586
+ var maxX = 0, maxY = 0, cellCount = 0;
4587
+ iframes.forEach(function (iframe, idx) {
4588
+ var doc = iframe.contentDocument;
4589
+ if (!doc) return;
4590
+ var node = iframe.closest('.wf-node');
4591
+ if (!node) return;
4592
+ var srcBody = doc.body;
4593
+ if (!srcBody) return;
4594
+ var iw = parseInt(iframe.style.width, 10) || iframe.offsetWidth;
4595
+ var ih = parseInt(iframe.style.height, 10) || iframe.offsetHeight;
4596
+ var nx, ny;
4597
+ if (mode === 'linear') {
4598
+ // Stack screens left-to-right. Y stays at 0 — tall screens
4599
+ // hang below short ones, but the row stays predictable.
4600
+ nx = linearX;
4601
+ ny = 0;
4602
+ linearX += iw + LINEAR_GAP;
4603
+ } else {
4604
+ // 'arrows' mode — keep each node's elk-laid position.
4605
+ nx = (parseFloat(node.style.left) || 0) * unscale;
4606
+ ny = (parseFloat(node.style.top) || 0) * unscale;
4607
+ }
4608
+
4609
+ var htmlClass = 'fwf-h-' + idx;
4610
+ var bodyClass = 'fwf-b-' + idx;
4611
+
4612
+ // The cell is the outer positioning frame. Inside, we mirror the
4613
+ // iframe's html/body chain as plain <div>s so body { ... } rules
4614
+ // from the page's `[block styles]` can target an element via
4615
+ // the rewriter above.
4616
+ var cell = document.createElement('div');
4617
+ // transform:translateZ(0) makes the cell a containing block for
4618
+ // position:fixed descendants (snackbars, toasts) so they land
4619
+ // inside their own screen instead of at the wrapper bottom.
4620
+ cell.style.cssText = 'position:absolute;left:' + nx + 'px;top:' + ny + 'px;width:' + iw + 'px;height:' + ih + 'px;overflow:hidden;transform:translateZ(0);';
4621
+
4622
+ var htmlProxy = document.createElement('div');
4623
+ htmlProxy.className = htmlClass + (doc.documentElement.className ? ' ' + doc.documentElement.className : '');
4624
+ // Fill the cell so percentage/min-height rules behave like a viewport.
4625
+ htmlProxy.style.cssText = 'width:100%;height:100%;';
4626
+
4627
+ var bodyProxy = document.createElement('div');
4628
+ bodyProxy.className = bodyClass + (srcBody.className ? ' ' + srcBody.className : '');
4629
+ // Default to filling the cell — gets overridden by any cloned
4630
+ // body rule (e.g. `body { width: 402px; min-height: 874px }`).
4631
+ bodyProxy.style.cssText = 'width:100%;min-height:100%;';
4632
+
4633
+ // Clone all body children into bodyProxy.
4634
+ Array.from(srcBody.children).forEach(function (child) {
4635
+ bodyProxy.appendChild(child.cloneNode(true));
4636
+ });
4637
+
4638
+ // Pull inline <style> blocks from iframe <head> (the page's
4639
+ // `[block styles]`) and inject a rewritten copy into the cell
4640
+ // so layout rules like .invite-form { padding:16px } and
4641
+ // body { background:#fff; width:402px } actually apply.
4642
+ var pageStyleNodes = doc.head.querySelectorAll('style');
4643
+ pageStyleNodes.forEach(function (s) {
4644
+ var raw = s.textContent || '';
4645
+ if (!raw.trim()) return;
4646
+ var styleEl = document.createElement('style');
4647
+ styleEl.textContent = rewriteRootSelectors(raw, htmlClass, bodyClass);
4648
+ cell.appendChild(styleEl);
4649
+ });
4650
+
4651
+ // Suppress the harness chrome (proto-topbar / proto-bottombar)
4652
+ // that would otherwise render now that we cloned the whole body
4653
+ // instead of just .proto-content. The iframe hides these via
4654
+ // html.is-embedded; we replicate that hide here.
4655
+ var hideChrome = document.createElement('style');
4656
+ hideChrome.textContent =
4657
+ '.' + bodyClass + ' .proto-topbar,' +
4658
+ '.' + bodyClass + ' .proto-bottombar,' +
4659
+ '.' + bodyClass + ' .proto-chrome-dot,' +
4660
+ '.' + bodyClass + ' .screen-comments-panel,' +
4661
+ '.' + bodyClass + ' .screen-auth-gate { display: none !important; }';
4662
+ cell.appendChild(hideChrome);
4663
+
4664
+ htmlProxy.appendChild(bodyProxy);
4665
+ cell.appendChild(htmlProxy);
4666
+ wrapper.appendChild(cell);
4667
+ cellCount++;
4668
+ maxX = Math.max(maxX, nx + iw);
4669
+ maxY = Math.max(maxY, ny + ih);
4670
+ });
4671
+ if (!cellCount) {
4672
+ setLabel('Nothing to capture', 'is-error');
4673
+ return;
4674
+ }
4675
+ // 'arrows' mode: append labels on top of the cells (matches the
4676
+ // live z-order — labels paint above frames so edge text isn't
4677
+ // clipped by adjacent screens).
4678
+ if (mode === 'arrows' && wrapper._figmaLabelsSvg) {
4679
+ wrapper.appendChild(wrapper._figmaLabelsSvg);
4680
+ }
4681
+ // Wrapper size:
4682
+ // - 'arrows' mode: max(elk canvas extent, cell extent) so arrow
4683
+ // paths extending past the rightmost cell aren't clipped.
4684
+ // - 'linear' mode: pure cell extent.
4685
+ if (mode === 'arrows') {
4686
+ wrapper.style.width = Math.max(totalW, Math.ceil(maxX)) + 'px';
4687
+ wrapper.style.height = Math.max(totalH, Math.ceil(maxY)) + 'px';
4688
+ } else {
4689
+ wrapper.style.width = Math.ceil(maxX) + 'px';
4690
+ wrapper.style.height = Math.ceil(maxY) + 'px';
4691
+ }
4692
+ document.body.appendChild(wrapper);
4693
+
4694
+ // 3. One frame so the cloned subtree lays out before Figit measures.
4695
+ await new Promise(function (r) { requestAnimationFrame(r); });
4696
+
4697
+ // 4. Capture + write to clipboard.
4698
+ setLabel('Capturing ' + cellCount + ' screen' + (cellCount === 1 ? '' : 's') + '…');
4699
+ var converter = await getConverter();
4700
+ var capW = Math.max(1, parseFloat(wrapper.style.width) || maxX);
4701
+ var capH = Math.max(1, parseFloat(wrapper.style.height) || maxY);
4702
+ var result = await converter.convert({
4703
+ element: wrapper,
4704
+ width: Math.ceil(capW),
4705
+ height: Math.ceil(capH),
4706
+ name: (document.title || 'Wireflow').replace(/\s+—.*$/, '') + ' — wireflow',
4707
+ });
4708
+ await navigator.clipboard.write([result.toClipboardItem()]);
4709
+ setLabel('Copied — ⌘V in Figma', 'is-done');
4710
+ } catch (err) {
4711
+ console.error('[wf-figma-copy] failed', err);
4712
+ setLabel('Copy failed', 'is-error');
4713
+ } finally {
4714
+ if (wrapper) wrapper.remove();
4715
+ injected.forEach(function (l) { l.remove(); });
4716
+ btn.disabled = false;
4717
+ setTimeout(function () { setLabel('Copy to Figma'); }, 2400);
4718
+ }
4719
+ }
4720
+
4721
+ // Wire the dropdown items. The trigger button itself only opens the
4722
+ // menu (handled by the generic wf-menu logic above) — copying is
4723
+ // initiated from the menu items.
4724
+ var linearBtn = document.getElementById('wfFigmaCopyLinear');
4725
+ var arrowsBtn = document.getElementById('wfFigmaCopyArrows');
4726
+ if (linearBtn) linearBtn.addEventListener('click', function () { doCopy('linear'); });
4727
+ if (arrowsBtn) arrowsBtn.addEventListener('click', function () { doCopy('arrows'); });
4728
+ })();
4729
+ </script>
4730
+ </body>
4731
+ </html>