@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.
@@ -0,0 +1,926 @@
1
+ // ---------------------------------------------------------------- check mode
2
+
3
+ // Reviewing the agent's fixes, one at a time. The page dims, a thin outline moves
4
+ // from fix to fix, and a card docked above the toolbar asks for one key: A to
5
+ // accept, R to redo. Calm on purpose: you do this many times a round. The page
6
+ // itself is the "after"; hold Space to lay the "before" over it. Approvals that
7
+ // changed since you gave them (guards) come up the same way: send them back, or
8
+ // keep the new look. At the end, the round's first-time accept rate.
9
+
10
+ const checkReduced = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
11
+ const checkWait = (ms) => new Promise((ok) => setTimeout(ok, ms));
12
+
13
+ /** An approval that changed (or vanished) since it was given, and hasn't been dealt with. */
14
+ function guardBroken(a) {
15
+ return !!(a && !a.draft && a.guard && !a.guard.off && !a.guard.sentBack && a.guard.now && a.guard.now.status !== 'held' && (a.accepted || a.verdict === 'good') &&
16
+ a.guard.now.at > a.guard.base.at);
17
+ }
18
+
19
+ const checkKind = (a) =>
20
+ guardBroken(a) ? (a.guard.now.status === 'missing' ? 'gone' : 'guard') : 'fix';
21
+
22
+ // Where the thing to look at is now: where the agent says it moved to, else the
23
+ // element the mark was made on. Selectors break when the page is rebuilt around them
24
+ // (a new section shifts every :nth-of-type), so as a last resort find the element by
25
+ // its words. Nothing on screen: null, and the item is shown from its pictures.
26
+ function checkEl(a) {
27
+ const el = (a.afterTarget && queryOne(a.afterTarget)) || anchorEl(a) || findByText(a);
28
+ return el && el.isConnected && el.getClientRects().length ? el : null;
29
+ }
30
+
31
+ const textFinds = new Map();
32
+ function findByText(a) {
33
+ const t = a.target;
34
+ if (!t || !t.text || !t.selector) return null;
35
+ if (textFinds.has(a.id)) {
36
+ const hit = textFinds.get(a.id);
37
+ if (!hit || hit.isConnected) return hit;
38
+ }
39
+ // Compared without spaces: a stored innerText has them between links, textContent doesn't.
40
+ const norm = (x) => (x || '').replace(/\s+/g, '').toLowerCase();
41
+ const want = norm(t.text).slice(0, 40);
42
+ let best = null;
43
+ if (want.length >= 10) {
44
+ // Same kind of element (the selector's last tag), whose text starts the same way,
45
+ // closest in size to how it was when marked.
46
+ const tag = (t.selector.split(/[\s>+~]+/).pop().match(/^[a-z][a-z0-9-]*/i) || ['*'])[0];
47
+ let bestScore = Infinity;
48
+ const all = document.body.querySelectorAll(tag);
49
+ for (let i = 0; i < all.length && i < 3000; i++) {
50
+ const el = all[i];
51
+ if (!norm(el.textContent).startsWith(want)) continue;
52
+ const r = el.getBoundingClientRect();
53
+ if (!r.width && !r.height) continue;
54
+ const score = t.rect ? Math.abs(r.width - t.rect.w) + Math.abs(r.height - t.rect.h) : -el.querySelectorAll('*').length;
55
+ if (score < bestScore) (bestScore = score), (best = el);
56
+ }
57
+ }
58
+ textFinds.set(a.id, best);
59
+ return best;
60
+ }
61
+
62
+ // Top to bottom, so the spotlight travels one way down the page.
63
+ function checkItems() {
64
+ const at = (a) => {
65
+ const el = checkEl(a);
66
+ const r = el && el.getBoundingClientRect();
67
+ return r && (r.width || r.height) ? r.top + scrollY : (a.target && a.target.rect ? a.target.rect.y : 1e9);
68
+ };
69
+ return state.annotations
70
+ .filter((a) => !a.draft && ((a.status === 'resolved' && !a.accepted) || guardBroken(a)))
71
+ .map((a) => ({ a, y: at(a) }))
72
+ .sort((x, y) => x.y - y.y)
73
+ .map((x) => x.a);
74
+ }
75
+
76
+ let chk = null;
77
+
78
+ function startCheck(focusId) {
79
+ if (chk) return;
80
+ const items = checkItems();
81
+ if (!items.length) return toast('Nothing to check right now');
82
+ if (state.devices) toggleDevices();
83
+ closeComposer();
84
+ closeMenu();
85
+ closeCoach();
86
+ hideMarkChip(true);
87
+ toastEl.classList.remove('show');
88
+ if (state.panel) togglePanel();
89
+ setMode('browse');
90
+ const ui = {
91
+ stage: h('div', { class: 'check-stage', role: 'dialog', 'aria-label': 'Check the fixes', 'aria-modal': 'true' }),
92
+ soft: h('div', { class: 'check-soft' }),
93
+ spot: h('div', { class: 'check-spot' }),
94
+ peekBg: h('div', { class: 'check-peek-bg' }),
95
+ peek: h('img', { class: 'check-peek', alt: '' }),
96
+ card: h('section', { class: 'check-card' }),
97
+ };
98
+ ui.stage.append(ui.soft, ui.spot, ui.peekBg, ui.peek, ui.card);
99
+ chk = {
100
+ items,
101
+ i: Math.max(0, items.findIndex((a) => a.id === focusId)),
102
+ decided: new Map(), // id -> accepted | redo | sent-back | new-look | let-go
103
+ ui,
104
+ cur: null, // starts right on the item: nothing flies in from the toolbar
105
+ cardSize: { w: 340, h: 260 },
106
+ t: 0,
107
+ raf: 0,
108
+ phase: 'item', // item | redo | done | leaving
109
+ peeking: false,
110
+ busy: false,
111
+ };
112
+ new ResizeObserver(([e]) => {
113
+ if (!chk) return;
114
+ chk.cardSize = { w: e.target.offsetWidth, h: e.target.offsetHeight };
115
+ wakeCheck();
116
+ }).observe(ui.card);
117
+ host.dataset.check = '';
118
+ root.insertBefore(ui.stage, bar);
119
+ checkShow(chk.i, 0);
120
+ chk.raf = requestAnimationFrame(checkFrame);
121
+ }
122
+
123
+ const checkCurrent = () => chk && chk.items[chk.i];
124
+
125
+ // ---- the spotlight: a critically damped follower, so it glides between items and
126
+ // rides along with scrolling without a single hard jump.
127
+
128
+ function targetRect() {
129
+ // Showing the before: the outline takes the old element's size and place, so a
130
+ // fix that resized something visibly grows or shrinks as you switch.
131
+ if (chk.peeking && chk.peekRect && chk.phase === 'item') {
132
+ // In place: a little room around it. As a framed picture: the outline is its frame.
133
+ const p = chk.peekRect, pad = chk.ui.stage.classList.contains('picture') ? 0 : 10;
134
+ return { x: p.x - pad, y: p.y - pad, w: p.w + pad * 2, h: p.h + pad * 2, r: 14 };
135
+ }
136
+ // Leaving: stay put and fade with the stage. Done, hopping far, or nothing to point
137
+ // at: the spotlight closes to the middle (its outline is hidden then).
138
+ if (chk.phase === 'leaving') return { ...chk.cur };
139
+ if (chk.hop || chk.ui.stage.classList.contains('lost')) return { x: innerWidth / 2, y: innerHeight / 2, w: 0, h: 0, r: 0 };
140
+ if (chk.phase === 'done') return { x: innerWidth / 2, y: innerHeight / 2, w: 0, h: 0, r: 0 };
141
+ const el = checkEl(checkCurrent());
142
+ const r = el && el.getBoundingClientRect();
143
+ if (!r || (!r.width && !r.height)) return { x: innerWidth / 2, y: innerHeight / 2, w: 0, h: 0, r: 0 };
144
+ const pad = 10, m = 8;
145
+ const x = Math.max(m, r.left - pad), y = Math.max(m, r.top - pad);
146
+ return { x, y, w: Math.min(innerWidth - m, r.right + pad) - x, h: Math.min(innerHeight - m, r.bottom + pad) - y, r: 14 };
147
+ }
148
+
149
+ function checkFrame(now) {
150
+ if (!chk) return;
151
+ const dt = Math.min(64, chk.t ? now - chk.t : 16);
152
+ chk.t = now;
153
+ const t = targetRect();
154
+ if (!chk.cur) chk.cur = { ...t };
155
+ // It glides from one item to the next close by; the rest of the time (scrolling,
156
+ // the page settling, a far hop) it sits exactly on the element, with no trail.
157
+ const k = checkReduced() || now > (chk.glideUntil || 0) ? 1 : 1 - Math.exp(-dt / 45);
158
+ let moving = false;
159
+ for (const p of ['x', 'y', 'w', 'h', 'r']) {
160
+ const d = t[p] - chk.cur[p];
161
+ if (Math.abs(d) > 0.25) moving = true;
162
+ chk.cur[p] = Math.abs(d) > 0.25 ? chk.cur[p] + d * k : t[p]; // snap the last quarter pixel
163
+ }
164
+ checkPaint();
165
+ // Once the spotlight has arrived, stop: no work at all until something moves
166
+ // (a new item, a scroll, a resize, the card changing size), which wakes it.
167
+ chk.raf = moving ? requestAnimationFrame(checkFrame) : 0;
168
+ if (!moving) chk.t = 0;
169
+ }
170
+
171
+ function wakeCheck() {
172
+ if (chk && !chk.raf) chk.raf = requestAnimationFrame(checkFrame);
173
+ }
174
+ addEventListener('scroll', wakeCheck, { capture: true, passive: true });
175
+ addEventListener('resize', wakeCheck, { passive: true });
176
+
177
+ function checkPaint() {
178
+ const { ui, cur } = chk;
179
+ const key = [cur.x, cur.y, cur.w, cur.h, cur.r].map((v) => Math.round(v * 2)).join() + chk.phase + (chk.peeking ? 1 : 0) + chk.cardSize.w + 'x' + chk.cardSize.h;
180
+ if (key === chk.painted) return; // nothing moved: leave the styles alone
181
+ chk.painted = key;
182
+ ui.spot.style.transform = `translate(${cur.x}px, ${cur.y}px)`;
183
+ ui.spot.style.width = Math.max(0, cur.w) + 'px';
184
+ ui.spot.style.height = Math.max(0, cur.h) + 'px';
185
+ ui.spot.style.borderRadius = Math.max(0, cur.r) + 'px';
186
+ // The soft-focus layer has a hole where the spotlight is.
187
+ ui.soft.style.setProperty('--hx', cur.x + 'px');
188
+ ui.soft.style.setProperty('--hy', cur.y + 'px');
189
+ ui.soft.style.setProperty('--hw', Math.max(0, cur.w) + 'px');
190
+ ui.soft.style.setProperty('--hh', Math.max(0, cur.h) + 'px');
191
+ placeCard();
192
+ if (chk.peeking) placePeek();
193
+ }
194
+
195
+ // The card is docked in one place, above the toolbar. It never jumps from side to
196
+ // side; instead each item is scrolled into the free space above it.
197
+ function placeCard() {
198
+ const { ui, cardSize } = chk;
199
+ const { w, h: ch } = cardSize;
200
+ const x = (innerWidth - w) / 2;
201
+ const y = chk.phase === 'done' ? (innerHeight - ch) / 2 : innerHeight - ch - CARD_BOTTOM;
202
+ ui.card.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
203
+ }
204
+ const CARD_BOTTOM = 84; // clear of the toolbar
205
+
206
+ // The free space above the docked card, where the item under review should sit.
207
+ // How much of the top of the screen the page's own sticky or fixed header covers.
208
+ function headerCover() {
209
+ let bottom = 0;
210
+ for (const fx of [0.2, 0.5, 0.8]) {
211
+ for (let el = pageElementAt(innerWidth * fx, 2); el && el !== document.body && el !== document.documentElement; el = el.parentElement) {
212
+ const pos = getComputedStyle(el).position;
213
+ if (pos === 'fixed' || pos === 'sticky') {
214
+ const r = el.getBoundingClientRect();
215
+ if (r.top <= 4 && r.bottom < innerHeight * 0.4) bottom = Math.max(bottom, r.bottom);
216
+ break;
217
+ }
218
+ }
219
+ }
220
+ return bottom;
221
+ }
222
+
223
+ function checkZone() {
224
+ const top = headerCover() + 24; // below the site's own sticky header
225
+ const bottom = Math.max(top + 120, innerHeight - (chk ? chk.cardSize.h : 260) - CARD_BOTTOM - 20);
226
+ return { top, bottom };
227
+ }
228
+
229
+ // ---- the card
230
+
231
+ function checkProgress() {
232
+ return h('div', { class: 'check-progress', 'aria-hidden': 'true' },
233
+ chk.items.map((a, i) => h('i', { class: `${chk.decided.get(a.id) || ''}${i === chk.i && chk.phase !== 'done' ? ' now' : ''}` })));
234
+ }
235
+
236
+ function checkKbd(k) {
237
+ return h('kbd', {}, k);
238
+ }
239
+
240
+ // What you asked for, in a few words: your note, or what the mark means.
241
+ function youSaid(a) {
242
+ if (a.comment) return a.comment;
243
+ if (a.kind === 'stroke') return markMeaning(a).split(':')[0];
244
+ return inkOf(a.verdict).name;
245
+ }
246
+
247
+ function imagesFor(a) {
248
+ const kind = checkKind(a);
249
+ const src = (path) => withVersion(assetSrc({ path }), a);
250
+ if (kind === 'fix') {
251
+ const shots = a.shots || {};
252
+ const boxes = a.shotBoxes || {};
253
+ // Laid over the page: the same moment without your mark on it, when it was taken.
254
+ const then = shots.plain ? { src: src(shots.plain), box: boxes.plain || boxes.before } : shots.before && { src: src(shots.before), box: boxes.before };
255
+ return {
256
+ then: then && { ...then, label: 'Before' },
257
+ now: shots.after && { src: src(shots.after), label: 'After the fix' },
258
+ };
259
+ }
260
+ const g = a.guard || {};
261
+ // The width that changed: its own, or the phone's (which can't be laid over this page).
262
+ const baseShot = (g.now && g.now.baseShot) || (g.base && g.base.shot);
263
+ const own = g.base && baseShot === g.base.shot;
264
+ return {
265
+ then: baseShot && { src: src(baseShot), label: own ? 'As approved' : 'As approved, on a phone', box: own ? g.base.box : null },
266
+ now: g.now && g.now.shot && { src: src(g.now.shot), label: 'Now' },
267
+ };
268
+ }
269
+
270
+ function checkHead() {
271
+ return h('header', {},
272
+ h('span', { class: 'check-count' }, `${chk.i + 1} of ${chk.items.length}`),
273
+ checkProgress(),
274
+ h('button', { type: 'button', class: 'check-close', title: 'Stop reviewing (Esc)', 'aria-label': 'Stop reviewing', html: ICONS.close, onclick: exitCheck }),
275
+ );
276
+ }
277
+
278
+ // The agent's note, as a title you can read at a glance and the rest underneath.
279
+ // Split at the first natural break (a full stop, a colon, a semicolon, a comma) after
280
+ // a few words; the title never cuts mid-phrase, and CSS clamps what's still too long.
281
+ function splitNote(text) {
282
+ const t = (text || '').trim();
283
+ if (t.length <= 70) return { title: t, rest: '' };
284
+ const m = t.match(/^(.{16,100}?)(?:[.!?:;]|,(?=\s))\s+(.+)$/s);
285
+ if (!m) return { title: t, rest: '' };
286
+ const rest = m[2].charAt(0).toUpperCase() + m[2].slice(1);
287
+ return { title: m[1], rest };
288
+ }
289
+
290
+ function itemCard(a, lost = false) {
291
+ const kind = checkKind(a);
292
+ const imgs = imagesFor(a);
293
+ const note = kind === 'fix' ? splitNote(a.resolution || 'Fixed') : null;
294
+ const title = kind === 'fix' ? note.title : kind === 'gone' ? 'You approved this, and it has left the page' : 'You approved this. It changed since.';
295
+ // Before / After, right on the page: the live page is the after, and the before
296
+ // is laid over it where it was. Tap Space (or Before) to switch and stay; hold to peek.
297
+ const before = imgs.then && !lost
298
+ ? h('div', { class: 'check-ab' },
299
+ h('div', { class: 'ab-switch', role: 'group', 'aria-label': 'Show before or after' },
300
+ h('button', {
301
+ type: 'button',
302
+ class: 'ab before',
303
+ 'aria-pressed': 'false',
304
+ onpointerdown: (e) => (e.preventDefault(), peekDown()),
305
+ onpointerup: () => peekUp(),
306
+ onpointerleave: () => chk && chk.peeking && !chk.peekSticky && peekUp(),
307
+ }, 'Before'),
308
+ h('button', { type: 'button', class: 'ab after', 'aria-pressed': 'true', onclick: () => checkPeek(false) }, 'After'),
309
+ ),
310
+ h('div', { class: 'ab-row' },
311
+ h('span', { class: 'ab-hint' }, h('span', { class: 'on-keys' }, checkKbd('Space'), ' switch · hold to peek'), h('span', { class: 'on-touch' }, 'tap to switch · hold to peek')),
312
+ imgs.now ? h('button', { type: 'button', class: 'check-link', onclick: () => openCompare(imgs.then, imgs.now) }, 'Side by side') : null))
313
+ : null;
314
+ // Not on the page anymore (rebuilt around it): judge it from its two pictures.
315
+ const pictures = lost && (imgs.then || imgs.now)
316
+ ? h('div', { class: 'check-lost' },
317
+ h('p', {}, 'It moved or changed too much to point at. Compare the pictures:'),
318
+ h('div', { class: 'check-thumbs' },
319
+ [imgs.then, imgs.now].filter(Boolean).map((im) =>
320
+ h('button', { type: 'button', class: 'check-thumb', onclick: () => (imgs.then && imgs.now ? openCompare(imgs.then, imgs.now) : null) },
321
+ h('img', { src: im.src, alt: im.label }), h('span', {}, im.label)))))
322
+ : null;
323
+ const [no, yes] = {
324
+ fix: ['Redo', 'Accept'],
325
+ guard: ['Send back', 'Keep new look'],
326
+ gone: ['Send back', 'Let it go'],
327
+ }[kind];
328
+ return h('div', { class: `check-body k-${kind}` },
329
+ checkHead(),
330
+ kind !== 'fix' ? h('p', { class: `check-kicker k-${kind}` }, kind === 'gone' ? 'Gone from the page' : 'Changed after you approved it') : null,
331
+ h('h2', { class: 'check-title' }, title),
332
+ note && note.rest ? h('p', { class: 'check-detail' }, note.rest) : null,
333
+ kind === 'fix'
334
+ ? h('p', { class: 'check-you' }, h('span', {}, 'You asked'), youSaid(a))
335
+ : h('ul', { class: 'check-diff' }, ((a.guard.now && a.guard.now.diff) || []).slice(0, 5).map((d) => h('li', {}, d.replace(/`/g, '')))),
336
+ // Words and pictures that change by themselves aren't a regression: say so once.
337
+ kind === 'guard' && !(a.guardIgnore || []).includes('text')
338
+ ? h('button', { type: 'button', class: 'check-live', onclick: checkLive }, 'Changes by itself (a feed, a date)? ', h('b', {}, 'Watch only its look'), checkKbd('L'))
339
+ : null,
340
+ before,
341
+ pictures,
342
+ h('div', { class: 'check-actions' },
343
+ h('button', { type: 'button', class: 'btn check-no', onclick: checkRedo }, no, checkKbd('R')),
344
+ h('button', { type: 'button', class: 'btn primary check-yes', onclick: checkAccept }, yes, checkKbd('A')),
345
+ ),
346
+ );
347
+ }
348
+
349
+ function redoCard(a) {
350
+ const text = h('textarea', { rows: 2, placeholder: 'What is still wrong? (optional)', 'aria-label': 'What is still wrong' });
351
+ const send = () => submitRedo(text.value.trim());
352
+ text.addEventListener('keydown', (e) => {
353
+ if (e.key === 'Enter' && !e.shiftKey) (e.preventDefault(), send());
354
+ else if (e.key === 'Escape') (e.preventDefault(), (chk.phase = 'item'), checkSwap(itemCard(a), 0));
355
+ e.stopPropagation();
356
+ });
357
+ queueMicrotask(() => text.focus({ preventScroll: true }));
358
+ return h('div', { class: 'check-body k-redo' },
359
+ checkHead(),
360
+ h('h2', { class: 'check-title' }, splitNote(a.resolution || 'Fixed').title),
361
+ text,
362
+ h('div', { class: 'check-actions' },
363
+ h('button', { type: 'button', class: 'btn', onclick: () => ((chk.phase = 'item'), checkSwap(itemCard(a), 0)) }, 'Cancel'),
364
+ h('button', { type: 'button', class: 'btn primary', onclick: send }, 'Add to next round', checkKbd('↵')),
365
+ ),
366
+ );
367
+ }
368
+
369
+ // Swap the card's content in place: a quick crossfade, never a slide across.
370
+ function checkSwap(node, dir) {
371
+ wakeCheck(); // a new card (or phase) may move or resize the spotlight's companion
372
+ const card = chk.ui.card;
373
+ const h0 = card.offsetHeight;
374
+ const dy = checkReduced() ? 0 : 4;
375
+ const leaving = [...card.children];
376
+ for (const old of leaving) {
377
+ // Out of the way at once: no focus, no keys, no clicks; gone when its exit is over.
378
+ old.inert = true;
379
+ if (old.classList.contains('leaving')) continue;
380
+ old.classList.add('leaving');
381
+ if (!checkReduced()) old.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 90, easing: 'ease-out', fill: 'forwards' });
382
+ setTimeout(() => old.remove(), checkReduced() ? 0 : 100);
383
+ }
384
+ card.append(node);
385
+ // Grow or shrink to the new content instead of jumping (it's docked by its bottom edge).
386
+ if (h0 && !checkReduced()) {
387
+ const h1 = node.offsetHeight;
388
+ if (Math.abs(h1 - h0) > 2) card.animate([{ height: h0 + 'px' }, { height: h1 + 'px' }], { duration: 200, easing: 'cubic-bezier(0.23, 1, 0.32, 1)' });
389
+ }
390
+ node.animate([{ opacity: 0, transform: `translateY(${dy}px)` }, { opacity: 1, transform: 'none' }], { duration: checkReduced() ? 1 : 180, easing: 'cubic-bezier(0.23, 1, 0.32, 1)', delay: leaving.length ? 50 : 0, fill: 'backwards' });
391
+ }
392
+
393
+ function checkShow(i, dir) {
394
+ checkPeek(false); // every item starts on the live page, the after
395
+ chk.i = i;
396
+ chk.phase = 'item';
397
+ const a = checkCurrent();
398
+ const el = checkEl(a);
399
+ const stage = chk.ui.stage;
400
+ stage.classList.toggle('lost', !el);
401
+ stage.dataset.kind = checkKind(a);
402
+ checkSwap(itemCard(a, !el), dir);
403
+ if (!el) return wakeCheck();
404
+ // Into the space above the card, unless it already sits there; room for the taller
405
+ // of the two versions, so switching never hides behind the card.
406
+ const r0 = el.getBoundingClientRect();
407
+ const b = imagesFor(a).then && imagesFor(a).then.box;
408
+ const r = b && b.h > r0.height ? { top: r0.top, bottom: r0.top + b.h, height: b.h } : r0;
409
+ const z = checkZone();
410
+ let to = null;
411
+ if (r.top < z.top || r.bottom > z.bottom) {
412
+ const room = z.bottom - z.top;
413
+ to = Math.max(0, r.height > room ? scrollY + r.top - z.top - 16 : scrollY + r.top + r.height / 2 - (z.top + room / 2));
414
+ to = Math.min(to, document.documentElement.scrollHeight - innerHeight);
415
+ }
416
+ const far = to !== null && Math.abs(to - scrollY) > innerHeight * 0.5;
417
+ if (far && !checkReduced()) {
418
+ // A far hop: the outline steps out, the page travels quickly, and the outline
419
+ // appears where it belongs. It never sweeps across the screen with the scroll.
420
+ stage.classList.add('hop');
421
+ const hop = (chk.hop = {});
422
+ checkScrollTo(to, 340).then(() => {
423
+ if (!chk || chk.hop !== hop) return;
424
+ chk.hop = null;
425
+ stage.classList.remove('hop');
426
+ // Arrive by focusing: the hole opens a little wide and settles onto the element.
427
+ const t = targetRect();
428
+ chk.cur = { x: t.x - 16, y: t.y - 16, w: t.w + 32, h: t.h + 32, r: t.r + 8 };
429
+ chk.glideUntil = performance.now() + 300;
430
+ stage.classList.add('arrive');
431
+ setTimeout(() => stage.classList.remove('arrive'), 220);
432
+ wakeCheck();
433
+ });
434
+ } else {
435
+ chk.hop = null;
436
+ stage.classList.remove('hop');
437
+ chk.glideUntil = performance.now() + 420; // close by: glide there
438
+ if (to !== null) checkScrollTo(to, checkReduced() ? 0 : 260);
439
+ }
440
+ wakeCheck();
441
+ }
442
+
443
+ // Scroll the page ourselves: short, eased, and instant underneath (a page's own
444
+ // scroll-behavior: smooth would otherwise stretch every step). A wheel or touch from
445
+ // the reviewer takes over at once.
446
+ function checkScrollTo(to, ms) {
447
+ cancelAnimationFrame(checkScrollTo.raf);
448
+ const from = scrollY, d = to - from;
449
+ if (!ms || Math.abs(d) < 2) {
450
+ scrollTo({ top: to, behavior: 'instant' });
451
+ return Promise.resolve();
452
+ }
453
+ return new Promise((done) => {
454
+ const t0 = performance.now();
455
+ let stop = false;
456
+ const cancel = () => (stop = true);
457
+ addEventListener('wheel', cancel, { once: true, passive: true });
458
+ addEventListener('touchstart', cancel, { once: true, passive: true });
459
+ const step = (now) => {
460
+ const p = Math.min(1, (now - t0) / ms);
461
+ const e = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
462
+ if (!stop) scrollTo({ top: from + d * e, behavior: 'instant' });
463
+ if (p < 1 && !stop) checkScrollTo.raf = requestAnimationFrame(step);
464
+ else {
465
+ removeEventListener('wheel', cancel);
466
+ removeEventListener('touchstart', cancel);
467
+ done();
468
+ }
469
+ };
470
+ checkScrollTo.raf = requestAnimationFrame(step);
471
+ });
472
+ }
473
+
474
+ function checkMove(d) {
475
+ if (!chk || chk.phase !== 'item' || chk.busy) return;
476
+ const j = chk.i + d;
477
+ if (j >= 0 && j < chk.items.length) checkShow(j, d);
478
+ }
479
+
480
+ // The next item without a verdict, looking forward first; none left: the finale.
481
+ function checkAdvance() {
482
+ const n = chk.items.length;
483
+ for (let k = 1; k <= n; k++) {
484
+ const j = (chk.i + k) % n;
485
+ if (!chk.decided.has(chk.items[j].id)) return checkShow(j, j > chk.i ? 1 : -1);
486
+ }
487
+ checkFinale();
488
+ }
489
+
490
+ // ---- verdicts
491
+
492
+ // A verdict shows as the outline taking its colour for a moment (and the bar
493
+ // filling), then on to the next. Quiet: you will do this many times.
494
+ function checkFlash(color) {
495
+ const stage = chk.ui.stage;
496
+ stage.style.setProperty('--ring', color);
497
+ clearTimeout(chk.flashTimer);
498
+ chk.flashTimer = setTimeout(() => stage.style.removeProperty('--ring'), 420);
499
+ }
500
+
501
+ async function checkDecide(a, patch, verdict, ms) {
502
+ chk.busy = true;
503
+ try {
504
+ await store.update(a.id, patch);
505
+ } catch (err) {
506
+ chk.busy = false;
507
+ return toast('Could not save: ' + err.message);
508
+ }
509
+ chk.decided.set(a.id, verdict);
510
+ const bar2 = chk.ui.card.querySelector('.check-progress');
511
+ if (bar2) bar2.children[chk.i].className = verdict;
512
+ await Promise.all([load(), checkWait(checkReduced() ? 0 : ms)]);
513
+ if (!chk) return;
514
+ chk.busy = false;
515
+ checkAdvance();
516
+ }
517
+
518
+ function checkAccept() {
519
+ if (!chk || chk.busy || chk.phase !== 'item') return;
520
+ const a = checkCurrent();
521
+ const kind = checkKind(a);
522
+ if (kind === 'fix') {
523
+ checkFlash(COLORS.good);
524
+ return checkDecide(a, { accepted: true }, 'accepted', 300);
525
+ }
526
+ checkFlash(COLORS.good);
527
+ // Keep the new look: it becomes the approved one. Gone for good: stop guarding it.
528
+ const done = a.status === 'resolved' && !a.accepted ? { accepted: true } : {};
529
+ return checkDecide(a, kind === 'gone' ? { unguard: true, ...done } : { rebaseline: true, ...done }, kind === 'gone' ? 'let-go' : 'new-look', 320);
530
+ }
531
+
532
+ // Live content: stop comparing its words and images, keep watching its look.
533
+ function checkLive() {
534
+ if (!chk || chk.busy || chk.phase !== 'item') return;
535
+ const a = checkCurrent();
536
+ if (checkKind(a) !== 'guard') return;
537
+ checkFlash(COLORS.good);
538
+ const done = a.status === 'resolved' && !a.accepted ? { accepted: true } : {};
539
+ checkDecide(a, { dynamic: true, ...done }, 'new-look', 300);
540
+ }
541
+
542
+ function checkRedo() {
543
+ if (!chk || chk.busy || chk.phase !== 'item') return;
544
+ const a = checkCurrent();
545
+ if (checkKind(a) === 'fix') {
546
+ chk.phase = 'redo';
547
+ wakeCheck();
548
+ checkPeek(false);
549
+ return checkSwap(redoCard(a), 0);
550
+ }
551
+ // An approval that changed goes straight back: the difference says what to restore.
552
+ checkFlash(COLORS.fix);
553
+ const diff = (a.guard.now && a.guard.now.diff) || [];
554
+ checkDecide(a, {
555
+ sendBack: true, verdict: 'good', status: 'open', draft: true, accepted: false,
556
+ reply: `This changed after I approved it${diff.length ? ` (${diff.join('; ').replace(/`/g, '')})` : ''}. Put it back the way it was.`,
557
+ }, 'sent-back', 360);
558
+ }
559
+
560
+ function submitRedo(why) {
561
+ const a = checkCurrent();
562
+ const el = checkEl(a);
563
+ chk.phase = 'item';
564
+ wakeCheck();
565
+ checkFlash(COLORS.idea);
566
+ checkDecide(a, { status: 'open', draft: true, accepted: false, reply: why || 'Not quite yet.', ...(el ? { target: describeEl(el) } : {}) }, 'redo', 280);
567
+ }
568
+
569
+ // ---- peek: the "before" laid over the live page, where it was.
570
+
571
+ const PEEK_HOLD = 260; // longer than this is a hold (peek); shorter is a tap (switch)
572
+
573
+ // Space or the Before button went down: show the before, or (if a tap had left it
574
+ // on) switch back to the after.
575
+ function peekDown() {
576
+ if (!chk) return;
577
+ if (chk.peeking && chk.peekSticky) return checkPeek(false);
578
+ chk.peekDownAt = performance.now();
579
+ checkPeek(true);
580
+ }
581
+
582
+ // ...and came up: a hold returns to the after; a tap leaves the before showing.
583
+ function peekUp() {
584
+ if (!chk || !chk.peeking || chk.peekSticky) return;
585
+ if (performance.now() - (chk.peekDownAt || 0) > PEEK_HOLD) checkPeek(false);
586
+ else chk.peekSticky = true;
587
+ }
588
+
589
+ function checkPeek(on) {
590
+ if (!chk) return;
591
+ const a = checkCurrent();
592
+ const then = on && chk.phase === 'item' && a && imagesFor(a).then;
593
+ const { peek: img, stage, card } = chk.ui;
594
+ const showing = !!then;
595
+ chk.peeking = showing;
596
+ if (!showing) stage.classList.remove('covering', 'picture');
597
+ if (!showing) chk.peekSticky = false;
598
+ stage.classList.toggle('peeking', showing);
599
+ chk.ui.spot.dataset.label = showing ? 'Before' : 'After';
600
+ for (const b of card.querySelectorAll('.ab')) b.setAttribute('aria-pressed', String(b.classList.contains('before') === showing));
601
+ wakeCheck();
602
+ if (!showing) return;
603
+ chk.peekBox = then.box || null;
604
+ chk.peekRect = null;
605
+ chk.glideUntil = performance.now() + 260; // the outline eases to the before's size and back
606
+ if (img.getAttribute('src') !== then.src) {
607
+ img.style.visibility = 'hidden'; // placed once its size is known, never at a guess
608
+ img.src = then.src;
609
+ chk.peekTone = null;
610
+ }
611
+ // Placing it changes where the outline should go: wake the spotlight to glide there.
612
+ const ready = () => {
613
+ if (!chk || !chk.peeking) return;
614
+ if (chk.peekTone === null) chk.peekTone = toneMismatch(img);
615
+ placePeek();
616
+ wakeCheck();
617
+ };
618
+ if (img.complete && img.naturalWidth) ready();
619
+ else img.onload = ready;
620
+ }
621
+
622
+ // Light page, dark picture (or the other way round): the before was taken in the other
623
+ // theme, and laid over the page it would read as a black hole. Show it as a picture then.
624
+ function toneMismatch(img) {
625
+ try {
626
+ const c = document.createElement('canvas');
627
+ c.width = c.height = 12;
628
+ const g = c.getContext('2d', { willReadFrequently: true });
629
+ g.drawImage(img, 0, 0, 12, 12);
630
+ const d = g.getImageData(0, 0, 12, 12).data;
631
+ let sum = 0, n = 0;
632
+ for (let i = 0; i < 144; i++) {
633
+ const x = i % 12, y = (i / 12) | 0;
634
+ if (x > 1 && x < 10 && y > 1 && y < 10) continue; // the edges: the page around the element
635
+ sum += (0.2126 * d[i * 4] + 0.7152 * d[i * 4 + 1] + 0.0722 * d[i * 4 + 2]) / 255;
636
+ n++;
637
+ }
638
+ return Math.abs(sum / n - lightness(pageBackground())) > 0.4;
639
+ } catch {
640
+ return false;
641
+ }
642
+ }
643
+
644
+ function lightness(css) {
645
+ const ok = /^oklch\(\s*([\d.]+)(%?)/.exec(css);
646
+ if (ok) return ok[2] ? +ok[1] / 100 : +ok[1];
647
+ const m = css.match(/[\d.]+/g);
648
+ if (!m || m.length < 3) return 1;
649
+ return (0.2126 * m[0] + 0.7152 * m[1] + 0.0722 * m[2]) / 255;
650
+ }
651
+
652
+ function pageBackground() {
653
+ for (const el of [document.body, document.documentElement]) {
654
+ const c = getComputedStyle(el).backgroundColor;
655
+ if (c && c !== 'rgba(0, 0, 0, 0)' && c !== 'transparent') return c;
656
+ }
657
+ return '#fff';
658
+ }
659
+
660
+ function placePeek() {
661
+ const img = chk.ui.peek;
662
+ const stage = chk.ui.stage;
663
+ const el = checkEl(checkCurrent());
664
+ const r = el && el.getBoundingClientRect();
665
+ const box = chk.peekBox;
666
+ const nw = img.naturalWidth, nh = img.naturalHeight;
667
+ // The card's full height (not its compact one), so covering can't flip back and forth.
668
+ const cardTop = innerHeight - Math.max(chk.cardFullH || 0, chk.cardSize.h) - CARD_BOTTOM;
669
+ const inPlace = !!(r && box && !chk.peekTone);
670
+ stage.classList.toggle('picture', !inPlace);
671
+ if (inPlace) {
672
+ // Same width: line up the corners. A fix that resized it: line up the top centres.
673
+ const x = Math.abs(box.w - r.width) < 4 ? r.left - box.x : r.left + r.width / 2 - (box.x + box.w / 2);
674
+ const y = r.top - box.y;
675
+ chk.peekRect = { x: x + box.x, y: y + box.y, w: box.w, h: box.h };
676
+ Object.assign(img.style, { transform: `translate(${Math.round(x)}px, ${Math.round(y)}px)`, width: nw + 'px', height: nh + 'px' });
677
+ // Cover the live element (the after) wherever the before doesn't reach, in the page's colour.
678
+ const u = unionRect({ x: r.left, y: r.top, w: r.width, h: r.height }, chk.peekRect);
679
+ Object.assign(chk.ui.peekBg.style, { transform: `translate(${u.x - 10}px, ${u.y - 10}px)`, width: u.w + 20 + 'px', height: u.h + 20 + 'px', borderRadius: '14px', background: pageBackground() });
680
+ } else {
681
+ // As a picture: scaled to the free space above the card, centred, clearly framed.
682
+ const top = headerCover() + 40, room = Math.max(120, cardTop - top - 24);
683
+ const k = Math.min(1, (innerWidth - 64) / nw, room / nh);
684
+ const w = Math.round(nw * k), h = Math.round(nh * k);
685
+ const x = Math.round((innerWidth - w) / 2), y = Math.round(top + (room - h) / 2);
686
+ chk.peekRect = { x, y, w, h };
687
+ Object.assign(img.style, { transform: `translate(${x}px, ${y}px)`, width: w + 'px', height: h + 'px' });
688
+ }
689
+ img.style.visibility = '';
690
+ // A before taller than the room above the card: the card becomes just its switch.
691
+ const pr = chk.peekRect;
692
+ const covers = inPlace && pr.y + pr.h + 10 > cardTop;
693
+ if (covers && !stage.classList.contains('covering')) chk.cardFullH = chk.cardSize.h;
694
+ stage.classList.toggle('covering', covers);
695
+ }
696
+
697
+ function unionRect(a, b) {
698
+ if (!a || !b) return a || b || null;
699
+ const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
700
+ return { x, y, w: Math.max(a.x + a.w, b.x + b.w) - x, h: Math.max(a.y + a.h, b.y + b.h) - y };
701
+ }
702
+
703
+ // ---- the end of the round
704
+
705
+ function checkFinale() {
706
+ checkPeek(false);
707
+ chk.phase = 'done';
708
+ wakeCheck();
709
+ delete chk.ui.stage.dataset.kind;
710
+ const v = [...chk.decided.values()];
711
+ const count = (k) => v.filter((x) => x === k).length;
712
+ const accepted = count('accepted'), redone = count('redo'), back = count('sent-back');
713
+ const judged = accepted + redone;
714
+ const rate = judged ? accepted / judged : 1;
715
+ const guarded = state.annotations.filter((a) => !a.draft && (a.accepted || a.verdict === 'good')).length;
716
+ const drafts = state.annotations.filter((a) => a.draft).length;
717
+ const round = state.rounds && state.rounds[state.rounds.length - 1];
718
+ const R = 44, C = 2 * Math.PI * R;
719
+ const ring = s('svg', { class: 'check-ring', viewBox: '0 0 104 104', 'aria-hidden': 'true' });
720
+ ring.append(s('circle', { cx: 52, cy: 52, r: R, class: 'track' }), s('circle', { cx: 52, cy: 52, r: R, class: 'fill', 'stroke-dasharray': C, 'stroke-dashoffset': C }));
721
+ const num = h('b', { class: 'check-rate' }, judged ? '0%' : '✓');
722
+ const done = () => exitCheck();
723
+ const sendNow = async () => {
724
+ exitCheck();
725
+ await load();
726
+ sendRound();
727
+ };
728
+ const body = h('div', { class: 'check-body finale' },
729
+ h('div', { class: 'check-ring-wrap' }, ring, num),
730
+ h('h2', { class: 'check-title' }, judged ? 'accepted first time' : 'All checked'),
731
+ h('p', { class: 'check-sum' },
732
+ [round ? `Round ${round.n}` : null, judged ? `${accepted} accepted` : null, redone ? `${redone} to redo` : null, back ? `${back} sent back` : null].filter(Boolean).join(' · ')),
733
+ guarded ? h('p', { class: 'check-guarded' }, h('i'), `${guarded} approval${guarded === 1 ? ' is' : 's are'} guarded now. If one changes, it comes back here.`) : null,
734
+ h('div', { class: 'check-actions' },
735
+ drafts ? h('button', { type: 'button', class: 'btn', onclick: done }, 'Later') : null,
736
+ h('span', { class: 'grow' }),
737
+ h('button', { type: 'button', class: 'btn primary', onclick: drafts ? sendNow : done }, drafts ? `Send ${drafts} to your agent` : 'Done', checkKbd('↵')),
738
+ ),
739
+ );
740
+ chk.finish = drafts ? sendNow : done;
741
+ checkSwap(body, 0);
742
+ // The ring fills to the rate while the number counts up with it.
743
+ const fill = ring.querySelector('.fill');
744
+ const ms = checkReduced() ? 1 : 900;
745
+ fill.animate([{ strokeDashoffset: C }, { strokeDashoffset: C * (1 - rate) }], { duration: ms, delay: 180, easing: 'cubic-bezier(0.23, 1, 0.32, 1)', fill: 'forwards' });
746
+ if (judged) {
747
+ const t0 = performance.now() + 180;
748
+ const count2 = (now) => {
749
+ const p = Math.min(1, Math.max(0, (now - t0) / ms));
750
+ num.textContent = Math.round(rate * 100 * (1 - Math.pow(1 - p, 3))) + '%';
751
+ if (p < 1 && chk) requestAnimationFrame(count2);
752
+ };
753
+ requestAnimationFrame(count2);
754
+ }
755
+ }
756
+
757
+ function exitCheck() {
758
+ if (!chk || chk.phase === 'leaving') return;
759
+ checkPeek(false);
760
+ chk.phase = 'leaving';
761
+ wakeCheck();
762
+ const { stage } = chk.ui;
763
+ stage.classList.add('out');
764
+ delete host.dataset.check;
765
+ const c = chk;
766
+ setTimeout(() => {
767
+ cancelAnimationFrame(c.raf);
768
+ stage.remove();
769
+ if (chk === c) chk = null;
770
+ renderBar();
771
+ }, checkReduced() ? 0 : 280);
772
+ }
773
+
774
+ // Keys while checking: one per verdict. Returns true when the key was ours.
775
+ function checkKey(e) {
776
+ // An open Compare slider (or image) owns the keyboard until it closes.
777
+ if (!chk || chk.phase === 'leaving' || galleryEl || isTyping(e) || e.metaKey || e.ctrlKey || e.altKey) return false;
778
+ const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
779
+ const act = chk.phase === 'done'
780
+ ? { Enter: () => chk.finish(), Escape: exitCheck }
781
+ : {
782
+ a: checkAccept, Enter: checkAccept, r: checkRedo, l: checkLive,
783
+ ArrowRight: () => checkMove(1), ArrowDown: () => checkMove(1), ArrowLeft: () => checkMove(-1), ArrowUp: () => checkMove(-1),
784
+ ' ': () => !e.repeat && peekDown(),
785
+ Escape: exitCheck,
786
+ };
787
+ const fn = act[k];
788
+ // Swallow everything else too: the page's own shortcuts wait until you're done.
789
+ e.preventDefault();
790
+ e.stopPropagation();
791
+ if (fn) fn();
792
+ return true;
793
+ }
794
+
795
+ addEventListener('keyup', (e) => chk && e.key === ' ' && peekUp(), true);
796
+ addEventListener('blur', () => chk && checkPeek(false));
797
+
798
+ // ---- styles
799
+
800
+ addStyles(`
801
+ .check-stage { position: fixed; inset: 0; pointer-events: auto; --dim: 0 0 0 200vmax rgba(9,9,11,.5);
802
+ animation: check-in 140ms ease-out; }
803
+ .check-stage.out { opacity: 0; transition: opacity 260ms ease; pointer-events: none; }
804
+ @keyframes check-in { from { opacity: 0; } }
805
+
806
+ /* Everything but the spotlight goes soft: a touch of blur and less colour. */
807
+ .check-soft { position: absolute; inset: 0; pointer-events: none; }
808
+ @supports ((mask-composite: exclude) or (-webkit-mask-composite: xor)) {
809
+ /* A flat tint, not a blur: re-blurring the whole page every frame while the spotlight glides costs 15fps. */
810
+ .check-soft { background: rgba(9, 9, 11, .12);
811
+ -webkit-mask: linear-gradient(#000 0 0), linear-gradient(#000 0 0) var(--hx) var(--hy) / var(--hw) var(--hh) no-repeat;
812
+ -webkit-mask-composite: xor; mask: linear-gradient(#000 0 0), linear-gradient(#000 0 0) var(--hx) var(--hy) / var(--hw) var(--hh) no-repeat;
813
+ mask-composite: exclude; }
814
+ }
815
+ .check-spot { position: absolute; left: 0; top: 0; pointer-events: none; z-index: 3; /* above the before picture: its outline and label stay visible */ box-shadow: var(--dim), 0 0 0 1.5px var(--ring, rgba(255,255,255,.92));
816
+ transition: box-shadow 180ms ease; will-change: transform, width, height; }
817
+ /* Hopping to a far item, or nothing to point at: the page stays dim, no outline, no label. */
818
+ .check-stage:is(.hop, .lost) .check-spot { box-shadow: var(--dim), 0 0 0 1.5px transparent; transition: none; }
819
+ .check-spot::after { transition: opacity 140ms ease; }
820
+ .check-stage:is(.hop, .lost) .check-spot::after { opacity: 0; }
821
+ .check-peek-bg { position: absolute; left: 0; top: 0; pointer-events: none; opacity: 0; transition: opacity 140ms ease; z-index: 1; }
822
+ .check-stage.peeking .check-peek-bg { opacity: 1; }
823
+ /* Which version the page shows, on the outline itself. */
824
+ .check-spot::after { content: attr(data-label); position: absolute; left: -1.5px; bottom: 100%; margin-bottom: 6px; font-size: 11px; font-weight: 650;
825
+ padding: 2px 8px; border-radius: 6px; background: rgba(24,24,27,.92); color: #fafafa; white-space: nowrap; }
826
+ .check-stage.peeking .check-spot::after { background: #fafafa; color: #18181b; }
827
+ .check-peek { position: absolute; left: 0; top: 0; max-width: none; pointer-events: none; opacity: 0; transition: opacity 140ms ease; z-index: 2;
828
+ border-radius: 10px; box-shadow: 0 0 0 1px rgba(255,255,255,.5), 0 18px 50px rgba(0,0,0,.5);
829
+ -webkit-mask: linear-gradient(90deg, transparent, #000 18px, #000 calc(100% - 18px), transparent), linear-gradient(transparent, #000 18px, #000 calc(100% - 18px), transparent);
830
+ -webkit-mask-composite: source-in; mask: linear-gradient(90deg, transparent, #000 18px, #000 calc(100% - 18px), transparent), linear-gradient(transparent, #000 18px, #000 calc(100% - 18px), transparent);
831
+ mask-composite: intersect; }
832
+ .check-stage.peeking .check-peek { opacity: 1; }
833
+ /* While you look at the before, the card steps back. */
834
+ .check-card { transition: opacity 160ms ease; }
835
+ /* The card stays readable while you look at the before: it is docked clear of the item.
836
+ Only when the before is too tall does it step back, keeping the switch usable. */
837
+ .check-card { transition: background-color 140ms ease, border-color 140ms ease, box-shadow 140ms ease; }
838
+ /* The before reaches down to the card: the card becomes just its switch, never a ghost of itself. */
839
+ .check-stage.peeking.covering .check-body > :not(.check-ab), .check-stage.peeking.covering .check-ab > :not(.ab-switch) { display: none; }
840
+ /* Before as a picture (it can't line up with the page): framed, not blended in. */
841
+ .check-stage.picture .check-peek-bg { opacity: 0 !important; }
842
+ .check-stage.picture .check-peek { -webkit-mask: none; mask: none; border-radius: 12px; box-shadow: 0 0 0 1px rgba(255,255,255,.16), 0 24px 60px -14px rgba(0,0,0,.65); }
843
+ .check-body > * { transition: opacity 140ms ease; }
844
+ .check-card { position: absolute; left: 0; top: 0; z-index: 4; width: 340px; max-width: calc(100vw - 32px); background: var(--bg); border: 1px solid var(--line);
845
+ border-radius: 14px; box-shadow: 0 16px 40px -12px rgba(0,0,0,.55); background: #18181b; /* opaque: a moving backdrop blur re-renders every frame */
846
+ overflow: hidden; display: grid; animation: card-in 180ms var(--ease-out) backwards; }
847
+ @keyframes card-in { from { opacity: 0; translate: 0 6px; } }
848
+ .check-card > * { grid-area: 1 / 1; }
849
+ .check-body { padding: 12px 14px 14px; display: grid; gap: 10px; align-content: start; }
850
+ .check-body > header { display: flex; align-items: center; gap: 10px; }
851
+ .check-close { width: 24px; height: 24px; border-radius: 6px; display: grid; place-items: center; color: #71717a; margin-right: -4px; }
852
+ .check-close svg { width: 14px; height: 14px; }
853
+ @media (hover: hover) and (pointer: fine) { .check-close:hover { color: #fafafa; background: rgba(255,255,255,.08); } }
854
+ .check-ab { display: grid; gap: 6px; }
855
+ .ab-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 20px; }
856
+ .ab-switch { display: grid; grid-template-columns: 1fr 1fr; padding: 3px; gap: 2px; border-radius: 10px; background: rgba(255,255,255,.07); }
857
+ .check-ab .ab-hint { grid-column: 1 / -1; }
858
+ .check-stage:not([data-kind]) .check-spot::after { display: none; } /* the finale has no page to label */
859
+ .ab { height: 30px; padding: 0 14px; border-radius: 8px; font-size: 13px; font-weight: 600; color: #a1a1aa; user-select: none; -webkit-user-select: none; touch-action: none;
860
+ transition: background-color 120ms ease, color 120ms ease, transform 120ms var(--ease-out); }
861
+ .ab[aria-pressed="true"] { background: #fafafa; color: #18181b; }
862
+ .ab:active { transform: scale(.97); }
863
+ .ab-hint { font-size: 11.5px; color: #71717a; white-space: nowrap; }
864
+ .ab-hint kbd { margin: 0 2px 0 0; }
865
+ .check-kicker { margin: 0; font-size: 11.5px; font-weight: 600; color: #a1a1aa; }
866
+ .check-kicker i { width: 8px; height: 8px; border-radius: 50%; background: var(--good); box-shadow: 0 0 0 4px rgba(34,197,94,.18); }
867
+ .check-kicker.k-guard, .check-kicker.k-gone { color: #fecdd3; } .check-kicker.k-guard i, .check-kicker.k-gone i { background: var(--fix); box-shadow: 0 0 0 4px rgba(244,63,94,.18); }
868
+ .check-kicker.k-redo { color: #fde68a; } .check-kicker.k-redo i { background: var(--idea); box-shadow: 0 0 0 4px rgba(245,158,11,.18); }
869
+ .check-count { font-size: 12px; font-weight: 600; color: #a1a1aa; font-variant-numeric: tabular-nums; white-space: nowrap; }
870
+ .check-progress { display: flex; gap: 3px; flex: 1; }
871
+ .check-progress i { flex: 1; height: 2px; border-radius: 2px; background: rgba(255,255,255,.12); transition: background-color 260ms ease, transform 260ms var(--ease-out); transform-origin: left; }
872
+ .check-progress i.now { background: rgba(255,255,255,.6); }
873
+ .check-progress i.accepted, .check-progress i.new-look, .check-progress i.let-go { background: var(--good); }
874
+ .check-progress i.redo { background: var(--idea); } .check-progress i.sent-back { background: var(--fix); }
875
+ .check-title { margin: 2px 0 0; font-size: 15px; line-height: 1.4; font-weight: 600; letter-spacing: -.005em; color: #fafafa; text-wrap: pretty;
876
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
877
+ .check-detail { margin: -6px 0 0; font-size: 12.5px; line-height: 1.45; color: #a1a1aa;
878
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
879
+ .check-you { margin: -4px 0 0; font-size: 12.5px; line-height: 1.45; color: #a1a1aa;
880
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
881
+ .check-lost { display: grid; gap: 8px; }
882
+ .check-lost p { margin: 0; font-size: 12px; color: #a1a1aa; }
883
+ .check-thumbs { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
884
+ .check-thumb { display: grid; gap: 4px; padding: 0; text-align: left; font-size: 11px; font-weight: 600; color: #a1a1aa; }
885
+ .check-thumb img { width: 100%; height: 84px; object-fit: cover; object-position: top; border-radius: 8px; background: #fff; box-shadow: 0 0 0 1px rgba(255,255,255,.1); }
886
+ .check-you span { color: #71717a; margin-right: 6px; }
887
+ .check-diff { margin: -2px 0 0; padding: 0; list-style: none; display: grid; gap: 4px; font: 11.5px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; color: #e4e4e7; }
888
+ .check-diff li { padding: 5px 8px; border-radius: 6px; background: rgba(244,63,94,.08); border-left: 2px solid var(--fix); overflow-wrap: anywhere; }
889
+ .check-live { justify-self: start; margin-top: -4px; font-size: 11.5px; color: #a1a1aa; display: inline-flex; align-items: center; gap: 4px; padding: 2px 0; }
890
+ .check-live b { color: #e4e4e7; font-weight: 600; }
891
+ .check-link { color: #93c5fd; font-weight: 600; font-size: 11.5px; padding: 2px 4px; border-radius: 5px; }
892
+ .check-actions { display: flex; gap: 8px; align-items: center; }
893
+ .check-actions .grow { flex: 1; }
894
+ .check-actions .btn { height: 34px; padding: 0 12px; border-radius: 9px; display: inline-flex; align-items: center; gap: 6px; font-size: 13px; }
895
+ .check-actions .check-yes { flex: 1; justify-content: center; }
896
+ .check-actions .check-no { color: #e4e4e7; }
897
+ .check-body kbd { font: 600 10.5px ui-monospace, SFMono-Regular, Menlo, monospace; min-width: 18px; height: 18px; padding: 0 4px; border-radius: 5px;
898
+ display: inline-grid; place-items: center; background: rgba(255,255,255,.1); color: inherit; opacity: .75; margin-left: 2px; }
899
+ .btn.primary kbd { background: rgba(0,0,0,.08); }
900
+ .check-body.k-redo textarea { min-height: 64px; }
901
+ .check-body.finale { padding: 26px 22px 18px; justify-items: center; text-align: center; gap: 10px; }
902
+ .check-ring-wrap { position: relative; width: 104px; height: 104px; display: grid; place-items: center; margin-bottom: 4px; }
903
+ .check-ring { position: absolute; inset: 0; transform: rotate(-90deg); }
904
+ .check-ring circle { fill: none; stroke-width: 7; }
905
+ .check-ring .track { stroke: rgba(255,255,255,.1); }
906
+ .check-ring .fill { stroke: var(--good); stroke-linecap: round; }
907
+ .check-rate { font-size: 28px; font-weight: 750; letter-spacing: -.03em; font-variant-numeric: tabular-nums; color: #fafafa; }
908
+ .check-body.finale .check-title { font-size: 15px; font-weight: 600; color: #d4d4d8; }
909
+ .check-sum { margin: 0; font-size: 12.5px; color: #a1a1aa; }
910
+ .check-guarded { margin: 4px 0 2px; font-size: 12.5px; line-height: 1.45; color: #d4d4d8; display: flex; gap: 8px; text-align: left; padding: 10px 12px;
911
+ border-radius: 10px; background: rgba(34,197,94,.08); border: 1px solid rgba(34,197,94,.2); }
912
+ .check-guarded i { flex: none; width: 14px; height: 16px; margin-top: 1px; background: var(--good);
913
+ clip-path: polygon(50% 0, 100% 18%, 100% 55%, 50% 100%, 0 55%, 0 18%); }
914
+ .check-body.finale .check-actions { width: 100%; margin-top: 8px; }
915
+ :host([data-check]) .bar { opacity: 0; translate: 0 14px; pointer-events: none; transition: opacity 180ms ease, translate 220ms var(--ease-out); }
916
+ .bar { transition: opacity 220ms ease, translate 260ms var(--ease-out); }
917
+ :host([data-check]) .layer { opacity: 0; transition: opacity 160ms ease; }
918
+ .layer { transition: opacity 240ms ease; }
919
+ @media (hover: hover) and (pointer: fine) { .check-link:hover { background: rgba(147,197,253,.12); } }
920
+ @media (max-width: 640px) { .check-card { width: calc(100vw - 32px); } }
921
+ /* Touch: no keyboard to hint at; the picture is what you press and hold. */
922
+ .on-touch { display: none; }
923
+ @media (hover: none) and (pointer: coarse) {
924
+ .check-body kbd, .on-touch { display: inline; }
925
+ }
926
+ `);