@artymclabin/qa-review 0.3.7

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,858 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ // Gamified on-page QA review overlay. An onboarding-style spotlight walkthrough:
4
+ // dims the page except the current review target, shows a card with the item's
5
+ // title/subtitle + Approve/Reject/Prev/Next, and persists verdicts as you go to
6
+ // a DURABLE server-side ledger (the state endpoint) with localStorage as a fast
7
+ // local cache. Round-based: the set of actionable (not-fully-approved) items is
8
+ // FROZEN at hydration with a fixed denominator, so approved items never
9
+ // re-appear mid-round and the counter counts up "1 of N" against a stable N.
10
+ // 🚨 There is NO reset-all anywhere: the ledger is never bulk-wiped; a single
11
+ // item is re-queued by invalidating just it (state POST with `verdict: null`).
12
+ //
13
+ // Capabilities:
14
+ // - TASK ITEMS: an item without a `selector` renders as a centered card (no
15
+ // spotlight) with an optional action link. Same verdict/undo/note flow.
16
+ // - JOURNEY: ordered multi-page review. The moment a page's round is done the
17
+ // overlay navigates IMMEDIATELY to the next page with pending items (no
18
+ // interstitial); a completion panel shows only when NOTHING is pending
19
+ // anywhere. Activation query params survive the hop.
20
+ // - NOT-ALTERED POKA-YOKE: every verdict stores a content fingerprint. A
21
+ // re-shown REJECTED item whose content fingerprints identical gets a
22
+ // prominent system-computed "NOT ALTERED since your rejection" badge (the
23
+ // overlay judges by hashing - never an operating agent's claim).
24
+ // - DEVICE-SPLIT APPROVALS: items can require per-device sign-off (PC/mobile);
25
+ // approved only when every required device approved. Plain historical
26
+ // approvals are grandfathered as fully approved.
27
+ // - SUB-HIGHLIGHT: `highlightWords` wraps matching words inside the anchored
28
+ // element with a secondary mark, replacing "where to look" prose.
29
+ // - CODENAMES: every item gets a stable two-word codename + "Copy ref".
30
+ // - MINIMIZE BUBBLE: the panel collapses to a draggable floating bubble
31
+ // (mouse + touch); tapping it restores the panel.
32
+ import * as React from "react";
33
+ import { createPortal } from "react-dom";
34
+ import { Check, X, ChevronLeft, ChevronRight, ClipboardCopy, PartyPopper, Database, Undo2, Crosshair, Locate, ExternalLink, Minimize2, Monitor, Smartphone, ClipboardCheck, } from "lucide-react";
35
+ import { isTaskItem } from "./types.js";
36
+ import { QAStore } from "./store.js";
37
+ import { buildJourneyNavUrl, ensurePrefetchLink, fetchPendingCounts, isPrefetchFresh, journeyFinishView, journeyIndex, nextPendingPage, resolveFinishAction, shouldPrefetch, } from "./journey.js";
38
+ import { fingerprintStatus, itemFingerprint } from "./fingerprint.js";
39
+ import { DEVICE_LABEL, DEVICE_TOOLTIP, approvedDevicesOf, detectDevice, isFullyApproved, requiredDevices, toggleDevice, } from "./device.js";
40
+ import { applySubHighlights } from "./highlight.js";
41
+ import { describeRevisit } from "./revisit.js";
42
+ import { MOBILE_PREVIEW_HEIGHT, MOBILE_PREVIEW_WIDTH, buildMobilePreviewUrl, isEmbeddedPreview, parseVariantMessage, postVariantToPreview, scrollPreviewToSelector, } from "./preview.js";
43
+ import { codenameFor, formatQARef } from "../shared/codename.js";
44
+ import { ensureQAStyles } from "./styles.js";
45
+ const RING = 8; // px padding of the spotlight around the target
46
+ const CARD_W = 340;
47
+ const CARD_H = 544;
48
+ const DEFAULT_STATE_URL = "/api/qa/state";
49
+ /** Pointer movement below this (px) counts as a click, not a drag. */
50
+ const CLICK_DRAG_THRESHOLD_PX = 6;
51
+ /** Was the pointer gesture a click (vs a drag)? Exported for tests. */
52
+ export function isClickGesture(dx, dy) {
53
+ return Math.hypot(dx, dy) < CLICK_DRAG_THRESHOLD_PX;
54
+ }
55
+ /** Note-reference text for a picked element: « label » (truncated) or tag. */
56
+ export function pickedElementRef(textContent, tagName) {
57
+ const label = (textContent || "").replace(/\s+/g, " ").trim().slice(0, 48) || tagName.toLowerCase();
58
+ return ` «${label}» `;
59
+ }
60
+ /**
61
+ * Element-pick mode semantics (0.3.1): LEFT click picks and exits the mode;
62
+ * RIGHT click picks and STAYS for multi-select (context menu suppressed).
63
+ */
64
+ export function shouldExitPickMode(button) {
65
+ return button === "left";
66
+ }
67
+ /** Viewport width at/below which the panel auto-minimizes to the bubble. */
68
+ export const AUTO_BUBBLE_MAX_WIDTH_PX = 767;
69
+ /**
70
+ * Auto-bubble state machine (0.3.1): a VIEWPORT switch always applies its
71
+ * auto state (mobile-ish width -> bubble, desktop -> panel); a MANUAL
72
+ * minimize/restore wins immediately and holds until the NEXT viewport switch.
73
+ * Exported for tests.
74
+ */
75
+ export function nextMinimized(current, ev) {
76
+ return ev.type === "viewport" ? ev.mobile : ev.minimized;
77
+ }
78
+ const DEVICE_EMOJI_TIP = {
79
+ "💻": "review on PC",
80
+ "📱": "review on mobile",
81
+ "💻📱": "review on PC and mobile",
82
+ };
83
+ /** Pointer-based drag (mouse + touch). Calls onClickInstead for taps. */
84
+ function usePointerDrag(getBase, onMove, onClickInstead) {
85
+ return React.useCallback((e) => {
86
+ if (e.pointerType === "mouse" && e.button !== 0)
87
+ return;
88
+ if (e.target.closest("button, textarea, input, a"))
89
+ return;
90
+ e.preventDefault();
91
+ const startX = e.clientX;
92
+ const startY = e.clientY;
93
+ const base = getBase();
94
+ let moved = false;
95
+ const move = (ev) => {
96
+ const dx = ev.clientX - startX;
97
+ const dy = ev.clientY - startY;
98
+ if (!isClickGesture(dx, dy))
99
+ moved = true;
100
+ if (moved)
101
+ onMove({ x: base.x + dx, y: base.y + dy });
102
+ };
103
+ const up = () => {
104
+ window.removeEventListener("pointermove", move);
105
+ window.removeEventListener("pointerup", up);
106
+ window.removeEventListener("pointercancel", up);
107
+ if (!moved && onClickInstead)
108
+ onClickInstead();
109
+ };
110
+ window.addEventListener("pointermove", move);
111
+ window.addEventListener("pointerup", up);
112
+ window.addEventListener("pointercancel", up);
113
+ }, [getBase, onMove, onClickInstead]);
114
+ }
115
+ export function QAReviewOverlay({ items, target, gateParam, stateUrl, submitUrl, storageKey, theme, journey, }) {
116
+ // Stable per-target store. storageKey is captured on first render by design.
117
+ const storageKeyRef = React.useRef(storageKey);
118
+ const store = React.useMemo(() => new QAStore({ target, stateUrl, storageKey: storageKeyRef.current }), [target, stateUrl]);
119
+ // Inside the mobile-preview iframe the overlay stays DORMANT (no nested chrome).
120
+ const embedded = typeof window !== "undefined" && isEmbeddedPreview(window.location.search);
121
+ const [active, setActive] = React.useState(!gateParam && !embedded);
122
+ const [hydrated, setHydrated] = React.useState(false);
123
+ const [index, setIndex] = React.useState(0);
124
+ const [results, setResults] = React.useState({});
125
+ // Partial device approvals (item not yet fully approved).
126
+ const [partials, setPartials] = React.useState({});
127
+ // Stored content fingerprints per item (from the ledger / this session).
128
+ const [fps, setFps] = React.useState({});
129
+ // Re-queue context per item (revisit reason + prior verdict/note).
130
+ const [revisits, setRevisits] = React.useState({});
131
+ // Current item's LIVE fingerprint (system-computed, drives the badges).
132
+ const [currentFp, setCurrentFp] = React.useState(null);
133
+ // The ROUND: ids of the items ACTIONABLE this session (not fully approved
134
+ // under device-aware semantics), frozen at hydration.
135
+ const [roundIds, setRoundIds] = React.useState([]);
136
+ const [note, setNote] = React.useState("");
137
+ const [rect, setRect] = React.useState(null);
138
+ // Panel lives on the LEFT and is draggable by its header (null = default).
139
+ const [pos, setPos] = React.useState(null);
140
+ const cardRef = React.useRef(null);
141
+ const noteRef = React.useRef(null);
142
+ // When set, the note effect uses this text instead of the stored note (so
143
+ // Undo keeps what was typed instead of wiping the textarea).
144
+ const keepNoteRef = React.useRef(null);
145
+ // Element-picker: click an element on the page to insert a reference into the note.
146
+ const [picking, setPicking] = React.useState(false);
147
+ const [finished, setFinished] = React.useState(false);
148
+ const [copied, setCopied] = React.useState(false);
149
+ const [refCopied, setRefCopied] = React.useState(false);
150
+ const [save, setSave] = React.useState({ status: "idle" });
151
+ // Minimized-to-bubble state (replaces the old 3s peek).
152
+ const [minimized, setMinimized] = React.useState(false);
153
+ const [bubblePos, setBubblePos] = React.useState(null);
154
+ const bubbleRef = React.useRef(null);
155
+ // Stack of item ids in the order they were decided, for Undo.
156
+ const [undoStack, setUndoStack] = React.useState([]);
157
+ // Journey: pending counts (for the journey-complete panel only - page-level
158
+ // completion navigates IMMEDIATELY with no interstitial).
159
+ const [journeyCounts, setJourneyCounts] = React.useState(null);
160
+ const [journeyComplete, setJourneyComplete] = React.useState(false);
161
+ // The journey page a full-page navigation is in flight to (loading label).
162
+ const [navigatingTo, setNavigatingTo] = React.useState(null);
163
+ // Prefetched journey pending counts (warmed near the end of the round).
164
+ const prefetchRef = React.useRef(null);
165
+ // Verdicts recorded in THIS session/run (round-scoped counters).
166
+ const [sessionVerdicts, setSessionVerdicts] = React.useState({});
167
+ // Phone-sized same-origin preview of the current page (Approve Mobile on desktop).
168
+ const [mobilePreview, setMobilePreview] = React.useState(false);
169
+ const previewIframeRef = React.useRef(null);
170
+ const journeyIdx = journey ? journeyIndex(journey.pages, target) : -1;
171
+ const inJourney = !!journey && journeyIdx >= 0;
172
+ const detectedDevice = React.useMemo(() => detectDevice(), []);
173
+ // Optional opt-in gate (URL param), read on mount so SSR stays static.
174
+ React.useEffect(() => {
175
+ if (!gateParam || embedded)
176
+ return;
177
+ const params = new URLSearchParams(window.location.search);
178
+ if (params.has(gateParam))
179
+ setActive(true);
180
+ // eslint-disable-next-line react-hooks/exhaustive-deps
181
+ }, [gateParam]);
182
+ // EMBEDDED (inside the mobile-preview iframe): listen for variant messages
183
+ // from the parent and run the item's OWN variation callback here, so the
184
+ // DOM mutation executes in THIS (iframe) document - live variant updates
185
+ // without a close+reopen. Same-origin only.
186
+ React.useEffect(() => {
187
+ if (!embedded)
188
+ return;
189
+ const onMsg = (e) => {
190
+ if (e.origin !== window.location.origin)
191
+ return;
192
+ const msg = parseVariantMessage(e.data);
193
+ if (!msg)
194
+ return;
195
+ const item = items.find((i) => i.id === msg.itemId);
196
+ item?.variations?.onSelect(msg.variant);
197
+ };
198
+ window.addEventListener("message", onMsg);
199
+ return () => window.removeEventListener("message", onMsg);
200
+ }, [embedded, items]);
201
+ // Mirror the theme vars onto <html> so styles applied to PAGE content (the
202
+ // sub-highlight <mark>s) resolve them - they live outside any .qar-theme
203
+ // scope. The stylesheet also carries hard fallbacks (0.3.1 fix).
204
+ React.useEffect(() => {
205
+ const root = document.documentElement;
206
+ const vars = [
207
+ ["--qar-accent", theme?.accent],
208
+ ["--qar-accent-contrast", theme?.accentContrast],
209
+ ["--qar-panel-bg", theme?.panelBg],
210
+ ["--qar-input-bg", theme?.inputBg],
211
+ ];
212
+ const prev = new Map();
213
+ for (const [k, v] of vars) {
214
+ if (!v)
215
+ continue;
216
+ prev.set(k, root.style.getPropertyValue(k));
217
+ root.style.setProperty(k, v);
218
+ }
219
+ return () => {
220
+ for (const [k, old] of prev) {
221
+ if (old)
222
+ root.style.setProperty(k, old);
223
+ else
224
+ root.style.removeProperty(k);
225
+ }
226
+ };
227
+ }, [theme?.accent, theme?.accentContrast, theme?.panelBg, theme?.inputBg]);
228
+ // AUTO-BUBBLE: a viewport switch to mobile-ish width minimizes to the
229
+ // bubble; switching back restores. Manual minimize/restore wins until the
230
+ // next switch (see nextMinimized). Only CHANGE events apply - the initial
231
+ // viewport never force-minimizes.
232
+ React.useEffect(() => {
233
+ if (typeof window.matchMedia !== "function")
234
+ return; // older embedders / test DOMs
235
+ const mq = window.matchMedia(`(max-width: ${AUTO_BUBBLE_MAX_WIDTH_PX}px)`);
236
+ const onChange = (e) => setMinimized((m) => nextMinimized(m, { type: "viewport", mobile: e.matches }));
237
+ mq.addEventListener("change", onChange);
238
+ return () => mq.removeEventListener("change", onChange);
239
+ }, []);
240
+ // Hydrate verdicts and LAND on the first outstanding item. The DURABLE
241
+ // server ledger is the source of truth; localStorage is only a fast cache.
242
+ // 🚨 The store NEVER pre-approves (no seed). The round derives from
243
+ // DEVICE-AWARE semantics: plain historical approvals are GRANDFATHERED as
244
+ // fully approved; device-split items stay pending until every required
245
+ // device is approved.
246
+ React.useEffect(() => {
247
+ ensureQAStyles();
248
+ let cancelled = false;
249
+ const apply = (map) => {
250
+ if (cancelled)
251
+ return;
252
+ const restored = {};
253
+ const parts = {};
254
+ const storedFps = {};
255
+ const revs = {};
256
+ for (const [id, v] of Object.entries(map)) {
257
+ if (v.fp)
258
+ storedFps[id] = v.fp;
259
+ if (v.verdict) {
260
+ const it = items.find((i) => i.id === id);
261
+ restored[id] = { id, title: it?.title ?? id, verdict: v.verdict, note: v.note, variant: v.variant };
262
+ }
263
+ else {
264
+ if (v.approvedDevices?.length)
265
+ parts[id] = approvedDevicesOf(v);
266
+ // Re-queue context: why is this item back + what did they say last time.
267
+ if (v.revisitReason || v.prevVerdict) {
268
+ revs[id] = { reason: v.revisitReason, prevVerdict: v.prevVerdict, prevNote: v.note };
269
+ }
270
+ }
271
+ }
272
+ setResults(restored);
273
+ setPartials(parts);
274
+ setFps(storedFps);
275
+ setRevisits(revs);
276
+ const rIds = items
277
+ .filter((i) => !isFullyApproved(map[i.id], requiredDevices(i)))
278
+ .map((i) => i.id);
279
+ setRoundIds(rIds);
280
+ setIndex(0);
281
+ setFinished(rIds.length === 0);
282
+ setHydrated(true);
283
+ };
284
+ // Paint from the local cache first (may be empty), then reconcile to the server.
285
+ apply(store.load());
286
+ void (async () => {
287
+ const server = await store.serverLoad();
288
+ if (cancelled || !server)
289
+ return;
290
+ store.save(server); // refresh the local cache from the durable source
291
+ apply(server);
292
+ })();
293
+ return () => {
294
+ cancelled = true;
295
+ };
296
+ // Run once per store; items are stable for the initial hydration snapshot.
297
+ // eslint-disable-next-line react-hooks/exhaustive-deps
298
+ }, [store]);
299
+ // The frozen round, resolved to items in page order.
300
+ const roundItems = React.useMemo(() => {
301
+ const set = new Set(roundIds);
302
+ return items.filter((i) => set.has(i.id));
303
+ }, [items, roundIds]);
304
+ const roundTotal = roundItems.length;
305
+ const current = roundItems[index];
306
+ const currentIsTask = !!current && isTaskItem(current);
307
+ // Track the target element's rect: scroll it into view when the step changes,
308
+ // then keep the spotlight glued to it on scroll/resize. Task items (no
309
+ // selector) render as a centered card over a full-page dim instead.
310
+ React.useEffect(() => {
311
+ if (!active || finished || minimized || mobilePreview || !current)
312
+ return;
313
+ const el = current.selector ? document.querySelector(current.selector) : null;
314
+ if (!el) {
315
+ const clear = requestAnimationFrame(() => setRect(null));
316
+ return () => cancelAnimationFrame(clear);
317
+ }
318
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
319
+ let raf = 0;
320
+ const measure = () => {
321
+ const r = el.getBoundingClientRect();
322
+ setRect({ top: r.top, left: r.left, width: r.width, height: r.height });
323
+ raf = requestAnimationFrame(measure);
324
+ };
325
+ raf = requestAnimationFrame(measure);
326
+ return () => cancelAnimationFrame(raf);
327
+ }, [active, finished, minimized, mobilePreview, current, index]);
328
+ // SYSTEM-COMPUTED live fingerprint of the current item (drives the
329
+ // NOT-ALTERED badge - never an agent's claim).
330
+ React.useEffect(() => {
331
+ if (!active || finished || !current)
332
+ return;
333
+ setCurrentFp(itemFingerprint(current, document));
334
+ }, [active, finished, current, index]);
335
+ // SUB-HIGHLIGHT: wrap the configured words/phrases inside the anchored
336
+ // element; fully restored when the step changes.
337
+ React.useEffect(() => {
338
+ if (!active || finished || minimized || mobilePreview || !current?.selector || !current.highlightWords?.length)
339
+ return;
340
+ const el = document.querySelector(current.selector);
341
+ if (!el)
342
+ return;
343
+ return applySubHighlights(el, current.highlightWords);
344
+ }, [active, finished, minimized, mobilePreview, current]);
345
+ // Restore any existing note when revisiting a step. keepNoteRef (set by Undo)
346
+ // wins once so Undo keeps what was typed instead of wiping the textarea.
347
+ React.useEffect(() => {
348
+ if (keepNoteRef.current !== null) {
349
+ setNote(keepNoteRef.current);
350
+ keepNoteRef.current = null;
351
+ return;
352
+ }
353
+ if (current)
354
+ setNote(results[current.id]?.note ?? "");
355
+ }, [index, current, results]);
356
+ // Drag the panel by its header (pointer events: mouse + touch).
357
+ const onCardDragStart = usePointerDrag(React.useCallback(() => {
358
+ const r = cardRef.current?.getBoundingClientRect();
359
+ return { x: r?.left ?? 16, y: r?.top ?? 24 };
360
+ }, []), setPos);
361
+ // Drag the bubble anywhere; a plain tap/click restores the panel.
362
+ const onBubbleDragStart = usePointerDrag(React.useCallback(() => {
363
+ const r = bubbleRef.current?.getBoundingClientRect();
364
+ return { x: r?.left ?? 16, y: r?.top ?? 24 };
365
+ }, []), setBubblePos, React.useCallback(() => setMinimized(false), []));
366
+ // Element-picker: while picking, a click on the page (outside the panel)
367
+ // inserts a reference to that element into the note textarea at the cursor.
368
+ // LEFT click picks and EXITS the mode; RIGHT click picks and STAYS in the
369
+ // mode (multi-select; the native context menu is suppressed).
370
+ React.useEffect(() => {
371
+ if (!picking)
372
+ return;
373
+ const pick = (e, exitAfter) => {
374
+ const el = e.target;
375
+ if (!el || cardRef.current?.contains(el))
376
+ return; // ignore the panel itself
377
+ e.preventDefault();
378
+ e.stopPropagation();
379
+ const ref = pickedElementRef(el.textContent, el.tagName);
380
+ const ta = noteRef.current;
381
+ if (ta) {
382
+ const s = ta.selectionStart ?? note.length;
383
+ const nv = note.slice(0, s) + ref + note.slice(s);
384
+ setNote(nv);
385
+ requestAnimationFrame(() => {
386
+ ta.focus();
387
+ const p = s + ref.length;
388
+ ta.setSelectionRange(p, p);
389
+ });
390
+ }
391
+ else {
392
+ setNote((n) => n + ref);
393
+ }
394
+ if (exitAfter)
395
+ setPicking(false);
396
+ };
397
+ const onClick = (e) => pick(e, shouldExitPickMode("left"));
398
+ const onContextMenu = (e) => pick(e, shouldExitPickMode("right"));
399
+ const t = window.setTimeout(() => {
400
+ document.addEventListener("click", onClick, true);
401
+ document.addEventListener("contextmenu", onContextMenu, true);
402
+ }, 0);
403
+ return () => {
404
+ window.clearTimeout(t);
405
+ document.removeEventListener("click", onClick, true);
406
+ document.removeEventListener("contextmenu", onContextMenu, true);
407
+ };
408
+ }, [picking, note]);
409
+ const total = items.length;
410
+ const advance = React.useCallback(() => {
411
+ // Advance within the FROZEN round (denominator stays fixed). The decided
412
+ // item stays in the round so Prev can walk back; finish past the last.
413
+ setIndex((i) => {
414
+ if (i + 1 >= roundTotal) {
415
+ setFinished(true);
416
+ return i;
417
+ }
418
+ return i + 1;
419
+ });
420
+ }, [roundTotal]);
421
+ const reject = React.useCallback(() => {
422
+ if (!current)
423
+ return;
424
+ const trimmed = note.trim() || undefined;
425
+ const variant = current.variations?.current;
426
+ const fp = currentFp ?? itemFingerprint(current, document) ?? undefined;
427
+ setResults((prev) => ({
428
+ ...prev,
429
+ [current.id]: { id: current.id, title: current.title, verdict: "reject", note: trimmed, variant },
430
+ }));
431
+ if (fp)
432
+ setFps((prev) => ({ ...prev, [current.id]: fp }));
433
+ store.cancelPendingNote(current.id); // the verdict write carries the note
434
+ store.persist(current.id, { verdict: "reject", note: trimmed, variant, fp }); // REAL-TIME
435
+ setSessionVerdicts((prev) => ({ ...prev, [current.id]: "reject" }));
436
+ setUndoStack((s) => [...s, current.id]);
437
+ advance();
438
+ }, [current, note, currentFp, store, advance]);
439
+ /**
440
+ * Device approvals already in effect for an item: a full approval implies
441
+ * every required device (incl. grandfathered plain approvals); otherwise
442
+ * the recorded partial set.
443
+ */
444
+ const effectiveApprovedFor = React.useCallback((item) => results[item.id]?.verdict === "approve"
445
+ ? requiredDevices(item)
446
+ : (partials[item.id] ?? []), [results, partials]);
447
+ /**
448
+ * Device-scoped approval TOGGLE. Clicking an unapproved device approves it;
449
+ * clicking an approved one UNSETS it (the item falls back to pending when it
450
+ * loses full approval). The item is APPROVED - and navigation advances -
451
+ * only when every required device is approved; rejection stays whole-item.
452
+ * Every transition persists in real time.
453
+ */
454
+ const approveDevice = React.useCallback((device) => {
455
+ if (!current)
456
+ return;
457
+ const required = requiredDevices(current);
458
+ const trimmed = note.trim() || undefined;
459
+ const variant = current.variations?.current;
460
+ const fp = currentFp ?? itemFingerprint(current, document) ?? undefined;
461
+ const wasFullyApproved = results[current.id]?.verdict === "approve";
462
+ const { devices: newDevs, action } = toggleDevice(effectiveApprovedFor(current), device);
463
+ const complete = action === "approve" && required.every((d) => newDevs.includes(d));
464
+ if (fp)
465
+ setFps((prev) => ({ ...prev, [current.id]: fp }));
466
+ store.cancelPendingNote(current.id);
467
+ setUndoStack((s) => [...s, current.id]);
468
+ if (complete) {
469
+ setResults((prev) => ({
470
+ ...prev,
471
+ [current.id]: { id: current.id, title: current.title, verdict: "approve", note: trimmed, variant },
472
+ }));
473
+ setPartials((prev) => {
474
+ const next = { ...prev };
475
+ delete next[current.id];
476
+ return next;
477
+ });
478
+ store.persist(current.id, { verdict: "approve", note: trimmed, variant, fp, approvedDevices: newDevs });
479
+ setSessionVerdicts((prev) => ({ ...prev, [current.id]: "approve" }));
480
+ advance();
481
+ return;
482
+ }
483
+ // Partial approve OR unset: the item is (or returns to) pending.
484
+ setResults((prev) => {
485
+ if (!prev[current.id])
486
+ return prev;
487
+ const next = { ...prev };
488
+ delete next[current.id]; // losing full approval clears the verdict
489
+ return next;
490
+ });
491
+ setPartials((prev) => ({ ...prev, [current.id]: newDevs }));
492
+ setSessionVerdicts((prev) => {
493
+ if (!prev[current.id])
494
+ return prev;
495
+ const next = { ...prev };
496
+ delete next[current.id]; // partial / unset = no session verdict
497
+ return next;
498
+ });
499
+ if (action === "unset" && wasFullyApproved) {
500
+ // The row's verdict must clear: delete it, then re-write the fields we
501
+ // still know (note/variant/fp + remaining devices). The write-behind
502
+ // queue guarantees the order server-side.
503
+ store.remove(current.id);
504
+ store.persist(current.id, { approvedDevices: newDevs, note: trimmed, variant, fp });
505
+ }
506
+ else {
507
+ store.persist(current.id, { approvedDevices: newDevs, note: trimmed, variant, fp });
508
+ }
509
+ setFinished(false);
510
+ }, [current, note, currentFp, results, partials, store, advance, effectiveApprovedFor]);
511
+ // Undo the most recent verdict/approval: drop the WHOLE item entry locally +
512
+ // on the server (single-item invalidation) and jump back to it.
513
+ const undo = React.useCallback(() => {
514
+ setUndoStack((stack) => {
515
+ if (!stack.length)
516
+ return stack;
517
+ const id = stack[stack.length - 1];
518
+ setResults((prev) => {
519
+ keepNoteRef.current = prev[id]?.note ?? ""; // Undo keeps the typed note
520
+ const next = { ...prev };
521
+ delete next[id];
522
+ return next;
523
+ });
524
+ setPartials((prev) => {
525
+ const next = { ...prev };
526
+ delete next[id];
527
+ return next;
528
+ });
529
+ setSessionVerdicts((prev) => {
530
+ const next = { ...prev };
531
+ delete next[id];
532
+ return next;
533
+ });
534
+ store.remove(id);
535
+ const idx = roundItems.findIndex((i) => i.id === id);
536
+ setFinished(false);
537
+ if (idx >= 0)
538
+ setIndex(idx);
539
+ return stack.slice(0, -1);
540
+ });
541
+ }, [roundItems, store]);
542
+ // Jump: scroll the currently-spotlighted target back into view.
543
+ const jump = React.useCallback(() => {
544
+ if (!current?.selector)
545
+ return;
546
+ document.querySelector(current.selector)?.scrollIntoView({ behavior: "smooth", block: "center" });
547
+ }, [current]);
548
+ // Full-page journey hop, preserving the QA activation query params. The
549
+ // loading indicator persists from here until the browser unloads this page.
550
+ const navigateTo = React.useCallback((page) => {
551
+ setNavigatingTo(page);
552
+ window.location.assign(buildJourneyNavUrl(page.path, window.location.search));
553
+ }, []);
554
+ const prevJourneyPage = inJourney && journeyIdx > 0 ? journey.pages[journeyIdx - 1] : null;
555
+ const prev = React.useCallback(() => {
556
+ if (index > 0)
557
+ setIndex(index - 1);
558
+ else if (prevJourneyPage)
559
+ navigateTo(prevJourneyPage);
560
+ }, [index, prevJourneyPage, navigateTo]);
561
+ const next = React.useCallback(() => {
562
+ if (index + 1 < roundTotal)
563
+ setIndex(index + 1);
564
+ else if (inJourney)
565
+ setFinished(true);
566
+ }, [index, roundTotal, inJourney]);
567
+ const exit = React.useCallback(() => setActive(false), []);
568
+ // Same-origin phone-frame URL of the CURRENT page (embed marker keeps the
569
+ // iframe's own overlay dormant). Recomputed each time the preview opens.
570
+ const previewUrl = React.useMemo(() => (mobilePreview && typeof window !== "undefined" ? buildMobilePreviewUrl(window.location.href) : null), [mobilePreview]);
571
+ // Keep the OPEN preview aimed at the current item (0.3.7). previewUrl is keyed
572
+ // only on `mobilePreview`, so advancing to the next item does NOT reload the
573
+ // iframe and the onLoad scroll never fires again - without this the frame
574
+ // stays parked wherever the previous item left it.
575
+ const currentSelector = current?.selector;
576
+ React.useEffect(() => {
577
+ if (!mobilePreview || !currentSelector)
578
+ return;
579
+ return scrollPreviewToSelector(previewIframeRef.current, currentSelector);
580
+ }, [mobilePreview, currentSelector]);
581
+ // The device whose approval is most actionable NOW (visual hint + "A" key):
582
+ // the detected device if required and unapproved, else the first unapproved
583
+ // required device. ALL buttons stay clickable regardless.
584
+ const currentRequired = current ? requiredDevices(current) : [];
585
+ const currentApproved = current ? effectiveApprovedFor(current) : [];
586
+ const hintDevice = current
587
+ ? currentRequired.includes(detectedDevice) && !currentApproved.includes(detectedDevice)
588
+ ? detectedDevice
589
+ : (currentRequired.find((d) => !currentApproved.includes(d)) ?? null)
590
+ : null;
591
+ // Keyboard: A approve (hint device), R reject, ←/→, U/Ctrl+Z undo, M
592
+ // minimize, Esc exit.
593
+ React.useEffect(() => {
594
+ if (!active || finished || minimized)
595
+ return;
596
+ const onKey = (e) => {
597
+ if (e.target instanceof HTMLTextAreaElement)
598
+ return;
599
+ if (e.key === "a" || e.key === "A") {
600
+ if (hintDevice)
601
+ approveDevice(hintDevice);
602
+ }
603
+ else if (e.key === "r" || e.key === "R")
604
+ reject();
605
+ else if (e.key === "ArrowRight")
606
+ next();
607
+ else if (e.key === "ArrowLeft")
608
+ prev();
609
+ else if (e.key === "u" || e.key === "U" || ((e.key === "z" || e.key === "Z") && (e.ctrlKey || e.metaKey)))
610
+ undo();
611
+ else if (e.key === "m" || e.key === "M")
612
+ setMinimized(true);
613
+ else if (e.key === "Escape")
614
+ exit();
615
+ };
616
+ window.addEventListener("keydown", onKey);
617
+ return () => window.removeEventListener("keydown", onKey);
618
+ }, [active, finished, minimized, reject, approveDevice, hintDevice, next, prev, exit, undo]);
619
+ // Live NAVIGATION count of the current page (unverdicted = untouched this
620
+ // run; rejects/partials are handled - see 0.3.2).
621
+ const liveCurrentPending = React.useCallback(() => items.filter((i) => !results[i.id]?.verdict && !partials[i.id]?.length).length, [items, results, partials]);
622
+ // JOURNEY PRELOAD (0.3.5): within the final items of the round, prefetch
623
+ // the other pages' pending counts in the background and warm the likely
624
+ // next page with a <link rel="prefetch"> - so round exhaustion navigates
625
+ // instantly instead of sitting on "Checking remaining pages…".
626
+ React.useEffect(() => {
627
+ if (!active || finished || !inJourney)
628
+ return;
629
+ if (!shouldPrefetch(index, roundTotal))
630
+ return;
631
+ if (prefetchRef.current && isPrefetchFresh(prefetchRef.current.at, Date.now()))
632
+ return;
633
+ let cancelled = false;
634
+ void (async () => {
635
+ const counts = await fetchPendingCounts(stateUrl ?? DEFAULT_STATE_URL, journey.pages);
636
+ if (cancelled)
637
+ return;
638
+ prefetchRef.current = { counts, at: Date.now() };
639
+ const withLive = { ...counts, [target]: liveCurrentPending() };
640
+ const next = nextPendingPage(journey.pages, withLive, journeyIdx);
641
+ if (next)
642
+ ensurePrefetchLink(document, buildJourneyNavUrl(next.path, window.location.search));
643
+ })();
644
+ return () => {
645
+ cancelled = true;
646
+ };
647
+ // eslint-disable-next-line react-hooks/exhaustive-deps
648
+ }, [active, finished, inJourney, index, roundTotal]);
649
+ // Journey: the moment this page's round is done, resolve the next step and
650
+ // navigate IMMEDIATELY (no interstitial, no success popup). Uses the
651
+ // PREFETCHED counts when fresh (instant hop); falls back to an on-demand
652
+ // fetch otherwise. The completion panel shows ONLY when nothing is pending
653
+ // anywhere in the journey.
654
+ React.useEffect(() => {
655
+ if (!finished || !active || !inJourney) {
656
+ setJourneyComplete(false);
657
+ return;
658
+ }
659
+ let cancelled = false;
660
+ void (async () => {
661
+ const cached = prefetchRef.current;
662
+ const counts = cached && isPrefetchFresh(cached.at, Date.now())
663
+ ? { ...cached.counts }
664
+ : await fetchPendingCounts(stateUrl ?? DEFAULT_STATE_URL, journey.pages);
665
+ if (cancelled)
666
+ return;
667
+ // Current page from LIVE state, with NAVIGATION semantics (0.3.2).
668
+ counts[target] = liveCurrentPending();
669
+ setJourneyCounts(counts);
670
+ const action = resolveFinishAction(journey.pages, counts, journeyIdx);
671
+ if (action.kind === "navigate")
672
+ navigateTo(action.page); // IMMEDIATE
673
+ else
674
+ setJourneyComplete(true);
675
+ })();
676
+ return () => {
677
+ cancelled = true;
678
+ };
679
+ // Resolve once per finish; results are frozen while finished.
680
+ // eslint-disable-next-line react-hooks/exhaustive-deps
681
+ }, [finished, active, inJourney]);
682
+ const buildPayload = React.useCallback(() => {
683
+ const merged = items.map((it) => results[it.id] ?? { id: it.id, title: it.title, verdict: "skipped" });
684
+ const approved = merged.filter((r) => r.verdict === "approve").length;
685
+ const rejected = merged.filter((r) => r.verdict === "reject").length;
686
+ return {
687
+ target,
688
+ reviewer: undefined,
689
+ approved,
690
+ rejected,
691
+ total: items.length,
692
+ results: merged,
693
+ };
694
+ }, [items, results, target]);
695
+ const copyResults = React.useCallback(() => {
696
+ const json = JSON.stringify(buildPayload().results, null, 2);
697
+ navigator.clipboard.writeText(json).then(() => {
698
+ setCopied(true);
699
+ window.setTimeout(() => setCopied(false), 1500);
700
+ }, () => {
701
+ window.prompt("Copy QA results:", json);
702
+ });
703
+ }, [buildPayload]);
704
+ const copyRef = React.useCallback(() => {
705
+ if (!current)
706
+ return;
707
+ const line = formatQARef(target, current.id, current.title);
708
+ navigator.clipboard.writeText(line).then(() => {
709
+ setRefCopied(true);
710
+ window.setTimeout(() => setRefCopied(false), 1500);
711
+ }, () => {
712
+ window.prompt("Copy QA reference:", line);
713
+ });
714
+ }, [current, target]);
715
+ // Persist the completed run as a session snapshot via the submit endpoint.
716
+ const saveToDb = React.useCallback(async () => {
717
+ if (!submitUrl)
718
+ return;
719
+ setSave({ status: "saving" });
720
+ try {
721
+ const res = await fetch(submitUrl, {
722
+ method: "POST",
723
+ headers: { "Content-Type": "application/json" },
724
+ body: JSON.stringify(buildPayload()),
725
+ });
726
+ const data = (await res.json().catch(() => null));
727
+ if (res.ok && data?.ok)
728
+ setSave({ status: "ok" });
729
+ else
730
+ setSave({ status: "error", message: data?.error || `HTTP ${res.status}` });
731
+ }
732
+ catch {
733
+ setSave({ status: "error", message: "Network error - use Copy JSON instead." });
734
+ }
735
+ }, [submitUrl, buildPayload]);
736
+ if (!active || !hydrated || typeof document === "undefined")
737
+ return null;
738
+ // Inline CSS-variable overrides for the injected default theme.
739
+ const themeStyle = {
740
+ ...(theme?.accent ? { "--qar-accent": theme.accent } : null),
741
+ ...(theme?.accentContrast ? { "--qar-accent-contrast": theme.accentContrast } : null),
742
+ ...(theme?.panelBg ? { "--qar-panel-bg": theme.panelBg } : null),
743
+ ...(theme?.inputBg ? { "--qar-input-bg": theme.inputBg } : null),
744
+ };
745
+ // LEDGER (all-time on this page) vs THIS ROUND (acted on in this run).
746
+ const approved = Object.values(results).filter((r) => r.verdict === "approve").length;
747
+ const rejected = Object.values(results).filter((r) => r.verdict === "reject").length;
748
+ const sessionApproved = Object.values(sessionVerdicts).filter((v) => v === "approve").length;
749
+ const sessionRejected = Object.values(sessionVerdicts).filter((v) => v === "reject").length;
750
+ /* ------------------------------ finish panel ------------------------------ */
751
+ if (finished) {
752
+ // In a journey the overlay navigates away IMMEDIATELY when other pages
753
+ // are pending. Show an UNMISTAKABLE loading state from the moment the
754
+ // round exhausts until the next page unloads this one - never a blank
755
+ // screen (0.3.1: a reviewer almost exited thinking the review was done).
756
+ if (journeyFinishView(inJourney, journeyComplete) === "loading") {
757
+ return createPortal(_jsx("div", { className: "qar-theme qar-finish-backdrop", style: themeStyle, children: _jsxs("div", { className: "qar-loading-card", role: "status", "aria-live": "polite", children: [_jsx("div", { className: "qar-spinner", "aria-hidden": true }), navigatingTo
758
+ ? `Loading ${navigatingTo.label ?? navigatingTo.path}…`
759
+ : "Checking remaining pages…", _jsx("span", { className: "qar-muted", style: { fontSize: 11, fontWeight: 400 }, children: "The review continues on the next page - do not close this tab." })] }) }), document.body);
760
+ }
761
+ return createPortal(_jsx("div", { className: "qar-theme qar-finish-backdrop", style: themeStyle, children: _jsxs("div", { className: "qar-finish-card", children: [_jsx(PartyPopper, { className: "qar-finish-icon", size: 40, "aria-hidden": true }), _jsx("h2", { className: "qar-finish-title", children: inJourney ? "Journey complete" : "QA review complete" }), _jsxs("p", { className: "qar-finish-stats", "data-qatip": "Verdicts you recorded in THIS review run", children: [_jsxs("span", { className: "qar-green", children: [sessionApproved, " approved"] }), " \u00B7", " ", _jsxs("span", { className: "qar-red", children: [sessionRejected, " rejected"] }), " \u00B7 this round"] }), _jsxs("p", { className: "qar-finish-alltime", "data-qatip": "Ledger totals for this page across all runs (durable review database)", children: ["all-time on this page: ", approved, " approved \u00B7 ", rejected, " rejected \u00B7 ", total, " items"] }), _jsxs("p", { className: "qar-finish-autosaved", "data-qatip": "Every verdict was written to the server ledger the moment you decided it", children: [_jsx(Database, { size: 14, "aria-hidden": true }), " Auto-saved to the review database as you go."] }), inJourney && journeyCounts && (_jsx("div", { className: "qar-journey", children: journey.pages.map((p, i) => {
762
+ const left = journeyCounts[p.target] ?? 0;
763
+ return (_jsxs("div", { className: `qar-journey-row${i === journeyIdx ? " qar-current" : ""}`, children: [_jsxs("span", { children: [p.label ?? p.path, i === journeyIdx ? " (here)" : ""] }), _jsx("span", { className: left === 0 ? "qar-journey-done" : "qar-journey-left", children: left === 0 ? "✓ done" : `${left} left` })] }, p.target));
764
+ }) })), _jsxs("div", { className: "qar-finish-actions", children: [submitUrl && (_jsxs(_Fragment, { children: [_jsxs("button", { type: "button", onClick: saveToDb, disabled: save.status === "saving" || save.status === "ok", className: "qar-btn-primary", "data-qatip": "Store a snapshot of this whole run (verdicts are already saved individually)", children: [_jsx(Database, { size: 16, "aria-hidden": true }), save.status === "saving"
765
+ ? "Saving…"
766
+ : save.status === "ok"
767
+ ? "Saved to database ✓"
768
+ : "Save review to database"] }), save.status === "error" && _jsx("p", { className: "qar-warn", children: save.message })] })), _jsxs("button", { type: "button", onClick: copyResults, className: "qar-btn-outline-accent", "data-qatip": "Copy all verdicts of this page as JSON", children: [_jsx(ClipboardCopy, { size: 16, "aria-hidden": true }), copied ? "Copied!" : "Copy JSON"] }), roundTotal > 0 && (_jsx("button", { type: "button", onClick: () => {
769
+ setFinished(false);
770
+ setIndex(0);
771
+ }, className: "qar-btn-outline", children: "Back to review" })), _jsx("button", { type: "button", onClick: exit, className: "qar-btn-ghost", children: "Exit QA mode" })] })] }) }), document.body);
772
+ }
773
+ if (!current)
774
+ return null;
775
+ /* ---------------------------- minimized bubble ---------------------------- */
776
+ if (minimized) {
777
+ const bubbleStyle = bubblePos
778
+ ? { top: bubblePos.y, left: bubblePos.x }
779
+ : { bottom: 24, left: 24 }; // 0.3.6: dock LEFT by default (still draggable)
780
+ return createPortal(_jsxs("div", { ref: bubbleRef, className: "qar-theme qar-bubble", style: { ...themeStyle, ...bubbleStyle }, onPointerDown: onBubbleDragStart, role: "button", "aria-label": "Restore the QA review panel", "data-qatip": "QA review - items left on this page (tap to restore, drag to move)", children: [_jsx(ClipboardCheck, { size: 24, "aria-hidden": true }), _jsx("span", { className: "qar-bubble-count", children: Math.max(roundTotal - index, 0) })] }), document.body);
781
+ }
782
+ const existing = results[current.id]?.verdict;
783
+ const approvedCount = items.filter((it) => results[it.id]?.verdict === "approve").length;
784
+ // Decided-this-round = verdicts recorded in THIS run (not hydrated history).
785
+ const decidedInRound = Object.keys(sessionVerdicts).length;
786
+ const hasNext = index < roundTotal - 1 || inJourney;
787
+ const hasPrev = index > 0 || !!prevJourneyPage;
788
+ const codename = codenameFor(target, current.id);
789
+ // NOT-ALTERED poka-yoke: system-computed comparison of the stored
790
+ // fingerprint (last verdict / pre-invalidation) vs the page content NOW.
791
+ const currentRevisit = !existing ? revisits[current.id] : undefined;
792
+ const fpStatus = existing === "reject" || currentRevisit
793
+ ? fingerprintStatus(fps[current.id], currentFp)
794
+ : null;
795
+ // Re-queue context card block ("Back for review: ...", prior verdict).
796
+ const revisitDisplay = describeRevisit(currentRevisit, fpStatus);
797
+ const partialApproved = currentApproved.filter((d) => currentRequired.includes(d));
798
+ const multiDevice = currentRequired.length > 1;
799
+ return createPortal(_jsxs(_Fragment, { children: [mobilePreview && previewUrl && (_jsx("div", { className: "qar-theme qar-preview-backdrop", style: themeStyle, children: _jsxs("div", { className: "qar-preview-frame", children: [_jsx("iframe", { ref: previewIframeRef, src: previewUrl, title: "Mobile preview of the current page", width: MOBILE_PREVIEW_WIDTH, height: MOBILE_PREVIEW_HEIGHT, onLoad: () => {
800
+ // Sync the current item's live variant into the freshly loaded
801
+ // frame so it opens already matching the parent selection.
802
+ const v = current?.variations?.current;
803
+ if (current && typeof v === "number") {
804
+ postVariantToPreview(previewIframeRef.current, current.id, v);
805
+ }
806
+ // ...and scroll the frame to the section under review, rather
807
+ // than leaving the reviewer at the top of the page (0.3.7).
808
+ scrollPreviewToSelector(previewIframeRef.current, current?.selector);
809
+ } }), _jsx("button", { type: "button", onClick: () => setMobilePreview(false), "aria-label": "Close the mobile preview", "data-qatip": "Close the mobile preview and return to the normal page", className: "qar-preview-close", children: _jsx(X, { size: 16 }) })] }) })), mobilePreview ? null : rect && !currentIsTask ? (_jsx("div", { "aria-hidden": true, className: "qar-theme qar-spotlight", style: {
810
+ ...themeStyle,
811
+ top: rect.top - RING,
812
+ left: rect.left - RING,
813
+ width: rect.width + RING * 2,
814
+ height: rect.height + RING * 2,
815
+ } })) : (_jsx("div", { "aria-hidden": true, className: "qar-theme qar-dim", style: themeStyle })), _jsxs("div", { ref: cardRef, className: "qar-theme qar-card", style: {
816
+ ...themeStyle,
817
+ ...(pos
818
+ ? { top: pos.y, left: pos.x }
819
+ : currentIsTask
820
+ ? { top: "50%", left: "50%", transform: "translate(-50%, -50%)" }
821
+ : { top: 24, left: 16 }),
822
+ width: CARD_W,
823
+ height: CARD_H,
824
+ }, children: [_jsxs("div", { className: "qar-card-header", onPointerDown: onCardDragStart, style: { cursor: "move" }, children: [_jsxs("span", { className: "qar-counter", "data-qatip": `Position in this page's review round${inJourney ? " · journey progress across the reviewed pages" : ""}. Drag this bar to move the panel.`, children: [index + 1, " of ", roundTotal, " to review", inJourney && (_jsxs("span", { className: "qar-muted", children: [" ", "\u00B7 page ", journeyIdx + 1, "/", journey.pages.length] }))] }), _jsxs("span", { className: "qar-header-tools", children: [current.device && (_jsx("span", { "data-qatip": DEVICE_EMOJI_TIP[current.device] ?? "target viewport(s)", children: current.device })), !currentIsTask && (_jsx("button", { type: "button", onClick: jump, "aria-label": "Jump to the highlighted section", "data-qatip": "Jump: scroll to the highlighted section", className: "qar-tool-btn", children: _jsx(Locate, { size: 14 }) })), _jsx("button", { type: "button", onClick: undo, disabled: !undoStack.length, "aria-label": "Undo the last approve/reject", "data-qatip": "Undo the last approve/reject (U or Ctrl+Z)", className: "qar-tool-btn", children: _jsx(Undo2, { size: 14 }) }), _jsx("button", { type: "button", onClick: () => setMobilePreview((v) => !v), "aria-label": "Toggle the mobile preview", "data-qatip": "Mobile preview: view this page in a phone-sized frame (for Approve Mobile on desktop)", className: `qar-tool-btn${mobilePreview ? " qar-picking" : ""}`, children: _jsx(Smartphone, { size: 14 }) }), _jsx("button", { type: "button", onClick: () => setMinimized(true), "aria-label": "Minimize to a floating bubble", "data-qatip": "Minimize: collapse into a floating bubble (M). Tap the bubble to restore.", className: "qar-tool-btn", children: _jsx(Minimize2, { size: 14 }) }), _jsx("button", { type: "button", onClick: exit, "aria-label": "Exit QA mode", "data-qatip": "Exit QA mode (Esc)", className: "qar-close-btn", children: _jsx(X, { size: 16 }) })] })] }), _jsxs("div", { className: "qar-ref-row", children: [_jsx("span", { className: "qar-codename", "data-qatip": "Stable codename for this item - say or paste it to reference the item in chat", children: codename }), _jsxs("button", { type: "button", onClick: copyRef, className: "qar-pick-btn", "data-qatip": "Copy a reference line (codename + target + item id) for chat/issues", children: [_jsx(ClipboardCopy, { size: 12 }), " ", refCopied ? "Copied!" : "Copy ref"] })] }), _jsxs("div", { className: "qar-card-body", children: [!rect && !currentIsTask && (_jsxs("p", { className: "qar-warn", children: ["Target not on this viewport (", current.selector, ") - may be a mobile-only element."] })), _jsx("h3", { className: "qar-item-title", children: current.title }), current.sub && (_jsx("div", { className: "qar-item-sub", children: current.sub
825
+ .split("\n")
826
+ .map((line) => line.trim())
827
+ .filter(Boolean)
828
+ .map((line, i) => (_jsx("p", { children: line }, i))) })), current.action && (_jsxs("a", { className: "qar-action-link", href: current.action.href, target: "_blank", rel: "noopener noreferrer", "data-qatip": `Open ${current.action.href} in a new tab`, children: [_jsx(ExternalLink, { size: 14, "aria-hidden": true }), " ", current.action.label ?? "Open"] })), revisitDisplay?.headline && (_jsxs("p", { className: "qar-revisit", "data-qatip": "Why this item was re-queued for your review", children: ["\u21BB ", revisitDisplay.headline] })), revisitDisplay?.notAltered && (_jsxs("p", { className: "qar-fp-unchanged", "data-qatip": "The content hash equals the hash recorded at your rejection - it was not altered", children: ["\u26A0 NOT ALTERED since your rejection", currentRevisit?.prevNote && _jsxs("small", { children: ["Your rejection note: ", currentRevisit.prevNote] })] })), revisitDisplay?.prior && !revisitDisplay.notAltered && (_jsx("p", { className: "qar-revisit-prior", "data-qatip": "Your verdict before this item was re-queued", children: revisitDisplay.prior })), !revisitDisplay && fpStatus === "unchanged" && (_jsxs("p", { className: "qar-fp-unchanged", "data-qatip": "The content hash equals the hash recorded when you rejected this item - it was not altered", children: ["\u26A0 NOT ALTERED since your rejection", results[current.id]?.note && _jsxs("small", { children: ["Your rejection note: ", results[current.id].note] })] })), !revisitDisplay && fpStatus === "changed" && (_jsx("p", { className: "qar-fp-changed", "data-qatip": "The content hash differs from the one recorded at your last verdict", children: "Changed since your last review." })), existing && fpStatus !== "unchanged" && (_jsxs("p", { className: `qar-recorded ${existing === "approve" ? "qar-recorded-approve" : "qar-recorded-reject"}`, children: ["Recorded: ", existing] })), multiDevice && partialApproved.length > 0 && (_jsxs("p", { className: "qar-partial-note", "data-qatip": "Approved on some required devices; the item stays pending until all are approved", children: [partialApproved.map((d) => DEVICE_LABEL[d]).join(" + "), " approved \u2713 - awaiting", " ", currentRequired
829
+ .filter((d) => !partialApproved.includes(d))
830
+ .map((d) => DEVICE_LABEL[d])
831
+ .join(" + ")] })), current.variations && (_jsxs("div", { className: "qar-var-wrap", children: [_jsx("p", { className: "qar-var-label", children: "Preview a variation (click to swap live)" }), _jsx("div", { className: "qar-var-btns", children: Array.from({ length: current.variations.count }, (_, k) => {
832
+ const v = k + 1;
833
+ const on = current.variations.current === v;
834
+ return (_jsx("button", { type: "button", onClick: () => {
835
+ current.variations.onSelect(v); // apply on the parent page
836
+ // ...and propagate into the OPEN preview iframe live.
837
+ if (mobilePreview)
838
+ postVariantToPreview(previewIframeRef.current, current.id, v);
839
+ }, className: `qar-var-btn${on ? " qar-on" : ""}`, "data-qatip": `Preview variant ${v} live on the page${mobilePreview ? " and in the phone preview" : ""}`, children: v }, v));
840
+ }) }), _jsxs("p", { className: "qar-var-note", children: ["Approving records variant #", current.variations.current, "."] })] })), _jsxs("div", { className: "qar-note-row", children: [_jsx("span", { className: "qar-note-label", children: "Note" }), _jsxs("button", { type: "button", onClick: () => setPicking((p) => !p), "data-qatip": "Click, then click any element on the page to drop a reference into the note. Left click picks + exits; RIGHT click picks and stays for multi-select.", className: `qar-pick-btn${picking ? " qar-picking" : ""}`, children: [_jsx(Crosshair, { size: 12 }), " ", picking ? "Click an element… (right-click = pick more)" : "Pick element"] })] }), _jsx("textarea", { ref: noteRef, value: note, onChange: (e) => {
841
+ setNote(e.target.value);
842
+ // REAL-TIME note persistence (debounced): typed notes reach the
843
+ // durable ledger even if the item is never re-verdicted.
844
+ if (current)
845
+ store.persistNoteDebounced(current.id, e.target.value);
846
+ }, placeholder: "Optional note (esp. on reject). Use 'Pick element' to reference a spot on the page\u2026", className: "qar-note-input" })] }), _jsxs("div", { className: "qar-actions", children: [_jsx("button", { type: "button", onClick: prev, disabled: !hasPrev, "aria-label": "Previous item", "data-qatip": index === 0 && prevJourneyPage
847
+ ? `Back to ${prevJourneyPage.label ?? prevJourneyPage.path}`
848
+ : "Previous item (←)", className: "qar-nav-btn", children: _jsx(ChevronLeft, { size: 16 }) }), _jsxs("button", { type: "button", onClick: reject, className: "qar-reject-btn", "data-qatip": "Reject this item (R)", children: [_jsx(X, { size: 16 }), " Reject"] }), currentRequired.map((d) => {
849
+ const done = partialApproved.includes(d);
850
+ const isHint = hintDevice === d;
851
+ const cls = multiDevice
852
+ ? `${isHint ? "qar-approve-btn" : "qar-approve-alt"}${done ? " qar-dev-done" : ""}`
853
+ : "qar-approve-btn";
854
+ return (_jsxs("button", { type: "button", onClick: () => approveDevice(d), className: cls, "data-qatip": `${DEVICE_TOOLTIP[d]}${isHint ? " (A)" : ""}${done ? " - already approved" : ""}`, children: [multiDevice ? (d === "pc" ? (_jsx(Monitor, { size: 16, "aria-hidden": true })) : (_jsx(Smartphone, { size: 16, "aria-hidden": true }))) : (_jsx(Check, { size: 16, "aria-hidden": true })), multiDevice ? `${done ? "✓ " : ""}${DEVICE_LABEL[d]}` : "Approve"] }, d));
855
+ }), _jsx("button", { type: "button", onClick: next, disabled: !hasNext, "aria-label": "Next item", "data-qatip": index >= roundTotal - 1 && inJourney
856
+ ? "Finish this page and continue the journey (→)"
857
+ : "Next item (→)", className: "qar-nav-btn", children: _jsx(ChevronRight, { size: 16 }) })] }), _jsxs("div", { className: "qar-footer", children: [_jsx("span", { "data-qatip": "Keyboard shortcuts", children: "keys: A approve \u00B7 R reject \u00B7 \u2190/\u2192 \u00B7 U undo \u00B7 M minimize \u00B7 Esc" }), _jsxs("span", { children: [_jsxs("span", { className: "qar-footer-green", "data-qatip": "Items decided in this round", children: [decidedInRound, "/", roundTotal, " this round"] }), " ", _jsxs("span", { "data-qatip": "Fully approved items on this page across all runs (ledger)", children: ["\u00B7 ", approvedCount, " approved all-time"] })] })] })] })] }), document.body);
858
+ }