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,1818 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en"{% if phoneFrame %} class="is-phone-frame"{% endif %}>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
{# Absolute /favicon.svg so the icon shows up at every depth
|
|
7
|
+
(`/p/F-001/3-enter-phone.html`, `/t/auth-web/...`, etc.) without
|
|
8
|
+
each page needing to compute the right relative path. #}
|
|
9
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
10
|
+
<base href="{{ assetBase }}/" />
|
|
11
|
+
{# Surface the step's friendly label to the wireflow generator so the
|
|
12
|
+
diagram nodes can show "Sign up (from SMS)" instead of the slug
|
|
13
|
+
"2-alt-sign-up". Parsed by vite.config.ts via wfLabelMetaRe. #}
|
|
14
|
+
{% if stepLabel %}<meta name="wf-label" content="{{ stepLabel }}">{% endif %}
|
|
15
|
+
{# Phone-frame pages auto-declare their wireflow size as phone so the
|
|
16
|
+
wireflow renders them with the 402×874 phone iframe (not the 1280×800
|
|
17
|
+
desktop default). Saves the page from setting wf-size manually when
|
|
18
|
+
it already declared phoneFrame. #}
|
|
19
|
+
{% if phoneFrame %}<meta name="wf-size" content="phone">{% endif %}
|
|
20
|
+
{# Page states opt into the wireflow as named journey entities via
|
|
21
|
+
`{% set wfExpose = 'step' %}` (or 'variant', reserved for Phase 2
|
|
22
|
+
and not yet wired). Without it a --state page stays hidden in
|
|
23
|
+
the diagram by default and just contributes to its base's +N
|
|
24
|
+
states count. See plan-page-states.md. #}
|
|
25
|
+
{% if wfExpose %}<meta name="wf-expose" content="{{ wfExpose }}">{% endif %}
|
|
26
|
+
{# A hub screen with return edges never reaches in-degree 0, so it
|
|
27
|
+
declares itself the wireflow's entry point. `{% set wfStart = true %}` #}
|
|
28
|
+
{% if wfStart %}<meta name="wf-start" content="true">{% endif %}
|
|
29
|
+
<title>{{ stepNum }}. {{ stepLabel }} — {{ meta.name }}</title>
|
|
30
|
+
<script>
|
|
31
|
+
// Honor the shared chrome theme override (set from the dashboard /
|
|
32
|
+
// wireflow user menu) before paint. Only affects the harness chrome
|
|
33
|
+
// tokens — the prototype's own DS surface stays light.
|
|
34
|
+
try {
|
|
35
|
+
var t = localStorage.getItem('bedrockTheme');
|
|
36
|
+
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
|
|
37
|
+
} catch (e) {}
|
|
38
|
+
</script>
|
|
39
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
40
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
41
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;900&display=swap" />
|
|
42
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Sharp:opsz,wght,FILL,GRAD@24,400,0..1,0&display=swap" />
|
|
43
|
+
<link rel="stylesheet" href="{{ dsHref }}" />
|
|
44
|
+
<link rel="stylesheet" href="/proto-chrome.css" />
|
|
45
|
+
<link rel="stylesheet" href="/screen-comments.css" />
|
|
46
|
+
{# Shared auth-gate card styling — single source across dashboard/wireflow/screen. #}
|
|
47
|
+
<link rel="stylesheet" href="/auth-gate.css" />
|
|
48
|
+
<link rel="stylesheet" href="/ds-xray.css" />
|
|
49
|
+
{# Shared breakpoint config — single source of truth for the Viewport
|
|
50
|
+
control (also used by the delivery-site nav and the wireflow). #}
|
|
51
|
+
<script src="/bedrock/data/bedrock-config.js"></script>
|
|
52
|
+
<script>
|
|
53
|
+
(function () {
|
|
54
|
+
var html = document.documentElement;
|
|
55
|
+
// Persist visibility across page navigations so a designer scrubbing
|
|
56
|
+
// through a flow doesn't have to re-press `.` on every screen.
|
|
57
|
+
var STORAGE_KEY = 'proto-chrome-visible';
|
|
58
|
+
var saved = null;
|
|
59
|
+
try { saved = localStorage.getItem(STORAGE_KEY); } catch (e) {}
|
|
60
|
+
// First-time visitors see the chrome — that's how they discover the
|
|
61
|
+
// step pill, the back/continue nav, and (by extension) the `.` toggle
|
|
62
|
+
// and the `↩ wireflow` link. Returning users get whatever they last
|
|
63
|
+
// toggled to: '0' explicitly hides, anything else (incl. '1' or null)
|
|
64
|
+
// keeps it visible.
|
|
65
|
+
if (saved === '0') html.classList.add('is-chrome-hidden');
|
|
66
|
+
// Keyboardless override — testing on a phone has no `.` key to toggle
|
|
67
|
+
// the chrome. `?chrome=0` hides the harness chrome, `?chrome=1` brings
|
|
68
|
+
// it back; the choice persists (same key as the `.` toggle) so the rest
|
|
69
|
+
// of the flow keeps it while navigating between steps.
|
|
70
|
+
try {
|
|
71
|
+
var chromeParam = new URLSearchParams(window.location.search).get('chrome');
|
|
72
|
+
if (chromeParam === '0' || chromeParam === '1') {
|
|
73
|
+
html.classList.toggle('is-chrome-hidden', chromeParam === '0');
|
|
74
|
+
localStorage.setItem(STORAGE_KEY, chromeParam);
|
|
75
|
+
}
|
|
76
|
+
} catch (e) {}
|
|
77
|
+
// Inside the wireflow's iframe previews the chrome should stay hidden
|
|
78
|
+
// so each screen looks like the real product.
|
|
79
|
+
if (window.self !== window.top) {
|
|
80
|
+
html.classList.add('is-embedded');
|
|
81
|
+
html.classList.add('is-chrome-hidden');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function persist() {
|
|
85
|
+
try {
|
|
86
|
+
localStorage.setItem(STORAGE_KEY, html.classList.contains('is-chrome-hidden') ? '0' : '1');
|
|
87
|
+
} catch (e) {}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isEditable(el) {
|
|
91
|
+
if (!el) return false;
|
|
92
|
+
var tag = el.tagName;
|
|
93
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
|
94
|
+
if (el.isContentEditable) return true;
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function toggleChrome() {
|
|
99
|
+
html.classList.toggle('is-chrome-hidden');
|
|
100
|
+
persist();
|
|
101
|
+
}
|
|
102
|
+
function hideChrome() {
|
|
103
|
+
html.classList.add('is-chrome-hidden');
|
|
104
|
+
persist();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Arrow-left / arrow-right step through the harness Back / Continue
|
|
108
|
+
// links so a reviewer can keyboard through a flow without clicking.
|
|
109
|
+
// Top-level only — inside wireflow iframe previews the arrows
|
|
110
|
+
// belong to pan/scroll, not navigation.
|
|
111
|
+
function navByArrow(dir) {
|
|
112
|
+
var nav = document.querySelector('.proto-nav');
|
|
113
|
+
if (!nav) return false;
|
|
114
|
+
var target = dir === 'next'
|
|
115
|
+
? nav.querySelector('a.primary')
|
|
116
|
+
: nav.querySelector('a:not(.primary)');
|
|
117
|
+
if (!target) return false;
|
|
118
|
+
target.click();
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
document.addEventListener('keydown', function (e) {
|
|
123
|
+
if (e.defaultPrevented) return;
|
|
124
|
+
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
|
|
125
|
+
if (isEditable(e.target)) return;
|
|
126
|
+
if (e.key === '.') {
|
|
127
|
+
e.preventDefault();
|
|
128
|
+
toggleChrome();
|
|
129
|
+
} else if (e.key === 'd' || e.key === 'D') {
|
|
130
|
+
// Toggle the harness chrome between light and dark. Persists via
|
|
131
|
+
// the shared bedrockTheme override (same one the dashboard /
|
|
132
|
+
// wireflow user menu writes); the prototype's own DS surface is
|
|
133
|
+
// unaffected. Applied to the TOP document so the toggle works even
|
|
134
|
+
// when focus sits inside a same-origin preview iframe (where the
|
|
135
|
+
// chrome is hidden and a local flip would be invisible).
|
|
136
|
+
e.preventDefault();
|
|
137
|
+
var themeRoot = html;
|
|
138
|
+
try { themeRoot = window.top.document.documentElement; } catch (err) {}
|
|
139
|
+
var cur = themeRoot.getAttribute('data-theme') ||
|
|
140
|
+
(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
|
141
|
+
var next = cur === 'dark' ? 'light' : 'dark';
|
|
142
|
+
themeRoot.setAttribute('data-theme', next);
|
|
143
|
+
try { localStorage.setItem('bedrockTheme', next); } catch (err) {}
|
|
144
|
+
} else if (e.key === 'Escape') {
|
|
145
|
+
if (!html.classList.contains('is-chrome-hidden')) {
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
hideChrome();
|
|
148
|
+
}
|
|
149
|
+
} else if (window.self === window.top && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) {
|
|
150
|
+
if (navByArrow(e.key === 'ArrowRight' ? 'next' : 'prev')) {
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Two-finger triple-tap toggles the chrome on touch devices — a phone
|
|
157
|
+
// has no `.` key, and a permanent on-screen button is chrome of its
|
|
158
|
+
// own. The gesture is deliberately one no user performs naturally
|
|
159
|
+
// (single-finger taps, swipes and long-presses never trigger it), yet
|
|
160
|
+
// a tester can replicate it on demand: tap with two fingers, three
|
|
161
|
+
// times quickly. Top-level only.
|
|
162
|
+
var tfTaps = 0, tfLast = 0, tfStart = 0, tfArmed = false;
|
|
163
|
+
document.addEventListener('touchstart', function (e) {
|
|
164
|
+
if (window.self !== window.top) return;
|
|
165
|
+
if (e.touches.length === 2) { tfArmed = true; tfStart = Date.now(); }
|
|
166
|
+
else if (e.touches.length > 2) tfArmed = false;
|
|
167
|
+
}, true);
|
|
168
|
+
document.addEventListener('touchend', function (e) {
|
|
169
|
+
if (!tfArmed || e.touches.length !== 0) return; // wait for all fingers up
|
|
170
|
+
tfArmed = false;
|
|
171
|
+
var now = Date.now();
|
|
172
|
+
if (now - tfStart > 350) return; // a hold or pinch, not a tap
|
|
173
|
+
tfTaps = (now - tfLast < 600) ? tfTaps + 1 : 1;
|
|
174
|
+
tfLast = now;
|
|
175
|
+
if (tfTaps >= 3) {
|
|
176
|
+
tfTaps = 0;
|
|
177
|
+
toggleChrome();
|
|
178
|
+
}
|
|
179
|
+
}, true);
|
|
180
|
+
})();
|
|
181
|
+
</script>
|
|
182
|
+
<script>
|
|
183
|
+
// Detail-screen Viewport control — the Bedrock breakpoint preview, inline
|
|
184
|
+
// in the flow chrome's top bar. Picking a width loads this screen in a
|
|
185
|
+
// width-constrained iframe so real CSS media queries fire (the embedded
|
|
186
|
+
// copy auto-hides its own chrome via the is-embedded guard above).
|
|
187
|
+
// Top-level only: inside the wireflow's / its own preview iframe this is a
|
|
188
|
+
// no-op, so there are no nested previews.
|
|
189
|
+
(function () {
|
|
190
|
+
if (window.self !== window.top) return;
|
|
191
|
+
// Intended viewport range, resolved server-side (meta.json `viewport`
|
|
192
|
+
// wins over the DS version's ds.json `intendedViewport`). Advisory —
|
|
193
|
+
// breakpoints outside it stay clickable; the control marks them and the
|
|
194
|
+
// resize readout warns when the frame leaves the range.
|
|
195
|
+
var RANGE = {{ viewportRange | dump | safe if viewportRange else 'null' }};
|
|
196
|
+
function outOfRange(w) { return !!(RANGE && w && (w < RANGE.min.w || w > RANGE.max.w)); }
|
|
197
|
+
var BREAKPOINTS = (window.BEDROCK_CONFIG && window.BEDROCK_CONFIG.breakpoints) || [
|
|
198
|
+
{ id: 'full', label: 'Full width', width: null },
|
|
199
|
+
{ id: 'desktop', label: 'Desktop', width: 1280 },
|
|
200
|
+
{ id: 'tablet-lg', label: 'Large Tablet', width: 960 },
|
|
201
|
+
{ id: 'tablet', label: 'Tablet', width: 720 },
|
|
202
|
+
{ id: 'mobile', label: 'Mobile', width: 375, height: 812 },
|
|
203
|
+
];
|
|
204
|
+
// Device glyphs as a descending width staircase (monitor → large tablet
|
|
205
|
+
// → tablet → phone) so the breakpoints read by silhouette width.
|
|
206
|
+
var ICONS = {
|
|
207
|
+
'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>',
|
|
208
|
+
'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>',
|
|
209
|
+
'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>',
|
|
210
|
+
'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>',
|
|
211
|
+
'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>',
|
|
212
|
+
};
|
|
213
|
+
var KEY = 'protoNavViewport'; // shared with the delivery-site control
|
|
214
|
+
var current = 'full';
|
|
215
|
+
try { current = localStorage.getItem(KEY) || 'full'; } catch (e) {}
|
|
216
|
+
|
|
217
|
+
function bpById(id) { for (var i = 0; i < BREAKPOINTS.length; i++) if (BREAKPOINTS[i].id === id) return BREAKPOINTS[i]; return null; }
|
|
218
|
+
|
|
219
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
220
|
+
var topbar = document.querySelector('.proto-topbar');
|
|
221
|
+
var content = document.querySelector('.proto-content');
|
|
222
|
+
var stage = document.querySelector('.proto-stage');
|
|
223
|
+
if (!topbar || !content || !stage) return;
|
|
224
|
+
|
|
225
|
+
// Split-button: main half quick-toggles desktop ↔ mobile; the caret
|
|
226
|
+
// opens a dropdown listing every breakpoint. Keeps the top bar compact.
|
|
227
|
+
var CARET = '<svg viewBox="0 0 24 24" width="11" height="11" fill="currentColor" aria-hidden="true"><path d="M7 10l5 5 5-5z"/></svg>';
|
|
228
|
+
// Out-of-range breakpoints are hidden entirely (not greyed) — only
|
|
229
|
+
// widths the flow's range allows are offered. Full width always shows.
|
|
230
|
+
var itemsHTML = BREAKPOINTS.filter(function (bp) {
|
|
231
|
+
return !bp.width || !outOfRange(bp.width);
|
|
232
|
+
}).map(function (bp) {
|
|
233
|
+
return '<button type="button" role="menuitemradio" class="proto-viewport__item" data-vp="' + bp.id + '">'
|
|
234
|
+
+ '<span class="proto-viewport__item-icon">' + (ICONS[bp.id] || '') + '</span>'
|
|
235
|
+
+ '<span class="proto-viewport__item-label">' + bp.label + '</span>'
|
|
236
|
+
+ '<span class="proto-viewport__item-w">' + (bp.width ? bp.width + 'px' : '') + '</span>'
|
|
237
|
+
+ '</button>';
|
|
238
|
+
}).join('');
|
|
239
|
+
// Footer stating the intended range and which level declared it.
|
|
240
|
+
var rangeHTML = '';
|
|
241
|
+
if (RANGE) {
|
|
242
|
+
var rangeSrc = RANGE.source === 'prototype' ? 'set by this flow' : 'from the design system';
|
|
243
|
+
rangeHTML = '<div class="proto-viewport__range" title="Intended viewport range, ' + rangeSrc + '">'
|
|
244
|
+
+ 'Intended: ' + RANGE.min.label + ' – ' + RANGE.max.label
|
|
245
|
+
+ ' <span>(' + RANGE.min.w + '–' + RANGE.max.w + 'px)</span></div>';
|
|
246
|
+
}
|
|
247
|
+
var ctrl = document.createElement('div');
|
|
248
|
+
ctrl.className = 'proto-viewport';
|
|
249
|
+
ctrl.setAttribute('role', 'group');
|
|
250
|
+
ctrl.setAttribute('aria-label', 'Viewport');
|
|
251
|
+
ctrl.innerHTML =
|
|
252
|
+
'<button type="button" class="proto-viewport__main" title="Toggle desktop / mobile" aria-label="Toggle desktop / mobile"></button>'
|
|
253
|
+
+ '<details class="proto-viewport__menu">'
|
|
254
|
+
+ '<summary class="proto-viewport__caret" title="All breakpoints" aria-label="Choose breakpoint">' + CARET + '</summary>'
|
|
255
|
+
+ '<div class="proto-viewport__panel" role="menu">' + itemsHTML + rangeHTML + '</div>'
|
|
256
|
+
+ '</details>';
|
|
257
|
+
topbar.appendChild(ctrl);
|
|
258
|
+
|
|
259
|
+
var mainBtn = ctrl.querySelector('.proto-viewport__main');
|
|
260
|
+
var details = ctrl.querySelector('.proto-viewport__menu');
|
|
261
|
+
|
|
262
|
+
var preview = document.createElement('div');
|
|
263
|
+
preview.className = 'proto-preview';
|
|
264
|
+
var frameWrap = document.createElement('div');
|
|
265
|
+
frameWrap.className = 'proto-preview__frame';
|
|
266
|
+
var iframe = document.createElement('iframe');
|
|
267
|
+
iframe.className = 'proto-preview__iframe';
|
|
268
|
+
iframe.title = 'Viewport preview';
|
|
269
|
+
frameWrap.appendChild(iframe);
|
|
270
|
+
preview.appendChild(frameWrap);
|
|
271
|
+
content.insertAdjacentElement('afterend', preview);
|
|
272
|
+
|
|
273
|
+
// When the preview reloads at a new breakpoint, re-run the DS X-ray if
|
|
274
|
+
// it's active so its outlines re-attach to the (now-visible) iframe doc.
|
|
275
|
+
iframe.addEventListener('load', function () {
|
|
276
|
+
if (window.dsXrayActive && window.dsXrayActive()) window.dsXrayReapply();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Drag handle on the frame's right edge + a live size readout, so the
|
|
280
|
+
// preview container can be resized to any (non-standard) width.
|
|
281
|
+
var resizeHandle = document.createElement('div');
|
|
282
|
+
resizeHandle.className = 'proto-preview__resize';
|
|
283
|
+
resizeHandle.title = 'Drag to resize';
|
|
284
|
+
frameWrap.appendChild(resizeHandle);
|
|
285
|
+
var sizeLabel = document.createElement('div');
|
|
286
|
+
sizeLabel.className = 'proto-preview__size';
|
|
287
|
+
frameWrap.appendChild(sizeLabel);
|
|
288
|
+
|
|
289
|
+
// True once the container has been hand-resized off a named breakpoint.
|
|
290
|
+
var isCustom = false;
|
|
291
|
+
|
|
292
|
+
// The size readout is transient: it appears while the size is actually
|
|
293
|
+
// changing (drag-resize or breakpoint switch) and fades out shortly
|
|
294
|
+
// after, so it doesn't permanently cover the screen under review.
|
|
295
|
+
var sizeTimer = null;
|
|
296
|
+
function updateSize(hold) {
|
|
297
|
+
if (!stage.classList.contains('is-previewing')) return;
|
|
298
|
+
var r = frameWrap.getBoundingClientRect();
|
|
299
|
+
var out = outOfRange(Math.round(r.width));
|
|
300
|
+
sizeLabel.textContent = Math.round(r.width) + ' × ' + Math.round(r.height)
|
|
301
|
+
+ (out ? ' · outside intended range' : '');
|
|
302
|
+
sizeLabel.classList.toggle('is-out-of-range', out);
|
|
303
|
+
sizeLabel.classList.add('is-on');
|
|
304
|
+
clearTimeout(sizeTimer);
|
|
305
|
+
if (!hold) sizeTimer = setTimeout(function () { sizeLabel.classList.remove('is-on'); }, 1200);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function syncUI() {
|
|
309
|
+
var bp = bpById(current) || BREAKPOINTS[0];
|
|
310
|
+
mainBtn.innerHTML = ICONS[bp.id] || '';
|
|
311
|
+
mainBtn.classList.toggle('is-active', !isCustom && !!bp.width);
|
|
312
|
+
ctrl.querySelectorAll('.proto-viewport__item').forEach(function (it) {
|
|
313
|
+
var on = !isCustom && it.dataset.vp === current;
|
|
314
|
+
it.classList.toggle('is-active', on);
|
|
315
|
+
it.setAttribute('aria-checked', on ? 'true' : 'false');
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function apply(id) {
|
|
320
|
+
var bp = bpById(id) || BREAKPOINTS[0];
|
|
321
|
+
current = bp.id;
|
|
322
|
+
isCustom = false; // picking a breakpoint resets any hand-resize
|
|
323
|
+
try { localStorage.setItem(KEY, current); } catch (e) {}
|
|
324
|
+
if (bp.width) {
|
|
325
|
+
if (iframe.src !== location.href) iframe.src = location.href;
|
|
326
|
+
frameWrap.style.width = bp.width + 'px';
|
|
327
|
+
frameWrap.style.height = bp.height ? (bp.height + 'px') : '100%';
|
|
328
|
+
stage.classList.add('is-previewing');
|
|
329
|
+
} else {
|
|
330
|
+
stage.classList.remove('is-previewing');
|
|
331
|
+
iframe.src = 'about:blank';
|
|
332
|
+
}
|
|
333
|
+
syncUI();
|
|
334
|
+
updateSize();
|
|
335
|
+
// Full view + already-loaded iframe reapply here; a fresh iframe load
|
|
336
|
+
// reapplies via the load listener above.
|
|
337
|
+
if (window.dsXrayActive && window.dsXrayActive()) window.dsXrayReapply();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
mainBtn.addEventListener('click', function () { apply(current === 'mobile' ? 'desktop' : 'mobile'); });
|
|
341
|
+
ctrl.querySelectorAll('.proto-viewport__item').forEach(function (it) {
|
|
342
|
+
it.addEventListener('click', function () { apply(it.dataset.vp); details.removeAttribute('open'); });
|
|
343
|
+
});
|
|
344
|
+
// Native <details> doesn't close on outside click — wire that up.
|
|
345
|
+
document.addEventListener('click', function (e) {
|
|
346
|
+
if (details.hasAttribute('open') && !ctrl.contains(e.target)) details.removeAttribute('open');
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Drag the right-edge handle to resize the preview container. Pointer
|
|
350
|
+
// capture keeps move events flowing even over the iframe; we also
|
|
351
|
+
// disable the iframe's pointer events for the duration of the drag.
|
|
352
|
+
var resizing = false, startX = 0, startW = 0;
|
|
353
|
+
resizeHandle.addEventListener('pointerdown', function (e) {
|
|
354
|
+
resizing = true;
|
|
355
|
+
startX = e.clientX;
|
|
356
|
+
startW = frameWrap.getBoundingClientRect().width;
|
|
357
|
+
try { resizeHandle.setPointerCapture(e.pointerId); } catch (x) {}
|
|
358
|
+
iframe.style.pointerEvents = 'none';
|
|
359
|
+
document.body.style.cursor = 'ew-resize';
|
|
360
|
+
e.preventDefault();
|
|
361
|
+
});
|
|
362
|
+
resizeHandle.addEventListener('pointermove', function (e) {
|
|
363
|
+
if (!resizing) return;
|
|
364
|
+
var maxW = preview.clientWidth - 32; // pane width minus its 16px padding
|
|
365
|
+
// The frame is centered, so both edges move as it grows — double the
|
|
366
|
+
// cursor delta to keep the right edge (and the handle) under the pointer.
|
|
367
|
+
var w = Math.max(280, Math.min(maxW, startW + 2 * (e.clientX - startX)));
|
|
368
|
+
frameWrap.style.width = w + 'px';
|
|
369
|
+
if (!isCustom) { isCustom = true; syncUI(); }
|
|
370
|
+
updateSize(true); // hold while dragging
|
|
371
|
+
});
|
|
372
|
+
function endResize(e) {
|
|
373
|
+
if (!resizing) return;
|
|
374
|
+
resizing = false;
|
|
375
|
+
updateSize(); // re-arm the fade now the drag is done
|
|
376
|
+
try { resizeHandle.releasePointerCapture(e.pointerId); } catch (x) {}
|
|
377
|
+
iframe.style.pointerEvents = '';
|
|
378
|
+
document.body.style.cursor = '';
|
|
379
|
+
}
|
|
380
|
+
resizeHandle.addEventListener('pointerup', endResize);
|
|
381
|
+
resizeHandle.addEventListener('pointercancel', endResize);
|
|
382
|
+
|
|
383
|
+
apply(current);
|
|
384
|
+
});
|
|
385
|
+
})();
|
|
386
|
+
</script>
|
|
387
|
+
<style>
|
|
388
|
+
/* Neutral default canvas, at zero specificity (:where) so any body
|
|
389
|
+
background the design system itself declares always wins — the harness
|
|
390
|
+
must never repaint the product's canvas. */
|
|
391
|
+
:where(body) { background: var(--color-surface, var(--ds-color-surface, #f8fafc)); }
|
|
392
|
+
{% block styles %}{% endblock %}
|
|
393
|
+
</style>
|
|
394
|
+
</head>
|
|
395
|
+
<body>
|
|
396
|
+
<div class="proto-stage">
|
|
397
|
+
<header class="proto-topbar">
|
|
398
|
+
{% set backIcon %}<svg class="proto-home__icon" width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M14 18L8 12L14 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>{% endset %}
|
|
399
|
+
<a class="proto-home" href="{{ assetBase }}/">{{ backIcon | safe }}<span>Wireflow</span></a>
|
|
400
|
+
{# Prefix + step label collapse away on touch devices (CSS), leaving the
|
|
401
|
+
shortest useful form: "2 of 5". #}
|
|
402
|
+
<span class="proto-pill"><span class="proto-pill__prefix">Step </span>{{ stepNum }} of {{ stepTotal }}<span class="proto-pill__detail"> · {{ stepLabel }}</span></span>
|
|
403
|
+
|
|
404
|
+
{# Page-states dropdown — appears when the current screen has sibling
|
|
405
|
+
<base>--<state>.njk files, OR when the page declares URL-param
|
|
406
|
+
states via `pageParamStates` (a list of {id, label} pairs). Lets a
|
|
407
|
+
reviewer cycle through variants without going back to the wireflow.
|
|
408
|
+
The list is built server-side; the <details> element handles the
|
|
409
|
+
open/close state with no JS. #}
|
|
410
|
+
{% set hasFileStates = pageStates and pageStates.length > 1 %}
|
|
411
|
+
{% set hasParamStates = pageParamStates and pageParamStates.length > 0 %}
|
|
412
|
+
{% if hasFileStates or hasParamStates %}
|
|
413
|
+
<details class="proto-states">
|
|
414
|
+
{% if hasFileStates %}
|
|
415
|
+
{% set _activeState = none %}
|
|
416
|
+
{% for s in pageStates %}{% if s.id == page %}{% set _activeState = s %}{% endif %}{% endfor %}
|
|
417
|
+
<summary class="proto-states__summary">
|
|
418
|
+
<span class="proto-states__label">State</span>
|
|
419
|
+
<span class="proto-states__value">{{ _activeState.label if _activeState else 'Default' }}</span>
|
|
420
|
+
<span class="proto-states__caret" aria-hidden="true"><svg width="11" height="11" 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>
|
|
421
|
+
</summary>
|
|
422
|
+
<div class="proto-states__panel" role="menu">
|
|
423
|
+
{% for s in pageStates %}
|
|
424
|
+
<a role="menuitem" class="proto-states__item{% if s.id == page %} is-active{% endif %}" href="{{ assetBase }}/{{ s.id }}" data-wf-skip>{{ s.label }}</a>
|
|
425
|
+
{% endfor %}
|
|
426
|
+
</div>
|
|
427
|
+
{% else %}
|
|
428
|
+
{# URL-param states: page sets `pageParamStates = [{id:'empty',label:'Empty'},…]`
|
|
429
|
+
and the dropdown writes ?state=<id>. Active state inferred from
|
|
430
|
+
the current URL via JS (server can't read query params on a
|
|
431
|
+
static site). #}
|
|
432
|
+
<summary class="proto-states__summary" data-states-summary>
|
|
433
|
+
<span class="proto-states__label">State</span>
|
|
434
|
+
<span class="proto-states__value" data-states-value>Default</span>
|
|
435
|
+
<span class="proto-states__caret" aria-hidden="true"><svg width="11" height="11" 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>
|
|
436
|
+
</summary>
|
|
437
|
+
<div class="proto-states__panel" role="menu" data-states-panel>
|
|
438
|
+
{% for s in pageParamStates %}
|
|
439
|
+
<a role="menuitem" class="proto-states__item" data-state-id="{{ s.id }}" href="{{ assetBase }}/{{ page }}{% if s.id %}?state={{ s.id }}{% endif %}" data-wf-skip>{{ s.label }}</a>
|
|
440
|
+
{% endfor %}
|
|
441
|
+
</div>
|
|
442
|
+
{% endif %}
|
|
443
|
+
</details>
|
|
444
|
+
<script>
|
|
445
|
+
(function () {
|
|
446
|
+
// For URL-param states, infer the active item from the current URL
|
|
447
|
+
// and update the summary value + add .is-active to the matching
|
|
448
|
+
// panel item. For file-based states this whole script is a no-op
|
|
449
|
+
// (server already marked the active item).
|
|
450
|
+
var panel = document.querySelector('[data-states-panel]');
|
|
451
|
+
var valueEl = document.querySelector('[data-states-value]');
|
|
452
|
+
if (!panel || !valueEl) return;
|
|
453
|
+
var current = new URLSearchParams(location.search).get('state') || '';
|
|
454
|
+
panel.querySelectorAll('.proto-states__item').forEach(function (a) {
|
|
455
|
+
var id = a.getAttribute('data-state-id') || '';
|
|
456
|
+
if (id === current) {
|
|
457
|
+
a.classList.add('is-active');
|
|
458
|
+
valueEl.textContent = a.textContent.trim();
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
})();
|
|
462
|
+
</script>
|
|
463
|
+
{% endif %}
|
|
464
|
+
</header>
|
|
465
|
+
<main class="proto-content">
|
|
466
|
+
{% block content %}{% endblock %}
|
|
467
|
+
</main>
|
|
468
|
+
|
|
469
|
+
<script type="module">
|
|
470
|
+
// Skip everything when this page is rendered inside the wireflow's
|
|
471
|
+
// iframe preview — comments only make sense when the page is the
|
|
472
|
+
// top-level view.
|
|
473
|
+
if (window.self === window.top) {
|
|
474
|
+
const PROTO_ID = '{{ meta.id }}';
|
|
475
|
+
// PAGE_ID scopes comments to the exact screen *and* its URL-param
|
|
476
|
+
// state, so a comment posted on `?photo=removed` is its own thread
|
|
477
|
+
// rather than landing on the base screen. The harness-only
|
|
478
|
+
// `comments` deep-link param is stripped so it never splits a thread.
|
|
479
|
+
const PAGE_ID = (function () {
|
|
480
|
+
const q = new URLSearchParams(location.search);
|
|
481
|
+
q.delete('comments');
|
|
482
|
+
q.sort();
|
|
483
|
+
const s = q.toString();
|
|
484
|
+
return s ? '{{ page }}' + '?' + s : '{{ page }}';
|
|
485
|
+
})();
|
|
486
|
+
// Live comments live on the deployed worker (KV-backed). When
|
|
487
|
+
// running locally and bedrock.commentsProdOrigin is set, we hit the
|
|
488
|
+
// prod origin directly so local previews see the same data the
|
|
489
|
+
// deployed wireflow does. Empty origin → same-origin (local-only).
|
|
490
|
+
const COMMENTS_PROD_ORIGIN = {{ (bedrock.modules.commenting.prodOrigin or '') | dump | safe }};
|
|
491
|
+
const COMMENTS_IS_LOCAL = /^(localhost|127\.0\.0\.1)$/.test(window.location.hostname);
|
|
492
|
+
const COMMENTS_BASE = COMMENTS_IS_LOCAL ? COMMENTS_PROD_ORIGIN : '';
|
|
493
|
+
const COMMENTS_API = COMMENTS_BASE + '/__wf-comments/' + PROTO_ID + '.json';
|
|
494
|
+
const COMMENTS_ADMIN_EMAILS = [];
|
|
495
|
+
// Always same-origin auth (cross-site localhost↔prod auth can't
|
|
496
|
+
// work with SameSite cookies — see wireflow.njk note).
|
|
497
|
+
const { createAuthClient } = await import('https://esm.sh/better-auth@1.4.21/client');
|
|
498
|
+
const authClient = createAuthClient();
|
|
499
|
+
|
|
500
|
+
const fab = document.getElementById('screenCommentsFab');
|
|
501
|
+
const panel = document.getElementById('screenCommentsPanel');
|
|
502
|
+
const listEl = document.getElementById('screenCommentsList');
|
|
503
|
+
const composeEl = document.getElementById('screenCommentsCompose');
|
|
504
|
+
const inputEl = document.getElementById('screenCommentsInput');
|
|
505
|
+
const postBtn = document.getElementById('screenCommentsPost');
|
|
506
|
+
const closeBtn = document.getElementById('screenCommentsClose');
|
|
507
|
+
const countBadge = document.getElementById('screenCommentsCount');
|
|
508
|
+
const gateEl = document.getElementById('screenAuthGate');
|
|
509
|
+
const authForm = document.getElementById('screenAuthForm');
|
|
510
|
+
const authEmail = document.getElementById('screenAuthEmail');
|
|
511
|
+
const authPassword = document.getElementById('screenAuthPassword');
|
|
512
|
+
const authSubmit = document.getElementById('screenAuthSubmit');
|
|
513
|
+
const authError = document.getElementById('screenAuthError');
|
|
514
|
+
const authGoogle = document.getElementById('screenAuthGoogle');
|
|
515
|
+
|
|
516
|
+
let comments = [];
|
|
517
|
+
let directory = [];
|
|
518
|
+
let currentUser = null;
|
|
519
|
+
// Role helpers. Designers (and admins) run the agentic loop: their
|
|
520
|
+
// comments are actionable and they approve manager feedback. Managers
|
|
521
|
+
// only give comments — never directly actionable (worker-enforced too).
|
|
522
|
+
function isDesignerUser(u) { return !!u && (u.role === 'designer' || u.role === 'admin'); }
|
|
523
|
+
function isManagerUser(u) { return !!u && u.role === 'manager'; }
|
|
524
|
+
const PEOPLE_API = COMMENTS_BASE + '/__wf-people.json';
|
|
525
|
+
|
|
526
|
+
// ── Image attachments on comments ─────────────────────────────────
|
|
527
|
+
// Selected images are downscaled client-side (longest edge ≤ 1200px),
|
|
528
|
+
// staged as removable thumbnails, uploaded on Post, and referenced by
|
|
529
|
+
// id from the comment's `attachments` array.
|
|
530
|
+
const ATTACH_API = COMMENTS_BASE + '/__wf-comments/' + PROTO_ID + '/attachments';
|
|
531
|
+
const ATTACH_MAX_BYTES = 10 * 1024 * 1024;
|
|
532
|
+
const attachBtn = document.getElementById('screenCommentsAttach');
|
|
533
|
+
const attachInput = document.getElementById('screenCommentsFile');
|
|
534
|
+
const attachRowEl = document.getElementById('screenCommentsAttachRow');
|
|
535
|
+
const attachErrorEl = document.getElementById('screenCommentsAttachError');
|
|
536
|
+
let pendingAttachments = []; // [{ blob, url }]
|
|
537
|
+
function attachmentUrl(id) {
|
|
538
|
+
return ATTACH_API + '/' + encodeURIComponent(id);
|
|
539
|
+
}
|
|
540
|
+
function showAttachError(msg) {
|
|
541
|
+
attachErrorEl.textContent = msg || '';
|
|
542
|
+
attachErrorEl.hidden = !msg;
|
|
543
|
+
}
|
|
544
|
+
function syncPostDisabled() {
|
|
545
|
+
postBtn.disabled = inputEl.value.trim().length === 0 && pendingAttachments.length === 0;
|
|
546
|
+
}
|
|
547
|
+
function loadImageFromFile(file) {
|
|
548
|
+
return new Promise((resolve, reject) => {
|
|
549
|
+
const url = URL.createObjectURL(file);
|
|
550
|
+
const img = new Image();
|
|
551
|
+
img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
|
|
552
|
+
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('not a readable image')); };
|
|
553
|
+
img.src = url;
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
function canvasToBlob(canvas, type, quality) {
|
|
557
|
+
return new Promise((resolve, reject) =>
|
|
558
|
+
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('encode failed'))), type, quality));
|
|
559
|
+
}
|
|
560
|
+
// Design evidence needs its detail: keep images up to a 4K-class edge
|
|
561
|
+
// (≤ 4096px) and only re-encode when the upload cap demands it. PNG
|
|
562
|
+
// inputs stay PNG (transparency/crisp UI) unless too large, then JPEG.
|
|
563
|
+
async function downscaleImage(file) {
|
|
564
|
+
const img = await loadImageFromFile(file);
|
|
565
|
+
const long = Math.max(img.naturalWidth, img.naturalHeight) || 1;
|
|
566
|
+
const scale = Math.min(1, 4096 / long);
|
|
567
|
+
if (scale === 1 && file.size <= ATTACH_MAX_BYTES) return file; // keep original bytes
|
|
568
|
+
const w = Math.max(1, Math.round(img.naturalWidth * scale));
|
|
569
|
+
const h = Math.max(1, Math.round(img.naturalHeight * scale));
|
|
570
|
+
const canvas = document.createElement('canvas');
|
|
571
|
+
canvas.width = w; canvas.height = h;
|
|
572
|
+
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
|
|
573
|
+
if (file.type === 'image/png') {
|
|
574
|
+
const png = await canvasToBlob(canvas, 'image/png');
|
|
575
|
+
if (png.size <= ATTACH_MAX_BYTES) return png;
|
|
576
|
+
}
|
|
577
|
+
const jpg = await canvasToBlob(canvas, 'image/jpeg', 0.85);
|
|
578
|
+
if (jpg.size > ATTACH_MAX_BYTES) throw new Error('image too large');
|
|
579
|
+
return jpg;
|
|
580
|
+
}
|
|
581
|
+
function renderPendingAttachments() {
|
|
582
|
+
attachRowEl.innerHTML = '';
|
|
583
|
+
attachRowEl.hidden = pendingAttachments.length === 0;
|
|
584
|
+
pendingAttachments.forEach((att) => {
|
|
585
|
+
const wrap = document.createElement('span');
|
|
586
|
+
wrap.className = 'screen-comments-panel__attach-thumb';
|
|
587
|
+
const img = document.createElement('img');
|
|
588
|
+
img.src = att.url;
|
|
589
|
+
img.alt = 'Pending image attachment';
|
|
590
|
+
const rm = document.createElement('button');
|
|
591
|
+
rm.type = 'button';
|
|
592
|
+
rm.className = 'screen-comments-panel__attach-remove';
|
|
593
|
+
rm.title = 'Remove image';
|
|
594
|
+
rm.setAttribute('aria-label', 'Remove image');
|
|
595
|
+
rm.textContent = '×';
|
|
596
|
+
rm.addEventListener('click', () => {
|
|
597
|
+
URL.revokeObjectURL(att.url);
|
|
598
|
+
pendingAttachments = pendingAttachments.filter((x) => x !== att);
|
|
599
|
+
renderPendingAttachments();
|
|
600
|
+
syncPostDisabled();
|
|
601
|
+
});
|
|
602
|
+
wrap.appendChild(img);
|
|
603
|
+
wrap.appendChild(rm);
|
|
604
|
+
attachRowEl.appendChild(wrap);
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
function clearPendingAttachments() {
|
|
608
|
+
pendingAttachments.forEach((att) => URL.revokeObjectURL(att.url));
|
|
609
|
+
pendingAttachments = [];
|
|
610
|
+
renderPendingAttachments();
|
|
611
|
+
}
|
|
612
|
+
// POST each staged image; returns the stored ids. Throws on the first
|
|
613
|
+
// failure so the caller can keep the compose state intact.
|
|
614
|
+
async function uploadPendingAttachments() {
|
|
615
|
+
const ids = [];
|
|
616
|
+
for (const att of pendingAttachments) {
|
|
617
|
+
const r = await fetch(ATTACH_API, {
|
|
618
|
+
method: 'POST',
|
|
619
|
+
headers: { 'Content-Type': att.blob.type },
|
|
620
|
+
body: att.blob,
|
|
621
|
+
credentials: 'include',
|
|
622
|
+
});
|
|
623
|
+
if (!r.ok) throw new Error('upload failed (' + r.status + ')');
|
|
624
|
+
const data = await r.json();
|
|
625
|
+
if (!data || typeof data.id !== 'string') throw new Error('upload failed (bad response)');
|
|
626
|
+
ids.push(data.id);
|
|
627
|
+
}
|
|
628
|
+
return ids;
|
|
629
|
+
}
|
|
630
|
+
async function stageFiles(files) {
|
|
631
|
+
showAttachError('');
|
|
632
|
+
for (const f of files) {
|
|
633
|
+
try {
|
|
634
|
+
const blob = await downscaleImage(f);
|
|
635
|
+
pendingAttachments.push({ blob, url: URL.createObjectURL(blob) });
|
|
636
|
+
} catch {
|
|
637
|
+
showAttachError('Couldn’t process ' + (f.name || 'image') + ' — try a smaller image.');
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
renderPendingAttachments();
|
|
641
|
+
syncPostDisabled();
|
|
642
|
+
}
|
|
643
|
+
attachBtn.addEventListener('click', () => attachInput.click());
|
|
644
|
+
attachInput.addEventListener('change', async () => {
|
|
645
|
+
const files = Array.from(attachInput.files || []);
|
|
646
|
+
attachInput.value = '';
|
|
647
|
+
await stageFiles(files);
|
|
648
|
+
});
|
|
649
|
+
// Paste an image straight from the clipboard into the compose box —
|
|
650
|
+
// screenshots land as staged attachments, text pastes pass through.
|
|
651
|
+
inputEl.addEventListener('paste', (e) => {
|
|
652
|
+
const items = Array.from((e.clipboardData && e.clipboardData.items) || []);
|
|
653
|
+
const files = items
|
|
654
|
+
.filter((it) => it.kind === 'file' && it.type && it.type.indexOf('image/') === 0)
|
|
655
|
+
.map((it) => it.getAsFile())
|
|
656
|
+
.filter(Boolean);
|
|
657
|
+
if (!files.length) return;
|
|
658
|
+
e.preventDefault();
|
|
659
|
+
stageFiles(files);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
// ── Node-anchored comments (EXPERIMENTAL) ─────────────────────────
|
|
663
|
+
// A comment can pin to the DOM element it's about. Pick mode lets the
|
|
664
|
+
// reviewer hover + click an element on the VISIBLE rendering: the
|
|
665
|
+
// top-level document, or the same-origin preview iframe's document
|
|
666
|
+
// when a Viewport breakpoint preview is active (same previewDoc/
|
|
667
|
+
// activeDoc pattern as ds-xray.js). The anchor stores a resilient
|
|
668
|
+
// CSS path scoped to `.proto-content` plus a human-readable label;
|
|
669
|
+
// comment cards re-resolve the path on demand and fail soft when the
|
|
670
|
+
// element no longer exists (other breakpoint, redesigned screen).
|
|
671
|
+
const pinBtn = document.getElementById('screenCommentsPin');
|
|
672
|
+
const anchorRowEl = document.getElementById('screenCommentsAnchorRow');
|
|
673
|
+
const ANCHOR_PIN_SVG = '<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"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>';
|
|
674
|
+
let pendingAnchor = null; // { selector, label } — one per comment, picking again replaces
|
|
675
|
+
let pickUI = null; // { doc, box, tag } while pick mode is active
|
|
676
|
+
|
|
677
|
+
function previewDoc() {
|
|
678
|
+
const stage = document.querySelector('.proto-stage');
|
|
679
|
+
if (!stage || !stage.classList.contains('is-previewing')) return null;
|
|
680
|
+
const ifr = document.querySelector('.proto-preview__iframe');
|
|
681
|
+
try {
|
|
682
|
+
const d = ifr && ifr.contentDocument;
|
|
683
|
+
return d && d.querySelector('.proto-content') ? d : null;
|
|
684
|
+
} catch (e) { return null; } // cross-origin (shouldn't happen, same-origin)
|
|
685
|
+
}
|
|
686
|
+
function activeDoc() { return previewDoc() || document; }
|
|
687
|
+
|
|
688
|
+
// Classes that identify the element in the product's own terms —
|
|
689
|
+
// skip utility/harness/state classes, which are unstable or shared.
|
|
690
|
+
function anchorClasses(el) {
|
|
691
|
+
const out = [];
|
|
692
|
+
el.classList.forEach((c) => {
|
|
693
|
+
if (c.indexOf('u-') === 0 || c.indexOf('ds-xray') === 0 || c.indexOf('is-') === 0) return;
|
|
694
|
+
out.push(c);
|
|
695
|
+
});
|
|
696
|
+
return out;
|
|
697
|
+
}
|
|
698
|
+
function cssEscape(s) {
|
|
699
|
+
return (window.CSS && CSS.escape) ? CSS.escape(s) : s.replace(/([^a-zA-Z0-9_-])/g, '\\$1');
|
|
700
|
+
}
|
|
701
|
+
// Resilient-ish CSS path, scoped to `.proto-content`: prefer the
|
|
702
|
+
// nearest #id, else `tag.firstMeaningfulClass:nth-of-type(n)` per
|
|
703
|
+
// level, capped at 6 levels (a partial chain still resolves as a
|
|
704
|
+
// descendant of the content root).
|
|
705
|
+
function anchorSelectorFor(el, root) {
|
|
706
|
+
const parts = [];
|
|
707
|
+
let cur = el;
|
|
708
|
+
while (cur && cur !== root && cur.nodeType === 1 && parts.length < 6) {
|
|
709
|
+
if (cur.id) { parts.unshift('#' + cssEscape(cur.id)); break; }
|
|
710
|
+
let part = cur.tagName.toLowerCase();
|
|
711
|
+
const cls = anchorClasses(cur);
|
|
712
|
+
if (cls[0]) part += '.' + cssEscape(cls[0]);
|
|
713
|
+
let n = 1, sib = cur;
|
|
714
|
+
while ((sib = sib.previousElementSibling)) if (sib.tagName === cur.tagName) n++;
|
|
715
|
+
part += ':nth-of-type(' + n + ')';
|
|
716
|
+
parts.unshift(part);
|
|
717
|
+
cur = cur.parentElement;
|
|
718
|
+
}
|
|
719
|
+
return parts.join(' > ');
|
|
720
|
+
}
|
|
721
|
+
function anchorLabelFor(el) {
|
|
722
|
+
let label = el.tagName.toLowerCase();
|
|
723
|
+
const cls = anchorClasses(el);
|
|
724
|
+
if (cls[0]) label += '.' + cls[0];
|
|
725
|
+
let txt = '';
|
|
726
|
+
for (const n of el.childNodes) if (n.nodeType === 3) txt += n.textContent;
|
|
727
|
+
txt = txt.replace(/\s+/g, ' ').trim();
|
|
728
|
+
if (txt) label += ' “' + (txt.length > 40 ? txt.slice(0, 39) + '…' : txt) + '”';
|
|
729
|
+
return label;
|
|
730
|
+
}
|
|
731
|
+
function resolveAnchor(selector) {
|
|
732
|
+
if (!selector) return null;
|
|
733
|
+
const doc = activeDoc();
|
|
734
|
+
const root = doc.querySelector('.proto-content');
|
|
735
|
+
if (!root) return null;
|
|
736
|
+
try { return root.querySelector(selector); } catch (e) { return null; }
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function pickTargetFromEvent(e, doc) {
|
|
740
|
+
const t = e.target;
|
|
741
|
+
if (!t || t.nodeType !== 1 || !t.closest) return null;
|
|
742
|
+
const root = doc.querySelector('.proto-content');
|
|
743
|
+
if (!root || t === root || !root.contains(t)) return null;
|
|
744
|
+
return t;
|
|
745
|
+
}
|
|
746
|
+
function positionPickBox(el) {
|
|
747
|
+
const doc = pickUI.doc;
|
|
748
|
+
const win = doc.defaultView || window;
|
|
749
|
+
const r = el.getBoundingClientRect();
|
|
750
|
+
const x = r.left + win.scrollX, y = r.top + win.scrollY;
|
|
751
|
+
pickUI.box.style.display = 'block';
|
|
752
|
+
pickUI.box.style.left = x + 'px';
|
|
753
|
+
pickUI.box.style.top = y + 'px';
|
|
754
|
+
pickUI.box.style.width = r.width + 'px';
|
|
755
|
+
pickUI.box.style.height = r.height + 'px';
|
|
756
|
+
pickUI.tag.textContent = anchorLabelFor(el);
|
|
757
|
+
pickUI.tag.style.display = 'block';
|
|
758
|
+
pickUI.tag.style.left = x + 'px';
|
|
759
|
+
// Tag above the box when there's room, else just inside its top edge.
|
|
760
|
+
pickUI.tag.style.top = (y - 22 > win.scrollY ? y - 22 : y + 2) + 'px';
|
|
761
|
+
}
|
|
762
|
+
function onPickMove(e) {
|
|
763
|
+
if (!pickUI) return;
|
|
764
|
+
const el = pickTargetFromEvent(e, pickUI.doc);
|
|
765
|
+
if (el) positionPickBox(el);
|
|
766
|
+
else { pickUI.box.style.display = 'none'; pickUI.tag.style.display = 'none'; }
|
|
767
|
+
}
|
|
768
|
+
function onPickClick(e) {
|
|
769
|
+
if (!pickUI) return;
|
|
770
|
+
// The pin button's own handler toggles pick mode off — let it.
|
|
771
|
+
if (pinBtn.contains(e.target)) return;
|
|
772
|
+
const doc = pickUI.doc;
|
|
773
|
+
const el = pickTargetFromEvent(e, doc);
|
|
774
|
+
if (!el) { stopPick(); return; } // clicked outside the screen body — cancel, allow default
|
|
775
|
+
// Capture-phase select: the page must not react (no link nav, no
|
|
776
|
+
// button handlers) when the click is really an element pick.
|
|
777
|
+
e.preventDefault();
|
|
778
|
+
e.stopPropagation();
|
|
779
|
+
const root = doc.querySelector('.proto-content');
|
|
780
|
+
pendingAnchor = { selector: anchorSelectorFor(el, root), label: anchorLabelFor(el) };
|
|
781
|
+
renderPendingAnchor();
|
|
782
|
+
stopPick();
|
|
783
|
+
}
|
|
784
|
+
function onPickKey(e) {
|
|
785
|
+
if (e.key === 'Escape' && pickUI) { e.preventDefault(); e.stopPropagation(); stopPick(); }
|
|
786
|
+
}
|
|
787
|
+
function startPick() {
|
|
788
|
+
if (pickUI) return;
|
|
789
|
+
const doc = activeDoc();
|
|
790
|
+
const box = doc.createElement('div');
|
|
791
|
+
box.className = 'screen-anchor-pick-box';
|
|
792
|
+
const tag = doc.createElement('div');
|
|
793
|
+
tag.className = 'screen-anchor-pick-tag';
|
|
794
|
+
doc.body.appendChild(box);
|
|
795
|
+
doc.body.appendChild(tag);
|
|
796
|
+
pickUI = { doc, box, tag };
|
|
797
|
+
pinBtn.classList.add('is-picking');
|
|
798
|
+
doc.documentElement.classList.add('is-anchor-picking');
|
|
799
|
+
doc.addEventListener('mouseover', onPickMove, true);
|
|
800
|
+
doc.addEventListener('click', onPickClick, true);
|
|
801
|
+
doc.addEventListener('keydown', onPickKey, true);
|
|
802
|
+
// Esc must work whether focus sits in the preview iframe or the top page.
|
|
803
|
+
if (doc !== document) document.addEventListener('keydown', onPickKey, true);
|
|
804
|
+
}
|
|
805
|
+
function stopPick() {
|
|
806
|
+
if (!pickUI) return;
|
|
807
|
+
const doc = pickUI.doc;
|
|
808
|
+
doc.removeEventListener('mouseover', onPickMove, true);
|
|
809
|
+
doc.removeEventListener('click', onPickClick, true);
|
|
810
|
+
doc.removeEventListener('keydown', onPickKey, true);
|
|
811
|
+
if (doc !== document) document.removeEventListener('keydown', onPickKey, true);
|
|
812
|
+
pickUI.box.remove();
|
|
813
|
+
pickUI.tag.remove();
|
|
814
|
+
doc.documentElement.classList.remove('is-anchor-picking');
|
|
815
|
+
pickUI = null;
|
|
816
|
+
pinBtn.classList.remove('is-picking');
|
|
817
|
+
}
|
|
818
|
+
function renderPendingAnchor() {
|
|
819
|
+
anchorRowEl.innerHTML = '';
|
|
820
|
+
anchorRowEl.hidden = !pendingAnchor;
|
|
821
|
+
if (!pendingAnchor) return;
|
|
822
|
+
const chip = document.createElement('span');
|
|
823
|
+
chip.className = 'screen-comments-panel__anchor-chip';
|
|
824
|
+
chip.title = pendingAnchor.selector;
|
|
825
|
+
const txt = document.createElement('span');
|
|
826
|
+
txt.className = 'screen-comments-panel__anchor-chip-label';
|
|
827
|
+
txt.textContent = '📍 ' + pendingAnchor.label;
|
|
828
|
+
const rm = document.createElement('button');
|
|
829
|
+
rm.type = 'button';
|
|
830
|
+
rm.className = 'screen-comments-panel__anchor-remove';
|
|
831
|
+
rm.title = 'Remove element pin';
|
|
832
|
+
rm.setAttribute('aria-label', 'Remove element pin');
|
|
833
|
+
rm.textContent = '×';
|
|
834
|
+
rm.addEventListener('click', () => { pendingAnchor = null; renderPendingAnchor(); });
|
|
835
|
+
chip.appendChild(txt);
|
|
836
|
+
chip.appendChild(rm);
|
|
837
|
+
anchorRowEl.appendChild(chip);
|
|
838
|
+
}
|
|
839
|
+
// Scroll the anchored element into view and pulse an overlay outline
|
|
840
|
+
// over it (~2s) — an overlay div, never a class on the target itself.
|
|
841
|
+
function highlightAnchor(el) {
|
|
842
|
+
const doc = el.ownerDocument;
|
|
843
|
+
const win = doc.defaultView || window;
|
|
844
|
+
el.scrollIntoView({ block: 'center' });
|
|
845
|
+
const r = el.getBoundingClientRect();
|
|
846
|
+
const pulse = doc.createElement('div');
|
|
847
|
+
pulse.className = 'screen-anchor-pulse';
|
|
848
|
+
pulse.style.left = (r.left + win.scrollX - 4) + 'px';
|
|
849
|
+
pulse.style.top = (r.top + win.scrollY - 4) + 'px';
|
|
850
|
+
pulse.style.width = (r.width + 8) + 'px';
|
|
851
|
+
pulse.style.height = (r.height + 8) + 'px';
|
|
852
|
+
doc.body.appendChild(pulse);
|
|
853
|
+
setTimeout(() => pulse.remove(), 2000);
|
|
854
|
+
}
|
|
855
|
+
if (pinBtn) pinBtn.addEventListener('click', () => { if (pickUI) stopPick(); else startPick(); });
|
|
856
|
+
|
|
857
|
+
function fmtDate(iso) {
|
|
858
|
+
try {
|
|
859
|
+
const d = new Date(iso);
|
|
860
|
+
const now = new Date();
|
|
861
|
+
const sameYear = d.getFullYear() === now.getFullYear();
|
|
862
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', ...(sameYear ? {} : { year: 'numeric' }), hour: '2-digit', minute: '2-digit' });
|
|
863
|
+
} catch { return iso; }
|
|
864
|
+
}
|
|
865
|
+
function pageScopedComments() {
|
|
866
|
+
return comments.filter(c => c.screenId === PAGE_ID);
|
|
867
|
+
}
|
|
868
|
+
// @mention support — handle = first word of a commenter's display
|
|
869
|
+
// name, lowercased. Mentionable = anyone who commented in this flow.
|
|
870
|
+
function mentionablePeople() {
|
|
871
|
+
const seen = new Map();
|
|
872
|
+
// Registered users first — the directory makes someone mentionable
|
|
873
|
+
// before they've ever commented. Handle = email local-part so
|
|
874
|
+
// `@alice` resolves alice@example.com regardless of display name.
|
|
875
|
+
for (const u of (directory || [])) {
|
|
876
|
+
const email = (u.email || '').trim().toLowerCase();
|
|
877
|
+
const local = email.split('@')[0] || '';
|
|
878
|
+
const handle = local.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
879
|
+
if (!handle || seen.has(handle)) continue;
|
|
880
|
+
seen.set(handle, {
|
|
881
|
+
handle: handle,
|
|
882
|
+
name: (u.name || '').trim() || local,
|
|
883
|
+
userId: u.id || null,
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
// Then anyone who has commented in this flow but isn't a known
|
|
887
|
+
// account (kept for back-compat with pre-directory threads).
|
|
888
|
+
for (const c of (comments || [])) {
|
|
889
|
+
const name = (c.author || '').trim();
|
|
890
|
+
if (!name) continue;
|
|
891
|
+
const handle = name.split(/\s+/)[0].toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
892
|
+
if (!handle || seen.has(handle)) continue;
|
|
893
|
+
seen.set(handle, { handle: handle, name: name, userId: c.userId || null });
|
|
894
|
+
}
|
|
895
|
+
return Array.from(seen.values());
|
|
896
|
+
}
|
|
897
|
+
async function loadPeople() {
|
|
898
|
+
try {
|
|
899
|
+
const r = await fetch(PEOPLE_API, { cache: 'no-store', credentials: 'include' });
|
|
900
|
+
if (r.ok) {
|
|
901
|
+
const data = await r.json();
|
|
902
|
+
directory = Array.isArray(data && data.people) ? data.people : [];
|
|
903
|
+
}
|
|
904
|
+
} catch {}
|
|
905
|
+
}
|
|
906
|
+
// Short, friendly label for a long URL (full URL stays the href + title),
|
|
907
|
+
// e.g. a giant Figma link → "figma.com · 11219-12911".
|
|
908
|
+
function shortLinkLabel(url) {
|
|
909
|
+
try {
|
|
910
|
+
const u = new URL(url);
|
|
911
|
+
const host = u.hostname.replace(/^www\./, '');
|
|
912
|
+
if (host.includes('figma.com')) {
|
|
913
|
+
const node = u.searchParams.get('node-id');
|
|
914
|
+
return node ? 'figma.com · ' + node : 'figma.com';
|
|
915
|
+
}
|
|
916
|
+
let tail = (u.pathname || '').replace(/\/+$/, '');
|
|
917
|
+
if (tail.length > 22) tail = tail.slice(0, 22) + '…';
|
|
918
|
+
return host + tail;
|
|
919
|
+
} catch (e) { return url.length > 42 ? url.slice(0, 42) + '…' : url; }
|
|
920
|
+
}
|
|
921
|
+
// Append plain text with http(s) URLs rendered as safe clickable links
|
|
922
|
+
// (only http/https match, so javascript: URLs can't slip through).
|
|
923
|
+
function appendTextWithLinks(el, chunk) {
|
|
924
|
+
chunk.split(/(https?:\/\/[^\s]+)/g).forEach((part) => {
|
|
925
|
+
if (/^https?:\/\//.test(part)) {
|
|
926
|
+
const a = document.createElement('a');
|
|
927
|
+
a.href = part;
|
|
928
|
+
a.target = '_blank';
|
|
929
|
+
a.rel = 'noreferrer';
|
|
930
|
+
a.className = 'screen-comment-card__link';
|
|
931
|
+
a.title = part;
|
|
932
|
+
a.textContent = shortLinkLabel(part);
|
|
933
|
+
el.appendChild(a);
|
|
934
|
+
} else if (part) {
|
|
935
|
+
el.appendChild(document.createTextNode(part));
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
function renderTextWithMentions(el, str) {
|
|
940
|
+
el.textContent = '';
|
|
941
|
+
const byHandle = {};
|
|
942
|
+
mentionablePeople().forEach((p) => { byHandle[p.handle] = p; });
|
|
943
|
+
const re = /@([a-z0-9]+)/gi;
|
|
944
|
+
let last = 0, m;
|
|
945
|
+
while ((m = re.exec(str)) !== null) {
|
|
946
|
+
const person = byHandle[m[1].toLowerCase()];
|
|
947
|
+
if (!person) continue;
|
|
948
|
+
if (m.index > last) appendTextWithLinks(el, str.slice(last, m.index));
|
|
949
|
+
const pill = document.createElement('span');
|
|
950
|
+
pill.className = 'screen-mention';
|
|
951
|
+
pill.textContent = '@' + person.name;
|
|
952
|
+
el.appendChild(pill);
|
|
953
|
+
last = m.index + m[0].length;
|
|
954
|
+
}
|
|
955
|
+
if (last < str.length) appendTextWithLinks(el, str.slice(last));
|
|
956
|
+
}
|
|
957
|
+
// Deterministic 4-letter code from the comment id (same algorithm
|
|
958
|
+
// as the wireflow panel) so a comment has one stable, promptable
|
|
959
|
+
// handle everywhere it's shown.
|
|
960
|
+
function commentCode(id) {
|
|
961
|
+
let h = 2166136261 >>> 0;
|
|
962
|
+
const s = String(id || '');
|
|
963
|
+
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; }
|
|
964
|
+
let out = '';
|
|
965
|
+
for (let i = 0; i < 4; i++) { out += String.fromCharCode(65 + (h % 26)); h = Math.floor(h / 26) + 131; }
|
|
966
|
+
return out;
|
|
967
|
+
}
|
|
968
|
+
async function toggleResolved(id) {
|
|
969
|
+
const idx = comments.findIndex((c) => c.id === id);
|
|
970
|
+
if (idx < 0 || !currentUser) return;
|
|
971
|
+
const next = !comments[idx].resolved;
|
|
972
|
+
comments[idx] = {
|
|
973
|
+
...comments[idx],
|
|
974
|
+
// `status` is canonical (open → designed-by-agent → approved);
|
|
975
|
+
// a human resolving in this panel jumps straight to approved.
|
|
976
|
+
status: next ? 'approved' : 'open',
|
|
977
|
+
resolved: next,
|
|
978
|
+
resolvedAt: next ? new Date().toISOString() : undefined,
|
|
979
|
+
resolvedBy: next ? (currentUser.name || currentUser.email || 'Signed in user') : undefined,
|
|
980
|
+
resolvedById: next ? currentUser.id : undefined,
|
|
981
|
+
};
|
|
982
|
+
render();
|
|
983
|
+
await saveComments();
|
|
984
|
+
}
|
|
985
|
+
function render() {
|
|
986
|
+
listEl.innerHTML = '';
|
|
987
|
+
const scoped = pageScopedComments();
|
|
988
|
+
if (!scoped.length) {
|
|
989
|
+
const empty = document.createElement('div');
|
|
990
|
+
empty.className = 'screen-comments-panel__empty';
|
|
991
|
+
empty.textContent = 'No comments on this screen yet.';
|
|
992
|
+
listEl.appendChild(empty);
|
|
993
|
+
} else {
|
|
994
|
+
// Resolved sink to the bottom; otherwise newest first.
|
|
995
|
+
const sorted = scoped.slice().sort((a, b) =>
|
|
996
|
+
(a.resolved ? 1 : 0) - (b.resolved ? 1 : 0)
|
|
997
|
+
|| (b.createdAt || '').localeCompare(a.createdAt || ''));
|
|
998
|
+
sorted.forEach(c => {
|
|
999
|
+
const card = document.createElement('div');
|
|
1000
|
+
card.className = 'screen-comment-card' + (c.resolved ? ' is-resolved' : '');
|
|
1001
|
+
if (c.resolved) {
|
|
1002
|
+
const rb = document.createElement('span');
|
|
1003
|
+
rb.className = 'screen-comment-card__resolved';
|
|
1004
|
+
rb.textContent = '✓ Resolved'
|
|
1005
|
+
+ (c.resolvedBy ? ' · ' + c.resolvedBy : '')
|
|
1006
|
+
+ (c.resolvedAt ? ' · ' + fmtDate(c.resolvedAt) : '');
|
|
1007
|
+
card.appendChild(rb);
|
|
1008
|
+
}
|
|
1009
|
+
// Agent considered it but it needs a human decision — a soft-blue
|
|
1010
|
+
// pill in the same treatment as the green Resolved badge, at the
|
|
1011
|
+
// top of the card.
|
|
1012
|
+
if (!c.resolved && c.status === 'needs-human') {
|
|
1013
|
+
const nh = document.createElement('span');
|
|
1014
|
+
nh.className = 'screen-comment-card__needs-human';
|
|
1015
|
+
nh.textContent = '⚑ Needs human';
|
|
1016
|
+
card.appendChild(nh);
|
|
1017
|
+
}
|
|
1018
|
+
const text = document.createElement('div');
|
|
1019
|
+
text.className = 'screen-comment-card__text';
|
|
1020
|
+
renderTextWithMentions(text, c.text || '');
|
|
1021
|
+
card.appendChild(text);
|
|
1022
|
+
// Image attachments — thumbnail row; click opens the full image.
|
|
1023
|
+
if (Array.isArray(c.attachments) && c.attachments.length) {
|
|
1024
|
+
const attRow = document.createElement('div');
|
|
1025
|
+
attRow.className = 'screen-comment-card__attachments';
|
|
1026
|
+
c.attachments.forEach((aid) => {
|
|
1027
|
+
if (typeof aid !== 'string' || !aid) return;
|
|
1028
|
+
const a = document.createElement('a');
|
|
1029
|
+
a.href = attachmentUrl(aid);
|
|
1030
|
+
a.target = '_blank';
|
|
1031
|
+
a.rel = 'noreferrer';
|
|
1032
|
+
// Lightbox when available; the plain link is the fallback.
|
|
1033
|
+
a.addEventListener('click', (ev) => {
|
|
1034
|
+
if (!window.bfLightbox) return;
|
|
1035
|
+
ev.preventDefault();
|
|
1036
|
+
window.bfLightbox.open(a.href, 'Comment attachment', c.text || '');
|
|
1037
|
+
});
|
|
1038
|
+
const img = document.createElement('img');
|
|
1039
|
+
img.loading = 'lazy';
|
|
1040
|
+
img.src = a.href;
|
|
1041
|
+
img.alt = 'Comment attachment';
|
|
1042
|
+
a.appendChild(img);
|
|
1043
|
+
attRow.appendChild(a);
|
|
1044
|
+
});
|
|
1045
|
+
card.appendChild(attRow);
|
|
1046
|
+
}
|
|
1047
|
+
// Designer context added when a manager comment was approved —
|
|
1048
|
+
// this is what the agent is actually asked to do with it.
|
|
1049
|
+
if (c.designerNote) {
|
|
1050
|
+
const dn = document.createElement('div');
|
|
1051
|
+
dn.className = 'screen-comment-card__designer-note';
|
|
1052
|
+
const dnWho = document.createElement('b');
|
|
1053
|
+
dnWho.textContent = (c.designerNoteBy ? c.designerNoteBy : 'Designer') + ': ';
|
|
1054
|
+
dn.appendChild(dnWho);
|
|
1055
|
+
dn.appendChild(document.createTextNode(c.designerNote));
|
|
1056
|
+
card.appendChild(dn);
|
|
1057
|
+
}
|
|
1058
|
+
const meta = document.createElement('div');
|
|
1059
|
+
meta.className = 'screen-comment-card__meta';
|
|
1060
|
+
const stamp = document.createElement('span');
|
|
1061
|
+
const codeEl = document.createElement('span');
|
|
1062
|
+
codeEl.className = 'screen-comment-card__code';
|
|
1063
|
+
codeEl.textContent = commentCode(c.id);
|
|
1064
|
+
codeEl.title = 'Comment code — refer to this comment by "' + codeEl.textContent + '"';
|
|
1065
|
+
const who = c.author ? c.author + ' · ' : '';
|
|
1066
|
+
const edited = c.editedAt ? ' · edited' : '';
|
|
1067
|
+
stamp.textContent = ' ' + who + (c.createdAt ? fmtDate(c.createdAt) : '') + edited;
|
|
1068
|
+
// Chips live on their own wrapping row — sharing a line with the
|
|
1069
|
+
// byline cramped both once viewport + anchor chips arrived.
|
|
1070
|
+
const chipsRow = document.createElement('div');
|
|
1071
|
+
chipsRow.className = 'screen-comment-card__chips';
|
|
1072
|
+
chipsRow.appendChild(codeEl);
|
|
1073
|
+
if (c.kind === 'note') {
|
|
1074
|
+
const noteChip = document.createElement('span');
|
|
1075
|
+
noteChip.className = 'screen-comment-card__chip';
|
|
1076
|
+
noteChip.textContent = 'note';
|
|
1077
|
+
noteChip.title = 'Side note — agents won’t act on this comment';
|
|
1078
|
+
chipsRow.appendChild(noteChip);
|
|
1079
|
+
}
|
|
1080
|
+
if (c.status === 'pending-review') {
|
|
1081
|
+
const prChip = document.createElement('span');
|
|
1082
|
+
prChip.className = 'screen-comment-card__chip screen-comment-card__chip--pending';
|
|
1083
|
+
prChip.textContent = 'awaiting review';
|
|
1084
|
+
prChip.title = 'Manager comment — a designer must approve it before agents act on it';
|
|
1085
|
+
chipsRow.appendChild(prChip);
|
|
1086
|
+
}
|
|
1087
|
+
if (c.viewport && c.viewport.w) {
|
|
1088
|
+
const vpChip = document.createElement('span');
|
|
1089
|
+
vpChip.className = 'screen-comment-card__chip';
|
|
1090
|
+
vpChip.textContent = c.viewport.bp && c.viewport.bp !== 'full'
|
|
1091
|
+
? c.viewport.bp + ' ' + c.viewport.w
|
|
1092
|
+
: c.viewport.w + (c.viewport.h ? '×' + c.viewport.h : '');
|
|
1093
|
+
vpChip.title = 'Viewport this comment was made against';
|
|
1094
|
+
chipsRow.appendChild(vpChip);
|
|
1095
|
+
}
|
|
1096
|
+
// Element anchor — click re-resolves the selector on the active
|
|
1097
|
+
// rendering and highlights the node; fails soft when the element
|
|
1098
|
+
// doesn't exist there (other breakpoint, redesigned screen).
|
|
1099
|
+
if (c.anchor && c.anchor.selector) {
|
|
1100
|
+
const aChip = document.createElement('button');
|
|
1101
|
+
aChip.type = 'button';
|
|
1102
|
+
aChip.className = 'screen-comment-card__chip screen-comment-card__chip--anchor';
|
|
1103
|
+
aChip.innerHTML = ANCHOR_PIN_SVG;
|
|
1104
|
+
aChip.appendChild(document.createTextNode(c.anchor.label || c.anchor.selector));
|
|
1105
|
+
aChip.title = 'Pinned to ' + c.anchor.selector + ' — click to highlight';
|
|
1106
|
+
aChip.addEventListener('click', () => {
|
|
1107
|
+
const el = resolveAnchor(c.anchor.selector);
|
|
1108
|
+
if (el) { highlightAnchor(el); return; }
|
|
1109
|
+
aChip.classList.add('is-missing');
|
|
1110
|
+
aChip.title = 'Not found on this rendering';
|
|
1111
|
+
setTimeout(() => aChip.classList.remove('is-missing'), 1600);
|
|
1112
|
+
});
|
|
1113
|
+
chipsRow.appendChild(aChip);
|
|
1114
|
+
}
|
|
1115
|
+
card.appendChild(chipsRow);
|
|
1116
|
+
stamp.className = 'screen-comment-card__byline';
|
|
1117
|
+
meta.appendChild(stamp);
|
|
1118
|
+
const isOwn = currentUser && c.userId && c.userId === currentUser.id;
|
|
1119
|
+
const isAdmin = currentUser && COMMENTS_ADMIN_EMAILS.includes((currentUser.email || '').toLowerCase());
|
|
1120
|
+
if (!COMMENTS_IS_LOCAL && currentUser) {
|
|
1121
|
+
const actions = document.createElement('span');
|
|
1122
|
+
actions.className = 'screen-comment-card__actions';
|
|
1123
|
+
|
|
1124
|
+
// A pending manager comment needs a designer verdict: Approve
|
|
1125
|
+
// opens an inline row to add context for the agent, then
|
|
1126
|
+
// promotes the comment to 'open' (the actionable status).
|
|
1127
|
+
if (c.status === 'pending-review' && isDesignerUser(currentUser)) {
|
|
1128
|
+
const approve = document.createElement('button');
|
|
1129
|
+
approve.type = 'button';
|
|
1130
|
+
approve.className = 'screen-comment-card__icon-btn';
|
|
1131
|
+
approve.innerHTML = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="8" stroke="currentColor" stroke-width="1.5"/><path d="M8.5 12.5L11 15L15.5 9.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
1132
|
+
approve.title = 'Approve for the agent queue — optionally add context first';
|
|
1133
|
+
approve.setAttribute('aria-label', 'Approve for the agent queue');
|
|
1134
|
+
approve.addEventListener('click', () => beginApprove(c, card));
|
|
1135
|
+
actions.appendChild(approve);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// Resolve is an icon with a tooltip rather than a text
|
|
1139
|
+
// button — primary, always-available action. Custom /icons:
|
|
1140
|
+
// single check at rest, double check when already resolved
|
|
1141
|
+
// (mirrors the iMessage / Telegram delivered/read affordance).
|
|
1142
|
+
// Managers only close their OWN comments (retract feedback)
|
|
1143
|
+
// and never reopen — reopening re-enters the actionable flow.
|
|
1144
|
+
const canResolve = !isManagerUser(currentUser)
|
|
1145
|
+
? true
|
|
1146
|
+
: (isOwn && !c.resolved);
|
|
1147
|
+
if (canResolve) {
|
|
1148
|
+
const resolve = document.createElement('button');
|
|
1149
|
+
resolve.type = 'button';
|
|
1150
|
+
resolve.className = 'screen-comment-card__icon-btn'
|
|
1151
|
+
+ (c.resolved ? ' is-resolved' : '');
|
|
1152
|
+
const checkPaths = c.resolved
|
|
1153
|
+
? '<path d="M20 8L12 18L11.0773 16.9733" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 11L8 16L16 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'
|
|
1154
|
+
: '<path d="M4 12L9.33333 18L20 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>';
|
|
1155
|
+
resolve.innerHTML = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">' + checkPaths + '</svg>';
|
|
1156
|
+
const resolveLabel = c.resolved ? 'Reopen comment' : 'Resolve comment';
|
|
1157
|
+
resolve.title = resolveLabel;
|
|
1158
|
+
resolve.setAttribute('aria-label', resolveLabel);
|
|
1159
|
+
resolve.addEventListener('click', () => toggleResolved(c.id));
|
|
1160
|
+
actions.appendChild(resolve);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// Edit/Delete move behind a "⋯" more-menu so the row stays
|
|
1164
|
+
// to two icons. Only built when the user can do something.
|
|
1165
|
+
const canEdit = isOwn;
|
|
1166
|
+
const canDelete = isOwn || isAdmin;
|
|
1167
|
+
if (canEdit || canDelete) {
|
|
1168
|
+
const moreWrap = document.createElement('span');
|
|
1169
|
+
moreWrap.className = 'screen-comment-card__more';
|
|
1170
|
+
const moreBtn = document.createElement('button');
|
|
1171
|
+
moreBtn.type = 'button';
|
|
1172
|
+
moreBtn.className = 'screen-comment-card__icon-btn';
|
|
1173
|
+
moreBtn.innerHTML = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M18.006 11.994V12.006" stroke="currentColor" stroke-width="3" stroke-linecap="round"/><path d="M12.006 11.994V12.006" stroke="currentColor" stroke-width="3" stroke-linecap="round"/><path d="M6.00598 11.994V12.006" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>';
|
|
1174
|
+
moreBtn.title = 'More';
|
|
1175
|
+
moreBtn.setAttribute('aria-label', 'More actions');
|
|
1176
|
+
moreBtn.setAttribute('aria-haspopup', 'menu');
|
|
1177
|
+
moreBtn.setAttribute('aria-expanded', 'false');
|
|
1178
|
+
const menu = document.createElement('div');
|
|
1179
|
+
menu.className = 'screen-comment-card__menu';
|
|
1180
|
+
menu.setAttribute('role', 'menu');
|
|
1181
|
+
function closeMenu() {
|
|
1182
|
+
moreWrap.classList.remove('is-open');
|
|
1183
|
+
moreBtn.setAttribute('aria-expanded', 'false');
|
|
1184
|
+
document.removeEventListener('click', onDocClick, true);
|
|
1185
|
+
}
|
|
1186
|
+
function onDocClick(ev) {
|
|
1187
|
+
if (!moreWrap.contains(ev.target)) closeMenu();
|
|
1188
|
+
}
|
|
1189
|
+
moreBtn.addEventListener('click', (ev) => {
|
|
1190
|
+
ev.stopPropagation();
|
|
1191
|
+
const open = moreWrap.classList.toggle('is-open');
|
|
1192
|
+
moreBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
1193
|
+
if (open) document.addEventListener('click', onDocClick, true);
|
|
1194
|
+
else document.removeEventListener('click', onDocClick, true);
|
|
1195
|
+
});
|
|
1196
|
+
if (canEdit) {
|
|
1197
|
+
const edit = document.createElement('button');
|
|
1198
|
+
edit.type = 'button';
|
|
1199
|
+
edit.className = 'screen-comment-card__menu-item';
|
|
1200
|
+
edit.setAttribute('role', 'menuitem');
|
|
1201
|
+
edit.textContent = 'Edit';
|
|
1202
|
+
edit.addEventListener('click', () => { closeMenu(); beginEdit(c, card, text); });
|
|
1203
|
+
menu.appendChild(edit);
|
|
1204
|
+
}
|
|
1205
|
+
if (canDelete) {
|
|
1206
|
+
const del = document.createElement('button');
|
|
1207
|
+
del.type = 'button';
|
|
1208
|
+
del.className = 'screen-comment-card__menu-item screen-comment-card__menu-item--danger';
|
|
1209
|
+
del.setAttribute('role', 'menuitem');
|
|
1210
|
+
del.textContent = 'Delete';
|
|
1211
|
+
del.addEventListener('click', () => { closeMenu(); showDeleteConfirm(c.id, card); });
|
|
1212
|
+
menu.appendChild(del);
|
|
1213
|
+
}
|
|
1214
|
+
moreWrap.appendChild(moreBtn);
|
|
1215
|
+
moreWrap.appendChild(menu);
|
|
1216
|
+
actions.appendChild(moreWrap);
|
|
1217
|
+
}
|
|
1218
|
+
meta.appendChild(actions);
|
|
1219
|
+
}
|
|
1220
|
+
card.appendChild(meta);
|
|
1221
|
+
listEl.appendChild(card);
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
// Badge = OPEN (unresolved) comments on this screen.
|
|
1225
|
+
const openN = scoped.filter((c) => !c.resolved).length;
|
|
1226
|
+
countBadge.hidden = openN === 0;
|
|
1227
|
+
countBadge.textContent = String(openN);
|
|
1228
|
+
}
|
|
1229
|
+
async function loadComments() {
|
|
1230
|
+
try {
|
|
1231
|
+
const r = await fetch(COMMENTS_API, { cache: 'no-store', credentials: 'include' });
|
|
1232
|
+
if (r.ok) {
|
|
1233
|
+
const data = await r.json();
|
|
1234
|
+
comments = Array.isArray(data) ? data : [];
|
|
1235
|
+
}
|
|
1236
|
+
} catch {}
|
|
1237
|
+
render();
|
|
1238
|
+
}
|
|
1239
|
+
async function saveComments() {
|
|
1240
|
+
try {
|
|
1241
|
+
await fetch(COMMENTS_API, {
|
|
1242
|
+
method: 'PUT',
|
|
1243
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1244
|
+
body: JSON.stringify(comments),
|
|
1245
|
+
credentials: 'include',
|
|
1246
|
+
});
|
|
1247
|
+
} catch (err) {
|
|
1248
|
+
console.error('[screen-comments] save failed', err);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
async function postComment() {
|
|
1252
|
+
const text = inputEl.value.trim();
|
|
1253
|
+
if ((!text && !pendingAttachments.length) || !currentUser) return;
|
|
1254
|
+
const author = currentUser.name || currentUser.email || 'Signed in user';
|
|
1255
|
+
const id = (crypto.randomUUID && crypto.randomUUID()) || String(Date.now()) + '-' + Math.random().toString(16).slice(2);
|
|
1256
|
+
// Managers only give comments: theirs start in 'pending-review' and a
|
|
1257
|
+
// designer must approve (and contextualize) them before agents act.
|
|
1258
|
+
// The worker enforces the same rule server-side.
|
|
1259
|
+
const entry = {
|
|
1260
|
+
id, text, author,
|
|
1261
|
+
createdAt: new Date().toISOString(),
|
|
1262
|
+
userId: currentUser.id,
|
|
1263
|
+
screenId: PAGE_ID,
|
|
1264
|
+
status: isManagerUser(currentUser) ? 'pending-review' : 'open',
|
|
1265
|
+
};
|
|
1266
|
+
// Upload staged images FIRST — on failure keep the compose state
|
|
1267
|
+
// (text + thumbnails) so nothing is lost, and surface the error inline.
|
|
1268
|
+
if (pendingAttachments.length) {
|
|
1269
|
+
showAttachError('');
|
|
1270
|
+
postBtn.disabled = true;
|
|
1271
|
+
try {
|
|
1272
|
+
entry.attachments = await uploadPendingAttachments();
|
|
1273
|
+
} catch (err) {
|
|
1274
|
+
showAttachError('Image upload failed — your comment was not posted. Try again.');
|
|
1275
|
+
syncPostDisabled();
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
// Opt-out: a side note is reference-only — automated agents skip it.
|
|
1280
|
+
// (Not for managers — their comments are never directly actionable,
|
|
1281
|
+
// so the side-note distinction doesn't apply.)
|
|
1282
|
+
const noteToggle = document.getElementById('screenCommentsNoteToggle');
|
|
1283
|
+
if (!isManagerUser(currentUser) && noteToggle && noteToggle.checked) entry.kind = 'note';
|
|
1284
|
+
// Record the viewport this comment was made against (preview frame
|
|
1285
|
+
// size + named breakpoint when one is active, else the window) so
|
|
1286
|
+
// reviewers can re-create the same rendering when evaluating it.
|
|
1287
|
+
try {
|
|
1288
|
+
const stageEl = document.querySelector('.proto-stage');
|
|
1289
|
+
if (stageEl && stageEl.classList.contains('is-previewing')) {
|
|
1290
|
+
const fr = document.querySelector('.proto-preview__frame').getBoundingClientRect();
|
|
1291
|
+
entry.viewport = { w: Math.round(fr.width), h: Math.round(fr.height) };
|
|
1292
|
+
const bps = (window.BEDROCK_CONFIG && window.BEDROCK_CONFIG.breakpoints) || [];
|
|
1293
|
+
const hit = bps.find((b) => b.width && Math.abs(b.width - entry.viewport.w) <= 1);
|
|
1294
|
+
if (hit) entry.viewport.bp = hit.id;
|
|
1295
|
+
} else {
|
|
1296
|
+
entry.viewport = { w: window.innerWidth, h: window.innerHeight, bp: 'full' };
|
|
1297
|
+
}
|
|
1298
|
+
} catch (err) {}
|
|
1299
|
+
// Element anchor staged via pick mode — stored as-is; the thread
|
|
1300
|
+
// PUT passes arbitrary entry fields through unchanged.
|
|
1301
|
+
if (pendingAnchor) entry.anchor = pendingAnchor;
|
|
1302
|
+
comments.push(entry);
|
|
1303
|
+
inputEl.value = '';
|
|
1304
|
+
clearPendingAttachments();
|
|
1305
|
+
pendingAnchor = null;
|
|
1306
|
+
renderPendingAnchor();
|
|
1307
|
+
showAttachError('');
|
|
1308
|
+
if (noteToggle) noteToggle.checked = false;
|
|
1309
|
+
postBtn.disabled = true;
|
|
1310
|
+
render();
|
|
1311
|
+
await saveComments();
|
|
1312
|
+
}
|
|
1313
|
+
function showDeleteConfirm(id, card) {
|
|
1314
|
+
if (card.querySelector('.screen-comment-card__delete-confirm')) return;
|
|
1315
|
+
const row = document.createElement('div');
|
|
1316
|
+
row.className = 'screen-comment-card__delete-confirm';
|
|
1317
|
+
const msg = document.createElement('span');
|
|
1318
|
+
msg.className = 'screen-comment-card__delete-confirm-msg';
|
|
1319
|
+
msg.textContent = 'Delete this comment?';
|
|
1320
|
+
const cancelBtn = document.createElement('button');
|
|
1321
|
+
cancelBtn.type = 'button';
|
|
1322
|
+
cancelBtn.className = 'screen-comment-card__edit';
|
|
1323
|
+
cancelBtn.textContent = 'Cancel';
|
|
1324
|
+
cancelBtn.addEventListener('click', () => row.remove());
|
|
1325
|
+
const confirmBtn = document.createElement('button');
|
|
1326
|
+
confirmBtn.type = 'button';
|
|
1327
|
+
confirmBtn.className = 'screen-comment-card__delete';
|
|
1328
|
+
confirmBtn.textContent = 'Delete';
|
|
1329
|
+
confirmBtn.addEventListener('click', () => deleteComment(id));
|
|
1330
|
+
row.appendChild(msg);
|
|
1331
|
+
row.appendChild(cancelBtn);
|
|
1332
|
+
row.appendChild(confirmBtn);
|
|
1333
|
+
card.appendChild(row);
|
|
1334
|
+
}
|
|
1335
|
+
async function deleteComment(id) {
|
|
1336
|
+
comments = comments.filter(c => c.id !== id);
|
|
1337
|
+
render();
|
|
1338
|
+
await saveComments();
|
|
1339
|
+
}
|
|
1340
|
+
// Designer approval of a manager comment: inline context textarea +
|
|
1341
|
+
// Approve/Cancel. Approving promotes the comment to 'open' (actionable
|
|
1342
|
+
// to the agentic loop) and stores the context as designerNote — the
|
|
1343
|
+
// manager's words stay untouched; the designer's interpretation rides
|
|
1344
|
+
// along for the agent.
|
|
1345
|
+
function beginApprove(c, card) {
|
|
1346
|
+
if (card.querySelector('.screen-comment-card__approve-row')) return;
|
|
1347
|
+
const row = document.createElement('div');
|
|
1348
|
+
row.className = 'screen-comment-card__approve-row';
|
|
1349
|
+
const ta = document.createElement('textarea');
|
|
1350
|
+
ta.className = 'screen-comment-card__editor';
|
|
1351
|
+
ta.placeholder = 'Add context for the agent (optional) — what should be done with this?';
|
|
1352
|
+
ta.rows = 2;
|
|
1353
|
+
const btns = document.createElement('div');
|
|
1354
|
+
btns.className = 'screen-comment-card__edit-row';
|
|
1355
|
+
const ok = document.createElement('button');
|
|
1356
|
+
ok.type = 'button';
|
|
1357
|
+
ok.className = 'screen-comments-panel__post';
|
|
1358
|
+
ok.textContent = 'Approve for agent';
|
|
1359
|
+
const cancel = document.createElement('button');
|
|
1360
|
+
cancel.type = 'button';
|
|
1361
|
+
cancel.className = 'screen-comment-card__menu-item';
|
|
1362
|
+
cancel.textContent = 'Cancel';
|
|
1363
|
+
ok.addEventListener('click', async () => {
|
|
1364
|
+
const idx = comments.findIndex((x) => x.id === c.id);
|
|
1365
|
+
if (idx < 0 || !currentUser) return;
|
|
1366
|
+
const now = new Date().toISOString();
|
|
1367
|
+
const by = currentUser.name || currentUser.email || 'Designer';
|
|
1368
|
+
const note = ta.value.trim();
|
|
1369
|
+
comments[idx] = {
|
|
1370
|
+
...comments[idx],
|
|
1371
|
+
status: 'open',
|
|
1372
|
+
resolved: false,
|
|
1373
|
+
statusAt: now,
|
|
1374
|
+
statusBy: by,
|
|
1375
|
+
...(note ? { designerNote: note, designerNoteBy: by, designerNoteAt: now } : {}),
|
|
1376
|
+
};
|
|
1377
|
+
render();
|
|
1378
|
+
await saveComments();
|
|
1379
|
+
});
|
|
1380
|
+
cancel.addEventListener('click', () => row.remove());
|
|
1381
|
+
btns.appendChild(ok);
|
|
1382
|
+
btns.appendChild(cancel);
|
|
1383
|
+
row.appendChild(ta);
|
|
1384
|
+
row.appendChild(btns);
|
|
1385
|
+
card.appendChild(row);
|
|
1386
|
+
ta.focus();
|
|
1387
|
+
}
|
|
1388
|
+
function beginEdit(c, card, textEl) {
|
|
1389
|
+
card.classList.add('is-editing');
|
|
1390
|
+
const ta = document.createElement('textarea');
|
|
1391
|
+
ta.className = 'screen-comment-card__editor';
|
|
1392
|
+
ta.value = c.text;
|
|
1393
|
+
ta.rows = 2;
|
|
1394
|
+
const autosize = () => {
|
|
1395
|
+
ta.style.height = 'auto';
|
|
1396
|
+
ta.style.height = ta.scrollHeight + 'px';
|
|
1397
|
+
};
|
|
1398
|
+
ta.addEventListener('input', autosize);
|
|
1399
|
+
const row = document.createElement('div');
|
|
1400
|
+
row.className = 'screen-comment-card__edit-row';
|
|
1401
|
+
const save = document.createElement('button');
|
|
1402
|
+
save.type = 'button';
|
|
1403
|
+
save.className = 'screen-comments-panel__post';
|
|
1404
|
+
save.textContent = 'Save';
|
|
1405
|
+
const cancel = document.createElement('button');
|
|
1406
|
+
cancel.type = 'button';
|
|
1407
|
+
cancel.className = 'screen-comment-card__delete';
|
|
1408
|
+
cancel.textContent = 'Cancel';
|
|
1409
|
+
row.appendChild(save);
|
|
1410
|
+
row.appendChild(cancel);
|
|
1411
|
+
textEl.replaceWith(ta);
|
|
1412
|
+
card.insertBefore(row, card.querySelector('.screen-comment-card__meta'));
|
|
1413
|
+
autosize();
|
|
1414
|
+
ta.focus();
|
|
1415
|
+
ta.setSelectionRange(ta.value.length, ta.value.length);
|
|
1416
|
+
save.addEventListener('click', async () => {
|
|
1417
|
+
const next = ta.value.trim();
|
|
1418
|
+
if (!next || next === c.text) { render(); return; }
|
|
1419
|
+
const idx = comments.findIndex(x => x.id === c.id);
|
|
1420
|
+
if (idx >= 0) {
|
|
1421
|
+
comments[idx] = { ...comments[idx], text: next, editedAt: new Date().toISOString() };
|
|
1422
|
+
}
|
|
1423
|
+
render();
|
|
1424
|
+
await saveComments();
|
|
1425
|
+
});
|
|
1426
|
+
cancel.addEventListener('click', () => render());
|
|
1427
|
+
ta.addEventListener('keydown', (e) => {
|
|
1428
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); save.click(); }
|
|
1429
|
+
if (e.key === 'Escape') { e.preventDefault(); render(); }
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
const readonlyEl = document.getElementById('screenCommentsReadonly');
|
|
1433
|
+
const liveLinkEl = document.getElementById('screenCommentsLiveLink');
|
|
1434
|
+
if (liveLinkEl) liveLinkEl.href = COMMENTS_PROD_ORIGIN + location.pathname + location.search;
|
|
1435
|
+
|
|
1436
|
+
function applySession(user) {
|
|
1437
|
+
currentUser = user || null;
|
|
1438
|
+
// Local dev mirrors the prod comment store read-only — cross-site
|
|
1439
|
+
// authed writes from localhost aren't possible. No compose, no
|
|
1440
|
+
// gate; posting/editing happens on the live site.
|
|
1441
|
+
if (COMMENTS_IS_LOCAL) {
|
|
1442
|
+
gateEl.hidden = true;
|
|
1443
|
+
composeEl.hidden = true;
|
|
1444
|
+
if (readonlyEl) readonlyEl.hidden = false;
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
if (currentUser) {
|
|
1448
|
+
gateEl.hidden = true;
|
|
1449
|
+
composeEl.hidden = false;
|
|
1450
|
+
// Managers can't choose "side note vs actionable" — their comments
|
|
1451
|
+
// always wait on a designer, so swap the toggle for a hint.
|
|
1452
|
+
const noteLabel = document.getElementById('screenCommentsNoteLabel');
|
|
1453
|
+
const managerHint = document.getElementById('screenCommentsManagerHint');
|
|
1454
|
+
if (noteLabel) noteLabel.hidden = isManagerUser(currentUser);
|
|
1455
|
+
if (managerHint) managerHint.hidden = !isManagerUser(currentUser);
|
|
1456
|
+
// Now that we have a session cookie, pull the user directory so
|
|
1457
|
+
// un-commented accounts are mentionable; re-render so existing
|
|
1458
|
+
// @mentions resolve to pills.
|
|
1459
|
+
loadPeople().then(render);
|
|
1460
|
+
} else {
|
|
1461
|
+
// Don't auto-show the gate on every screen — only when the user
|
|
1462
|
+
// tries to open the comments panel without a session.
|
|
1463
|
+
composeEl.hidden = true;
|
|
1464
|
+
}
|
|
1465
|
+
// Comments are rendered on load (before the async session
|
|
1466
|
+
// resolves), so the per-card Resolve/Edit/Delete actions — which
|
|
1467
|
+
// depend on currentUser — would be missing until something else
|
|
1468
|
+
// re-renders. Re-render now that we know who's signed in.
|
|
1469
|
+
render();
|
|
1470
|
+
}
|
|
1471
|
+
async function refreshSession() {
|
|
1472
|
+
try {
|
|
1473
|
+
const { data } = await authClient.getSession();
|
|
1474
|
+
applySession(data?.user || null);
|
|
1475
|
+
} catch {
|
|
1476
|
+
applySession(null);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
function showError(msg) {
|
|
1480
|
+
authError.textContent = msg || '';
|
|
1481
|
+
authError.hidden = !msg;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
fab.addEventListener('click', async () => {
|
|
1485
|
+
const open = panel.classList.toggle('is-open');
|
|
1486
|
+
fab.classList.toggle('is-active', open);
|
|
1487
|
+
document.documentElement.classList.toggle('has-comments-open', open);
|
|
1488
|
+
if (open && !currentUser) {
|
|
1489
|
+
gateEl.hidden = false;
|
|
1490
|
+
}
|
|
1491
|
+
});
|
|
1492
|
+
closeBtn.addEventListener('click', () => {
|
|
1493
|
+
stopPick();
|
|
1494
|
+
panel.classList.remove('is-open');
|
|
1495
|
+
fab.classList.remove('is-active');
|
|
1496
|
+
document.documentElement.classList.remove('has-comments-open');
|
|
1497
|
+
});
|
|
1498
|
+
var mMenu = document.getElementById('screenMentionMenu');
|
|
1499
|
+
var mMatches = [], mActive = -1, mStart = -1;
|
|
1500
|
+
function mClose() { mMenu.hidden = true; mMatches = []; mActive = -1; mStart = -1; }
|
|
1501
|
+
function mOpen() {
|
|
1502
|
+
var val = inputEl.value, caret = inputEl.selectionStart;
|
|
1503
|
+
var mm = /(^|\s)@([a-z0-9]*)$/i.exec(val.slice(0, caret));
|
|
1504
|
+
if (!mm) { mClose(); return; }
|
|
1505
|
+
mStart = caret - mm[2].length - 1;
|
|
1506
|
+
var q = mm[2].toLowerCase();
|
|
1507
|
+
mMatches = mentionablePeople()
|
|
1508
|
+
.filter(function (p) { return !currentUser || p.userId !== currentUser.id; })
|
|
1509
|
+
.filter(function (p) { return p.handle.indexOf(q) === 0 || p.name.toLowerCase().indexOf(q) === 0; });
|
|
1510
|
+
if (!mMatches.length) { mClose(); return; }
|
|
1511
|
+
mActive = 0; mMenu.innerHTML = '';
|
|
1512
|
+
mMatches.forEach(function (p, i) {
|
|
1513
|
+
var it = document.createElement('div');
|
|
1514
|
+
it.className = 'screen-mention-menu__item' + (i === 0 ? ' is-active' : '');
|
|
1515
|
+
var nm = document.createElement('span'); nm.textContent = p.name;
|
|
1516
|
+
var hd = document.createElement('span'); hd.className = 'screen-mention-menu__handle'; hd.textContent = '@' + p.handle;
|
|
1517
|
+
it.appendChild(nm); it.appendChild(hd);
|
|
1518
|
+
it.addEventListener('mousedown', function (ev) { ev.preventDefault(); mPick(i); });
|
|
1519
|
+
mMenu.appendChild(it);
|
|
1520
|
+
});
|
|
1521
|
+
mMenu.hidden = false;
|
|
1522
|
+
}
|
|
1523
|
+
function mPick(i) {
|
|
1524
|
+
var p = mMatches[i]; if (!p || mStart < 0) return;
|
|
1525
|
+
var val = inputEl.value, caret = inputEl.selectionStart;
|
|
1526
|
+
inputEl.value = val.slice(0, mStart) + '@' + p.handle + ' ' + val.slice(caret);
|
|
1527
|
+
var pos = mStart + p.handle.length + 2;
|
|
1528
|
+
inputEl.setSelectionRange(pos, pos);
|
|
1529
|
+
syncPostDisabled();
|
|
1530
|
+
mClose(); inputEl.focus();
|
|
1531
|
+
}
|
|
1532
|
+
inputEl.addEventListener('input', () => { syncPostDisabled(); mOpen(); });
|
|
1533
|
+
inputEl.addEventListener('blur', () => setTimeout(mClose, 120));
|
|
1534
|
+
inputEl.addEventListener('keydown', (e) => {
|
|
1535
|
+
if (!mMenu.hidden && mMatches.length) {
|
|
1536
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
1537
|
+
e.preventDefault();
|
|
1538
|
+
mActive = (mActive + (e.key === 'ArrowDown' ? 1 : -1) + mMatches.length) % mMatches.length;
|
|
1539
|
+
Array.prototype.forEach.call(mMenu.children, function (el, idx) { el.classList.toggle('is-active', idx === mActive); });
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); mPick(mActive); return; }
|
|
1543
|
+
if (e.key === 'Escape') { e.preventDefault(); mClose(); return; }
|
|
1544
|
+
}
|
|
1545
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
1546
|
+
e.preventDefault();
|
|
1547
|
+
postComment();
|
|
1548
|
+
}
|
|
1549
|
+
});
|
|
1550
|
+
postBtn.addEventListener('click', postComment);
|
|
1551
|
+
|
|
1552
|
+
authForm.addEventListener('submit', async (e) => {
|
|
1553
|
+
e.preventDefault();
|
|
1554
|
+
showError('');
|
|
1555
|
+
authSubmit.disabled = true;
|
|
1556
|
+
try {
|
|
1557
|
+
const { error } = await authClient.signIn.email({ email: authEmail.value.trim(), password: authPassword.value });
|
|
1558
|
+
if (error) throw new Error(error.message || 'Sign in failed');
|
|
1559
|
+
authPassword.value = '';
|
|
1560
|
+
await refreshSession();
|
|
1561
|
+
} catch (err) {
|
|
1562
|
+
showError(err && err.message ? err.message : 'Sign in failed');
|
|
1563
|
+
} finally {
|
|
1564
|
+
authSubmit.disabled = false;
|
|
1565
|
+
}
|
|
1566
|
+
});
|
|
1567
|
+
authGoogle.addEventListener('click', async () => {
|
|
1568
|
+
showError('');
|
|
1569
|
+
try {
|
|
1570
|
+
await authClient.signIn.social({ provider: 'google', callbackURL: location.href });
|
|
1571
|
+
} catch (err) {
|
|
1572
|
+
showError(err && err.message ? err.message : 'Google sign-in failed');
|
|
1573
|
+
}
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
// "View local": on the deployed site, jump to this same screen on
|
|
1577
|
+
// the dev server. Port is set in the wireflow user menu (shared
|
|
1578
|
+
// 'bedrockLocalPort' key, default 5173). Desktop-only via CSS.
|
|
1579
|
+
(function () {
|
|
1580
|
+
const vl = document.getElementById('screenViewLocal');
|
|
1581
|
+
if (!vl || COMMENTS_IS_LOCAL) return;
|
|
1582
|
+
let p;
|
|
1583
|
+
try { p = localStorage.getItem('bedrockLocalPort'); } catch (e) {}
|
|
1584
|
+
if (!p || !/^\d{2,5}$/.test(p)) p = '5173';
|
|
1585
|
+
vl.href = 'http://localhost:' + p + location.pathname + location.search + location.hash;
|
|
1586
|
+
vl.hidden = false;
|
|
1587
|
+
})();
|
|
1588
|
+
|
|
1589
|
+
loadComments();
|
|
1590
|
+
refreshSession();
|
|
1591
|
+
// Auto-open the sidebar when arriving from a deep link (e.g. the
|
|
1592
|
+
// wireflow comment-card click sets ?comments=1).
|
|
1593
|
+
if (new URLSearchParams(location.search).get('comments') === '1') {
|
|
1594
|
+
fab.click();
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
</script>
|
|
1598
|
+
|
|
1599
|
+
<footer class="proto-bottombar">
|
|
1600
|
+
<nav class="proto-nav">
|
|
1601
|
+
{% if prevHref %}<a href="{{ prevHref }}" title="Back (←)">← Back</a>{% endif %}
|
|
1602
|
+
{% if nextHref %}<a class="primary" href="{{ nextHref }}" title="Continue (→)">Continue →</a>{% endif %}
|
|
1603
|
+
</nav>
|
|
1604
|
+
{# Comments toggle — sits in the chrome instead of floating over the
|
|
1605
|
+
prototype as a FAB. The bottom-right corner is too easily covered
|
|
1606
|
+
by product UI (chat widgets, FABs, native bottom bars), so the
|
|
1607
|
+
comments entry lives in the harness bar where it's always
|
|
1608
|
+
reachable. #}
|
|
1609
|
+
<div class="proto-bottombar__right">
|
|
1610
|
+
<a class="screen-viewlocal" id="screenViewLocal" href="#" title="Open this screen on your local dev server" hidden>View local ↗</a>
|
|
1611
|
+
{# Copy to Figma — captures `.proto-content` via Figit (MIT, @figit/dom-to-figma)
|
|
1612
|
+
and writes a Figma-compatible payload to the clipboard. User switches to
|
|
1613
|
+
Figma and pastes (⌘V). Figit is lazy-loaded from esm.sh on first click so
|
|
1614
|
+
it doesn't bloat every screen. #}
|
|
1615
|
+
<button type="button" class="screen-figma-copy" id="screenFigmaCopy" title="Copy this screen to the clipboard as Figma layers" aria-label="Copy to Figma">
|
|
1616
|
+
<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">
|
|
1617
|
+
<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"/>
|
|
1618
|
+
<rect x="8" y="2" width="8" height="4" rx="1"/>
|
|
1619
|
+
</svg>
|
|
1620
|
+
<span data-figma-copy-label>Copy to Figma</span>
|
|
1621
|
+
</button>
|
|
1622
|
+
<script type="module">
|
|
1623
|
+
(function () {
|
|
1624
|
+
var btn = document.getElementById('screenFigmaCopy');
|
|
1625
|
+
if (!btn) return;
|
|
1626
|
+
var labelEl = btn.querySelector('[data-figma-copy-label]');
|
|
1627
|
+
var converterPromise = null;
|
|
1628
|
+
function setLabel(text, state) {
|
|
1629
|
+
if (labelEl) labelEl.textContent = text;
|
|
1630
|
+
btn.classList.remove('is-busy', 'is-done', 'is-error');
|
|
1631
|
+
if (state) btn.classList.add(state);
|
|
1632
|
+
}
|
|
1633
|
+
async function getConverter() {
|
|
1634
|
+
if (!converterPromise) {
|
|
1635
|
+
converterPromise = import('https://esm.sh/@figit/dom-to-figma@0.0.2')
|
|
1636
|
+
.then(function (mod) { return mod.createFigmaConverter(); });
|
|
1637
|
+
}
|
|
1638
|
+
return converterPromise;
|
|
1639
|
+
}
|
|
1640
|
+
// Material Symbols ligature spans (e.g. <span class="material-symbols-sharp">arrow_back_ios</span>)
|
|
1641
|
+
// capture as literal text — Figma doesn't have the font and even if it
|
|
1642
|
+
// did, OpenType ligatures don't fire on text layers. Pre-rasterize each
|
|
1643
|
+
// span to a small PNG <img> so Figit emits an image fill instead. Swap
|
|
1644
|
+
// back on finally so the live page isn't permanently mutated.
|
|
1645
|
+
function rasterizeIconFonts(root) {
|
|
1646
|
+
// root may live in the preview iframe — use its own document/window
|
|
1647
|
+
// so computed styles and created nodes belong to the right realm.
|
|
1648
|
+
var doc = root.ownerDocument || document;
|
|
1649
|
+
var win = doc.defaultView || window;
|
|
1650
|
+
var spans = root.querySelectorAll('.material-symbols-sharp, .material-symbols-outlined, .material-symbols-rounded');
|
|
1651
|
+
var swaps = [];
|
|
1652
|
+
spans.forEach(function (span) {
|
|
1653
|
+
var text = (span.textContent || '').trim();
|
|
1654
|
+
if (!text) return;
|
|
1655
|
+
var cs = win.getComputedStyle(span);
|
|
1656
|
+
var fontSize = parseFloat(cs.fontSize) || 24;
|
|
1657
|
+
var color = cs.color || '#000';
|
|
1658
|
+
var fontFamily = cs.fontFamily || '"Material Symbols Sharp"';
|
|
1659
|
+
var dpr = 2; // crisp on retina
|
|
1660
|
+
var size = Math.max(1, Math.ceil(fontSize * dpr));
|
|
1661
|
+
var canvas = doc.createElement('canvas');
|
|
1662
|
+
canvas.width = size; canvas.height = size;
|
|
1663
|
+
var ctx = canvas.getContext('2d');
|
|
1664
|
+
// font shorthand: weight size family — match the span's weight too,
|
|
1665
|
+
// since some icon spans set font-weight to vary stroke.
|
|
1666
|
+
ctx.font = (cs.fontWeight || 400) + ' ' + (fontSize * dpr) + 'px ' + fontFamily;
|
|
1667
|
+
ctx.fillStyle = color;
|
|
1668
|
+
ctx.textAlign = 'center';
|
|
1669
|
+
ctx.textBaseline = 'middle';
|
|
1670
|
+
ctx.fillText(text, size / 2, size / 2);
|
|
1671
|
+
var img = doc.createElement('img');
|
|
1672
|
+
img.src = canvas.toDataURL('image/png');
|
|
1673
|
+
img.alt = text;
|
|
1674
|
+
img.dataset.iconReplacement = '1';
|
|
1675
|
+
// Inline-block so it sits where the span did. Use the original
|
|
1676
|
+
// computed size in CSS pixels so layout doesn't shift.
|
|
1677
|
+
img.style.cssText = 'display:inline-block;width:' + fontSize + 'px;height:' + fontSize + 'px;vertical-align:middle;';
|
|
1678
|
+
var parent = span.parentNode;
|
|
1679
|
+
parent.insertBefore(img, span);
|
|
1680
|
+
span.remove();
|
|
1681
|
+
swaps.push({ img: img, span: span, parent: parent });
|
|
1682
|
+
});
|
|
1683
|
+
return function restore() {
|
|
1684
|
+
swaps.forEach(function (s) {
|
|
1685
|
+
s.parent.insertBefore(s.span, s.img);
|
|
1686
|
+
s.img.remove();
|
|
1687
|
+
});
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
// The document whose .proto-content is actually visible: when a
|
|
1691
|
+
// viewport breakpoint preview is active the top-level content is
|
|
1692
|
+
// hidden (0×0 — capturing it pasted a 1×1 px into Figma) and the real
|
|
1693
|
+
// screen renders in the same-origin preview iframe.
|
|
1694
|
+
function captureDoc() {
|
|
1695
|
+
var stage = document.querySelector('.proto-stage');
|
|
1696
|
+
if (stage && stage.classList.contains('is-previewing')) {
|
|
1697
|
+
var ifr = document.querySelector('.proto-preview__iframe');
|
|
1698
|
+
try {
|
|
1699
|
+
var d = ifr && ifr.contentDocument;
|
|
1700
|
+
if (d && d.querySelector('.proto-content')) return d;
|
|
1701
|
+
} catch (e) {}
|
|
1702
|
+
}
|
|
1703
|
+
return document;
|
|
1704
|
+
}
|
|
1705
|
+
btn.addEventListener('click', async function () {
|
|
1706
|
+
if (btn.disabled) return;
|
|
1707
|
+
// Capture the screen body, not the harness chrome. `.proto-content`
|
|
1708
|
+
// is the <main> wrapper screen.njk renders pages into.
|
|
1709
|
+
var target = captureDoc().querySelector('.proto-content');
|
|
1710
|
+
if (!target) return;
|
|
1711
|
+
var rect = target.getBoundingClientRect();
|
|
1712
|
+
btn.disabled = true;
|
|
1713
|
+
setLabel('Preparing…', 'is-busy');
|
|
1714
|
+
var restoreIcons = null;
|
|
1715
|
+
try {
|
|
1716
|
+
var converter = await getConverter();
|
|
1717
|
+
restoreIcons = rasterizeIconFonts(target);
|
|
1718
|
+
// One frame so the swapped <img>s lay out before Figit measures.
|
|
1719
|
+
await new Promise(function (r) { requestAnimationFrame(r); });
|
|
1720
|
+
var result = await converter.convert({
|
|
1721
|
+
element: target,
|
|
1722
|
+
width: Math.max(1, Math.round(rect.width)),
|
|
1723
|
+
height: Math.max(1, Math.round(rect.height)),
|
|
1724
|
+
name: document.title.replace(/\s+—\s+.*$/, ''),
|
|
1725
|
+
});
|
|
1726
|
+
await navigator.clipboard.write([result.toClipboardItem()]);
|
|
1727
|
+
setLabel('Copied — ⌘V in Figma', 'is-done');
|
|
1728
|
+
} catch (err) {
|
|
1729
|
+
console.error('[figma-copy] failed', err);
|
|
1730
|
+
setLabel('Copy failed', 'is-error');
|
|
1731
|
+
} finally {
|
|
1732
|
+
if (restoreIcons) restoreIcons();
|
|
1733
|
+
btn.disabled = false;
|
|
1734
|
+
setTimeout(function () { setLabel('Copy to Figma'); }, 2200);
|
|
1735
|
+
}
|
|
1736
|
+
});
|
|
1737
|
+
})();
|
|
1738
|
+
</script>
|
|
1739
|
+
<button type="button" class="screen-comments-toggle" id="screenCommentsFab" title="Comments" aria-label="Open comments">
|
|
1740
|
+
<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">
|
|
1741
|
+
<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"/>
|
|
1742
|
+
</svg>
|
|
1743
|
+
<span class="screen-comments-toggle__label">Comments</span>
|
|
1744
|
+
<span class="screen-comments-toggle__count" id="screenCommentsCount" hidden>0</span>
|
|
1745
|
+
</button>
|
|
1746
|
+
</div>
|
|
1747
|
+
</footer>
|
|
1748
|
+
</div>{# /.proto-stage #}
|
|
1749
|
+
<aside class="screen-comments-panel" id="screenCommentsPanel" aria-label="Comments for this screen">
|
|
1750
|
+
<div class="screen-comments-panel__header">
|
|
1751
|
+
<h2 class="screen-comments-panel__title">Comments on this screen</h2>
|
|
1752
|
+
<button type="button" class="screen-comments-panel__close" id="screenCommentsClose" title="Close" aria-label="Close">
|
|
1753
|
+
<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>
|
|
1754
|
+
</button>
|
|
1755
|
+
</div>
|
|
1756
|
+
<div class="screen-comments-panel__readonly" id="screenCommentsReadonly" hidden>
|
|
1757
|
+
<span class="screen-comments-panel__readonly-line1">⚠ Read-only in local dev.</span>
|
|
1758
|
+
<span class="screen-comments-panel__readonly-line2"><a id="screenCommentsLiveLink" target="_blank" rel="noreferrer">Open the live site</a> to post or edit.</span>
|
|
1759
|
+
</div>
|
|
1760
|
+
<div class="screen-comments-panel__list" id="screenCommentsList"></div>
|
|
1761
|
+
<div class="screen-comments-panel__compose" id="screenCommentsCompose" hidden>
|
|
1762
|
+
<div class="screen-mention-wrap">
|
|
1763
|
+
<textarea id="screenCommentsInput" placeholder="Comment on this screen… use @ to mention" rows="3"></textarea>
|
|
1764
|
+
<div class="screen-mention-menu" id="screenMentionMenu" role="listbox" hidden></div>
|
|
1765
|
+
</div>
|
|
1766
|
+
{# Pending image attachments — removable thumbnails, filled by JS. #}
|
|
1767
|
+
<div class="screen-comments-panel__attachments" id="screenCommentsAttachRow" hidden></div>
|
|
1768
|
+
<p class="screen-comments-panel__attach-error" id="screenCommentsAttachError" hidden></p>
|
|
1769
|
+
{# Staged element anchor — one removable chip, filled by JS when the
|
|
1770
|
+
reviewer pins the comment to a DOM element via pick mode. #}
|
|
1771
|
+
<div class="screen-comments-panel__anchor-row" id="screenCommentsAnchorRow" hidden></div>
|
|
1772
|
+
{# Comments are agent-actionable by default; this opt-out marks one as a
|
|
1773
|
+
side note / reference so automated agents leave it alone.
|
|
1774
|
+
Managers don't get the choice — their comments always await a
|
|
1775
|
+
designer's approval, so the toggle swaps for a hint. #}
|
|
1776
|
+
<label class="screen-comments-panel__note-toggle" id="screenCommentsNoteLabel">
|
|
1777
|
+
<input type="checkbox" id="screenCommentsNoteToggle">
|
|
1778
|
+
<span>Side note — agents won't act on this</span>
|
|
1779
|
+
</label>
|
|
1780
|
+
<p class="screen-comments-panel__manager-hint" id="screenCommentsManagerHint" hidden>
|
|
1781
|
+
A designer reviews your comments before anything is changed.
|
|
1782
|
+
</p>
|
|
1783
|
+
<div class="screen-comments-panel__compose-row">
|
|
1784
|
+
<span class="screen-comments-panel__compose-tools">
|
|
1785
|
+
<button type="button" class="screen-comments-panel__attach" id="screenCommentsAttach" title="Attach image" aria-label="Attach image">
|
|
1786
|
+
<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>
|
|
1787
|
+
</button>
|
|
1788
|
+
{# Pin-to-element — enters pick mode on the visible rendering so the
|
|
1789
|
+
comment can be anchored to the DOM node it's about. #}
|
|
1790
|
+
<button type="button" class="screen-comments-panel__attach" id="screenCommentsPin" title="Pin to element" aria-label="Pin comment to an element">
|
|
1791
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="7"/><path d="M12 2v4M12 18v4M2 12h4M18 12h4"/></svg>
|
|
1792
|
+
</button>
|
|
1793
|
+
</span>
|
|
1794
|
+
<input type="file" id="screenCommentsFile" accept="image/*" multiple hidden>
|
|
1795
|
+
<button type="button" class="screen-comments-panel__post" id="screenCommentsPost" disabled>Post</button>
|
|
1796
|
+
</div>
|
|
1797
|
+
</div>
|
|
1798
|
+
</aside>
|
|
1799
|
+
{# Shared gate markup (_auth-gate.njk) + styling (/auth-gate.css) — the same
|
|
1800
|
+
card the wireflow pages and the dashboard render. #}
|
|
1801
|
+
{% import "_auth-gate.njk" as authUI %}
|
|
1802
|
+
{{ authUI.gate('screen', 'Sign in to comment', 'This prototype review tool is restricted to invited reviewers.') }}
|
|
1803
|
+
{# Delegated DS behavioural scripts (idempotent, harmless if the page
|
|
1804
|
+
never uses these components). data-table.js stays per-page since
|
|
1805
|
+
it's larger and only some flows sort. #}
|
|
1806
|
+
<script src="{{ dsBase }}/components/menu/menu.js" defer></script>
|
|
1807
|
+
<script src="{{ dsBase }}/components/form-validation/form-validation.js" defer></script>
|
|
1808
|
+
{# DS X-ray — review-only design-system provenance overlay (`x` key / chrome
|
|
1809
|
+
button). Reads the loaded /ds/<version>/style.css and outlines every
|
|
1810
|
+
element in .proto-content as DS / Mixed / Page. Top-level only. #}
|
|
1811
|
+
<script src="/ds-xray.js" defer></script>
|
|
1812
|
+
{# ⌘K command palette — search the DS, open a component in Storybook. Also
|
|
1813
|
+
powers the DS X-ray's ⌥⌘-click→Storybook shortcut (cmdkOpenFirst). #}
|
|
1814
|
+
<script src="/cmdk.js" defer></script>
|
|
1815
|
+
{# Shared image lightbox — comment attachment thumbnails open in it. #}
|
|
1816
|
+
<script src="/lightbox.js" defer></script>
|
|
1817
|
+
</body>
|
|
1818
|
+
</html>
|