@myrtilleio/redline 0.1.0

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/client/core.js ADDED
@@ -0,0 +1,1610 @@
1
+ // Redline overlay. Bundled after src/format.js inside one IIFE, so VERDICTS,
2
+ // describeMark and formatMarkdown are in scope. Everything the overlay draws
3
+ // lives in a closed-off shadow root so the page's CSS can't touch it (and vice versa).
4
+
5
+ if (window.__redline) return;
6
+
7
+ const currentScript = document.currentScript;
8
+ const SERVER =
9
+ window.REDLINE_SERVER ||
10
+ (currentScript && /\/__redline\/client\.js(\?|$)/.test(currentScript.src) ? new URL(currentScript.src).origin : null);
11
+ // A key from a shared link (?rk=…): kept for this tab, and taken out of the address bar
12
+ // so it doesn't end up in screenshots, bookmarks or referrers.
13
+ const KEY = (() => {
14
+ try {
15
+ const url = new URL(location.href);
16
+ const given = url.searchParams.get('rk');
17
+ if (given) {
18
+ sessionStorage.setItem('redline:key', given);
19
+ url.searchParams.delete('rk');
20
+ history.replaceState(history.state, '', url);
21
+ }
22
+ return sessionStorage.getItem('redline:key');
23
+ } catch {
24
+ return null;
25
+ }
26
+ })();
27
+ const withKey = (u) => (KEY ? u + (u.includes('?') ? '&' : '?') + 'rk=' + encodeURIComponent(KEY) : u);
28
+ const keyHeader = () => (KEY ? { 'x-redline-key': KEY } : {});
29
+ const pagePath = () => location.pathname.replace(/\/index\.html?$/i, '/') || '/';
30
+ let PAGE = pagePath(); // updated on client-side navigation, see watchRoutes()
31
+ const COLORS = { good: '#22c55e', fix: '#f43f5e', idea: '#f59e0b' };
32
+ const MOD_KEY = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? '⌘' : 'Ctrl';
33
+
34
+ // ---------------------------------------------------------------- storage
35
+
36
+ function prefs(key, value) {
37
+ try {
38
+ if (arguments.length === 1) return JSON.parse(localStorage.getItem('redline:ui:' + key));
39
+ localStorage.setItem('redline:ui:' + key, JSON.stringify(value));
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ function sessionPref(key, value) {
46
+ try {
47
+ if (arguments.length === 1) return sessionStorage.getItem('redline:' + key);
48
+ if (value == null) sessionStorage.removeItem('redline:' + key);
49
+ else sessionStorage.setItem('redline:' + key, value);
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ function localStore() {
56
+ const KEY = 'redline:annotations:v1';
57
+ const read = () => {
58
+ try {
59
+ return JSON.parse(localStorage.getItem(KEY)) || [];
60
+ } catch {
61
+ return [];
62
+ }
63
+ };
64
+ const write = (list) => {
65
+ try {
66
+ localStorage.setItem(KEY, JSON.stringify(list));
67
+ } catch {
68
+ toast('Could not save: browser storage is unavailable');
69
+ }
70
+ };
71
+ return {
72
+ remote: false,
73
+ all: async () => read(),
74
+ list: async () => read().filter((a) => a.page === PAGE),
75
+ async create(input) {
76
+ const list = read();
77
+ const now = new Date().toISOString();
78
+ const a = {
79
+ ...input,
80
+ id: 'a_' + Math.random().toString(16).slice(2, 10),
81
+ n: Number.isInteger(input.n) && !list.some((x) => x.n === input.n) ? input.n : list.reduce((m, x) => Math.max(m, x.n || 0), 0) + 1,
82
+ status: 'open',
83
+ createdAt: now,
84
+ updatedAt: now,
85
+ };
86
+ list.push(a);
87
+ write(list);
88
+ return a;
89
+ },
90
+ async update(id, patch) {
91
+ const list = read();
92
+ const i = list.findIndex((a) => a.id === id);
93
+ if (i < 0) return null;
94
+ const prev = list[i];
95
+ list[i] = { ...prev, ...patch, updatedAt: new Date().toISOString() };
96
+ if (patch.status === 'open' && prev.status === 'resolved') {
97
+ if (prev.resolution) list[i].history = [...(prev.history || []), prev.resolution];
98
+ delete list[i].resolution;
99
+ }
100
+ write(list);
101
+ return list[i];
102
+ },
103
+ async remove(id) {
104
+ write(read().filter((a) => a.id !== id));
105
+ },
106
+ // Rounds, in this browser: Send copies the brief for an AI chat and marks the drafts sent.
107
+ async rounds() {
108
+ try {
109
+ return JSON.parse(localStorage.getItem(KEY + ':rounds')) || { rounds: [] };
110
+ } catch {
111
+ return { rounds: [] };
112
+ }
113
+ },
114
+ async send() {
115
+ const list = read();
116
+ const data = await this.rounds();
117
+ const n = (data.rounds.at(-1)?.n || 0) + 1;
118
+ let count = 0;
119
+ for (const a of list) if (a.draft) (delete a.draft, (a.round = n), count++);
120
+ if (!count) return null;
121
+ write(list);
122
+ data.rounds.push({ n, sentAt: new Date().toISOString(), count });
123
+ try {
124
+ localStorage.setItem(KEY + ':rounds', JSON.stringify(data));
125
+ } catch {}
126
+ return data.rounds.at(-1);
127
+ },
128
+ async answerRound() {},
129
+ subscribe(on) {
130
+ window.addEventListener('storage', (e) => e.key && e.key.startsWith(KEY) && on.annotations());
131
+ },
132
+ };
133
+ }
134
+
135
+ function remoteStore(base) {
136
+ async function call(method, path, body) {
137
+ const res = await fetch(base + '/__redline/api' + path, {
138
+ method,
139
+ headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...keyHeader() },
140
+ body: body ? JSON.stringify(body) : undefined,
141
+ });
142
+ if (!res.ok) throw new Error(`Redline server: ${res.status}`);
143
+ return res.json();
144
+ }
145
+ return {
146
+ remote: true,
147
+ all: () => call('GET', '/annotations'),
148
+ list: () => call('GET', '/annotations?page=' + encodeURIComponent(PAGE)),
149
+ create: (a) => call('POST', '/annotations', a),
150
+ update: (id, patch) => call('PATCH', '/annotations/' + encodeURIComponent(id), patch),
151
+ remove: (id) => call('DELETE', '/annotations/' + encodeURIComponent(id)),
152
+ send: () => call('POST', '/send'),
153
+ rounds: () => call('GET', '/rounds'),
154
+ answerRound: (n, body) => call('PATCH', '/rounds/' + n, body),
155
+ subscribe(on) {
156
+ const es = new EventSource(withKey(base + '/__redline/api/events'));
157
+ es.addEventListener('hello', () => on.status(true));
158
+ es.addEventListener('annotations', () => on.annotations());
159
+ es.addEventListener('reload', () => on.reload());
160
+ es.onerror = () => on.status(false);
161
+ },
162
+ };
163
+ }
164
+
165
+ const store = SERVER ? remoteStore(SERVER) : localStore();
166
+
167
+ // ---------------------------------------------------------------- DOM helpers
168
+
169
+ function h(tag, attrs, ...children) {
170
+ const el = document.createElement(tag);
171
+ for (const [k, v] of Object.entries(attrs || {})) {
172
+ if (v == null || v === false) continue;
173
+ if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
174
+ else if (k === 'html') el.append(markup(v));
175
+ // Through CSSOM rather than the style attribute, which a strict style-src CSP blocks.
176
+ else if (k === 'style') el.style.cssText = v;
177
+ else el.setAttribute(k, v === true ? '' : v);
178
+ }
179
+ for (const c of children.flat()) if (c != null && c !== false) el.append(c.nodeType ? c : String(c));
180
+ return el;
181
+ }
182
+
183
+ // replaceChildren() would render null/false as text; skip them like h() does.
184
+ function fill(el, ...children) {
185
+ el.replaceChildren(...children.flat().filter((c) => c != null && c !== false));
186
+ return el;
187
+ }
188
+
189
+ function fillAppend(el, ...children) {
190
+ el.append(...children.flat().filter((c) => c != null && c !== false));
191
+ return el;
192
+ }
193
+
194
+ const SVGNS = 'http://www.w3.org/2000/svg';
195
+
196
+ // Build DOM from the overlay's own constant markup (icons, hints) without
197
+ // innerHTML, which pages that enforce Trusted Types (e.g. YouTube) forbid.
198
+ function markup(src) {
199
+ const frag = document.createDocumentFragment();
200
+ const stack = [frag];
201
+ for (const m of src.matchAll(/<(\/?)([a-zA-Z][\w-]*)((?:\s+[\w:-]+(?:="[^"]*")?)*)\s*(\/?)>|([^<]+)/g)) {
202
+ const parent = stack[stack.length - 1];
203
+ if (m[5] !== undefined) parent.append(m[5]);
204
+ else if (m[1]) stack.length > 1 && stack.pop();
205
+ else {
206
+ const el = m[2] === 'svg' || parent.namespaceURI === SVGNS ? document.createElementNS(SVGNS, m[2]) : document.createElement(m[2]);
207
+ for (const a of m[3].matchAll(/([\w:-]+)(?:="([^"]*)")?/g)) el.setAttribute(a[1], a[2] ?? '');
208
+ parent.append(el);
209
+ if (!m[4] && m[2] !== 'br') stack.push(el);
210
+ }
211
+ }
212
+ return frag;
213
+ }
214
+ function s(tag, attrs) {
215
+ const el = document.createElementNS(SVGNS, tag);
216
+ for (const [k, v] of Object.entries(attrs || {})) el.setAttribute(k, v);
217
+ return el;
218
+ }
219
+
220
+ const icon = (d, extra = '') =>
221
+ `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" ${extra}>${d}</svg>`;
222
+ const ICONS = {
223
+ report: icon('<path d="M7 3h7l5 5v13H7z"/><path d="M14 3v5h5M10 13h6M10 17h4"/>'),
224
+ link: icon('<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>'),
225
+ browse: icon('<path d="M4 4l7 17 2.5-7.5L21 11z"/>'),
226
+ select: icon('<path d="M4 8V5a1 1 0 0 1 1-1h3M16 4h3a1 1 0 0 1 1 1v3M20 16v3a1 1 0 0 1-1 1h-3M8 20H5a1 1 0 0 1-1-1v-3"/><rect x="9" y="9" width="6" height="6" rx="1"/>'),
227
+ pen: icon('<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/>'),
228
+ list: icon('<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>'),
229
+ copy: icon('<rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/>'),
230
+ eye: icon('<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>'),
231
+ eyeOff: icon('<path d="M3 3l18 18M10.6 10.6a2 2 0 0 0 2.8 2.8M9.9 5.2A9.8 9.8 0 0 1 12 5c6.5 0 10 7 10 7a17 17 0 0 1-3.2 4M6.6 6.6C3.8 8.4 2 12 2 12s3.5 7 10 7a9.6 9.6 0 0 0 5.4-1.6"/>'),
232
+ minimize: icon('<path d="M5 12h14"/>'),
233
+ close: icon('<path d="M18 6 6 18M6 6l12 12"/>'),
234
+ check: icon('<path d="M20 6 9 17l-5-5"/>'),
235
+ trash: icon('<path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/>'),
236
+ // Redline's mark: one confident red pen stroke. It's also the toolbar's drag handle.
237
+ mark: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true"><path d="M4 16.5c2.6-1.2 4.4-5.6 6.6-5.9 2.1-.3.9 4.3 3 4.1 2-.2 3.4-4.6 6.4-6.2" stroke="#f43f5e" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>',
238
+ grip: icon('<circle cx="9" cy="6" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="18" r="1"/>', 'stroke-width="2.5"'),
239
+ move: icon('<path d="M5 9l-3 3 3 3M9 5l3-3 3 3M15 19l-3 3-3-3M19 9l3 3-3 3M2 12h20M12 2v20"/>'),
240
+ image: icon('<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="M21 15l-5-5L5 21"/>'),
241
+ sliders: icon('<path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6"/>'),
242
+ options: icon('<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>'),
243
+ devices: icon('<rect x="2" y="4" width="14" height="11" rx="1.5"/><path d="M6 19h6"/><rect x="17" y="8" width="5" height="11" rx="1"/>'),
244
+ split: icon('<path d="M12 3v18"/><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7 12h2M15 12h2"/>'),
245
+ box: icon('<rect x="4" y="5" width="16" height="14" rx="2.5"/>'),
246
+ more: icon('<circle cx="5" cy="12" r="1.2"/><circle cx="12" cy="12" r="1.2"/><circle cx="19" cy="12" r="1.2"/>', 'stroke-width="2.4"'),
247
+ download: icon('<path d="M12 3v12M7 10l5 5 5-5M5 21h14"/>'),
248
+ };
249
+
250
+ // ---------------------------------------------------------------- element capture
251
+
252
+ function isOurs(node) {
253
+ return node === host || (node && host.contains(node));
254
+ }
255
+
256
+ function eventIsOurs(e) {
257
+ return e.composedPath().includes(host);
258
+ }
259
+
260
+ function queryOne(selector) {
261
+ try {
262
+ const found = document.querySelectorAll(selector);
263
+ return found.length === 1 ? found[0] : found[0] || null;
264
+ } catch {
265
+ return null;
266
+ }
267
+ }
268
+
269
+ function isUnique(selector) {
270
+ try {
271
+ return document.querySelectorAll(selector).length === 1;
272
+ } catch {
273
+ return false;
274
+ }
275
+ }
276
+
277
+ // Skip class names that won't match later: generated ones (css-1x2y3z, sc-AbCdE,
278
+ // jsx-123) change between builds, and state ones (open, is-active) come and go.
279
+ const STATE_CLASS = /^(is-|has-)|^(open|opened|active|selected|current|show|shown|visible|hidden|expanded|collapsed|focus|focused|hover|disabled|checked|on|off|in|out|fade|loading|loaded|sticky|scrolled)$/;
280
+ function stableClasses(el) {
281
+ return [...el.classList]
282
+ .filter((c) => /^[a-zA-Z][\w-]{0,40}$/.test(c) && !/(^(css|sc|jsx|svelte|emotion)-)|\d{3,}|^_/.test(c) && !STATE_CLASS.test(c))
283
+ .slice(0, 2);
284
+ }
285
+
286
+ function cssPath(el) {
287
+ if (el === document.body || el === document.documentElement) return el.tagName.toLowerCase();
288
+ if (el.id && isUnique('#' + CSS.escape(el.id))) return '#' + CSS.escape(el.id);
289
+ const parts = [];
290
+ for (let node = el; node && node.nodeType === 1 && node !== document.documentElement; node = node.parentElement) {
291
+ if (node !== el && node.id && isUnique('#' + CSS.escape(node.id))) {
292
+ parts.unshift('#' + CSS.escape(node.id));
293
+ break;
294
+ }
295
+ const testId = node.getAttribute('data-testid');
296
+ if (testId && isUnique(`[data-testid="${CSS.escape(testId)}"]`)) {
297
+ parts.unshift(`[data-testid="${CSS.escape(testId)}"]`);
298
+ break;
299
+ }
300
+ let part = node.tagName.toLowerCase();
301
+ if (node === document.body) {
302
+ parts.unshift(part);
303
+ break;
304
+ }
305
+ const classes = stableClasses(node);
306
+ if (classes.length) part += '.' + classes.map((c) => CSS.escape(c)).join('.');
307
+ const parent = node.parentElement;
308
+ if (parent && [...parent.children].filter((c) => c !== node && c.matches(part)).length) {
309
+ const same = [...parent.children].filter((c) => c.tagName === node.tagName);
310
+ part += `:nth-of-type(${same.indexOf(node) + 1})`;
311
+ }
312
+ parts.unshift(part);
313
+ if (isUnique(parts.join(' > '))) break;
314
+ }
315
+ return parts.join(' > ');
316
+ }
317
+
318
+ function hex(color) {
319
+ const m = /^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)$/.exec(color);
320
+ if (!m) return color;
321
+ if (m[4] !== undefined && Number(m[4]) < 1) return Number(m[4]) === 0 ? 'transparent' : color;
322
+ return '#' + [m[1], m[2], m[3]].map((n) => Number(n).toString(16).padStart(2, '0')).join('');
323
+ }
324
+
325
+ function openTag(el) {
326
+ const attrs = ['id', 'class', 'href', 'src', 'alt', 'type', 'role', 'aria-label', 'name', 'placeholder']
327
+ .filter((a) => el.hasAttribute(a))
328
+ .map((a) => {
329
+ const v = el.getAttribute(a);
330
+ return `${a}="${v.length > 60 ? v.slice(0, 57) + '…' : v}"`;
331
+ });
332
+ return `<${el.tagName.toLowerCase()}${attrs.length ? ' ' + attrs.join(' ') : ''}>`;
333
+ }
334
+
335
+ function label(el) {
336
+ let out = el.tagName.toLowerCase();
337
+ if (el.id) out += '#' + el.id;
338
+ const cls = stableClasses(el);
339
+ if (cls.length) out += '.' + cls.join('.');
340
+ return out;
341
+ }
342
+
343
+ // Notes describe the site as built, never the reviewer's own previews (an agent
344
+ // searching the code for previewed text or sizes would find nothing).
345
+ function asBuilt(fn) {
346
+ if (!previewed.size && !variantSheets.length && !variantHtml.size) return fn();
347
+ clearPreviews();
348
+ try {
349
+ return fn();
350
+ } finally {
351
+ applyPreviews();
352
+ }
353
+ }
354
+
355
+ function describeEl(el) {
356
+ return asBuilt(() => describeRaw(el));
357
+ }
358
+
359
+ function describeRaw(el) {
360
+ const r = el.getBoundingClientRect();
361
+ const cs = getComputedStyle(el);
362
+ const text = (el.innerText || el.textContent || el.getAttribute('alt') || el.getAttribute('aria-label') || '')
363
+ .replace(/\s+/g, ' ')
364
+ .trim();
365
+ const styles = {
366
+ font: `${cs.fontSize}/${cs.lineHeight} ${cs.fontFamily.split(',')[0].trim()} ${cs.fontWeight}`,
367
+ color: hex(cs.color),
368
+ };
369
+ const bg = hex(cs.backgroundColor);
370
+ if (bg !== 'transparent') styles.background = bg;
371
+ if (cs.backgroundImage !== 'none') styles.background = (styles.background ? styles.background + ' + ' : '') + (cs.backgroundImage.includes('gradient') ? 'gradient' : 'image');
372
+ if (cs.padding !== '0px') styles.padding = cs.padding;
373
+ if (cs.borderRadius !== '0px') styles.radius = cs.borderRadius;
374
+ if (cs.display === 'flex' || cs.display === 'grid' || cs.display === 'inline-flex') {
375
+ styles.layout = `${cs.display}${cs.gap && cs.gap !== 'normal' ? ' gap ' + cs.gap : ''}`;
376
+ }
377
+ return {
378
+ selector: cssPath(el),
379
+ html: openTag(el),
380
+ text: text.length > 120 ? text.slice(0, 117) + '…' : text,
381
+ rect: { x: r.left + scrollX, y: r.top + scrollY, w: r.width, h: r.height },
382
+ styles,
383
+ };
384
+ }
385
+
386
+ // What the page looked like beyond the element itself: its width, and the
387
+ // toggles, tabs and menus near it, on or off, so the agent can reproduce the view.
388
+ const UI_STATE = [
389
+ '[aria-pressed]', '[aria-selected="true"]', '[aria-checked]', '[role="switch"]', '[aria-expanded="true"]',
390
+ 'input[type="checkbox"]', 'input[type="radio"]:checked', 'details[open] > summary',
391
+ 'button.active', 'button.is-active', 'button.selected', 'button.on', '[role="tab"].active',
392
+ ].join(',');
393
+ const REGION = 'section, article, form, dialog, header, nav, footer, aside, [role="region"], [role="dialog"], [role="tabpanel"]';
394
+
395
+ function stateOf(el) {
396
+ if (el.matches('[aria-expanded="true"], details[open] > summary')) return 'open';
397
+ if (el.matches('[aria-selected="true"], [role="tab"].active, input[type="radio"]')) return 'selected';
398
+ if (el.matches('input[type="checkbox"]')) return el.checked ? 'on' : 'off';
399
+ const v = el.getAttribute('aria-checked') ?? el.getAttribute('aria-pressed');
400
+ if (v != null) return v === 'true' ? 'on' : v === 'mixed' ? 'mixed' : 'off';
401
+ return 'on';
402
+ }
403
+
404
+ // State of a control right now, including "off" for toggles that only mark the on state.
405
+ function stateNow(el) {
406
+ return el.matches(UI_STATE) ? stateOf(el) : 'off';
407
+ }
408
+
409
+ // The page section a mark is in: its landmark and first heading, so the agent can
410
+ // connect a drawing to what that part of the page is for.
411
+ function sectionFor(el) {
412
+ for (let n = el; n && n !== document.body; n = n.parentElement) {
413
+ if (/^(SECTION|HEADER|FOOTER|NAV|ASIDE|ARTICLE)$/.test(n.tagName) || (n.id && n.parentElement === document.body) || (n.parentElement && n.parentElement.tagName === 'MAIN')) {
414
+ const heading = n.querySelector('h1, h2, h3');
415
+ return { selector: cssPath(n), heading: heading ? heading.textContent.replace(/\s+/g, ' ').trim().slice(0, 100) : '' };
416
+ }
417
+ }
418
+ return null;
419
+ }
420
+
421
+ function captureContext(target) {
422
+ // Controls in the same section as the target (plus any open menu), not the whole page.
423
+ const scope = (target && target.closest && target.closest(REGION)) || null;
424
+ const ui = [];
425
+ const uiReplay = []; // the same controls by selector, so screenshots can restore them
426
+ for (const el of document.querySelectorAll(UI_STATE)) {
427
+ if (ui.length >= 8 || isOurs(el)) continue;
428
+ const global = el.matches('[aria-expanded="true"]');
429
+ if (scope && !scope.contains(el) && !global) continue;
430
+ const labelEl = el.matches('input') && el.labels && el.labels[0];
431
+ const r = (labelEl || el).getBoundingClientRect(); // styled switches hide the input itself
432
+ if (!r.width && !r.height) continue;
433
+ const text = labelEl ? labelEl.innerText : el.getAttribute('aria-label') || el.innerText || el.name || el.value;
434
+ const name = (text || label(el)).replace(/\s+/g, ' ').trim().slice(0, 40);
435
+ const line = `“${name}” ${stateOf(el)}`;
436
+ if (!ui.includes(line)) {
437
+ ui.push(line);
438
+ uiReplay.push({ selector: cssPath(el), state: stateOf(el) });
439
+ }
440
+ }
441
+ const ctx = { viewport: { w: innerWidth, h: innerHeight }, uiState: ui, uiReplay };
442
+ const section = target && sectionFor(target);
443
+ if (section) ctx.section = section;
444
+ if (location.search || location.hash) ctx.url = location.search + location.hash;
445
+ return ctx;
446
+ }
447
+
448
+ // The words under an underline: the text from where the stroke starts to where it
449
+ // ends, read just above the line.
450
+ function textAlong(points) {
451
+ const b = bboxOf(points);
452
+ const y = b.y - 8;
453
+ const at = (x) => {
454
+ if (document.caretPositionFromPoint) {
455
+ const p = document.caretPositionFromPoint(x, y);
456
+ return p && { node: p.offsetNode, offset: p.offset };
457
+ }
458
+ const r = document.caretRangeFromPoint && document.caretRangeFromPoint(x, y);
459
+ return r && { node: r.startContainer, offset: r.startOffset };
460
+ };
461
+ // Caret hit-testing would land on the pen surface; let it see through.
462
+ ink.style.pointerEvents = 'none';
463
+ let start, end;
464
+ try {
465
+ start = at(b.x + 2);
466
+ end = at(b.x + b.w - 2);
467
+ } finally {
468
+ ink.style.pointerEvents = '';
469
+ }
470
+ if (!start || !end || isOurs(start.node) || isOurs(end.node)) return '';
471
+ try {
472
+ const range = document.createRange();
473
+ range.setStart(start.node, start.offset);
474
+ range.setEnd(end.node, end.offset);
475
+ if (range.collapsed) return '';
476
+ return range.toString().replace(/\s+/g, ' ').trim().slice(0, 300);
477
+ } catch {
478
+ return '';
479
+ }
480
+ }
481
+
482
+ // ---------------------------------------------------------------- state
483
+
484
+ const state = {
485
+ mode: 'browse',
486
+ verdict: prefs('verdict') || 'fix', // the ink: red (change) by default
487
+ annotations: [],
488
+ hidden: false,
489
+ panel: false,
490
+ filter: 'open',
491
+ collapsed: !!prefs('collapsed'),
492
+ preview: false, // the page as visitors see it: Redline hidden until the backslash key brings it back
493
+ hoverEl: null,
494
+ hoverStack: [],
495
+ composer: null, // { annotation?, draft?, el?, point }
496
+ connected: !SERVER,
497
+ pendingReload: false,
498
+ showElsewhere: false,
499
+ previewEdits: true, // show open items' proposed edits and chosen options on the page
500
+ devices: false, // desktop / tablet / mobile side by side
501
+ lastStrokeIds: [],
502
+ };
503
+
504
+ // ---------------------------------------------------------------- shadow root + styles
505
+
506
+ const host = document.createElement('redline-overlay');
507
+ // Inline so it beats page CSS (set through CSSOM, which CSP allows); `all:initial`
508
+ // would otherwise also reset the font the :host rule sets.
509
+ host.style.cssText =
510
+ 'all:initial;position:fixed;inset:0;z-index:2147483647;pointer-events:none;display:block;' +
511
+ 'font:13px/1.4 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;color:#f4f4f5;';
512
+ const root = host.attachShadow({ mode: 'open' });
513
+
514
+ const CSS_TEXT = `
515
+ :host { --good:${COLORS.good}; --fix:${COLORS.fix}; --idea:${COLORS.idea};
516
+ --bg: #16161a; /* solid: backdrop blurs re-render on every scroll frame */ --bg2:#232328; --line: rgba(255,255,255,.1); --text:#f4f4f5; --muted:#a1a1aa;
517
+ --radius: 12px; --shadow: 0 10px 30px -8px rgba(0,0,0,.45), 0 2px 6px rgba(0,0,0,.25);
518
+ /* Motion: fast out of the gate, soft landing. Pop is for rare, happy moments only. */
519
+ --ease-out: cubic-bezier(0.23, 1, 0.32, 1); --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
520
+ --ease-pop: cubic-bezier(0.34, 1.56, 0.64, 1); --focus: #60a5fa; --agent: #8b5cf6; }
521
+ * { box-sizing: border-box; }
522
+ [hidden] { display: none !important; }
523
+ button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
524
+ button:focus-visible, [role="button"]:focus-visible, summary:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
525
+ .marks { position: fixed; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
526
+ .marks path { fill: none; stroke-linecap: round; stroke-linejoin: round; }
527
+
528
+ .marks path { vector-effect: non-scaling-stroke; }
529
+ .marks .elsewhere { stroke-dasharray: 2 8; opacity: .5; }
530
+ .marks-hit { position: fixed; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
531
+ .marks-hit path { fill: none; pointer-events: stroke; cursor: pointer; }
532
+ .marks-hit .resolved, :host([data-mode="select"]) .marks-hit, .hidden-marks .marks-hit, :host([data-devices]) .marks-hit { display: none; }
533
+ .marks g path { transition: stroke-opacity 140ms ease, stroke-width 140ms var(--ease-out); }
534
+ /* Boxes: a tint and a line in the ink colour; the inside stays usable. */
535
+ .marks rect.box { stroke-width: 1.5; stroke-opacity: .75; transition: fill-opacity 140ms ease, stroke-opacity 140ms ease; }
536
+ .marks rect.box.fix { fill: var(--fix); stroke: var(--fix); } .marks rect.box.good { fill: var(--good); stroke: var(--good); } .marks rect.box.idea { fill: var(--idea); stroke: var(--idea); }
537
+ .marks rect.box { fill-opacity: .07; }
538
+ .pins .pin.tab { position: absolute; overflow: visible; }
539
+ /* Conversation in the card */
540
+ .thread { display: grid; gap: 6px; max-height: 220px; overflow-y: auto; }
541
+ .msg { font-size: 12.5px; line-height: 1.45; padding: 7px 10px; border-radius: 12px; max-width: 88%; overflow-wrap: anywhere; }
542
+ .msg.you { justify-self: end; background: rgba(255,255,255,.1); border-bottom-right-radius: 4px; }
543
+ .msg.agent { justify-self: start; background: rgba(139,92,246,.2); border: 1px solid rgba(139,92,246,.35); border-bottom-left-radius: 4px; }
544
+ .msg.agent::before { content: '✦ '; color: #c4b5fd; }
545
+ .msg .tag { display: block; font-size: 10px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: #86efac; margin-bottom: 2px; }
546
+
547
+ .marks rect.box.hot { fill-opacity: .14; stroke-opacity: 1; }
548
+ .marks .sent, .pins .pin.tab.sent { opacity: .6; } /* with the agent */
549
+ .marks .fixed { opacity: .35; } /* fixed: check it */
550
+ .pins .pin.tab.check { background: #fafafa; color: #18181b; } /* fixed, waiting for your review: neutral, not a green alarm */
551
+ /* An approval that changed since: the one alarm on the page. */
552
+ .pins .pin.tab.changed { background: #18181b; color: #fecdd3; box-shadow: 0 0 0 1.5px var(--fix), 0 2px 10px rgba(244,63,94,.35); padding-left: 16px; }
553
+ .pins .pin.tab.changed::before { content: ''; position: absolute; left: 6px; top: 7px; width: 6px; height: 6px; border-radius: 50%; background: var(--fix);
554
+ animation: alarm 1.6s ease-in-out infinite; }
555
+ @keyframes alarm { 50% { opacity: .35; } }
556
+ .marks rect.box.changed { fill: var(--fix); stroke: var(--fix); fill-opacity: .05; stroke-dasharray: 5 4; stroke-opacity: .9; }
557
+ .marks g.changed path { stroke-dasharray: 3 7; }
558
+ .marks rect.box.pop { animation: ink-in 180ms ease-out; }
559
+ .marks-hit rect.box-hit { fill: none; stroke: transparent; stroke-width: 10; pointer-events: stroke; cursor: pointer; }
560
+ .marks-hit rect.box-hit.resolved { display: none; }
561
+ .box-draft { position: fixed; display: none; border: 2px solid; border-radius: 6px; pointer-events: none; }
562
+ /* A note's tab: its first words, on the box's corner. */
563
+ .pins .pin.tab { margin: -22px 0 0 -1px; height: 20px; min-width: 20px; max-width: 200px; padding: 0 7px; border-radius: 6px 6px 6px 2px;
564
+ font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; line-height: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.25); }
565
+ .marks g.hot path { stroke-opacity: 1; stroke-width: 6; }
566
+ .pin.elsewhere { opacity: .55; }
567
+ .ink { position: fixed; inset: 0; width: 100%; height: 100%; pointer-events: none; cursor: var(--pen-cursor, crosshair); touch-action: none; }
568
+ :host([data-mode="pen"]) .ink { pointer-events: auto; }
569
+ .pick { position: fixed; inset: 0; pointer-events: none; }
570
+ :host([data-mode="select"]) .pick { pointer-events: auto; cursor: default; }
571
+ .ink path { fill: none; stroke-linecap: round; stroke-linejoin: round; }
572
+ .hidden-marks .marks, .hidden-marks .pins { display: none; }
573
+
574
+ .pins { position: fixed; inset: 0; pointer-events: none; }
575
+ .pin { position: absolute; left: 0; top: 0; pointer-events: auto; min-width: 22px; height: 22px; padding: 0 6px; margin: -11px 0 0 -11px;
576
+ border-radius: 11px 11px 11px 3px; display: grid; place-items: center; font-size: 11px; font-weight: 700; color: #fff;
577
+ box-shadow: 0 2px 8px rgba(0,0,0,.35), 0 0 0 2px #fff; }
578
+ .pin.resolved { background: #71717a !important; opacity: .75; }
579
+ .pin svg { width: 12px; height: 12px; stroke-width: 3; }
580
+ .good { background: var(--good); } .fix { background: var(--fix); } .idea { background: var(--idea); }
581
+
582
+ .hover, .focus { position: fixed; left: 0; top: 0; pointer-events: none; border-radius: 3px; display: none; }
583
+ .hover { outline: 2px solid #3b82f6; background: rgba(59,130,246,.08); }
584
+ .focus { outline: 2px dashed #fff; box-shadow: 0 0 0 4px rgba(0,0,0,.35); }
585
+ .focus.flash { animation: flash 1.2s ease-out forwards; }
586
+ @keyframes flash { 0%,40% { opacity: 1 } 100% { opacity: 0 } }
587
+ .hover span { position: absolute; left: -2px; bottom: 100%; margin-bottom: 4px; white-space: nowrap; background: #3b82f6; color: #fff;
588
+ font: 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; padding: 4px 6px; border-radius: 4px; }
589
+ .hover span.below { bottom: auto; top: 100%; margin: 4px 0 0; }
590
+ .hover kbd { opacity: .75; font: inherit; }
591
+
592
+ .bar { position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); pointer-events: auto; display: flex; align-items: center; gap: 2px;
593
+ padding: 5px; background: var(--bg); border: 1px solid var(--line);
594
+ border-radius: 14px; box-shadow: var(--shadow); user-select: none; max-width: calc(100vw - 24px); }
595
+ .bar .sep { width: 1px; height: 20px; background: var(--line); margin: 0 4px; flex: none; }
596
+ .tool { height: 32px; min-width: 32px; padding: 0 9px; border-radius: 9px; display: inline-flex; align-items: center; justify-content: center; gap: 6px;
597
+ color: #d4d4d8; white-space: nowrap; position: relative; z-index: 1; }
598
+ .tool[aria-pressed="true"] { background: #fafafa; color: #18181b; }
599
+ /* Mode buttons share one sliding pill instead of each lighting up on its own. */
600
+ .modes { display: flex; gap: 2px; position: relative; }
601
+ .modes .tool[aria-pressed="true"] { background: transparent; }
602
+ .tool.icon-only { padding: 0; width: 34px; }
603
+ /* The turn: Send your marks / the agent is on it / check its fixes. */
604
+ .turn { height: 30px; padding: 0 12px; margin: 0 2px; border-radius: 999px; font-weight: 650; font-size: 12.5px; display: inline-flex; align-items: center; gap: 7px; white-space: nowrap;
605
+ transition: transform 140ms var(--ease-out), background-color 160ms ease, color 160ms ease; }
606
+ @starting-style { .turn { opacity: 0; transform: scale(.9); } }
607
+ .turn:active { transform: scale(.96); }
608
+ .turn.send { background: #fafafa; color: #18181b; }
609
+ .turn.check { background: #fafafa; color: #18181b; }
610
+ .turn.send ~ .turn.check { background: rgba(255,255,255,.1); color: #fafafa; } /* one primary at a time */
611
+ .turn.waiting { background: rgba(255,255,255,.08); color: #d4d4d8; font-weight: 500; }
612
+ .turn.waiting i { width: 7px; height: 7px; border-radius: 50%; background: #a1a1aa; animation: waiting 1.4s ease-in-out infinite; }
613
+ @keyframes waiting { 50% { opacity: .3; } }
614
+ .review-idle { font-size: 12px; color: var(--muted); padding: 0 10px; white-space: nowrap; }
615
+ .pen-ink { position: absolute; right: 6px; bottom: 6px; width: 7px; height: 7px; border-radius: 50%; box-shadow: 0 0 0 1.5px var(--bg); }
616
+ .menu { position: fixed; pointer-events: auto; min-width: 200px; padding: 5px; background: var(--bg); border: 1px solid var(--line); border-radius: 12px;
617
+ box-shadow: var(--shadow); display: grid; gap: 1px;
618
+ transition: opacity 150ms ease, transform 170ms var(--ease-out); }
619
+ @starting-style { .menu { opacity: 0; transform: scale(.96); } }
620
+ .menu-item { display: flex; align-items: center; gap: 10px; height: 32px; padding: 0 8px; border-radius: 8px; color: #e4e4e7; text-align: left; transition: background-color 120ms ease, transform 140ms var(--ease-out); }
621
+ .menu-item:active { transform: scale(.98); }
622
+ @media (hover: hover) and (pointer: fine) { .menu-item:hover { background: rgba(255,255,255,.08); } }
623
+ .menu-icon { width: 16px; height: 16px; display: grid; place-items: center; color: var(--muted); }
624
+ .menu-swatch { width: 14px; height: 14px; border-radius: 50%; margin: 0 1px; }
625
+ .menu-label { flex: 1; }
626
+ .menu-check svg { width: 14px; height: 14px; color: #fafafa; }
627
+ .menu kbd { font: 11px ui-monospace, monospace; color: #71717a; }
628
+ .menu-note { display: flex; align-items: center; gap: 8px; padding: 8px 8px 4px; margin-top: 4px; border-top: 1px solid var(--line); font-size: 11px; color: var(--muted); }
629
+ .menu-note i { width: 7px; height: 7px; border-radius: 50%; background: var(--fix); } .menu-note i.ok { background: var(--good); }
630
+ .mode-pill { position: absolute; left: 0; top: 0; height: 32px; border-radius: 9px; background: #fafafa; z-index: 0; }
631
+ .tool .count { font-size: 11px; font-weight: 700; background: rgba(255,255,255,.14); border-radius: 8px; padding: 1px 6px; }
632
+ .grip { cursor: grab; padding: 0 4px 0 6px; min-width: 28px; }
633
+ .grip svg path { stroke-dasharray: 30; }
634
+ .bar.intro .grip svg path { animation: mark-draw 700ms 180ms var(--ease-out) backwards; }
635
+ @keyframes mark-draw { from { stroke-dashoffset: 30; } }
636
+ .grip:active { cursor: grabbing; }
637
+ .verdicts { display: flex; gap: 2px; padding: 0 2px; }
638
+ .swatch { width: 28px; height: 32px; border-radius: 9px; display: grid; place-items: center; }
639
+ .swatch i { width: 14px; height: 14px; border-radius: 50%; display: block; box-shadow: inset 0 0 0 2px rgba(255,255,255,.25); }
640
+ .swatch[aria-pressed="true"] { background: rgba(255,255,255,.12); }
641
+ .swatch[aria-pressed="true"] i { box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px currentColor; }
642
+ .status { width: 7px; height: 7px; border-radius: 50%; background: var(--good); margin: 0 6px; flex: none; }
643
+ .status.off { background: var(--fix); }
644
+ .status.local { background: var(--muted); }
645
+ .label-sm { font-size: 12px; }
646
+ @media (max-width: 720px) { .tool .txt { display: none; } }
647
+ @media (max-width: 480px) {
648
+ .bar { gap: 0; padding: 4px; overflow-x: auto; scrollbar-width: none; }
649
+ .bar .grip, .bar .sep { display: none; }
650
+ .tool { min-width: 30px; padding: 0 6px; }
651
+ .swatch { width: 26px; }
652
+ .status { margin: 0 4px; }
653
+ }
654
+
655
+ .fab { position: fixed; right: 16px; bottom: 16px; pointer-events: auto; width: 40px; height: 40px; border-radius: 50%; background: var(--bg);
656
+ border: 1px solid var(--line); box-shadow: var(--shadow); display: grid; place-items: center; font-weight: 800; color: var(--fix); font-size: 15px; }
657
+
658
+ :is(.composer, .composer-out) { position: fixed; width: 320px; max-height: calc((100vh - 16px) / var(--ui-zoom, 1)); overflow-y: auto; pointer-events: auto; background: var(--bg);
659
+ border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 12px; display: grid; gap: 10px; }
660
+ :is(.composer, .composer-out) header { display: flex; align-items: center; gap: 8px; min-width: 0; }
661
+ :is(.composer, .composer-out) header code { font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
662
+ .seg { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; background: rgba(255,255,255,.05); padding: 3px; border-radius: 10px; }
663
+ .seg button { height: 30px; border-radius: 8px; display: flex; align-items: center; justify-content: center; gap: 6px; color: var(--muted); font-weight: 600; }
664
+ .seg button i { width: 8px; height: 8px; border-radius: 50%; }
665
+ .seg button[aria-pressed="true"] { background: var(--bg2); color: #fff; box-shadow: inset 0 0 0 1px var(--line); }
666
+ textarea { width: 100%; min-height: 72px; max-height: 240px; resize: none; overflow-y: auto; background: rgba(0,0,0,.25); border: 1px solid var(--line); border-radius: 8px;
667
+ color: var(--text); font: inherit; padding: 8px 10px; outline: none; }
668
+ textarea:focus { border-color: rgba(255,255,255,.35); }
669
+ textarea::placeholder { color: #71717a; }
670
+ .chips { display: flex; flex-wrap: wrap; gap: 4px; }
671
+ .chip { font-size: 11px; padding: 3px 8px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); }
672
+ .chip[aria-pressed="true"] { background: #fafafa; color: #18181b; border-color: #fafafa; }
673
+ .actions { display: flex; align-items: center; gap: 6px; }
674
+ .actions .grow { flex: 1; }
675
+ .btn { height: 30px; padding: 0 12px; border-radius: 8px; font-weight: 600; background: rgba(255,255,255,.08); }
676
+
677
+ .btn.primary { background: #fafafa; color: #18181b; }
678
+
679
+ .btn.icon { padding: 0; width: 30px; display: grid; place-items: center; color: var(--muted); }
680
+
681
+ .hint { font-size: 11px; color: #71717a; }
682
+ .resolution { font-size: 12px; background: rgba(34,197,94,.1); border: 1px solid rgba(34,197,94,.3); border-radius: 8px; padding: 8px 10px; color: #bbf7d0; }
683
+
684
+ .panel { position: fixed; top: 12px; right: 12px; bottom: 12px; width: 340px; max-width: calc(100vw - 24px); pointer-events: auto; background: var(--bg); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow);
685
+ display: flex; flex-direction: column; overflow: hidden; }
686
+ .panel[hidden] { display: none; }
687
+ .panel > header { padding: 14px 14px 10px; display: flex; align-items: center; gap: 8px; }
688
+ .panel h2 { font-size: 14px; margin: 0; flex: 1; }
689
+ .tabs { display: flex; gap: 4px; padding: 0 14px 10px; border-bottom: 1px solid var(--line); }
690
+ .tabs button { font-size: 12px; padding: 4px 10px; border-radius: 999px; color: var(--muted); }
691
+ .tabs button[aria-pressed="true"] { background: rgba(255,255,255,.1); color: #fff; }
692
+ .items { flex: 1; overflow: auto; padding: 8px; display: grid; align-content: start; gap: 6px; }
693
+ .item { cursor: pointer; text-align: left; display: grid; grid-template-columns: 22px 1fr; gap: 10px; padding: 10px; border-radius: 10px; background: rgba(255,255,255,.03); border: 1px solid transparent; }
694
+
695
+ .item .pin { position: static; margin: 0; box-shadow: none; }
696
+
697
+ .item > span { min-width: 0; }
698
+ .item b { font-weight: 600; display: block; margin-bottom: 2px; }
699
+ .item code { font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--muted); display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
700
+ .item p { margin: 4px 0 0; color: #d4d4d8; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
701
+ .cmp-btn { align-self: center; flex: none; }
702
+ .sbs { display: flex; gap: 16px; align-items: flex-start; justify-content: center; max-width: 100%; max-height: calc(100vh - 140px); overflow: auto; }
703
+ .sbs-pane { margin: 0; display: grid; gap: 6px; flex: 1 1 0; min-width: 0; }
704
+ .sbs-pane img { max-width: 100%; max-height: calc(100vh - 180px); object-fit: contain; border-radius: 8px; background: #fff; }
705
+ .sbs-pane figcaption { color: #e4e4e7; font-size: 12px; text-align: center; }
706
+ .item .res { color: #86efac; font-size: 12px; margin-top: 6px; }
707
+ .item .tagline { font-size: 11px; color: var(--muted); margin-top: 4px; }
708
+ .width-tag { margin-left: 6px; font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 6px; background: rgba(255,255,255,.1); color: var(--muted); }
709
+ .elsewhere-toggle { display: flex; gap: 8px; align-items: center; padding: 8px 14px; font-size: 12px; color: var(--muted); border-bottom: 1px solid var(--line); cursor: pointer; }
710
+ .empty { color: var(--muted); padding: 24px 12px; text-align: center; line-height: 1.6; }
711
+ .empty kbd, .hint kbd { font: 11px ui-monospace, monospace; padding: 1px 5px; border: 1px solid var(--line); border-radius: 4px; color: #d4d4d8; }
712
+ .group { display: grid; gap: 6px; }
713
+ .group + .group { margin-top: 10px; }
714
+ .group h3 { margin: 4px 4px 2px; font-size: 11px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: #71717a; display: flex; gap: 6px; align-items: center; }
715
+ .group h3 .n { font-weight: 600; color: #a1a1aa; background: rgba(255,255,255,.08); border-radius: 6px; padding: 0 5px; }
716
+ .item .ink-dot { width: 10px; height: 10px; border-radius: 50%; margin: 5px 6px 0; }
717
+ .item { grid-template-columns: 22px 1fr; }
718
+ .report { margin: 10px 12px 2px; padding: 12px; border-radius: 12px; background: rgba(34,197,94,.08); border: 1px solid rgba(34,197,94,.25); display: grid; gap: 10px; }
719
+ .report h3 { margin: 0; font-size: 13px; }
720
+ .report .stats { display: flex; flex-wrap: wrap; gap: 6px; margin-top: -2px; }
721
+ .report .stat { font-size: 12px; color: #d4d4d8; padding: 3px 9px 3px 8px; border-radius: 999px; background: rgba(34,197,94,.12); }
722
+ .report .stat b { font-size: 12px; letter-spacing: 0; text-transform: none; color: #86efac; font-variant-numeric: tabular-nums; }
723
+ .report .stat.bad { background: rgba(244,63,94,.14); } .report .stat.bad b { color: #fda4af; }
724
+ .report p { margin: 2px 0 0; color: #d4d4d8; font-size: 12.5px; line-height: 1.5; }
725
+ .report b { font-size: 11px; letter-spacing: .05em; text-transform: uppercase; color: #86efac; }
726
+ .report textarea { min-height: 34px; margin-top: 6px; font-size: 12.5px; }
727
+ .report .questions label { display: grid; gap: 4px; margin-top: 6px; font-size: 12.5px; color: #e4e4e7; }
728
+ .report .btn { justify-self: start; }
729
+ .panel footer { border-top: 1px solid var(--line); padding: 12px 14px; display: grid; gap: 8px; }
730
+ .panel footer .row { display: flex; gap: 6px; }
731
+ .panel footer .btn { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
732
+
733
+ .toast { position: fixed; left: 50%; bottom: 76px; transform: translate(-50%, 8px) scale(.98); opacity: 0; pointer-events: none;
734
+ background: var(--bg); color: var(--text); border: 1px solid var(--line);
735
+ font-weight: 500; padding: 9px 14px 9px 12px; border-radius: 12px; box-shadow: var(--shadow); max-width: calc(100vw - 32px);
736
+ display: flex; align-items: center; gap: 9px; transition: opacity 160ms ease, transform 160ms var(--ease-out); }
737
+ .toast.show { opacity: 1; transform: translate(-50%, 0) scale(1); pointer-events: auto; transition-duration: 240ms; }
738
+ .toast-action { margin-left: 4px; font-weight: 600; color: #93c5fd; padding: 2px 6px; border-radius: 6px; }
739
+ :is(.composer, .composer-out) .what { flex: 1; min-width: 0; display: grid; gap: 2px; }
740
+ :is(.composer, .composer-out) .what b { font-size: 12px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
741
+ :is(.composer, .composer-out) .what code { font-size: 11px; }
742
+
743
+ /* shot mode (headless screenshots) and inline text editing */
744
+ :host([data-shot]) .bar, :host([data-shot]) .fab, :host([data-shot]) .toast, :host([data-shot]) .panel, :host([data-shot]) .hover { display: none !important; }
745
+ :host([data-text-edit]) .pick, :host([data-text-edit]) .ink { pointer-events: none !important; }
746
+
747
+ /* Preview: the page exactly as visitors see it. Everything Redline draws is gone except a
748
+ small tab in the corner, which shows when you enter and when the pointer comes near it. */
749
+ :host([data-preview]) :not(.preview-tab, .preview-tab *) { display: none !important; }
750
+ .preview-tab { position: fixed; right: 14px; bottom: 14px; display: none; align-items: center; gap: 8px; padding: 6px 7px 6px 12px;
751
+ border-radius: 999px; background: var(--bg); color: var(--muted); font-size: 12px; font-weight: 500; border: 1px solid var(--line);
752
+ box-shadow: 0 10px 28px -10px rgba(0,0,0,.5); cursor: pointer; pointer-events: none; opacity: 0; transform: translateY(6px);
753
+ transition: opacity 160ms var(--ease-out), transform 200ms var(--ease-out); }
754
+ :host([data-preview]) .preview-tab { display: flex; }
755
+ .preview-tab.near, .preview-tab.announce, .preview-tab:focus-visible { opacity: 1; transform: none; pointer-events: auto; }
756
+ .preview-tab b { color: var(--text); font-weight: 600; }
757
+ .preview-tab kbd { font: 600 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text); background: var(--bg2);
758
+ border-radius: 6px; padding: 4px 7px; }
759
+
760
+ /* composer sections */
761
+ :is(.composer, .composer-out).wide { width: 360px; }
762
+ .more-section { display: grid; gap: 10px; }
763
+ .more-section[hidden] { display: none; }
764
+ .more-actions { display: flex; gap: 6px; }
765
+ .btn.danger { color: #fda4af; }
766
+ .ink-chip { display: inline-flex; align-items: center; gap: 7px; height: 28px; padding: 0 10px 0 8px; border-radius: 999px; background: rgba(255,255,255,.07); font-size: 12px; font-weight: 600; color: #e4e4e7; transition: background-color 140ms ease, transform 140ms var(--ease-out); }
767
+ .ink-chip:active { transform: scale(.97); }
768
+ .ink-chip i { width: 10px; height: 10px; border-radius: 50%; }
769
+ @media (hover: hover) and (pointer: fine) { .ink-chip:hover { background: rgba(255,255,255,.12); } }
770
+ .more-btn[aria-expanded="true"] { color: #fafafa; background: rgba(255,255,255,.1); }
771
+ :is(.composer, .composer-out) textarea { min-height: 44px; }
772
+ :is(.composer, .composer-out).compact { width: 260px; }
773
+ :is(.composer, .composer-out).compact textarea { min-height: 44px; }
774
+ :is(.composer, .composer-out).compact .chips, :is(.composer, .composer-out).compact .tools-row, :is(.composer, .composer-out).compact .adjust-slot, :is(.composer, .composer-out).compact .section-label, :is(.composer, .composer-out).compact .options, :is(.composer, .composer-out).compact .thumbs, :is(.composer, .composer-out).compact .hint { display: none; }
775
+ :is(.composer, .composer-out).dropping { outline: 2px dashed #60a5fa; outline-offset: -4px; }
776
+ .tools-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
777
+ .section-label { font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #71717a; margin-bottom: -4px; }
778
+ .btn.small { height: 26px; padding: 0 9px; font-size: 12px; display: inline-flex; align-items: center; gap: 5px; }
779
+ .btn.small svg { width: 14px; height: 14px; }
780
+ .btn[disabled] { opacity: .4; cursor: not-allowed; }
781
+ .adj-count { font-size: 10px; font-weight: 700; background: #3b82f6; color: #fff; border-radius: 8px; padding: 0 5px; }
782
+ .adj-count:empty { display: none; }
783
+ .adjust { display: grid; gap: 8px; }
784
+ .adj-group { border: 1px solid var(--line); border-radius: 8px; padding: 4px 8px; }
785
+ .adj-group[open] { padding-bottom: 8px; }
786
+ .adj-group > summary { font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #a1a1aa; cursor: pointer; padding: 4px 0; list-style-position: inside; }
787
+ .adj-group > .adj-row + .adj-row { margin-top: 4px; }
788
+ .adj-input { display: inline-flex; align-items: center; gap: 3px; }
789
+ .adj-unit { font: 11px ui-monospace, monospace; color: #71717a; }
790
+ .adj-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 26px; }
791
+ .adj-name, .adj-label { font-size: 12px; color: #d4d4d8; text-transform: capitalize; white-space: nowrap; }
792
+ .adj-label { cursor: ew-resize; user-select: none; }
793
+ .adj-label:hover { color: #fff; text-decoration: underline dotted; }
794
+ .adj-field { display: flex; align-items: center; justify-content: space-between; gap: 6px; flex: 1; }
795
+ .adj-field.side { flex: 0 0 auto; gap: 3px; }
796
+ .adj-field.side .adj-label { font-size: 10px; color: var(--muted); }
797
+ .adj-sides { display: flex; gap: 6px; }
798
+ .adj-num, .adj-hex, .adj-select { background: rgba(0,0,0,.3); border: 1px solid var(--line); border-radius: 6px; color: var(--text); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; height: 24px; padding: 0 6px; }
799
+ .adj-num { width: 58px; text-align: right; }
800
+ .adj-field.side .adj-num { width: 40px; padding: 0 4px; }
801
+ .adj-hex { width: 84px; }
802
+ .adj-select { width: 110px; }
803
+ .adj-colorwrap { display: flex; gap: 4px; align-items: center; }
804
+ .adj-color { width: 24px; height: 24px; padding: 0; border: 1px solid var(--line); border-radius: 6px; background: none; cursor: pointer; }
805
+ .adj-foot { display: flex; gap: 6px; align-items: center; }
806
+ .adj-scope { display: flex; gap: 6px; align-items: center; font-size: 12px; color: #d4d4d8; flex-wrap: wrap; }
807
+ .adj-scope code { font: 11px ui-monospace, monospace; color: #93c5fd; }
808
+
809
+ /* references and screenshots */
810
+ .refs { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
811
+ .ref { position: relative; }
812
+ .ref img { width: 40px; height: 30px; object-fit: cover; border-radius: 5px; border: 1px solid var(--line); cursor: zoom-in; display: block; }
813
+ .ref-x { position: absolute; top: -6px; right: -6px; width: 16px; height: 16px; border-radius: 50%; background: #3f3f46; font-size: 11px; line-height: 16px; }
814
+ .thumbs { display: flex; gap: 6px; align-items: flex-end; overflow-x: auto; padding-bottom: 2px; }
815
+ .thumb { display: grid; gap: 3px; flex: none; }
816
+ .thumb img { width: 84px; height: 56px; object-fit: contain; border-radius: 6px; border: 1px solid var(--line); background: #111; }
817
+ .thumb span { font-size: 10px; color: var(--muted); max-width: 84px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
818
+ .item .thumbs { margin-top: 8px; }
819
+ .gallery { position: fixed; inset: 0; pointer-events: auto; background: rgba(9,9,11,.88); display: grid; place-items: center; grid-template-rows: 1fr auto; padding: 48px 24px 24px; gap: 12px; z-index: 2; }
820
+ .gal-img, .cmp { max-width: min(1200px, 100%); max-height: calc(100vh - 140px); border-radius: 8px; box-shadow: var(--shadow); }
821
+ .gal-img { object-fit: contain; background: #fff; }
822
+ .gal-cap { color: #e4e4e7; font-size: 13px; }
823
+ .gal-close { position: absolute; top: 12px; right: 12px; width: 36px; height: 36px; border-radius: 50%; background: rgba(255,255,255,.1); display: grid; place-items: center; }
824
+ .gal-nav { position: absolute; top: 50%; width: 44px; height: 64px; font-size: 32px; color: #fff; background: rgba(255,255,255,.08); border-radius: 10px; }
825
+ .gal-nav.prev { left: 12px; } .gal-nav.next { right: 12px; }
826
+ .cmp { width: min(1100px, 92vw); position: relative; overflow: hidden; cursor: ew-resize; touch-action: none; user-select: none; background: #fff; }
827
+ .cmp img { display: block; width: 100%; height: auto; pointer-events: none; }
828
+ .cmp-top { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; object-position: top left; }
829
+ .cmp-line { position: absolute; top: 0; bottom: 0; width: 2px; margin-left: -1px; background: #3b82f6; box-shadow: 0 0 0 1px rgba(255,255,255,.6); }
830
+
831
+ /* options */
832
+ .options { display: grid; gap: 6px; }
833
+ .opt-row { display: flex; flex-wrap: wrap; gap: 4px; }
834
+ .opt { font-size: 12px; padding: 4px 9px; border-radius: 8px; border: 1px solid var(--line); color: #d4d4d8; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
835
+ .opt[aria-pressed="true"] { background: #fafafa; color: #18181b; border-color: #fafafa; }
836
+ .opt.chosen { box-shadow: 0 0 0 2px var(--good); }
837
+ .opt-note { font-size: 12px; color: var(--muted); }
838
+ .opt-wait { font-size: 12px; color: #fcd34d; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
839
+ .btn.small.primary { background: #fafafa; color: #18181b; }
840
+ .item .badge { font-size: 10px; font-weight: 700; border-radius: 6px; padding: 1px 6px; margin-left: 6px; background: rgba(59,130,246,.2); color: #93c5fd; }
841
+
842
+ /* devices stage */
843
+ .stage { position: fixed; inset: 0; pointer-events: auto; background: #18181b; display: grid; grid-template-rows: auto 1fr; }
844
+ .stage[hidden] { display: none; }
845
+ .stage-head { display: flex; align-items: center; gap: 12px; padding: 10px 16px; color: #e4e4e7; font-size: 13px; border-bottom: 1px solid var(--line); }
846
+ .stage-head .grow { flex: 1; }
847
+ .stage-head label { display: flex; gap: 6px; align-items: center; color: var(--muted); }
848
+ .stage-frames { display: flex; gap: 24px; justify-content: center; align-items: flex-start; padding: 16px 16px 80px; overflow: auto; }
849
+ .device { display: grid; gap: 6px; justify-items: start; flex: none; }
850
+ .device-label { font-size: 12px; color: var(--muted); }
851
+ .device-box { overflow: hidden; border-radius: 10px; box-shadow: 0 0 0 1px rgba(255,255,255,.12), var(--shadow); background: #fff; }
852
+ .device-box iframe { border: 0; display: block; transform-origin: 0 0; background: #fff; }
853
+ :host([data-frame]) .bar, :host([data-frame]) .fab { display: none !important; }
854
+ :is(.composer, .composer-out), .toast { zoom: var(--ui-zoom, 1); }
855
+ .pins .pin { scale: var(--pin-scale, 1); }
856
+ .stage-head .sep-v { width: 1px; height: 18px; background: var(--line); }
857
+ :host([data-devices]) .pick, :host([data-devices]) .ink, :host([data-devices]) .marks, :host([data-devices]) .pins { display: none; }
858
+
859
+ /* ------------------------------------------------ motion & feel */
860
+ /* Press feedback: everything you can press gives a little under your finger. */
861
+ .tool, .btn, .swatch, .chip, .opt, .seg button, .item, .thumb, .fab, .pin, .gal-nav, .gal-close, .toast-action {
862
+ transition: transform 140ms var(--ease-out), background-color 140ms ease, color 140ms ease, box-shadow 140ms ease, opacity 140ms ease; }
863
+ .tool:active, .btn:active, .chip:active, .opt:active, .seg button:active, .thumb:active, .toast-action:active { transform: scale(.97); }
864
+ .swatch:active, .fab:active, .pin:active { transform: scale(.92); }
865
+ .item:active { transform: scale(.99); }
866
+ .mode-pill.animate { transition: transform 200ms var(--ease-out), width 200ms var(--ease-out); }
867
+ .swatch i { transition: box-shadow 160ms var(--ease-out), transform 160ms var(--ease-out); }
868
+ .swatch[aria-pressed="true"] i { transform: scale(1.08); }
869
+ /* Hover only where there is a real pointer (touch would stick). */
870
+ @media (hover: hover) and (pointer: fine) {
871
+ .tool:hover { background: rgba(255,255,255,.08); color: #fff; }
872
+ .modes .tool[aria-pressed="true"]:hover { background: transparent; color: #18181b; }
873
+ .btn:hover { background: rgba(255,255,255,.14); }
874
+ .btn.primary:hover { background: #fff; }
875
+ .btn.icon:hover { color: var(--fix); }
876
+ .item:hover { background: rgba(255,255,255,.07); border-color: var(--line); }
877
+ .pins .pin:hover { box-shadow: 0 4px 14px rgba(0,0,0,.4), 0 0 0 2px #fff; transform: scale(1.12); }
878
+ .toast-action:hover { background: rgba(147,197,253,.12); }
879
+ .thumb:hover img { border-color: rgba(255,255,255,.35); }
880
+ }
881
+
882
+ /* Surfaces enter from where they come from, quickly; leave faster. */
883
+ :is(.composer, .composer-out) { transition: opacity 180ms ease, transform 200ms var(--ease-out); transform-origin: var(--origin, top left); }
884
+ @starting-style { .composer { opacity: 0; transform: scale(.96); } }
885
+ .composer-out { opacity: 0; transform: scale(.98); transition-duration: 120ms; pointer-events: none; }
886
+ .panel:not([hidden]) { transition: transform 260ms var(--ease-drawer), opacity 200ms ease; }
887
+ @starting-style { .panel:not([hidden]) { transform: translateX(20px); opacity: 0; } }
888
+ .panel.opening .item { animation: item-in 260ms var(--ease-out) backwards; animation-delay: calc(var(--i, 0) * 28ms); }
889
+ @keyframes item-in { from { opacity: 0; transform: translateY(6px); } }
890
+ .gallery { transition: opacity 180ms ease; }
891
+ @starting-style { .gallery { opacity: 0; } }
892
+ .gallery .gal-img, .gallery .cmp, .gallery .sbs { transition: transform 220ms var(--ease-out); }
893
+ @starting-style { .gallery .gal-img, .gallery .cmp, .gallery .sbs { transform: scale(.97); } }
894
+ .bar.intro { animation: bar-in 420ms var(--ease-out) backwards; }
895
+ @keyframes bar-in { from { opacity: 0; transform: translate(-50%, 14px); } }
896
+ .hover span { box-shadow: 0 2px 8px rgba(0,0,0,.25); border-radius: 5px; letter-spacing: .01em; }
897
+
898
+ /* A new pin lands; a fixed one rings green once. Rare moments, so they may play. */
899
+ /* Mark chip: the receipt after a stroke. Small, near the ink, never in the way. */
900
+ .mark-chip { position: fixed; pointer-events: auto; display: flex; align-items: center; gap: 6px; padding: 4px 4px 4px 6px;
901
+ background: var(--bg); border: 1px solid var(--line); border-radius: 999px; box-shadow: var(--shadow); font-size: 12px; transform-origin: left bottom;
902
+ transition: opacity 160ms ease, transform 180ms var(--ease-out); }
903
+ @starting-style { .mark-chip { opacity: 0; transform: scale(.9) translateY(4px); } }
904
+ .mark-chip.out { opacity: 0; transform: scale(.96); transition-duration: 120ms; pointer-events: none; }
905
+ .chip-dot { width: 16px; height: 16px; border-radius: 50%; box-shadow: inset 0 0 0 2px rgba(255,255,255,.3); transition: transform 140ms var(--ease-out), background-color 160ms ease; }
906
+ .chip-dot:active { transform: scale(.85); }
907
+ .chip-label { color: #d4d4d8; padding: 0 4px 0 2px; white-space: nowrap; }
908
+ .chip-btn { height: 24px; padding: 0 10px; border-radius: 999px; background: rgba(255,255,255,.1); font-weight: 600; font-size: 12px; transition: transform 140ms var(--ease-out), background-color 140ms ease; }
909
+ .chip-btn:active { transform: scale(.95); }
910
+ .chip-btn.icon { width: 24px; padding: 0; display: grid; place-items: center; color: var(--muted); }
911
+ .chip-btn.icon svg { width: 12px; height: 12px; }
912
+ @media (hover: hover) and (pointer: fine) { .chip-btn:hover { background: rgba(255,255,255,.18); } .chip-btn.icon:hover { color: var(--fix); } }
913
+ .pins .pin.pop { animation: pin-pop 240ms var(--ease-pop) backwards; }
914
+ @keyframes pin-pop { from { opacity: 0; transform: scale(.55); } }
915
+ .pins .pin.just-resolved { animation: pin-done 900ms var(--ease-out); }
916
+ @keyframes pin-done { 0% { box-shadow: 0 0 0 2px #fff, 0 0 0 0 rgba(34,197,94,.7); } 100% { box-shadow: 0 0 0 2px #fff, 0 0 0 16px rgba(34,197,94,0); } }
917
+ .marks g.pop path { animation: ink-in 180ms ease-out; }
918
+ @keyframes ink-in { from { opacity: 0; } }
919
+
920
+ /* Toast icons */
921
+ .toast-icon { width: 18px; height: 18px; flex: none; display: grid; place-items: center; border-radius: 50%; }
922
+ .toast-icon.ok { background: rgba(34,197,94,.18); color: var(--good); }
923
+ .toast-icon.info { background: rgba(147,197,253,.15); color: #93c5fd; }
924
+ .toast-icon.warn { background: rgba(244,63,94,.18); color: var(--fix); }
925
+ .toast-icon svg { width: 12px; height: 12px; stroke-width: 3; }
926
+ .toast-icon.draw path { stroke-dasharray: 24; animation: check-draw 280ms var(--ease-out) backwards; }
927
+ @keyframes check-draw { from { stroke-dashoffset: 24; } }
928
+
929
+ /* First-run coachmark */
930
+ .demo-intro { position: fixed; left: 50%; bottom: 84px; width: min(440px, calc(100vw - 32px)); transform: translateX(-50%); pointer-events: auto;
931
+ background: #18181b; color: var(--text); border: 1px solid var(--line); border-radius: 14px; box-shadow: 0 16px 40px -12px rgba(0,0,0,.55);
932
+ padding: 16px 18px; display: grid; gap: 8px; transition: opacity 160ms ease, translate 160ms var(--ease-out); }
933
+ @starting-style { .demo-intro { opacity: 0; translate: 0 8px; } }
934
+ .demo-intro.out { opacity: 0; translate: 0 6px; }
935
+ .demo-intro .demo-tag { justify-self: start; font-size: 11px; font-weight: 600; color: #a1a1aa; border: 1px solid var(--line); border-radius: 999px; padding: 1px 8px; }
936
+ .demo-intro h2 { margin: 2px 0 0; font-size: 16px; font-weight: 600; letter-spacing: -.01em; }
937
+ .demo-intro p { margin: 0; font-size: 13px; line-height: 1.5; color: #a1a1aa; }
938
+ .demo-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; }
939
+ .demo-actions .btn { height: 32px; padding: 0 12px; }
940
+ .coach { position: fixed; left: 50%; bottom: 78px; transform: translateX(-50%); pointer-events: auto; background: #fafafa; color: #18181b;
941
+ border-radius: 14px; box-shadow: var(--shadow); padding: 12px 14px; display: flex; gap: 14px; align-items: center; max-width: calc(100vw - 32px);
942
+ animation: coach-in 420ms 500ms var(--ease-out) backwards; }
943
+ @keyframes coach-in { from { opacity: 0; transform: translate(-50%, 10px); } }
944
+ .coach b { font-weight: 650; }
945
+ .coach kbd { font: 600 11px ui-monospace, monospace; padding: 2px 6px; border-radius: 5px; background: #f4f4f5; border: 1px solid #e4e4e7; }
946
+ .coach .step { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
947
+ .coach .dismiss { font-weight: 600; color: #52525b; padding: 4px 8px; border-radius: 7px; }
948
+ .coach .dismiss:hover { background: #f4f4f5; }
949
+ .coach::after { content: ''; position: absolute; left: 50%; bottom: -6px; width: 12px; height: 12px; background: #fafafa; transform: translateX(-50%) rotate(45deg); border-radius: 2px; }
950
+
951
+ /* Reduced motion: keep gentle fades, drop movement. */
952
+ @media (prefers-reduced-motion: reduce) {
953
+ *, *::before, *::after { animation-duration: 1ms !important; animation-delay: 0ms !important; transition-property: opacity, background-color, color, border-color !important; }
954
+ @starting-style { :is(.composer, .composer-out), .panel:not([hidden]), .gallery .gal-img { transform: none; } }
955
+ }
956
+ `;
957
+ // A constructed stylesheet isn't subject to the page's CSP; <style> is the fallback for old browsers.
958
+ function addStyles(css) {
959
+ try {
960
+ const sheet = new CSSStyleSheet();
961
+ sheet.replaceSync(css);
962
+ root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
963
+ } catch {
964
+ const sheet = document.createElement('style');
965
+ sheet.textContent = css;
966
+ root.prepend(sheet);
967
+ }
968
+ }
969
+ addStyles(CSS_TEXT);
970
+
971
+ const marks = s('svg', { class: 'marks' });
972
+ const ink = s('svg', { class: 'ink' });
973
+ const marksHit = s('svg', { class: 'marks-hit' });
974
+ const pick = h('div', { class: 'pick' });
975
+ const pins = h('div', { class: 'pins' });
976
+ const hoverBox = h('div', { class: 'hover' }, h('span'));
977
+ const focusBox = h('div', { class: 'focus' });
978
+ const bar = h('div', { class: 'bar', role: 'toolbar', 'aria-label': 'Redline design feedback' });
979
+ const fab = h('button', { class: 'fab', title: 'Open Redline (design feedback)', 'aria-label': 'Open Redline', html: ICONS.mark });
980
+ const panel = h('aside', { class: 'panel', hidden: true, 'aria-label': 'Feedback notes' });
981
+ const toastEl = h('div', { class: 'toast', role: 'status' });
982
+ const previewTab = h('button', { class: 'preview-tab', type: 'button', 'aria-label': 'Show Redline again', title: 'Show Redline (\\)' },
983
+ h('b', {}, 'Redline hidden'), h('kbd', {}, '\\'), 'to show');
984
+ const layer = h('div', { class: 'layer' });
985
+ layer.append(marks, pick, ink, marksHit, pins, hoverBox, focusBox); // marks stay clickable in browse and pen mode
986
+ root.append(layer, bar, fab, panel, toastEl, previewTab);
987
+
988
+ // ---------------------------------------------------------------- event isolation
989
+
990
+ // Keep the page's scripts from reacting to what happens inside the overlay:
991
+ // focus traps in the site's own modals would pull focus out of the note field,
992
+ // and global hotkeys would fire while typing (events from the shadow tree reach
993
+ // the page retargeted to <redline-overlay>, which doesn't look like an input).
994
+ // Focus and mouse events are stopped at window capture, before any page
995
+ // listener; the overlay itself only uses pointer and click events.
996
+ for (const type of ['focusin', 'focusout', 'focus', 'blur', 'mousedown', 'mouseup', 'touchstart', 'touchend']) {
997
+ addEventListener(type, (e) => eventIsOurs(e) && e.stopImmediatePropagation(), true);
998
+ }
999
+ // Everything else is stopped as it bubbles out of the overlay, after our own handlers ran.
1000
+ for (const type of ['keydown', 'keyup', 'keypress', 'beforeinput', 'input', 'change', 'click', 'dblclick', 'pointerdown', 'pointerup', 'pointermove', 'wheel', 'contextmenu', 'paste', 'copy', 'cut', 'compositionstart', 'compositionend', 'submit']) {
1001
+ host.addEventListener(type, (e) => e.stopPropagation());
1002
+ }
1003
+
1004
+ // ---------------------------------------------------------------- toast
1005
+
1006
+ let toastTimer;
1007
+ const TOAST_ICONS = {
1008
+ ok: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>',
1009
+ warn: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round"><path d="M12 7v6M12 17h.01"/></svg>',
1010
+ info: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round"><path d="M12 11v6M12 7h.01"/></svg>',
1011
+ };
1012
+ // Tone from the wording, so every call site stays a one-liner.
1013
+ function toneOf(msg) {
1014
+ if (/^(Could not|Redline server)|failed|blocked/i.test(msg)) return 'warn';
1015
+ if (/resolved|saved|Picked|Copied|All clear|Deleted|now points|is back|Sent round|Accepted/i.test(msg)) return 'ok';
1016
+ return 'info';
1017
+ }
1018
+ function toast(msg, action, { celebrate = false } = {}) {
1019
+ const tone = toneOf(msg);
1020
+ toastEl.replaceChildren(h('span', { class: `toast-icon ${tone}${celebrate || tone === 'ok' ? ' draw' : ''}`, html: TOAST_ICONS[tone] }), h('span', {}, msg));
1021
+ if (action) {
1022
+ toastEl.append(h('button', { class: 'toast-action', onclick: () => (toastEl.classList.remove('show'), action.run()) }, action.label));
1023
+ }
1024
+ toastEl.classList.add('show');
1025
+ clearTimeout(toastTimer);
1026
+ toastTimer = setTimeout(() => toastEl.classList.remove('show'), action ? 6000 : 2200);
1027
+ }
1028
+
1029
+ // ---------------------------------------------------------------- toolbar
1030
+
1031
+ // Ink colours carry the meaning: red = change, green = keep, amber = idea.
1032
+ const INKS = [
1033
+ { v: 'fix', name: 'Change', key: '1' },
1034
+ { v: 'good', name: 'Keep', key: '2' },
1035
+ { v: 'idea', name: 'Idea', key: '3' },
1036
+ ];
1037
+ const inkOf = (v) => INKS.find((i) => i.v === v) || INKS[0];
1038
+
1039
+ function renderBar() {
1040
+ bar.hidden = state.collapsed;
1041
+ fab.hidden = !state.collapsed;
1042
+ host.dataset.mode = state.mode;
1043
+ layer.classList.toggle('hidden-marks', state.hidden);
1044
+ const tool = (mode, key, text, icon) =>
1045
+ h('button', { class: 'tool icon-only', 'aria-pressed': String(state.mode === mode), title: `${text} (${key})`, 'aria-label': text, onclick: () => setMode(mode), html: ICONS[icon] });
1046
+ // The toolbar holds the next step and nothing else: use the site, mark it, and
1047
+ // whichever turn applies (Send, With agent, Check). Everything else is behind ⋯.
1048
+ const pen = tool('pen', 'P', 'Pen: draw, or click something to say why', 'pen');
1049
+ const review = state.reviewOnly;
1050
+ // The ink shows on the pen only when it isn't the default red (1 · 2 · 3 change it).
1051
+ if (state.verdict !== 'fix') pen.append(h('i', { class: 'pen-ink', style: `background:${COLORS[state.verdict]}` }));
1052
+ fill(bar,
1053
+ h('button', { class: 'tool grip', title: 'Redline · drag to move', 'aria-label': 'Move toolbar', html: ICONS.mark, onpointerdown: startDrag }),
1054
+ // Opened from a shared Check link: no tools, only the turn (someone else marks the page).
1055
+ review ? null : h('div', { class: 'modes', role: 'group', 'aria-label': 'Tool' }, modePill, tool('browse', 'V', 'Pointer', 'browse'), pen,
1056
+ // Box (B) is for the rare area that isn't an element: it shows up only while in use.
1057
+ state.mode === 'select' ? tool('select', 'B', 'Box an area', 'box') : null),
1058
+ turnPill(),
1059
+ review && !turnPill().some(Boolean) ? h('span', { class: 'review-idle' }, 'Nothing to check') : null,
1060
+ h('button', {
1061
+ class: 'tool icon-only',
1062
+ title: 'More',
1063
+ 'aria-label': 'More',
1064
+ 'aria-haspopup': 'menu',
1065
+ html: ICONS.more,
1066
+ onclick: (e) => openMenu(e.currentTarget, [
1067
+ { label: 'Notes', key: 'L', icon: ICONS.list, checked: state.panel, run: togglePanel },
1068
+ review ? null : { label: 'Box an area', key: 'B', icon: ICONS.box, checked: state.mode === 'select', run: () => setMode(state.mode === 'select' ? 'pen' : 'select') },
1069
+ review ? null : { label: 'Devices', key: 'D', icon: ICONS.devices, checked: state.devices, run: toggleDevices },
1070
+ review ? null : { label: 'Copy for AI', key: 'C', icon: ICONS.copy, run: copyPrompt },
1071
+ // Proof that leaves the tool: the round as one page, and a link that opens straight into Check.
1072
+ SERVER && (state.rounds || []).length ? { label: 'Round report', icon: ICONS.report, run: () => window.open(withKey(SERVER + '/__redline/api/report'), '_blank', 'noopener') } : null,
1073
+ SERVER && !review ? { label: 'Copy review link', icon: ICONS.link, run: copyCheckLink } : null,
1074
+ hasPreviews() ? { label: state.previewEdits ? 'Show the page as built' : 'Show my edits', key: 'E', icon: ICONS.split, run: togglePreviews } : null,
1075
+ { label: 'Preview the page', key: '\\', icon: ICONS.eye, run: () => setPreviewMode(true) },
1076
+ { label: state.hidden ? 'Show marks' : 'Hide marks', key: 'H', icon: state.hidden ? ICONS.eye : ICONS.eyeOff, run: toggleHidden },
1077
+ { label: 'Minimize', icon: ICONS.minimize, run: () => setCollapsed(true) },
1078
+ { note: SERVER ? (state.connected ? 'Synced with your project' : 'Redline server offline') : 'Saved in this browser only', ok: !SERVER || state.connected },
1079
+ ]),
1080
+ }),
1081
+ );
1082
+ const pos = prefs('barPos');
1083
+ if (pos) Object.assign(bar.style, { left: pos.x + 'px', top: pos.y + 'px', bottom: 'auto', transform: 'none' });
1084
+ placeModePill();
1085
+ host.style.setProperty('--pen-cursor', penCursor(COLORS[state.verdict]));
1086
+ }
1087
+
1088
+ // A small menu that grows out of its button; closes on choice, Esc or a click elsewhere.
1089
+ let menuEl = null;
1090
+ function closeMenu() {
1091
+ if (menuEl) menuEl.remove();
1092
+ menuEl = null;
1093
+ }
1094
+ function openMenu(anchor, items) {
1095
+ const again = menuEl && menuEl.anchor === anchor;
1096
+ closeMenu();
1097
+ if (again) return;
1098
+ const el = h('div', { class: 'menu', role: 'menu' },
1099
+ items.filter(Boolean).map((it) =>
1100
+ it.note
1101
+ ? h('div', { class: 'menu-note' }, h('i', { class: it.ok ? 'ok' : 'off' }), it.note)
1102
+ : h('button', {
1103
+ class: 'menu-item',
1104
+ role: 'menuitem',
1105
+ 'aria-checked': it.checked === undefined ? null : String(!!it.checked),
1106
+ onclick: () => {
1107
+ closeMenu();
1108
+ it.run();
1109
+ },
1110
+ },
1111
+ it.swatch ? h('i', { class: 'menu-swatch', style: `background:${it.swatch}` }) : h('span', { class: 'menu-icon', html: it.icon || '' }),
1112
+ h('span', { class: 'menu-label' }, it.label),
1113
+ it.checked ? h('span', { class: 'menu-check', html: ICONS.check }) : null,
1114
+ it.key ? h('kbd', {}, it.key) : null)),
1115
+ );
1116
+ el.anchor = anchor;
1117
+ root.append(el);
1118
+ const r = anchor.getBoundingClientRect();
1119
+ const w = el.offsetWidth, hh = el.offsetHeight;
1120
+ const above = r.top - hh - 8 > 8;
1121
+ el.style.left = Math.min(Math.max(8, r.left + r.width / 2 - w / 2), innerWidth - w - 8) + 'px';
1122
+ el.style.top = (above ? r.top - hh - 8 : r.bottom + 8) + 'px';
1123
+ el.style.transformOrigin = above ? 'bottom center' : 'top center';
1124
+ menuEl = el;
1125
+ }
1126
+ addEventListener('pointerdown', (e) => menuEl && !e.composedPath().includes(menuEl) && !e.composedPath().includes(menuEl.anchor) && closeMenu(), true);
1127
+
1128
+ // The pill glides to the chosen mode; switching by keyboard (done all day) is instant.
1129
+ const modePill = h('span', { class: 'mode-pill', 'aria-hidden': 'true' });
1130
+ let pillAt = null;
1131
+ function placeModePill() {
1132
+ requestAnimationFrame(() => {
1133
+ const btn = bar.querySelector('.modes .tool[aria-pressed="true"]');
1134
+ if (!btn || !btn.offsetWidth) return;
1135
+ const next = { x: btn.offsetLeft, w: btn.offsetWidth };
1136
+ const glide = pillAt && !state.viaKeyboard && (pillAt.x !== next.x || pillAt.w !== next.w);
1137
+ modePill.classList.toggle('animate', !!glide);
1138
+ modePill.style.transform = `translateX(${next.x}px)`;
1139
+ modePill.style.width = next.w + 'px';
1140
+ pillAt = next;
1141
+ });
1142
+ }
1143
+
1144
+ // Pen cursor: a dot in the colour you're about to draw with.
1145
+ function penCursor(color) {
1146
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"><circle cx="10" cy="10" r="5" fill="${color}" stroke="white" stroke-width="2"/><circle cx="10" cy="10" r="7.5" fill="none" stroke="rgba(0,0,0,.25)"/></svg>`;
1147
+ return `url("data:image/svg+xml,${encodeURIComponent(svg)}") 10 10, crosshair`;
1148
+ }
1149
+
1150
+ function startDrag(e) {
1151
+ e.preventDefault();
1152
+ const r = bar.getBoundingClientRect();
1153
+ const off = { x: e.clientX - r.left, y: e.clientY - r.top };
1154
+ const move = (ev) => {
1155
+ const x = Math.min(Math.max(ev.clientX - off.x, 4), innerWidth - r.width - 4);
1156
+ const y = Math.min(Math.max(ev.clientY - off.y, 4), innerHeight - r.height - 4);
1157
+ Object.assign(bar.style, { left: x + 'px', top: y + 'px', bottom: 'auto', transform: 'none' });
1158
+ };
1159
+ const up = () => {
1160
+ removeEventListener('pointermove', move, true);
1161
+ removeEventListener('pointerup', up, true);
1162
+ const b = bar.getBoundingClientRect();
1163
+ prefs('barPos', { x: b.left, y: b.top });
1164
+ };
1165
+ addEventListener('pointermove', move, true);
1166
+ addEventListener('pointerup', up, true);
1167
+ }
1168
+
1169
+ fab.addEventListener('click', () => setCollapsed(false));
1170
+
1171
+ function setCollapsed(v) {
1172
+ state.collapsed = v;
1173
+ prefs('collapsed', v);
1174
+ if (v) setMode('browse');
1175
+ renderBar();
1176
+ }
1177
+
1178
+ // Preview hides everything Redline draws, so the page looks exactly as it will ship.
1179
+ // The backslash key toggles it; the choice survives the agent's live reloads (this tab only), and a
1180
+ // ?redline=off link opens straight into it without remembering it.
1181
+ let previewTimer;
1182
+ function setPreviewMode(on, { remember = true } = {}) {
1183
+ state.preview = on;
1184
+ if (remember) sessionPref('preview', on ? '1' : null);
1185
+ if (on) {
1186
+ setMode('browse');
1187
+ hoverBox.style.display = 'none';
1188
+ }
1189
+ host.toggleAttribute('data-preview', on);
1190
+ // Say where the way back is, once, then keep the tab out of sight until the pointer comes near.
1191
+ clearTimeout(previewTimer);
1192
+ previewTab.classList.toggle('announce', on);
1193
+ if (on) previewTimer = setTimeout(() => previewTab.classList.remove('announce'), 2600);
1194
+ }
1195
+ previewTab.addEventListener('click', () => setPreviewMode(false));
1196
+ addEventListener(
1197
+ 'pointermove',
1198
+ (e) => {
1199
+ if (!state.preview) return;
1200
+ const near = innerWidth - e.clientX < 220 && innerHeight - e.clientY < 110;
1201
+ if (near !== previewTab.classList.contains('near')) previewTab.classList.toggle('near', near);
1202
+ },
1203
+ { passive: true },
1204
+ );
1205
+
1206
+ function setMode(mode) {
1207
+ if (state.mode === mode || (state.reviewOnly && mode !== 'browse')) return;
1208
+ state.mode = mode;
1209
+ sessionPref('mode', mode === 'browse' ? null : mode);
1210
+ state.hoverEl = null;
1211
+ hoverBox.style.display = 'none';
1212
+ if (mode !== 'browse') closeCoach();
1213
+ hideMarkChip(true);
1214
+ renderBar();
1215
+ syncFrames();
1216
+ }
1217
+
1218
+ function setVerdict(v) {
1219
+ state.verdict = v;
1220
+ prefs('verdict', v);
1221
+ renderBar();
1222
+ syncFrames();
1223
+ if (state.composer) state.composer.setVerdict(v);
1224
+ }
1225
+
1226
+ function toggleHidden() {
1227
+ state.hidden = !state.hidden;
1228
+ renderBar();
1229
+ }
1230
+
1231
+ // ---------------------------------------------------------------- geometry of annotations
1232
+
1233
+ const elCache = new Map();
1234
+ function anchorEl(a) {
1235
+ if (!a.target) return null;
1236
+ const cached = elCache.get(a.id);
1237
+ if (cached && cached.selector === a.target.selector && cached.el && cached.el.isConnected) return cached.el;
1238
+ const el = queryOne(a.target.selector);
1239
+ elCache.set(a.id, { el, selector: a.target.selector });
1240
+ return el;
1241
+ }
1242
+
1243
+ // Where an annotation's element is right now: on screen, present but hidden at
1244
+ // this width (display:none, collapsed menu), or gone from the page.
1245
+ function anchorRect(a) {
1246
+ const el = anchorEl(a);
1247
+ if (!el || !el.isConnected) return { state: 'missing' };
1248
+ const r = el.getBoundingClientRect();
1249
+ return r.width || r.height ? { state: 'visible', r } : { state: 'hidden' };
1250
+ }
1251
+
1252
+ function breakpoint(w) {
1253
+ return w < 768 ? 'mobile' : w < 1100 ? 'tablet' : 'desktop';
1254
+ }
1255
+
1256
+ function madeElsewhere(a) {
1257
+ return !!a.viewport && breakpoint(a.viewport.w) !== breakpoint(innerWidth);
1258
+ }
1259
+
1260
+ // How to draw a stroke right now. Marks are stored relative to their element and
1261
+ // scaled with it, so a circle drawn at 1440px still surrounds the same card at
1262
+ // 390px; if the element is gone, they fall back to where they were drawn on the page.
1263
+ function strokeFrame(a, at = anchorRect(a)) {
1264
+ const st = a.stroke || {};
1265
+ if (at.state === 'visible' && st.local) {
1266
+ const t = (a.target && a.target.rect) || {};
1267
+ return { mode: 'local', tx: at.r.left, ty: at.r.top, sx: t.w ? at.r.width / t.w : 1, sy: t.h ? at.r.height / t.h : 1 };
1268
+ }
1269
+ return { mode: 'page', tx: -scrollX, ty: -scrollY, sx: 1, sy: 1 };
1270
+ }
1271
+
1272
+ // Viewport-space points of a stroke (for hit-testing and joining strokes).
1273
+ function strokePaths(a, at) {
1274
+ const f = strokeFrame(a, at);
1275
+ const src = f.mode === 'local' ? a.stroke.local : a.stroke.paths || [];
1276
+ return src.map((path) => path.map(([x, y]) => [f.tx + x * f.sx, f.ty + y * f.sy]));
1277
+ }
1278
+
1279
+ // The box a note draws: its drawn region (scaled with the element) or the whole element.
1280
+ function noteRect(a, at = anchorRect(a)) {
1281
+ const t = (a.target && a.target.rect) || null;
1282
+ const reg = a.region;
1283
+ if (at.state === 'visible') {
1284
+ if (!reg) return { left: at.r.left, top: at.r.top, width: at.r.width, height: at.r.height };
1285
+ const sx = t && t.w ? at.r.width / t.w : 1, sy = t && t.h ? at.r.height / t.h : 1;
1286
+ return { left: at.r.left + reg.x * sx, top: at.r.top + reg.y * sy, width: reg.w * sx, height: reg.h * sy };
1287
+ }
1288
+ if (!t) return null;
1289
+ if (reg) return { left: t.x + reg.x - scrollX, top: t.y + reg.y - scrollY, width: reg.w, height: reg.h };
1290
+ return { left: t.x - scrollX, top: t.y - scrollY, width: t.w, height: t.h };
1291
+ }
1292
+
1293
+ function pinPoint(a, at = anchorRect(a)) {
1294
+ if (a.kind === 'stroke') {
1295
+ const f = strokeFrame(a, at);
1296
+ const first = ((f.mode === 'local' ? a.stroke.local : a.stroke.paths) || [])[0];
1297
+ return first && first.length ? { x: f.tx + first[0][0] * f.sx, y: f.ty + first[0][1] * f.sy } : null;
1298
+ }
1299
+ // A note's tab sits on the top-left corner of its box.
1300
+ const r = noteRect(a, at);
1301
+ return r ? { x: r.left, y: r.top } : null;
1302
+ }
1303
+
1304
+ const fmt = (n) => Math.round(n * 10) / 10;
1305
+
1306
+ // A smooth curve through the points: Catmull-Rom segments as cubic Béziers.
1307
+ function smoothPath(points) {
1308
+ if (!points.length) return '';
1309
+ const p = points;
1310
+ let d = `M${fmt(p[0][0])},${fmt(p[0][1])}`;
1311
+ if (p.length === 1) return d + ` L${fmt(p[0][0])},${fmt(p[0][1])}`;
1312
+ for (let i = 0; i < p.length - 1; i++) {
1313
+ const p0 = p[i - 1] || p[i], p1 = p[i], p2 = p[i + 1], p3 = p[i + 2] || p2;
1314
+ d += ` C${fmt(p1[0] + (p2[0] - p0[0]) / 6)},${fmt(p1[1] + (p2[1] - p0[1]) / 6)}` +
1315
+ ` ${fmt(p2[0] - (p3[0] - p1[0]) / 6)},${fmt(p2[1] - (p3[1] - p1[1]) / 6)} ${fmt(p2[0])},${fmt(p2[1])}`;
1316
+ }
1317
+ return d;
1318
+ }
1319
+
1320
+ // ---------------------------------------------------------------- rendering marks & pins
1321
+
1322
+ const nodes = new Map(); // id -> { pin, paths: [svg path] }
1323
+
1324
+ // What was on screen last time, so only genuinely new pins pop in (not every reload).
1325
+ let seenPins = null; // Map id -> status
1326
+
1327
+ function rebuildMarks() {
1328
+ marks.replaceChildren();
1329
+ marksHit.replaceChildren();
1330
+ pins.replaceChildren();
1331
+ nodes.clear();
1332
+ const seen = seenPins;
1333
+ seenPins = new Map(state.annotations.map((a) => [a.id, a.status]));
1334
+ for (const a of state.annotations) {
1335
+ // Accepted fixes are done: they leave the page, unless they changed since.
1336
+ const broken = guardBroken(a);
1337
+ if (a.accepted && !broken) continue;
1338
+ const resolved = a.status === 'resolved' && !a.accepted; // fixed by the agent, waiting for your check
1339
+ const sent = !a.draft && !resolved;
1340
+ const isNew = seen && !seen.has(a.id);
1341
+ const justResolved = seen && seen.get(a.id) === 'open' && resolved;
1342
+ // Drawn marks carry no numbered badge: the ink itself is the handle (see hit paths below).
1343
+ const isStroke = a.kind === 'stroke' && a.stroke;
1344
+ const ink = a.verdict;
1345
+ const pin = isStroke && !resolved && !broken ? null : h('button', {
1346
+ class: `pin tab ${resolved ? 'check' : broken ? 'changed' : ink}${sent && !broken ? ' sent' : ''}`,
1347
+ title: `#${a.n} ${VERDICTS[a.verdict].label}${a.comment ? ': ' + a.comment : ''}${resolved ? ' (resolved)' : ''}${a.viewport ? ` · made at ${a.viewport.w}px` : ''}`,
1348
+ 'aria-label': `Feedback ${a.n}: ${VERDICTS[a.verdict].label}`,
1349
+ onclick: (e) => {
1350
+ e.stopPropagation();
1351
+ resolved || broken ? startCheck(a.id) : openComposer({ annotation: a });
1352
+ },
1353
+ // In pen mode, dragging from a pin draws (re-marking reviewed elements); a click still opens it.
1354
+ onpointerdown: (e) => state.mode === 'pen' && e.button === 0 && startStroke(e, a),
1355
+ });
1356
+ if (pin) {
1357
+ // The tab shows the start of the note, or invites one.
1358
+ const first = a.comment || (a.thread && a.thread[0] && a.thread[0].text) || '';
1359
+ pin.textContent = resolved ? 'Fixed' : broken ? 'Changed' : first ? (first.length > 26 ? first.slice(0, 25) + '…' : first) : '+';
1360
+ if (isNew) pin.classList.add('pop');
1361
+ if (justResolved) pin.classList.add('just-resolved');
1362
+ pins.append(pin);
1363
+ }
1364
+ let group = null;
1365
+ let hit = null;
1366
+ let box = null;
1367
+ if (!isStroke) {
1368
+ box = s('rect', { class: `box ${ink}${resolved ? ' fixed' : ''}${broken ? ' changed' : sent ? ' sent' : ''}${isNew ? ' pop' : ''}`, rx: 6 });
1369
+ const boxHit = s('rect', { class: 'box-hit', rx: 6 });
1370
+ boxHit.addEventListener('pointerenter', () => box.classList.add('hot'));
1371
+ boxHit.addEventListener('pointerleave', () => box.classList.remove('hot'));
1372
+ boxHit.addEventListener('click', () => (resolved || broken ? startCheck(a.id) : openComposer({ annotation: a })));
1373
+ boxHit.addEventListener('pointerdown', (e) => state.mode === 'pen' && e.button === 0 && startStroke(e, a));
1374
+ pin.addEventListener('pointerenter', () => box.classList.add('hot'));
1375
+ pin.addEventListener('pointerleave', () => box.classList.remove('hot'));
1376
+ marks.append(box);
1377
+ marksHit.append(boxHit);
1378
+ hit = boxHit;
1379
+ }
1380
+ if (isStroke) {
1381
+ group = s('g', { class: (resolved ? 'fixed' : '') + (broken ? ' changed' : sent ? ' sent' : '') + (isNew ? ' pop' : '') });
1382
+ // A wider, invisible copy of the line above the drawing surface: hover it to
1383
+ // see which mark it is, click to open its (optional) note, drag in pen mode to draw.
1384
+ hit = s('g');
1385
+ const tip = s('title');
1386
+ tip.textContent = `${VERDICTS[a.verdict].label} · ${describeMark(a.stroke.paths)}${a.comment ? ': ' + a.comment : ''}`;
1387
+ hit.append(tip);
1388
+ for (let i = 0; i < (a.stroke.paths || []).length; i++) {
1389
+ // non-scaling-stroke keeps the line width constant when the mark is scaled with its element.
1390
+ group.append(s('path', { stroke: COLORS[a.verdict], 'stroke-width': a.stroke.width || 4, 'stroke-opacity': '.85', 'vector-effect': 'non-scaling-stroke' }));
1391
+ hit.append(s('path', { stroke: 'transparent', 'stroke-width': 18, 'vector-effect': 'non-scaling-stroke' }));
1392
+ }
1393
+ hit.addEventListener('pointerenter', () => group.classList.add('hot'));
1394
+ hit.addEventListener('pointerleave', () => group.classList.remove('hot'));
1395
+ hit.addEventListener('pointerdown', (e) => state.mode === 'pen' && e.button === 0 && startStroke(e, a));
1396
+ hit.addEventListener('click', (e) => (resolved || broken ? startCheck(a.id) : openComposer({ annotation: a, point: { x: e.clientX, y: e.clientY } })));
1397
+ marks.append(group);
1398
+ marksHit.append(hit);
1399
+ if (pin) {
1400
+ pin.addEventListener('pointerenter', () => group.classList.add('hot'));
1401
+ pin.addEventListener('pointerleave', () => group.classList.remove('hot'));
1402
+ }
1403
+ }
1404
+ nodes.set(a.id, { pin, group, hit, box, last: {} });
1405
+ }
1406
+ positionMarks();
1407
+ }
1408
+
1409
+ // Write only what changed: most frames (the periodic layout check) move nothing.
1410
+ function put(n, key, value, apply) {
1411
+ if (n.last[key] !== value) {
1412
+ n.last[key] = value;
1413
+ apply(value);
1414
+ }
1415
+ }
1416
+
1417
+ const FAN = [[0, 0], [-22, 0], [22, 0], [0, 22], [-22, 22], [22, 22], [-44, 0], [44, 0], [0, -22], [-44, 22], [44, 22]];
1418
+
1419
+ function positionMarks() {
1420
+ // Read all geometry first, then write, so the browser lays out once per frame.
1421
+ const frames = state.annotations.map((a) => ({ a, n: nodes.get(a.id), at: anchorRect(a) }));
1422
+ const placed = [];
1423
+ const margin = 200;
1424
+ for (const { a, n, at } of frames) {
1425
+ if (!n) continue;
1426
+ // Hidden at this width (e.g. a note on the mobile menu, seen on desktop): don't float it somewhere random.
1427
+ const show = at.state !== 'hidden';
1428
+ // Items made at another screen width are about another layout; they stay in
1429
+ // the Notes panel but only draw on the page when asked to.
1430
+ const otherWidth = madeElsewhere(a) && !state.showElsewhere;
1431
+ const p = show && !otherWidth && pinPoint(a, at);
1432
+ const onScreen = p && p.y > -margin && p.y < innerHeight + margin;
1433
+ const elsewhere = at.state === 'missing' || madeElsewhere(a);
1434
+ if (n.pin) put(n, 'pinShown', !!onScreen, (v) => (n.pin.style.display = v ? '' : 'none'));
1435
+ put(n, 'elsewhere', elsewhere, (v) => {
1436
+ if (n.pin) n.pin.classList.toggle('elsewhere', v);
1437
+ if (n.group) n.group.classList.toggle('elsewhere', v);
1438
+ });
1439
+ if (n.box) {
1440
+ const r = show && !otherWidth ? noteRect(a, at) : null;
1441
+ put(n, 'boxRect', r ? `${Math.round(r.left)},${Math.round(r.top)},${Math.round(r.width)},${Math.round(r.height)}` : '', (v) => {
1442
+ for (const el of [n.box, n.hit]) {
1443
+ if (!v) {
1444
+ el.style.display = 'none';
1445
+ continue;
1446
+ }
1447
+ const [x, y, w, hh] = v.split(',');
1448
+ el.style.display = '';
1449
+ el.setAttribute('x', x);
1450
+ el.setAttribute('y', y);
1451
+ el.setAttribute('width', Math.max(0, w));
1452
+ el.setAttribute('height', Math.max(0, hh));
1453
+ }
1454
+ });
1455
+ }
1456
+ if (onScreen && n.pin) {
1457
+ // Keep pins on screen horizontally, and fan out pins that land on the same spot.
1458
+ // A fixed list of nearby spots, so crowded pins can never loop.
1459
+ const clampX = (x) => Math.min(Math.max(x, 14), innerWidth - 14);
1460
+ let spot = { x: clampX(p.x), y: p.y };
1461
+ for (const [dx, dy] of FAN) {
1462
+ const c = { x: clampX(p.x + dx), y: p.y + dy };
1463
+ if (!placed.some((q) => Math.abs(q.x - c.x) < 16 && Math.abs(q.y - c.y) < 16)) {
1464
+ spot = c;
1465
+ break;
1466
+ }
1467
+ }
1468
+ placed.push(spot);
1469
+ put(n, 'pin', `${Math.round(spot.x)}px ${Math.round(spot.y)}px`, (v) => (n.pin.style.translate = v));
1470
+ }
1471
+ if (n.group) {
1472
+ const f = show && !otherWidth ? strokeFrame(a, at) : null;
1473
+ const visible = !!f && (f.mode === 'page' || (at.r.bottom > -margin && at.r.top < innerHeight + margin));
1474
+ put(n, 'groupShown', visible, (v) => {
1475
+ n.group.style.display = v ? '' : 'none';
1476
+ n.hit.style.display = v ? '' : 'none';
1477
+ });
1478
+ if (!visible) continue;
1479
+ put(n, 'mode', f.mode, (mode) => {
1480
+ const src = mode === 'local' ? a.stroke.local : a.stroke.paths || [];
1481
+ [...n.group.children].forEach((path, i) => path.setAttribute('d', smoothPath(src[i] || [])));
1482
+ n.hit.querySelectorAll('path').forEach((path, i) => path.setAttribute('d', smoothPath(src[i] || [])));
1483
+ });
1484
+ put(n, 'transform', `translate(${f.tx.toFixed(1)} ${f.ty.toFixed(1)}) scale(${f.sx.toFixed(4)} ${f.sy.toFixed(4)})`, (v) => {
1485
+ n.group.setAttribute('transform', v);
1486
+ n.hit.setAttribute('transform', v);
1487
+ });
1488
+ }
1489
+ }
1490
+ if (state.hoverEl) placeBox(hoverBox, state.hoverEl.getBoundingClientRect());
1491
+ if (state.composer && state.composer.el) placeBox(focusBox, state.composer.focusRect());
1492
+ }
1493
+
1494
+ let frame = 0;
1495
+ function schedule() {
1496
+ if (!frame) frame = requestAnimationFrame(() => {
1497
+ frame = 0;
1498
+ positionMarks();
1499
+ });
1500
+ }
1501
+
1502
+ addEventListener('scroll', schedule, { capture: true, passive: true });
1503
+ addEventListener('resize', schedule, { passive: true });
1504
+ setInterval(schedule, 600); // catch layout shifts from late-loading images, fonts, animations
1505
+
1506
+ function placeBox(box, r) {
1507
+ Object.assign(box.style, { display: 'block', transform: `translate(${r.left}px, ${r.top}px)`, width: r.width + 'px', height: r.height + 'px' });
1508
+ }
1509
+
1510
+ // ---------------------------------------------------------------- rounds
1511
+
1512
+ // Review happens in turns: your marks are drafts until you Send them; the agent
1513
+ // fixes them and closes the round with a short report; you check each fix.
1514
+ async function loadRounds() {
1515
+ try {
1516
+ state.rounds = (await store.rounds()).rounds || [];
1517
+ } catch {
1518
+ state.rounds = [];
1519
+ }
1520
+ const last = state.rounds[state.rounds.length - 1];
1521
+ // One signal when the agent is done, not a stream of pop-ups while it works.
1522
+ if (last && last.returnedAt && (prefs('seenRound') || 0) < last.n && !SHOT && !FRAME) {
1523
+ prefs('seenRound', last.n);
1524
+ toast(`Round ${last.n} is back: ${toCheck().length} fix${toCheck().length === 1 ? '' : 'es'} to review`, { label: 'Review', run: () => startCheck() });
1525
+ }
1526
+ }
1527
+
1528
+ const drafts = () => state.annotations.filter((a) => a.draft);
1529
+ const toCheck = () => state.annotations.filter((a) => (a.status === 'resolved' && !a.accepted) || guardBroken(a));
1530
+
1531
+ // A link for a PM or client: the same page, opened straight into Check, without the tools.
1532
+ async function copyCheckLink() {
1533
+ const url = new URL(location.href);
1534
+ url.searchParams.set('redline', 'check');
1535
+ url.hash = '';
1536
+ // The review key lets whoever opens it give verdicts, and nothing else.
1537
+ try {
1538
+ const info = await (await fetch(SERVER + '/__redline/api/info', { headers: keyHeader() })).json();
1539
+ if (info.reviewKey) url.searchParams.set('rk', info.reviewKey);
1540
+ } catch {}
1541
+ const link = url.toString();
1542
+ const local = /^(localhost|127\.|\[::1\])/.test(location.hostname);
1543
+ if (await writeClipboard(link)) toast(local ? 'Copied. Serve with --host 0.0.0.0 (or a tunnel) so others can open it' : 'Copied the review link');
1544
+ else toast(link);
1545
+ }
1546
+
1547
+ async function sendRound() {
1548
+ const n = drafts().length;
1549
+ if (!n) return;
1550
+ try {
1551
+ if (!store.remote) {
1552
+ // No server: the hand-off is your clipboard.
1553
+ await writeClipboard(formatMarkdown(state.annotations.filter((a) => a.draft), { includeDrafts: true }));
1554
+ }
1555
+ const round = await store.send();
1556
+ await load();
1557
+ toast(store.remote ? `Sent round ${round.n} to your agent` : `Copied round ${round.n}: paste it into your AI chat`);
1558
+ } catch (err) {
1559
+ toast('Could not send: ' + err.message);
1560
+ }
1561
+ }
1562
+
1563
+ // The turn, at a glance: every next step that applies (send your marks, the agent
1564
+ // is working, check its fixes), as small pills in the toolbar.
1565
+ function turnPill() {
1566
+ const d = drafts().length;
1567
+ const last = state.rounds && state.rounds[state.rounds.length - 1];
1568
+ const check = toCheck().length;
1569
+ const openPanel = () => !state.panel && togglePanel();
1570
+ const working = SERVER && last && !last.returnedAt && state.annotations.some((a) => a.round === last.n && a.status !== 'resolved');
1571
+ return [
1572
+ d ? h('button', { class: 'turn send', title: `Send ${d} mark${d === 1 ? '' : 's'} to your agent`, onclick: sendRound }, `Send ${d}`) : null,
1573
+ working ? h('button', { class: 'turn waiting', title: 'Your agent is working on round ' + last.n, onclick: openPanel }, h('i'), 'With agent') : null,
1574
+ check ? h('button', { class: 'turn check', title: `Your agent fixed ${check} thing${check === 1 ? '' : 's'} you marked. Review them one at a time`, onclick: () => startCheck() }, `Review ${check}`) : null,
1575
+ ];
1576
+ }
1577
+
1578
+ // ---------------------------------------------------------------- loading
1579
+
1580
+ async function load() {
1581
+ try {
1582
+ const before = new Map(state.annotations.map((a) => [a.id, a]));
1583
+ const openBefore = state.annotations.filter((a) => a.status !== 'resolved').length;
1584
+ state.annotations = (await store.list()).sort((a, b) => a.n - b.n);
1585
+ const openNow = state.annotations.filter((a) => a.status !== 'resolved').length;
1586
+ await loadRounds();
1587
+ if (openBefore > 0 && openNow === 0 && state.annotations.length && !SHOT) {
1588
+ setTimeout(() => toast('All clear: every item on this page is resolved', null, { celebrate: true }), 900);
1589
+ }
1590
+ for (const a of state.annotations) {
1591
+ const was = before.get(a.id);
1592
+ if (!was) continue;
1593
+ if (!(was.variants || []).length && (a.variants || []).length) toast(`${a.variants.length} options ready for #${a.n}. Open it to compare`);
1594
+ }
1595
+ if (SERVER && !state.connected) {
1596
+ state.connected = true;
1597
+ }
1598
+ } catch {
1599
+ state.connected = false;
1600
+ }
1601
+ elCache.clear();
1602
+ applyPreviews();
1603
+ rebuildMarks();
1604
+ renderBar();
1605
+ renderPanel();
1606
+ if (state.composer && state.composer.annotation) {
1607
+ const fresh = state.annotations.find((a) => a.id === state.composer.annotation.id);
1608
+ if (!fresh) closeComposer(true);
1609
+ }
1610
+ }