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.
- package/auth-schema.sql +8 -0
- package/bin/bedrock-flows.mjs +127 -0
- package/lib/setup.mjs +262 -0
- package/package.json +11 -0
- package/template/.storybook/main.js +46 -0
- package/template/.storybook/manager-head.html +963 -0
- package/template/.storybook/preview-head.html +35 -0
- package/template/.storybook/preview.js +23 -0
- package/template/CHANGELOG.md +236 -0
- package/template/README.md +26 -0
- package/template/apps/dashboard/index.html +15 -0
- package/template/apps/dashboard/package.json +22 -0
- package/template/apps/dashboard/src/App.module.css +1318 -0
- package/template/apps/dashboard/src/App.tsx +2716 -0
- package/template/apps/dashboard/src/auth-client.ts +17 -0
- package/template/apps/dashboard/src/changelog.tsx +92 -0
- package/template/apps/dashboard/src/index.css +86 -0
- package/template/apps/dashboard/src/main.tsx +15 -0
- package/template/apps/dashboard/src/theme.ts +31 -0
- package/template/apps/dashboard/src/vite-env.d.ts +4 -0
- package/template/apps/dashboard/vite.config.ts +48 -0
- package/template/apps/worker/.dev.vars.example +50 -0
- package/template/apps/worker/package.json +19 -0
- package/template/apps/worker/src/index.ts +295 -0
- package/template/apps/worker/tsconfig.json +11 -0
- package/template/apps/worker/wrangler.jsonc +29 -0
- package/template/bedrock.config.ts +16 -0
- package/template/design-system/README.md +97 -0
- package/template/design-system/starter-v1/components/button/component.css +42 -0
- package/template/design-system/starter-v1/components/button/danger.html +21 -0
- package/template/design-system/starter-v1/components/button/default.html +21 -0
- package/template/design-system/starter-v1/components/button/disabled.html +21 -0
- package/template/design-system/starter-v1/components/button/ghost.html +21 -0
- package/template/design-system/starter-v1/components/button/macro.njk +14 -0
- package/template/design-system/starter-v1/components/button/primary.html +21 -0
- package/template/design-system/starter-v1/components/button/variants.json +30 -0
- package/template/design-system/starter-v1/ds.json +3 -0
- package/template/design-system/starter-v1/global.css +52 -0
- package/template/design-system/starter-v1/style.css +107 -0
- package/template/gitignore +8 -0
- package/template/package.json +41 -0
- package/template/prototypes/F-001-hello/1-welcome.njk +30 -0
- package/template/prototypes/F-001-hello/2-form.njk +46 -0
- package/template/prototypes/F-001-hello/3-done.njk +29 -0
- package/template/prototypes/F-001-hello/meta.json +6 -0
- package/template/prototypes/_shared/_auth-gate.njk +54 -0
- package/template/prototypes/_shared/delivery.njk +43 -0
- package/template/prototypes/_shared/layout.njk +15 -0
- package/template/prototypes/_shared/screen.njk +1818 -0
- package/template/prototypes/_shared/wireflow.njk +4731 -0
- package/template/public/auth-gate.css +150 -0
- package/template/public/bedrock/color-inspector.js +284 -0
- package/template/public/bedrock/component-overlay.js +219 -0
- package/template/public/bedrock/data/bedrock-config.js +45 -0
- package/template/public/bedrock/font-size-overlay.js +590 -0
- package/template/public/bedrock/grid-overlay.js +379 -0
- package/template/public/bedrock/prototype-navigation.js +974 -0
- package/template/public/cmdk.js +146 -0
- package/template/public/ds-xray.css +112 -0
- package/template/public/ds-xray.js +271 -0
- package/template/public/favicon.svg +4 -0
- package/template/public/icons/bolt-fill.svg +3 -0
- package/template/public/icons/bolt.svg +3 -0
- package/template/public/icons/caret-down-fill.svg +3 -0
- package/template/public/icons/check-double.svg +4 -0
- package/template/public/icons/check.svg +3 -0
- package/template/public/icons/chevron-left.svg +3 -0
- package/template/public/icons/chevron-right.svg +3 -0
- package/template/public/icons/circle-info.svg +6 -0
- package/template/public/icons/grid.svg +6 -0
- package/template/public/icons/message-square-1.svg +3 -0
- package/template/public/icons/message-square.svg +3 -0
- package/template/public/icons/messages.svg +4 -0
- package/template/public/icons/options-horizontal.svg +5 -0
- package/template/public/icons/swatches.svg +6 -0
- package/template/public/icons/workflow.svg +6 -0
- package/template/public/lightbox.js +87 -0
- package/template/public/proto-chrome.css +596 -0
- package/template/public/screen-comments.css +723 -0
- package/template/public/wireflow-client.js +26 -0
- package/template/scripts/build-storybooks.mjs +8 -0
- package/template/scripts/dev-setup.mjs +15 -0
- package/template/scripts/generate-stories.mjs +12 -0
- package/template/scripts/generate-variants.mjs +22 -0
- 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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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>
|