@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,28 @@
|
|
|
1
|
+
import type { PersistedVerdict } from "./store.js";
|
|
2
|
+
import type { QAReviewItem } from "./types.js";
|
|
3
|
+
export type QADevice = "pc" | "mobile";
|
|
4
|
+
export declare const DEVICE_LABEL: Record<QADevice, string>;
|
|
5
|
+
export declare const DEVICE_TOOLTIP: Record<QADevice, string>;
|
|
6
|
+
/** Required review devices for an item. Default: PC only. */
|
|
7
|
+
export declare function requiredDevices(item: Pick<QAReviewItem, "devices">): QADevice[];
|
|
8
|
+
/** Devices already approved on a ledger entry. */
|
|
9
|
+
export declare function approvedDevicesOf(entry: PersistedVerdict | undefined): QADevice[];
|
|
10
|
+
/**
|
|
11
|
+
* Fully approved = a recorded whole-item approval (incl. grandfathered plain
|
|
12
|
+
* approvals with no device data) OR every required device approved.
|
|
13
|
+
*/
|
|
14
|
+
export declare function isFullyApproved(entry: PersistedVerdict | undefined, required: readonly QADevice[]): boolean;
|
|
15
|
+
/** Union of already-approved devices + a new approval. */
|
|
16
|
+
export declare function nextApprovedDevices(entry: PersistedVerdict | undefined, device: QADevice): QADevice[];
|
|
17
|
+
/**
|
|
18
|
+
* TOGGLE a device approval (0.3.1): clicking an approved device UNSETS it;
|
|
19
|
+
* clicking an unapproved one approves it. Returns the resulting device set +
|
|
20
|
+
* which action happened, so the caller can decide whether the item completed
|
|
21
|
+
* or fell back to pending.
|
|
22
|
+
*/
|
|
23
|
+
export declare function toggleDevice(effectiveApproved: readonly QADevice[], device: QADevice): {
|
|
24
|
+
devices: QADevice[];
|
|
25
|
+
action: "approve" | "unset";
|
|
26
|
+
};
|
|
27
|
+
/** Best-effort current-device detection (visual hint only - never a gate). */
|
|
28
|
+
export declare function detectDevice(): QADevice;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
export const DEVICE_LABEL = { pc: "PC", mobile: "Mobile" };
|
|
3
|
+
export const DEVICE_TOOLTIP = {
|
|
4
|
+
pc: "review on PC",
|
|
5
|
+
mobile: "review on mobile",
|
|
6
|
+
};
|
|
7
|
+
/** Required review devices for an item. Default: PC only. */
|
|
8
|
+
export function requiredDevices(item) {
|
|
9
|
+
return item.devices && item.devices.length ? item.devices : ["pc"];
|
|
10
|
+
}
|
|
11
|
+
/** Devices already approved on a ledger entry. */
|
|
12
|
+
export function approvedDevicesOf(entry) {
|
|
13
|
+
return (entry?.approvedDevices ?? []).filter((d) => d === "pc" || d === "mobile");
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Fully approved = a recorded whole-item approval (incl. grandfathered plain
|
|
17
|
+
* approvals with no device data) OR every required device approved.
|
|
18
|
+
*/
|
|
19
|
+
export function isFullyApproved(entry, required) {
|
|
20
|
+
if (!entry)
|
|
21
|
+
return false;
|
|
22
|
+
if (entry.verdict === "approve")
|
|
23
|
+
return true; // grandfathered / completed
|
|
24
|
+
if (entry.verdict === "reject")
|
|
25
|
+
return false;
|
|
26
|
+
const have = approvedDevicesOf(entry);
|
|
27
|
+
return required.length > 0 && required.every((d) => have.includes(d));
|
|
28
|
+
}
|
|
29
|
+
/** Union of already-approved devices + a new approval. */
|
|
30
|
+
export function nextApprovedDevices(entry, device) {
|
|
31
|
+
const have = approvedDevicesOf(entry);
|
|
32
|
+
return have.includes(device) ? have : [...have, device];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* TOGGLE a device approval (0.3.1): clicking an approved device UNSETS it;
|
|
36
|
+
* clicking an unapproved one approves it. Returns the resulting device set +
|
|
37
|
+
* which action happened, so the caller can decide whether the item completed
|
|
38
|
+
* or fell back to pending.
|
|
39
|
+
*/
|
|
40
|
+
export function toggleDevice(effectiveApproved, device) {
|
|
41
|
+
if (effectiveApproved.includes(device)) {
|
|
42
|
+
return { devices: effectiveApproved.filter((d) => d !== device), action: "unset" };
|
|
43
|
+
}
|
|
44
|
+
return { devices: [...effectiveApproved, device], action: "approve" };
|
|
45
|
+
}
|
|
46
|
+
/** Best-effort current-device detection (visual hint only - never a gate). */
|
|
47
|
+
export function detectDevice() {
|
|
48
|
+
try {
|
|
49
|
+
if (typeof navigator !== "undefined" && /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) {
|
|
50
|
+
return "mobile";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
/* default below */
|
|
55
|
+
}
|
|
56
|
+
return "pc";
|
|
57
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { QAReviewItem } from "./types.js";
|
|
2
|
+
/** Collapse all whitespace runs so formatting-only DOM churn doesn't count. */
|
|
3
|
+
export declare function normalizeText(text: string): string;
|
|
4
|
+
/** Deterministic fingerprint of a text blob (normalized FNV-1a, hex). */
|
|
5
|
+
export declare function fingerprintText(text: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Fingerprint the CURRENT content of an item: anchored items hash the
|
|
8
|
+
* element's rendered innerText; off-DOM task items hash their question text
|
|
9
|
+
* (sub, falling back to title). Returns null when the anchor is not present
|
|
10
|
+
* (e.g. other-viewport element) - no judgement is made then.
|
|
11
|
+
*/
|
|
12
|
+
export declare function itemFingerprint(item: QAReviewItem, doc: Document): string | null;
|
|
13
|
+
export type FingerprintStatus = "unchanged" | "changed";
|
|
14
|
+
/**
|
|
15
|
+
* Compare the fingerprint STORED with the last verdict against the current
|
|
16
|
+
* one. Null when either side is unknown (no stored fp / anchor missing) - the
|
|
17
|
+
* badge only ever asserts what the system actually measured.
|
|
18
|
+
*/
|
|
19
|
+
export declare function fingerprintStatus(stored: string | null | undefined, current: string | null | undefined): FingerprintStatus | null;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// NOT-ALTERED poka-yoke: a content fingerprint is stored with every verdict.
|
|
3
|
+
// When a previously-REJECTED item comes up again and the page content still
|
|
4
|
+
// fingerprints the same, the card shows a SYSTEM-COMPUTED "NOT ALTERED since
|
|
5
|
+
// your rejection" badge - judged by hashing, never by the operating agent, so
|
|
6
|
+
// the reviewer instantly sees nothing was worked on. A differing fingerprint
|
|
7
|
+
// shows a subtle "changed since last review" hint instead.
|
|
8
|
+
import { fnv1a } from "../shared/codename.js";
|
|
9
|
+
/** Collapse all whitespace runs so formatting-only DOM churn doesn't count. */
|
|
10
|
+
export function normalizeText(text) {
|
|
11
|
+
return text.replace(/\s+/g, " ").trim();
|
|
12
|
+
}
|
|
13
|
+
/** Deterministic fingerprint of a text blob (normalized FNV-1a, hex). */
|
|
14
|
+
export function fingerprintText(text) {
|
|
15
|
+
return fnv1a(normalizeText(text)).toString(16).padStart(8, "0");
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Fingerprint the CURRENT content of an item: anchored items hash the
|
|
19
|
+
* element's rendered innerText; off-DOM task items hash their question text
|
|
20
|
+
* (sub, falling back to title). Returns null when the anchor is not present
|
|
21
|
+
* (e.g. other-viewport element) - no judgement is made then.
|
|
22
|
+
*/
|
|
23
|
+
export function itemFingerprint(item, doc) {
|
|
24
|
+
if (!item.selector)
|
|
25
|
+
return fingerprintText(item.sub ?? item.title);
|
|
26
|
+
const el = doc.querySelector(item.selector);
|
|
27
|
+
if (!el)
|
|
28
|
+
return null;
|
|
29
|
+
return fingerprintText(el.innerText ?? el.textContent ?? "");
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Compare the fingerprint STORED with the last verdict against the current
|
|
33
|
+
* one. Null when either side is unknown (no stored fp / anchor missing) - the
|
|
34
|
+
* badge only ever asserts what the system actually measured.
|
|
35
|
+
*/
|
|
36
|
+
export function fingerprintStatus(stored, current) {
|
|
37
|
+
if (!stored || !current)
|
|
38
|
+
return null;
|
|
39
|
+
return stored === current ? "unchanged" : "changed";
|
|
40
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface MatchRange {
|
|
2
|
+
start: number;
|
|
3
|
+
end: number;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Case-insensitive, all-occurrence match ranges of the phrases inside a text.
|
|
7
|
+
* Overlapping later matches are dropped so ranges never intersect. Pure and
|
|
8
|
+
* unit-tested; the DOM wrapper below applies the same logic per text node.
|
|
9
|
+
*/
|
|
10
|
+
export declare function findMatchRanges(text: string, phrases: readonly string[]): MatchRange[];
|
|
11
|
+
/**
|
|
12
|
+
* Wrap phrase matches inside the element's text nodes with <mark> elements.
|
|
13
|
+
* Matches are found WITHIN single text nodes (phrases spanning element
|
|
14
|
+
* boundaries are not wrapped - keep highlight phrases short). Returns a
|
|
15
|
+
* cleanup function that fully restores the original DOM.
|
|
16
|
+
*/
|
|
17
|
+
export declare function applySubHighlights(el: HTMLElement, phrases: readonly string[]): () => void;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
/**
|
|
3
|
+
* Case-insensitive, all-occurrence match ranges of the phrases inside a text.
|
|
4
|
+
* Overlapping later matches are dropped so ranges never intersect. Pure and
|
|
5
|
+
* unit-tested; the DOM wrapper below applies the same logic per text node.
|
|
6
|
+
*/
|
|
7
|
+
export function findMatchRanges(text, phrases) {
|
|
8
|
+
const hay = text.toLowerCase();
|
|
9
|
+
const ranges = [];
|
|
10
|
+
for (const raw of phrases) {
|
|
11
|
+
const needle = raw.toLowerCase().trim();
|
|
12
|
+
if (!needle)
|
|
13
|
+
continue;
|
|
14
|
+
let from = 0;
|
|
15
|
+
for (;;) {
|
|
16
|
+
const at = hay.indexOf(needle, from);
|
|
17
|
+
if (at === -1)
|
|
18
|
+
break;
|
|
19
|
+
ranges.push({ start: at, end: at + needle.length });
|
|
20
|
+
from = at + needle.length;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
ranges.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const r of ranges) {
|
|
26
|
+
const last = out[out.length - 1];
|
|
27
|
+
if (last && r.start < last.end)
|
|
28
|
+
continue; // drop overlaps
|
|
29
|
+
out.push(r);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
const MARK_CLASS = "qar-subhl";
|
|
34
|
+
/**
|
|
35
|
+
* Wrap phrase matches inside the element's text nodes with <mark> elements.
|
|
36
|
+
* Matches are found WITHIN single text nodes (phrases spanning element
|
|
37
|
+
* boundaries are not wrapped - keep highlight phrases short). Returns a
|
|
38
|
+
* cleanup function that fully restores the original DOM.
|
|
39
|
+
*/
|
|
40
|
+
export function applySubHighlights(el, phrases) {
|
|
41
|
+
const doc = el.ownerDocument;
|
|
42
|
+
const walker = doc.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
43
|
+
const textNodes = [];
|
|
44
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode())
|
|
45
|
+
textNodes.push(n);
|
|
46
|
+
const marks = [];
|
|
47
|
+
for (const node of textNodes) {
|
|
48
|
+
const text = node.nodeValue ?? "";
|
|
49
|
+
const ranges = findMatchRanges(text, phrases);
|
|
50
|
+
if (!ranges.length)
|
|
51
|
+
continue;
|
|
52
|
+
const frag = doc.createDocumentFragment();
|
|
53
|
+
let cursor = 0;
|
|
54
|
+
for (const r of ranges) {
|
|
55
|
+
if (r.start > cursor)
|
|
56
|
+
frag.appendChild(doc.createTextNode(text.slice(cursor, r.start)));
|
|
57
|
+
const mark = doc.createElement("mark");
|
|
58
|
+
mark.className = MARK_CLASS;
|
|
59
|
+
mark.textContent = text.slice(r.start, r.end);
|
|
60
|
+
frag.appendChild(mark);
|
|
61
|
+
marks.push(mark);
|
|
62
|
+
cursor = r.end;
|
|
63
|
+
}
|
|
64
|
+
if (cursor < text.length)
|
|
65
|
+
frag.appendChild(doc.createTextNode(text.slice(cursor)));
|
|
66
|
+
node.parentNode?.replaceChild(frag, node);
|
|
67
|
+
}
|
|
68
|
+
return () => {
|
|
69
|
+
for (const mark of marks) {
|
|
70
|
+
const parent = mark.parentNode;
|
|
71
|
+
if (!parent)
|
|
72
|
+
continue;
|
|
73
|
+
parent.replaceChild(doc.createTextNode(mark.textContent ?? ""), mark);
|
|
74
|
+
parent.normalize();
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { QAReviewOverlay, isClickGesture, nextMinimized, AUTO_BUBBLE_MAX_WIDTH_PX, type BubbleEvent, type QAReviewOverlayProps, } from "./QAReviewOverlay.js";
|
|
2
|
+
export { QAStore, createQAStore, targetFromLocation, type QAStoreOptions, type PersistedVerdict, type PendingOp, type VerdictMap, } from "./store.js";
|
|
3
|
+
export { journeyIndex, countPending, countUnverdicted, nextPendingPage, buildJourneyNavUrl, fetchPendingCounts, resolveFinishAction, journeyFinishView, shouldPrefetch, isPrefetchFresh, ensurePrefetchLink, PREFETCH_WINDOW_ITEMS, PREFETCH_MAX_AGE_MS, type FinishAction, type QAJourneyConfig, type QAJourneyPage, } from "./journey.js";
|
|
4
|
+
export { normalizeText, fingerprintText, itemFingerprint, fingerprintStatus, type FingerprintStatus, } from "./fingerprint.js";
|
|
5
|
+
export { requiredDevices, approvedDevicesOf, isFullyApproved, nextApprovedDevices, toggleDevice, detectDevice, DEVICE_LABEL, DEVICE_TOOLTIP, type QADevice, } from "./device.js";
|
|
6
|
+
export { findMatchRanges, applySubHighlights, type MatchRange } from "./highlight.js";
|
|
7
|
+
export { describeRevisit, type RevisitInfo, type RevisitDisplay } from "./revisit.js";
|
|
8
|
+
export { buildMobilePreviewUrl, isEmbeddedPreview, postVariantToPreview, parseVariantMessage, MOBILE_PREVIEW_WIDTH, MOBILE_PREVIEW_HEIGHT, PREVIEW_MARKER_PARAM, PREVIEW_MESSAGE_TYPE, type VariantMessage, } from "./preview.js";
|
|
9
|
+
export { codenameFor, findByCodename, formatQARef, fnv1a, type CodenameEntry, } from "../shared/codename.js";
|
|
10
|
+
export { ensureQAStyles } from "./styles.js";
|
|
11
|
+
export { isTaskItem } from "./types.js";
|
|
12
|
+
export type { QAReviewItem, QAReviewDevice, QAResult, QAVerdict, QASubmission, QATheme, } from "./types.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { QAReviewOverlay, isClickGesture, nextMinimized, AUTO_BUBBLE_MAX_WIDTH_PX, } from "./QAReviewOverlay.js";
|
|
2
|
+
export { QAStore, createQAStore, targetFromLocation, } from "./store.js";
|
|
3
|
+
export { journeyIndex, countPending, countUnverdicted, nextPendingPage, buildJourneyNavUrl, fetchPendingCounts, resolveFinishAction, journeyFinishView, shouldPrefetch, isPrefetchFresh, ensurePrefetchLink, PREFETCH_WINDOW_ITEMS, PREFETCH_MAX_AGE_MS, } from "./journey.js";
|
|
4
|
+
export { normalizeText, fingerprintText, itemFingerprint, fingerprintStatus, } from "./fingerprint.js";
|
|
5
|
+
export { requiredDevices, approvedDevicesOf, isFullyApproved, nextApprovedDevices, toggleDevice, detectDevice, DEVICE_LABEL, DEVICE_TOOLTIP, } from "./device.js";
|
|
6
|
+
export { findMatchRanges, applySubHighlights } from "./highlight.js";
|
|
7
|
+
export { describeRevisit } from "./revisit.js";
|
|
8
|
+
export { buildMobilePreviewUrl, isEmbeddedPreview, postVariantToPreview, parseVariantMessage, MOBILE_PREVIEW_WIDTH, MOBILE_PREVIEW_HEIGHT, PREVIEW_MARKER_PARAM, PREVIEW_MESSAGE_TYPE, } from "./preview.js";
|
|
9
|
+
export { codenameFor, findByCodename, formatQARef, fnv1a, } from "../shared/codename.js";
|
|
10
|
+
export { ensureQAStyles } from "./styles.js";
|
|
11
|
+
export { isTaskItem } from "./types.js";
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { VerdictMap } from "./store.js";
|
|
2
|
+
export interface QAJourneyPage {
|
|
3
|
+
/** Pathname to navigate to (e.g. "/pricing"). */
|
|
4
|
+
path: string;
|
|
5
|
+
/** Ledger bucket for that page (e.g. "example-site:/pricing"). */
|
|
6
|
+
target: string;
|
|
7
|
+
/** Human label for the journey summary. Default: path. */
|
|
8
|
+
label?: string;
|
|
9
|
+
/** ALL reviewable item ids on that page (pending = not approved in ledger). */
|
|
10
|
+
itemIds: string[];
|
|
11
|
+
}
|
|
12
|
+
export interface QAJourneyConfig {
|
|
13
|
+
/** Ordered pages of the review journey. */
|
|
14
|
+
pages: QAJourneyPage[];
|
|
15
|
+
}
|
|
16
|
+
/** Index of the journey page whose target matches, or -1. */
|
|
17
|
+
export declare function journeyIndex(pages: readonly QAJourneyPage[], target: string): number;
|
|
18
|
+
/**
|
|
19
|
+
* ROUND-pending = items NOT approved in the ledger map (unreviewed OR
|
|
20
|
+
* rejected) - the overlay's round-freeze rule: rejected items re-present in
|
|
21
|
+
* FUTURE rounds. An empty/missing map counts every item as pending.
|
|
22
|
+
*/
|
|
23
|
+
export declare function countPending(itemIds: readonly string[], map: VerdictMap | null | undefined): number;
|
|
24
|
+
/**
|
|
25
|
+
* NAVIGATION-pending (0.3.2 fix) = items the reviewer has not TOUCHED yet:
|
|
26
|
+
* no verdict AND no device approvals recorded. Approve, reject, AND partial
|
|
27
|
+
* device states all count as HANDLED for the current run's navigation -
|
|
28
|
+
* 🚨 counting fresh REJECTS as pending made the journey ping-pong forever
|
|
29
|
+
* between a rejected page and the next one (wraparound kept returning to the
|
|
30
|
+
* rejects). Rejected items still re-enter FUTURE rounds via countPending /
|
|
31
|
+
* the round freeze - they just never re-enter THIS run's walkthrough.
|
|
32
|
+
*/
|
|
33
|
+
export declare function countUnverdicted(itemIds: readonly string[], map: VerdictMap | null | undefined): number;
|
|
34
|
+
/**
|
|
35
|
+
* Next journey page (excluding the current one) with pending items: searches
|
|
36
|
+
* FORWARD from the current page and wraps around, so a mid-journey start still
|
|
37
|
+
* covers earlier pages. Returns null when the whole journey is clean. Feed it
|
|
38
|
+
* NAVIGATION-pending counts (countUnverdicted) - never round counts, or pages
|
|
39
|
+
* with fresh rejects bounce the walkthrough back forever.
|
|
40
|
+
*/
|
|
41
|
+
export declare function nextPendingPage(pages: readonly QAJourneyPage[], pendingByTarget: Readonly<Record<string, number>>, currentIndex: number): QAJourneyPage | null;
|
|
42
|
+
/**
|
|
43
|
+
* What the finished-state UI shows in a journey (0.3.1): an UNMISTAKABLE
|
|
44
|
+
* loading indicator from the moment the round exhausts until the next page
|
|
45
|
+
* unloads this one - never a blank screen (a reviewer almost exited thinking the
|
|
46
|
+
* review was done) - or the journey-complete panel.
|
|
47
|
+
*/
|
|
48
|
+
export declare function journeyFinishView(inJourney: boolean, journeyComplete: boolean): "standard" | "loading" | "complete";
|
|
49
|
+
export type FinishAction = {
|
|
50
|
+
kind: "navigate";
|
|
51
|
+
page: QAJourneyPage;
|
|
52
|
+
} | {
|
|
53
|
+
kind: "journey-complete";
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* What happens the moment a page's round is exhausted: navigate IMMEDIATELY
|
|
57
|
+
* to the next page with pending items (no interstitial, no success popup), or
|
|
58
|
+
* show the journey-complete panel when nothing is pending anywhere.
|
|
59
|
+
*/
|
|
60
|
+
export declare function resolveFinishAction(pages: readonly QAJourneyPage[], pendingByTarget: Readonly<Record<string, number>>, currentIndex: number): FinishAction;
|
|
61
|
+
/**
|
|
62
|
+
* Build the navigation URL for a journey hop, preserving the current page's
|
|
63
|
+
* query params (QA gate param, auth key, ...) so activation survives the full
|
|
64
|
+
* page load. The per-page `target` override is dropped - the arriving page
|
|
65
|
+
* derives its own bucket.
|
|
66
|
+
*/
|
|
67
|
+
export declare function buildJourneyNavUrl(path: string, currentSearch: string): string;
|
|
68
|
+
/** Within how many final round items the journey prefetch kicks in. */
|
|
69
|
+
export declare const PREFETCH_WINDOW_ITEMS = 2;
|
|
70
|
+
/** Prefetched counts older than this are considered stale. */
|
|
71
|
+
export declare const PREFETCH_MAX_AGE_MS = 30000;
|
|
72
|
+
/** Should the background prefetch start? (within the final N round items) */
|
|
73
|
+
export declare function shouldPrefetch(index: number, roundTotal: number, windowItems?: number): boolean;
|
|
74
|
+
/** Is a prefetched result still fresh enough to navigate on? */
|
|
75
|
+
export declare function isPrefetchFresh(fetchedAt: number, now: number, maxAgeMs?: number): boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Warm the next journey page: add an idempotent <link rel="prefetch"> for the
|
|
78
|
+
* nav URL so the browser can fetch the document ahead of the hop. Best-effort
|
|
79
|
+
* hint - browsers may ignore it.
|
|
80
|
+
*/
|
|
81
|
+
export declare function ensurePrefetchLink(doc: Document, href: string): void;
|
|
82
|
+
/**
|
|
83
|
+
* Fetch the ledger for every journey page (one state GET per target) and
|
|
84
|
+
* return NAVIGATION-pending counts (unverdicted items - see countUnverdicted;
|
|
85
|
+
* rejected/partial items are handled-this-run and must not re-attract
|
|
86
|
+
* navigation). A failed fetch counts that page's items as pending (never lose
|
|
87
|
+
* a page silently).
|
|
88
|
+
*/
|
|
89
|
+
export declare function fetchPendingCounts(stateUrl: string, pages: readonly QAJourneyPage[]): Promise<Record<string, number>>;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Cross-page journey support: pure helpers (unit-tested) used by the overlay
|
|
2
|
+
// to compute per-page pending counts from the durable ledger, pick the next
|
|
3
|
+
// page that still needs review, and build navigation URLs that PRESERVE the
|
|
4
|
+
// QA activation params (gate param, auth key, etc.) across a full page load.
|
|
5
|
+
/** Index of the journey page whose target matches, or -1. */
|
|
6
|
+
export function journeyIndex(pages, target) {
|
|
7
|
+
return pages.findIndex((p) => p.target === target);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* ROUND-pending = items NOT approved in the ledger map (unreviewed OR
|
|
11
|
+
* rejected) - the overlay's round-freeze rule: rejected items re-present in
|
|
12
|
+
* FUTURE rounds. An empty/missing map counts every item as pending.
|
|
13
|
+
*/
|
|
14
|
+
export function countPending(itemIds, map) {
|
|
15
|
+
return itemIds.filter((id) => map?.[id]?.verdict !== "approve").length;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* NAVIGATION-pending (0.3.2 fix) = items the reviewer has not TOUCHED yet:
|
|
19
|
+
* no verdict AND no device approvals recorded. Approve, reject, AND partial
|
|
20
|
+
* device states all count as HANDLED for the current run's navigation -
|
|
21
|
+
* 🚨 counting fresh REJECTS as pending made the journey ping-pong forever
|
|
22
|
+
* between a rejected page and the next one (wraparound kept returning to the
|
|
23
|
+
* rejects). Rejected items still re-enter FUTURE rounds via countPending /
|
|
24
|
+
* the round freeze - they just never re-enter THIS run's walkthrough.
|
|
25
|
+
*/
|
|
26
|
+
export function countUnverdicted(itemIds, map) {
|
|
27
|
+
return itemIds.filter((id) => {
|
|
28
|
+
const e = map?.[id];
|
|
29
|
+
return !e?.verdict && !e?.approvedDevices?.length;
|
|
30
|
+
}).length;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Next journey page (excluding the current one) with pending items: searches
|
|
34
|
+
* FORWARD from the current page and wraps around, so a mid-journey start still
|
|
35
|
+
* covers earlier pages. Returns null when the whole journey is clean. Feed it
|
|
36
|
+
* NAVIGATION-pending counts (countUnverdicted) - never round counts, or pages
|
|
37
|
+
* with fresh rejects bounce the walkthrough back forever.
|
|
38
|
+
*/
|
|
39
|
+
export function nextPendingPage(pages, pendingByTarget, currentIndex) {
|
|
40
|
+
for (let step = 1; step < pages.length; step++) {
|
|
41
|
+
const p = pages[(currentIndex + step + pages.length) % pages.length];
|
|
42
|
+
if ((pendingByTarget[p.target] ?? 0) > 0)
|
|
43
|
+
return p;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* What the finished-state UI shows in a journey (0.3.1): an UNMISTAKABLE
|
|
49
|
+
* loading indicator from the moment the round exhausts until the next page
|
|
50
|
+
* unloads this one - never a blank screen (a reviewer almost exited thinking the
|
|
51
|
+
* review was done) - or the journey-complete panel.
|
|
52
|
+
*/
|
|
53
|
+
export function journeyFinishView(inJourney, journeyComplete) {
|
|
54
|
+
if (!inJourney)
|
|
55
|
+
return "standard";
|
|
56
|
+
return journeyComplete ? "complete" : "loading";
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* What happens the moment a page's round is exhausted: navigate IMMEDIATELY
|
|
60
|
+
* to the next page with pending items (no interstitial, no success popup), or
|
|
61
|
+
* show the journey-complete panel when nothing is pending anywhere.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveFinishAction(pages, pendingByTarget, currentIndex) {
|
|
64
|
+
const page = nextPendingPage(pages, pendingByTarget, currentIndex);
|
|
65
|
+
return page ? { kind: "navigate", page } : { kind: "journey-complete" };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Build the navigation URL for a journey hop, preserving the current page's
|
|
69
|
+
* query params (QA gate param, auth key, ...) so activation survives the full
|
|
70
|
+
* page load. The per-page `target` override is dropped - the arriving page
|
|
71
|
+
* derives its own bucket.
|
|
72
|
+
*/
|
|
73
|
+
export function buildJourneyNavUrl(path, currentSearch) {
|
|
74
|
+
const params = new URLSearchParams(currentSearch);
|
|
75
|
+
params.delete("target");
|
|
76
|
+
const qs = params.toString();
|
|
77
|
+
return qs ? `${path}?${qs}` : path;
|
|
78
|
+
}
|
|
79
|
+
/** Within how many final round items the journey prefetch kicks in. */
|
|
80
|
+
export const PREFETCH_WINDOW_ITEMS = 2;
|
|
81
|
+
/** Prefetched counts older than this are considered stale. */
|
|
82
|
+
export const PREFETCH_MAX_AGE_MS = 30_000;
|
|
83
|
+
/** Should the background prefetch start? (within the final N round items) */
|
|
84
|
+
export function shouldPrefetch(index, roundTotal, windowItems = PREFETCH_WINDOW_ITEMS) {
|
|
85
|
+
return roundTotal > 0 && roundTotal - index <= windowItems;
|
|
86
|
+
}
|
|
87
|
+
/** Is a prefetched result still fresh enough to navigate on? */
|
|
88
|
+
export function isPrefetchFresh(fetchedAt, now, maxAgeMs = PREFETCH_MAX_AGE_MS) {
|
|
89
|
+
return now - fetchedAt >= 0 && now - fetchedAt < maxAgeMs;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Warm the next journey page: add an idempotent <link rel="prefetch"> for the
|
|
93
|
+
* nav URL so the browser can fetch the document ahead of the hop. Best-effort
|
|
94
|
+
* hint - browsers may ignore it.
|
|
95
|
+
*/
|
|
96
|
+
export function ensurePrefetchLink(doc, href) {
|
|
97
|
+
const existing = Array.from(doc.querySelectorAll('link[data-qar-prefetch="1"]'));
|
|
98
|
+
if (existing.some((l) => l.getAttribute("href") === href))
|
|
99
|
+
return;
|
|
100
|
+
const link = doc.createElement("link");
|
|
101
|
+
link.rel = "prefetch";
|
|
102
|
+
link.href = href;
|
|
103
|
+
link.as = "document";
|
|
104
|
+
link.setAttribute("data-qar-prefetch", "1");
|
|
105
|
+
doc.head.appendChild(link);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Fetch the ledger for every journey page (one state GET per target) and
|
|
109
|
+
* return NAVIGATION-pending counts (unverdicted items - see countUnverdicted;
|
|
110
|
+
* rejected/partial items are handled-this-run and must not re-attract
|
|
111
|
+
* navigation). A failed fetch counts that page's items as pending (never lose
|
|
112
|
+
* a page silently).
|
|
113
|
+
*/
|
|
114
|
+
export async function fetchPendingCounts(stateUrl, pages) {
|
|
115
|
+
const out = {};
|
|
116
|
+
await Promise.all(pages.map(async (p) => {
|
|
117
|
+
let map = null;
|
|
118
|
+
try {
|
|
119
|
+
const r = await fetch(`${stateUrl}?target=${encodeURIComponent(p.target)}`, {
|
|
120
|
+
cache: "no-store",
|
|
121
|
+
});
|
|
122
|
+
if (r.ok) {
|
|
123
|
+
const d = (await r.json());
|
|
124
|
+
if (d?.ok && d.verdicts)
|
|
125
|
+
map = d.verdicts;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
map = null;
|
|
130
|
+
}
|
|
131
|
+
out[p.target] = countUnverdicted(p.itemIds, map);
|
|
132
|
+
}));
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Phone frame dimensions (iPhone-ish portrait). */
|
|
2
|
+
export declare const MOBILE_PREVIEW_WIDTH = 390;
|
|
3
|
+
export declare const MOBILE_PREVIEW_HEIGHT = 844;
|
|
4
|
+
/** Marker param: an overlay whose URL carries it never activates. */
|
|
5
|
+
export declare const PREVIEW_MARKER_PARAM = "qaMobilePreview";
|
|
6
|
+
/** Current URL with the embed marker appended (QA params preserved). */
|
|
7
|
+
export declare function buildMobilePreviewUrl(href: string): string;
|
|
8
|
+
/** True when THIS document is the embedded preview (overlay must not mount). */
|
|
9
|
+
export declare function isEmbeddedPreview(search: string): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Scroll the preview iframe so `selector` is centred in the phone frame.
|
|
12
|
+
*
|
|
13
|
+
* Returns a cleanup function that cancels any pending retries, so a caller that
|
|
14
|
+
* switches items mid-flight does not have two scroll loops fighting.
|
|
15
|
+
*
|
|
16
|
+
* A missing element is NOT an error: plenty of items are desktop-only, and the
|
|
17
|
+
* overlay already surfaces "target not on this viewport" separately. We simply
|
|
18
|
+
* keep retrying until the attempts run out, in case it is merely late.
|
|
19
|
+
*/
|
|
20
|
+
export declare function scrollPreviewToSelector(iframe: HTMLIFrameElement | null, selector: string | undefined): () => void;
|
|
21
|
+
export declare const PREVIEW_MESSAGE_TYPE = "qa-review:variant";
|
|
22
|
+
export interface VariantMessage {
|
|
23
|
+
type: typeof PREVIEW_MESSAGE_TYPE;
|
|
24
|
+
itemId: string;
|
|
25
|
+
variant: number;
|
|
26
|
+
}
|
|
27
|
+
/** Post a variant change to the open preview iframe (same-origin). */
|
|
28
|
+
export declare function postVariantToPreview(iframe: HTMLIFrameElement | null, itemId: string, variant: number): void;
|
|
29
|
+
/** Parse a message event into a VariantMessage, or null if it isn't one. */
|
|
30
|
+
export declare function parseVariantMessage(data: unknown): VariantMessage | null;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// Mobile preview (0.3.5): render the CURRENT page in a phone-sized, framed
|
|
3
|
+
// same-origin iframe on a dimmed backdrop, so "Approve Mobile" can be
|
|
4
|
+
// exercised on desktop without devtools. The iframe URL keeps the QA
|
|
5
|
+
// activation params but adds a marker param that tells the overlay INSIDE the
|
|
6
|
+
// iframe to stay dormant (no nested review chrome).
|
|
7
|
+
/** Phone frame dimensions (iPhone-ish portrait). */
|
|
8
|
+
export const MOBILE_PREVIEW_WIDTH = 390;
|
|
9
|
+
export const MOBILE_PREVIEW_HEIGHT = 844;
|
|
10
|
+
/** Marker param: an overlay whose URL carries it never activates. */
|
|
11
|
+
export const PREVIEW_MARKER_PARAM = "qaMobilePreview";
|
|
12
|
+
/** Current URL with the embed marker appended (QA params preserved). */
|
|
13
|
+
export function buildMobilePreviewUrl(href) {
|
|
14
|
+
const url = new URL(href);
|
|
15
|
+
url.searchParams.set(PREVIEW_MARKER_PARAM, "1");
|
|
16
|
+
return url.toString();
|
|
17
|
+
}
|
|
18
|
+
/** True when THIS document is the embedded preview (overlay must not mount). */
|
|
19
|
+
export function isEmbeddedPreview(search) {
|
|
20
|
+
try {
|
|
21
|
+
return new URLSearchParams(search).has(PREVIEW_MARKER_PARAM);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/* --------------------------- scroll-to-item ------------------------------- */
|
|
28
|
+
// 0.3.7 fix: the preview iframe loads the page at its TOP, so opening the phone
|
|
29
|
+
// frame showed the header instead of the section under review - the reviewer had
|
|
30
|
+
// to hunt for it by hand, on every item (a reviewer, 2026-07-30, reported twice).
|
|
31
|
+
//
|
|
32
|
+
// The frame is same-origin, so the parent can reach into its document directly.
|
|
33
|
+
// We retry rather than scroll once: the iframe fires `load` before Next.js has
|
|
34
|
+
// hydrated and before images have settled, and an early scroll gets undone by
|
|
35
|
+
// the layout shift that follows.
|
|
36
|
+
/** How many attempts, and how far apart, to land the scroll. */
|
|
37
|
+
const SCROLL_ATTEMPTS = 12;
|
|
38
|
+
const SCROLL_INTERVAL_MS = 120;
|
|
39
|
+
/**
|
|
40
|
+
* Scroll the preview iframe so `selector` is centred in the phone frame.
|
|
41
|
+
*
|
|
42
|
+
* Returns a cleanup function that cancels any pending retries, so a caller that
|
|
43
|
+
* switches items mid-flight does not have two scroll loops fighting.
|
|
44
|
+
*
|
|
45
|
+
* A missing element is NOT an error: plenty of items are desktop-only, and the
|
|
46
|
+
* overlay already surfaces "target not on this viewport" separately. We simply
|
|
47
|
+
* keep retrying until the attempts run out, in case it is merely late.
|
|
48
|
+
*/
|
|
49
|
+
export function scrollPreviewToSelector(iframe, selector) {
|
|
50
|
+
if (!iframe || !selector)
|
|
51
|
+
return () => { };
|
|
52
|
+
let attempts = 0;
|
|
53
|
+
let timer;
|
|
54
|
+
let cancelled = false;
|
|
55
|
+
const tick = () => {
|
|
56
|
+
if (cancelled)
|
|
57
|
+
return;
|
|
58
|
+
attempts += 1;
|
|
59
|
+
try {
|
|
60
|
+
const doc = iframe.contentDocument;
|
|
61
|
+
const el = doc?.querySelector(selector);
|
|
62
|
+
if (el) {
|
|
63
|
+
// "center" keeps a tall section's top edge visible in a 844px frame,
|
|
64
|
+
// which "start" does not once a sticky header is in play.
|
|
65
|
+
el.scrollIntoView({ behavior: "auto", block: "center", inline: "nearest" });
|
|
66
|
+
// Keep going a couple more rounds: late-loading images below the fold
|
|
67
|
+
// routinely shove the target back off screen after a correct scroll.
|
|
68
|
+
if (attempts >= 3)
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
/* cross-origin or frame torn down mid-flight: nothing to do */
|
|
74
|
+
}
|
|
75
|
+
if (attempts < SCROLL_ATTEMPTS)
|
|
76
|
+
timer = setTimeout(tick, SCROLL_INTERVAL_MS);
|
|
77
|
+
};
|
|
78
|
+
tick();
|
|
79
|
+
return () => {
|
|
80
|
+
cancelled = true;
|
|
81
|
+
if (timer)
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/* ------------------------- live variant propagation ------------------------ */
|
|
86
|
+
// When the phone-frame preview is OPEN and the reviewer picks a different
|
|
87
|
+
// variation, the variant must apply INSIDE the iframe document without a
|
|
88
|
+
// close+reopen. The parent posts the variant to the same-origin iframe; the
|
|
89
|
+
// embedded (dormant) overlay applies it by running the consumer's own
|
|
90
|
+
// variation callback there - so the DOM mutation executes in the iframe's
|
|
91
|
+
// document, not just the parent.
|
|
92
|
+
export const PREVIEW_MESSAGE_TYPE = "qa-review:variant";
|
|
93
|
+
/** Post a variant change to the open preview iframe (same-origin). */
|
|
94
|
+
export function postVariantToPreview(iframe, itemId, variant) {
|
|
95
|
+
const win = iframe?.contentWindow;
|
|
96
|
+
if (!win)
|
|
97
|
+
return;
|
|
98
|
+
const msg = { type: PREVIEW_MESSAGE_TYPE, itemId, variant };
|
|
99
|
+
try {
|
|
100
|
+
win.postMessage(msg, window.location.origin);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
/* best-effort */
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Parse a message event into a VariantMessage, or null if it isn't one. */
|
|
107
|
+
export function parseVariantMessage(data) {
|
|
108
|
+
if (data &&
|
|
109
|
+
typeof data === "object" &&
|
|
110
|
+
data.type === PREVIEW_MESSAGE_TYPE &&
|
|
111
|
+
typeof data.itemId === "string" &&
|
|
112
|
+
typeof data.variant === "number") {
|
|
113
|
+
return data;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|