@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.
- package/CHANGELOG.md +174 -0
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/dist/client/QAReviewOverlay.d.ts +59 -0
- package/dist/client/QAReviewOverlay.js +858 -0
- package/dist/client/device.d.ts +28 -0
- package/dist/client/device.js +57 -0
- package/dist/client/fingerprint.d.ts +19 -0
- package/dist/client/fingerprint.js +40 -0
- package/dist/client/highlight.d.ts +17 -0
- package/dist/client/highlight.js +77 -0
- package/dist/client/index.d.ts +12 -0
- package/dist/client/index.js +11 -0
- package/dist/client/journey.d.ts +89 -0
- package/dist/client/journey.js +134 -0
- package/dist/client/preview.d.ts +30 -0
- package/dist/client/preview.js +116 -0
- package/dist/client/revisit.d.ts +28 -0
- package/dist/client/revisit.js +27 -0
- package/dist/client/store.d.ts +100 -0
- package/dist/client/store.js +234 -0
- package/dist/client/styles.d.ts +3 -0
- package/dist/client/styles.js +211 -0
- package/dist/client/types.d.ts +87 -0
- package/dist/client/types.js +7 -0
- package/dist/server/handlers.d.ts +31 -0
- package/dist/server/handlers.js +186 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.js +3 -0
- package/dist/server/storage.d.ts +78 -0
- package/dist/server/storage.js +245 -0
- package/dist/shared/codename.d.ts +16 -0
- package/dist/shared/codename.js +55 -0
- package/package.json +61 -0
|
@@ -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
|
+
}
|