@artymclabin/qa-review 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ import type { FingerprintStatus } from "./fingerprint.js";
2
+ export interface RevisitInfo {
3
+ /** Why the item was re-queued (set by the invalidation call). */
4
+ reason?: string;
5
+ /** The reviewer's verdict before invalidation. */
6
+ prevVerdict?: string;
7
+ /** The reviewer's note from that prior verdict. */
8
+ prevNote?: string;
9
+ }
10
+ export interface RevisitDisplay {
11
+ /** Prominent "Back for review: ..." headline (reason, or generic change note). */
12
+ headline: string | null;
13
+ /** "Your last verdict: reject - "note"" context line. */
14
+ prior: string | null;
15
+ /** Show the prominent NOT-ALTERED badge (prior REJECT + identical content). */
16
+ notAltered: boolean;
17
+ }
18
+ /**
19
+ * Pure display logic for a re-queued item. Rules:
20
+ * - a provided reason headlines as "Back for review: <reason>";
21
+ * - no reason but a CHANGED fingerprint -> generic "Content changed since
22
+ * your last review.";
23
+ * - a prior verdict renders as context ("Your last verdict: ...") with the
24
+ * saved note;
25
+ * - prior REJECT + UNCHANGED fingerprint keeps the system-computed
26
+ * NOT-ALTERED badge (reason or not - the reviewer must see nothing moved).
27
+ */
28
+ export declare function describeRevisit(info: RevisitInfo | undefined, fpStatus: FingerprintStatus | null): RevisitDisplay | null;
@@ -0,0 +1,27 @@
1
+ "use client";
2
+ /**
3
+ * Pure display logic for a re-queued item. Rules:
4
+ * - a provided reason headlines as "Back for review: <reason>";
5
+ * - no reason but a CHANGED fingerprint -> generic "Content changed since
6
+ * your last review.";
7
+ * - a prior verdict renders as context ("Your last verdict: ...") with the
8
+ * saved note;
9
+ * - prior REJECT + UNCHANGED fingerprint keeps the system-computed
10
+ * NOT-ALTERED badge (reason or not - the reviewer must see nothing moved).
11
+ */
12
+ export function describeRevisit(info, fpStatus) {
13
+ if (!info)
14
+ return null;
15
+ const headline = info.reason
16
+ ? `Back for review: ${info.reason}`
17
+ : fpStatus === "changed"
18
+ ? "Content changed since your last review."
19
+ : null;
20
+ const prior = info.prevVerdict
21
+ ? `Your last verdict: ${info.prevVerdict}${info.prevNote ? ` - "${info.prevNote}"` : ""}`
22
+ : null;
23
+ const notAltered = fpStatus === "unchanged" && info.prevVerdict === "reject";
24
+ if (!headline && !prior && !notAltered)
25
+ return null;
26
+ return { headline, prior, notAltered };
27
+ }
@@ -0,0 +1,100 @@
1
+ import type { QAVerdict } from "./types.js";
2
+ export interface PersistedVerdict {
3
+ verdict?: QAVerdict;
4
+ variant?: number;
5
+ note?: string;
6
+ /** Content fingerprint at verdict time (NOT-ALTERED poka-yoke). */
7
+ fp?: string;
8
+ /** Per-device approvals ("pc"/"mobile") for device-split items. */
9
+ approvedDevices?: string[];
10
+ /** Server-computed codename (present in state GET responses; read-only). */
11
+ codename?: string;
12
+ /** Why the item was re-queued (set via invalidation; read-only here). */
13
+ revisitReason?: string;
14
+ /** Verdict in effect before the invalidation (read-only here). */
15
+ prevVerdict?: string;
16
+ }
17
+ /** item id -> persisted verdict/variant/note. */
18
+ export type VerdictMap = Record<string, PersistedVerdict>;
19
+ /** One queued server write. `patch: null` = delete (undo / invalidation). */
20
+ export interface PendingOp {
21
+ itemId: string;
22
+ patch: PersistedVerdict | null;
23
+ }
24
+ export interface QAStoreOptions {
25
+ /** Review bucket this store reads/writes (e.g. "example-site:/pricing"). */
26
+ target: string;
27
+ /** State endpoint URL. Default "/api/qa/state". */
28
+ stateUrl?: string;
29
+ /** localStorage key builder. Default: target-suffixed generic key. */
30
+ storageKey?: (target: string) => string;
31
+ /** Debounce for real-time note persistence, ms. Default 600. */
32
+ noteDebounceMs?: number;
33
+ }
34
+ /**
35
+ * Per-target verdict store. Construct one per review bucket; the overlay
36
+ * creates its own from props, and page wrappers may construct an identical one
37
+ * to persist variant picks outside the overlay.
38
+ */
39
+ export declare class QAStore {
40
+ readonly target: string;
41
+ private readonly stateUrl;
42
+ private readonly key;
43
+ private readonly pendingKey;
44
+ private readonly noteDebounceMs;
45
+ private flushing;
46
+ private noteTimers;
47
+ constructor(opts: QAStoreOptions);
48
+ /**
49
+ * Load the local verdict map. Returns an empty map (every item unreviewed)
50
+ * when the key was never written or is unparseable. NEVER seeds approvals.
51
+ */
52
+ load(): VerdictMap;
53
+ save(map: VerdictMap): void;
54
+ /** Merge-write a single item's fields locally (read-modify-write). */
55
+ persistLocal(id: string, patch: PersistedVerdict): void;
56
+ /** Delete a single item's verdict locally (undo). */
57
+ removeLocal(id: string): void;
58
+ /** Read the persisted pending-op queue. */
59
+ pendingOps(): PendingOp[];
60
+ private savePending;
61
+ /** POST one op to the state endpoint. Resolves false on any failure. */
62
+ private sendOp;
63
+ /**
64
+ * Replay the pending queue in order; stops at the first failure (the rest
65
+ * retries on the next action). Concurrency-guarded so parallel triggers
66
+ * cannot double-send.
67
+ */
68
+ flush(): Promise<void>;
69
+ /** Enqueue one op (persisted) and immediately try to flush the queue. */
70
+ private enqueue;
71
+ /**
72
+ * Merge-write one item locally AND to the server ledger in REAL TIME.
73
+ * The write is queued+flushed, so a failure is retried on the next action.
74
+ */
75
+ persist(id: string, patch: PersistedVerdict): void;
76
+ /** Merge-write a single item's chosen variant only (local + server). */
77
+ persistVariant(id: string, variant: number): void;
78
+ /**
79
+ * Real-time note persistence, debounced (default 600ms after the last
80
+ * keystroke). An empty note clears the stored note.
81
+ */
82
+ persistNoteDebounced(id: string, note: string): void;
83
+ /** Cancel a pending debounced note write (e.g. a verdict is about to carry it). */
84
+ cancelPendingNote(id: string): void;
85
+ /** Undo: delete one item's verdict locally AND on the server (verdict: null). */
86
+ remove(id: string): void;
87
+ /**
88
+ * Read the durable server verdict map. Flushes the pending queue first and
89
+ * replays any STILL-pending ops over the result, so an offline verdict is
90
+ * never clobbered by hydration. Returns null if the server is unavailable.
91
+ */
92
+ serverLoad(): Promise<VerdictMap | null>;
93
+ }
94
+ /** Convenience factory (equivalent to `new QAStore(opts)`). */
95
+ export declare function createQAStore(opts: QAStoreOptions): QAStore;
96
+ /**
97
+ * Read a `?target=` override from the current URL (useful for isolating E2E
98
+ * runs from the real review bucket). Falls back to the given default.
99
+ */
100
+ export declare function targetFromLocation(defaultTarget: string): string;
@@ -0,0 +1,234 @@
1
+ "use client";
2
+ const DEFAULT_STATE_URL = "/api/qa/state";
3
+ const defaultStorageKey = (target) => `qa-review-verdicts:${target}`;
4
+ function hasStorage() {
5
+ try {
6
+ return typeof window !== "undefined" && !!window.localStorage;
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ }
12
+ /**
13
+ * Per-target verdict store. Construct one per review bucket; the overlay
14
+ * creates its own from props, and page wrappers may construct an identical one
15
+ * to persist variant picks outside the overlay.
16
+ */
17
+ export class QAStore {
18
+ target;
19
+ stateUrl;
20
+ key;
21
+ pendingKey;
22
+ noteDebounceMs;
23
+ flushing = false;
24
+ noteTimers = new Map();
25
+ constructor(opts) {
26
+ this.target = opts.target;
27
+ this.stateUrl = opts.stateUrl ?? DEFAULT_STATE_URL;
28
+ this.key = (opts.storageKey ?? defaultStorageKey)(opts.target);
29
+ this.pendingKey = `${this.key}::pending`;
30
+ this.noteDebounceMs = opts.noteDebounceMs ?? 600;
31
+ }
32
+ /* ------------------------------ local cache ------------------------------ */
33
+ /**
34
+ * Load the local verdict map. Returns an empty map (every item unreviewed)
35
+ * when the key was never written or is unparseable. NEVER seeds approvals.
36
+ */
37
+ load() {
38
+ if (!hasStorage())
39
+ return {};
40
+ const raw = window.localStorage.getItem(this.key);
41
+ if (raw === null)
42
+ return {};
43
+ try {
44
+ const parsed = JSON.parse(raw);
45
+ return parsed && typeof parsed === "object" ? parsed : {};
46
+ }
47
+ catch {
48
+ return {};
49
+ }
50
+ }
51
+ save(map) {
52
+ if (!hasStorage())
53
+ return;
54
+ window.localStorage.setItem(this.key, JSON.stringify(map));
55
+ }
56
+ /** Merge-write a single item's fields locally (read-modify-write). */
57
+ persistLocal(id, patch) {
58
+ const map = this.load();
59
+ map[id] = { ...map[id], ...patch };
60
+ this.save(map);
61
+ }
62
+ /** Delete a single item's verdict locally (undo). */
63
+ removeLocal(id) {
64
+ if (!hasStorage())
65
+ return;
66
+ const map = this.load();
67
+ delete map[id];
68
+ this.save(map);
69
+ }
70
+ /* ----------------------- write-behind pending queue ----------------------- */
71
+ // Every server write goes through this queue. On success it drains
72
+ // immediately (real-time); on failure the ops stay (persisted in
73
+ // localStorage so they survive a reload) and are replayed IN ORDER before
74
+ // any later server interaction. A verdict is never lost silently.
75
+ /** Read the persisted pending-op queue. */
76
+ pendingOps() {
77
+ if (!hasStorage())
78
+ return [];
79
+ const raw = window.localStorage.getItem(this.pendingKey);
80
+ if (raw === null)
81
+ return [];
82
+ try {
83
+ const parsed = JSON.parse(raw);
84
+ return Array.isArray(parsed) ? parsed : [];
85
+ }
86
+ catch {
87
+ return [];
88
+ }
89
+ }
90
+ savePending(ops) {
91
+ if (!hasStorage())
92
+ return;
93
+ if (ops.length === 0)
94
+ window.localStorage.removeItem(this.pendingKey);
95
+ else
96
+ window.localStorage.setItem(this.pendingKey, JSON.stringify(ops));
97
+ }
98
+ /** POST one op to the state endpoint. Resolves false on any failure. */
99
+ async sendOp(op) {
100
+ try {
101
+ const body = op.patch === null
102
+ ? { target: this.target, itemId: op.itemId, verdict: null }
103
+ : { target: this.target, itemId: op.itemId, ...op.patch };
104
+ const r = await fetch(this.stateUrl, {
105
+ method: "POST",
106
+ headers: { "Content-Type": "application/json" },
107
+ body: JSON.stringify(body),
108
+ });
109
+ return r.ok;
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ }
115
+ /**
116
+ * Replay the pending queue in order; stops at the first failure (the rest
117
+ * retries on the next action). Concurrency-guarded so parallel triggers
118
+ * cannot double-send.
119
+ */
120
+ async flush() {
121
+ if (this.flushing)
122
+ return;
123
+ this.flushing = true;
124
+ try {
125
+ let ops = this.pendingOps();
126
+ while (ops.length) {
127
+ const ok = await this.sendOp(ops[0]);
128
+ if (!ok)
129
+ break; // offline / server down: keep the queue, retry later
130
+ ops = this.pendingOps().slice(1); // re-read: actions may have enqueued
131
+ this.savePending(ops);
132
+ }
133
+ }
134
+ finally {
135
+ this.flushing = false;
136
+ }
137
+ }
138
+ /** Enqueue one op (persisted) and immediately try to flush the queue. */
139
+ enqueue(op) {
140
+ this.savePending([...this.pendingOps(), op]);
141
+ void this.flush();
142
+ }
143
+ /* ----------------------------- real-time API ------------------------------ */
144
+ /**
145
+ * Merge-write one item locally AND to the server ledger in REAL TIME.
146
+ * The write is queued+flushed, so a failure is retried on the next action.
147
+ */
148
+ persist(id, patch) {
149
+ this.persistLocal(id, patch);
150
+ this.enqueue({ itemId: id, patch });
151
+ }
152
+ /** Merge-write a single item's chosen variant only (local + server). */
153
+ persistVariant(id, variant) {
154
+ this.persist(id, { variant });
155
+ }
156
+ /**
157
+ * Real-time note persistence, debounced (default 600ms after the last
158
+ * keystroke). An empty note clears the stored note.
159
+ */
160
+ persistNoteDebounced(id, note) {
161
+ const t = this.noteTimers.get(id);
162
+ if (t)
163
+ clearTimeout(t);
164
+ this.noteTimers.set(id, setTimeout(() => {
165
+ this.noteTimers.delete(id);
166
+ this.persist(id, { note: note.trim() });
167
+ }, this.noteDebounceMs));
168
+ }
169
+ /** Cancel a pending debounced note write (e.g. a verdict is about to carry it). */
170
+ cancelPendingNote(id) {
171
+ const t = this.noteTimers.get(id);
172
+ if (t) {
173
+ clearTimeout(t);
174
+ this.noteTimers.delete(id);
175
+ }
176
+ }
177
+ /** Undo: delete one item's verdict locally AND on the server (verdict: null). */
178
+ remove(id) {
179
+ this.cancelPendingNote(id);
180
+ this.removeLocal(id);
181
+ this.enqueue({ itemId: id, patch: null });
182
+ }
183
+ /* ------------------------------ server read ------------------------------- */
184
+ /**
185
+ * Read the durable server verdict map. Flushes the pending queue first and
186
+ * replays any STILL-pending ops over the result, so an offline verdict is
187
+ * never clobbered by hydration. Returns null if the server is unavailable.
188
+ */
189
+ async serverLoad() {
190
+ await this.flush();
191
+ let map = null;
192
+ try {
193
+ const r = await fetch(`${this.stateUrl}?target=${encodeURIComponent(this.target)}`, {
194
+ cache: "no-store",
195
+ });
196
+ if (r.ok) {
197
+ const d = (await r.json());
198
+ if (d?.ok && d.verdicts)
199
+ map = d.verdicts;
200
+ }
201
+ }
202
+ catch {
203
+ map = null;
204
+ }
205
+ if (!map)
206
+ return null;
207
+ // Replay unflushed local ops on top - local intent wins until delivered.
208
+ for (const op of this.pendingOps()) {
209
+ if (op.patch === null)
210
+ delete map[op.itemId];
211
+ else
212
+ map[op.itemId] = { ...map[op.itemId], ...op.patch };
213
+ }
214
+ return map;
215
+ }
216
+ }
217
+ /** Convenience factory (equivalent to `new QAStore(opts)`). */
218
+ export function createQAStore(opts) {
219
+ return new QAStore(opts);
220
+ }
221
+ /**
222
+ * Read a `?target=` override from the current URL (useful for isolating E2E
223
+ * runs from the real review bucket). Falls back to the given default.
224
+ */
225
+ export function targetFromLocation(defaultTarget) {
226
+ try {
227
+ if (typeof window === "undefined")
228
+ return defaultTarget;
229
+ return new URLSearchParams(window.location.search).get("target") || defaultTarget;
230
+ }
231
+ catch {
232
+ return defaultTarget;
233
+ }
234
+ }
@@ -0,0 +1,3 @@
1
+ export declare const QA_STYLES: string;
2
+ /** Inject the overlay stylesheet once per document. */
3
+ export declare function ensureQAStyles(): void;
@@ -0,0 +1,211 @@
1
+ "use client";
2
+ // Injected stylesheet for the overlay chrome. Framework-agnostic on purpose:
3
+ // no Tailwind (or any CSS toolchain) required in the consumer. Brand colors
4
+ // come from CSS custom properties (see QATheme) so one stylesheet serves every
5
+ // install; neutral grays/greens/reds are fixed.
6
+ const ACCENT = "var(--qar-accent)";
7
+ const ON_ACCENT = "var(--qar-accent-contrast)";
8
+ const PANEL = "var(--qar-panel-bg)";
9
+ const INPUT = "var(--qar-input-bg)";
10
+ /** Alpha-blend the accent (keeps theme a single color value). */
11
+ const mix = (pct) => `color-mix(in srgb, ${ACCENT} ${pct}%, transparent)`;
12
+ export const QA_STYLES = `
13
+ .qar-theme{--qar-accent:#ffde4d;--qar-accent-contrast:#0a0b12;--qar-panel-bg:#10121c;--qar-input-bg:#181b28;}
14
+ .qar-theme, .qar-theme *{box-sizing:border-box;}
15
+ .qar-theme button{font-family:inherit;cursor:pointer;}
16
+ .qar-theme button:disabled{cursor:default;}
17
+
18
+ /* ---- spotlight / dim ---- */
19
+ .qar-spotlight{pointer-events:none;position:fixed;z-index:99998;border-radius:8px;transition:all .2s;
20
+ box-shadow:0 0 0 2px ${ACCENT},0 0 0 9999px rgba(0,0,0,.72);}
21
+ .qar-dim{pointer-events:none;position:fixed;inset:0;z-index:99998;background:rgba(0,0,0,.72);}
22
+
23
+ /* ---- review card ---- */
24
+ .qar-card{position:fixed;z-index:99999;display:flex;flex-direction:column;border-radius:12px;
25
+ border:1px solid ${mix(50)};background:${PANEL};padding:16px;box-shadow:0 0 40px -8px ${mix(50)};}
26
+ .qar-card-header{margin-bottom:8px;display:flex;align-items:center;justify-content:space-between;font-size:12px;}
27
+ .qar-counter{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:700;color:${ACCENT};}
28
+ .qar-header-tools{display:flex;align-items:center;gap:8px;color:#94a3b8;}
29
+ .qar-tool-btn{display:inline-flex;align-items:center;justify-content:center;border-radius:4px;
30
+ border:1px solid #475569;background:transparent;padding:4px;color:#cbd5e1;}
31
+ .qar-tool-btn:hover{background:#1e293b;}
32
+ .qar-tool-btn:disabled{opacity:.3;}
33
+ .qar-tool-btn:disabled:hover{background:transparent;}
34
+ .qar-close-btn{display:inline-flex;border:none;background:none;padding:0;color:inherit;}
35
+ .qar-close-btn:hover{color:#e2e8f0;}
36
+
37
+ .qar-card-body{min-height:0;flex:1;overflow-y:auto;padding-right:4px;text-align:left;}
38
+ .qar-warn{margin:0 0 8px;border-radius:4px;background:rgba(127,29,29,.3);padding:4px 8px;font-size:11px;color:#fca5a5;}
39
+ .qar-item-title{margin:0;font-size:14px;font-weight:700;color:#fff;}
40
+ .qar-item-sub{margin-top:4px;display:flex;flex-direction:column;gap:4px;font-size:12px;line-height:1.625;color:#94a3b8;}
41
+ .qar-item-sub p{margin:0;}
42
+ .qar-recorded{margin:8px 0 0;font-size:11px;font-weight:600;}
43
+ .qar-recorded-approve{color:#4ade80;}
44
+ .qar-recorded-reject{color:#f87171;}
45
+
46
+ /* ---- variations picker ---- */
47
+ .qar-var-wrap{margin-top:12px;}
48
+ .qar-var-label{margin:0 0 6px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;}
49
+ .qar-var-btns{display:flex;flex-wrap:wrap;gap:6px;}
50
+ .qar-var-btn{height:28px;width:28px;border-radius:6px;border:1px solid ${mix(40)};background:transparent;
51
+ font-size:12px;font-weight:700;color:${ACCENT};transition:background-color .15s,color .15s;}
52
+ .qar-var-btn:hover{background:${mix(12)};}
53
+ .qar-var-btn.qar-on{border-color:${ACCENT};background:${ACCENT};color:${ON_ACCENT};}
54
+ .qar-var-note{margin:4px 0 0;font-size:10px;color:#64748b;}
55
+
56
+ /* ---- note ---- */
57
+ .qar-note-row{margin-top:12px;display:flex;align-items:center;justify-content:space-between;}
58
+ .qar-note-label{font-size:10px;color:#64748b;}
59
+ .qar-pick-btn{display:inline-flex;align-items:center;gap:4px;border-radius:4px;border:1px solid #475569;
60
+ background:transparent;padding:2px 6px;font-size:10px;color:#cbd5e1;}
61
+ .qar-pick-btn:hover{background:#1e293b;}
62
+ .qar-pick-btn.qar-picking{border-color:${ACCENT};background:${ACCENT};color:${ON_ACCENT};}
63
+ .qar-note-input{margin-top:4px;width:100%;resize:none;border-radius:6px;border:1px solid #334155;
64
+ background:${INPUT};padding:6px 8px;font-size:12px;color:#e2e8f0;outline:none;font-family:inherit;}
65
+ .qar-note-input:focus{border-color:${mix(60)};}
66
+ .qar-note-input::placeholder{color:#64748b;}
67
+
68
+ /* ---- action row ---- */
69
+ .qar-actions{margin-top:12px;display:flex;align-items:center;gap:8px;}
70
+ .qar-nav-btn{display:flex;height:36px;width:36px;align-items:center;justify-content:center;border-radius:8px;
71
+ border:1px solid #334155;background:transparent;color:#cbd5e1;}
72
+ .qar-nav-btn:hover{background:#1e293b;}
73
+ .qar-nav-btn:disabled{opacity:.3;background:transparent;}
74
+ .qar-reject-btn{display:flex;flex:1;align-items:center;justify-content:center;gap:6px;border-radius:8px;
75
+ border:1px solid rgba(239,68,68,.5);background:rgba(127,29,29,.2);padding:8px 12px;font-size:14px;font-weight:700;color:#fca5a5;}
76
+ .qar-reject-btn:hover{background:rgba(127,29,29,.4);}
77
+ .qar-approve-btn{display:flex;flex:1;align-items:center;justify-content:center;gap:6px;border-radius:8px;
78
+ border:none;background:${ACCENT};padding:8px 12px;font-size:14px;font-weight:700;color:${ON_ACCENT};}
79
+ .qar-approve-btn:hover{filter:brightness(1.05);}
80
+ .qar-footer{margin-top:8px;display:flex;align-items:center;justify-content:space-between;font-size:10px;color:#64748b;}
81
+ .qar-footer-green{color:#22c55e;}
82
+
83
+ /* ---- finish panel ---- */
84
+ .qar-finish-backdrop{position:fixed;inset:0;z-index:100000;display:flex;align-items:center;justify-content:center;
85
+ background:rgba(0,0,0,.8);padding:16px;}
86
+ .qar-finish-card{width:100%;max-width:28rem;border-radius:16px;border:1px solid ${mix(40)};background:${PANEL};
87
+ padding:24px;text-align:center;box-shadow:0 0 50px -10px ${mix(50)};}
88
+ .qar-finish-icon{margin:0 auto 12px;display:block;color:${ACCENT};}
89
+ .qar-finish-title{margin:0;font-size:20px;line-height:28px;font-weight:900;color:${ACCENT};}
90
+ .qar-finish-stats{margin:8px 0 0;font-size:14px;color:#cbd5e1;}
91
+ .qar-green{font-weight:700;color:#4ade80;}
92
+ .qar-red{font-weight:700;color:#f87171;}
93
+ .qar-finish-autosaved{margin-top:12px;display:inline-flex;align-items:center;gap:6px;font-size:11px;color:#86efac;}
94
+ .qar-finish-actions{margin-top:16px;display:flex;flex-direction:column;gap:8px;}
95
+ .qar-btn-primary{display:inline-flex;align-items:center;justify-content:center;gap:8px;border-radius:8px;border:none;
96
+ background:${ACCENT};padding:8px 16px;font-size:14px;font-weight:700;color:${ON_ACCENT};}
97
+ .qar-btn-primary:hover{filter:brightness(1.05);}
98
+ .qar-btn-primary:disabled{opacity:.6;filter:none;}
99
+ .qar-btn-outline-accent{display:inline-flex;align-items:center;justify-content:center;gap:8px;border-radius:8px;
100
+ border:1px solid ${mix(40)};background:transparent;padding:8px 16px;font-size:14px;font-weight:700;color:${ACCENT};}
101
+ .qar-btn-outline-accent:hover{background:${mix(10)};}
102
+ .qar-btn-outline{border-radius:8px;border:1px solid #475569;background:transparent;padding:8px 16px;font-size:14px;color:#cbd5e1;}
103
+ .qar-btn-outline:hover{background:#1e293b;}
104
+ .qar-btn-ghost{border:none;background:none;border-radius:8px;padding:8px 16px;font-size:14px;color:#64748b;}
105
+ .qar-btn-ghost:hover{color:#cbd5e1;}
106
+
107
+ /* ---- misc ---- */
108
+ .qar-muted{color:#64748b;font-weight:400;}
109
+
110
+ /* ---- task-item action link ---- */
111
+ .qar-action-link{margin-top:10px;display:inline-flex;align-items:center;gap:6px;border-radius:8px;
112
+ border:1px solid ${mix(40)};padding:6px 12px;font-size:12px;font-weight:700;color:${ACCENT};text-decoration:none;}
113
+ .qar-action-link:hover{background:${mix(10)};}
114
+
115
+ /* ---- journey summary ---- */
116
+ .qar-journey{margin-top:14px;display:flex;flex-direction:column;gap:4px;text-align:left;font-size:12px;color:#cbd5e1;}
117
+ .qar-journey-row{display:flex;justify-content:space-between;gap:12px;padding:2px 8px;border-radius:4px;}
118
+ .qar-journey-row.qar-current{background:${mix(8)};}
119
+ .qar-journey-left{color:${ACCENT};font-weight:700;}
120
+ .qar-journey-done{color:#4ade80;}
121
+ .qar-journey-nav{margin:6px 0 0;font-size:12px;color:#86efac;}
122
+
123
+ /* ---- 0.3.0: note field fills the card ---- */
124
+ .qar-card-body{display:flex;flex-direction:column;}
125
+ .qar-note-input{flex:1 1 auto;min-height:110px;}
126
+
127
+ /* ---- 0.3.0: NOT-ALTERED fingerprint badges ---- */
128
+ .qar-fp-unchanged{margin:8px 0 0;border-radius:6px;border:1px solid rgba(239,68,68,.6);
129
+ background:rgba(127,29,29,.35);padding:6px 8px;font-size:12px;font-weight:700;color:#fecaca;}
130
+ .qar-fp-unchanged small{display:block;margin-top:4px;font-weight:400;color:#fca5a5;}
131
+ .qar-fp-changed{margin:8px 0 0;font-size:11px;color:#94a3b8;}
132
+
133
+ /* ---- sub-highlight mark: injected into PAGE content, i.e. OUTSIDE any
134
+ .qar-theme scope - so it must never depend on scoped vars without a
135
+ fallback (0.3.1 fix: unfallbacked vars made the mark compute to no
136
+ background at all = invisible highlights). ---- */
137
+ mark.qar-subhl{background:color-mix(in srgb, var(--qar-accent, #ffde4d) 40%, transparent);
138
+ color:inherit;border-radius:3px;padding:0 2px;
139
+ box-shadow:0 0 0 1px color-mix(in srgb, var(--qar-accent, #ffde4d) 70%, transparent);}
140
+
141
+ /* ---- 0.3.0: codename / copy-ref ---- */
142
+ .qar-ref-row{margin-top:6px;display:flex;align-items:center;gap:6px;font-size:10px;color:#64748b;}
143
+ .qar-codename{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:${ACCENT};}
144
+
145
+ /* ---- 0.3.0: device-split approvals ---- */
146
+ .qar-approve-alt{display:flex;flex:1;align-items:center;justify-content:center;gap:6px;border-radius:8px;
147
+ border:1px solid ${mix(50)};background:${mix(12)};padding:8px 12px;font-size:14px;font-weight:700;color:${ACCENT};}
148
+ .qar-approve-alt:hover{background:${mix(20)};}
149
+ .qar-dev-done{opacity:.55;}
150
+ .qar-partial-note{margin:6px 0 0;font-size:11px;color:#86efac;}
151
+
152
+ /* ---- 0.3.0: minimize bubble ---- */
153
+ .qar-card-header{touch-action:none;}
154
+ .qar-bubble{position:fixed;z-index:99999;display:flex;align-items:center;justify-content:center;
155
+ width:52px;height:52px;border-radius:9999px;border:1px solid ${mix(60)};background:${PANEL};color:${ACCENT};
156
+ box-shadow:0 0 24px -6px ${mix(60)};cursor:pointer;touch-action:none;user-select:none;}
157
+ .qar-bubble-count{position:absolute;top:-4px;right:-4px;display:flex;align-items:center;justify-content:center;
158
+ min-width:18px;height:18px;border-radius:9999px;background:${ACCENT};color:${ON_ACCENT};font-size:10px;font-weight:700;padding:0 4px;}
159
+
160
+ /* ---- 0.3.1: journey loading indicator ---- */
161
+ .qar-loading-card{display:flex;flex-direction:column;align-items:center;gap:12px;border-radius:16px;
162
+ border:1px solid ${mix(40)};background:${PANEL};padding:28px 36px;color:#e2e8f0;font-size:14px;font-weight:700;
163
+ box-shadow:0 0 50px -10px ${mix(50)};}
164
+ .qar-spinner{width:28px;height:28px;border-radius:9999px;border:3px solid ${mix(25)};
165
+ border-top-color:${ACCENT};animation:qar-spin .8s linear infinite;}
166
+ @keyframes qar-spin{to{transform:rotate(360deg);}}
167
+
168
+ /* ---- 0.3.1: INSTANT tooltips (replace the ~500ms native title delay) ---- */
169
+ .qar-theme [data-qatip]{position:relative;}
170
+ /* Exactly ONE tooltip per hover point: suppress an ancestor's tooltip while a
171
+ descendant tooltip target is hovered (0.3.4 double-tooltip fix). */
172
+ .qar-theme [data-qatip]:has([data-qatip]:hover)::after{display:none;}
173
+ .qar-theme [data-qatip]:hover::after{content:attr(data-qatip);position:absolute;bottom:calc(100% + 6px);
174
+ left:50%;transform:translateX(-50%);z-index:100001;width:max-content;max-width:230px;white-space:normal;
175
+ border-radius:6px;border:1px solid #334155;background:#0b1020;padding:4px 8px;
176
+ font-size:11px;font-weight:400;line-height:1.4;color:#e2e8f0;text-align:left;pointer-events:none;}
177
+ .qar-theme .qar-actions [data-qatip]:hover::after,
178
+ .qar-theme .qar-footer [data-qatip]:hover::after{bottom:auto;top:calc(-6px - 2.6em);}
179
+
180
+ /* ---- 0.3.3: re-queue context ---- */
181
+ .qar-revisit{margin:8px 0 0;border-radius:6px;border:1px solid ${mix(55)};background:${mix(12)};
182
+ padding:6px 8px;font-size:12px;font-weight:700;color:${ACCENT};}
183
+ .qar-revisit-prior{margin:6px 0 0;font-size:11px;color:#94a3b8;}
184
+
185
+ /* ---- 0.3.5: round-scoped counters ---- */
186
+ .qar-finish-alltime{margin:6px 0 0;font-size:11px;color:#64748b;}
187
+
188
+ /* ---- 0.3.5: mobile preview ---- */
189
+ .qar-preview-backdrop{position:fixed;inset:0;z-index:99997;display:flex;align-items:center;
190
+ justify-content:center;background:rgba(0,0,0,.82);}
191
+ .qar-preview-frame{position:relative;width:390px;max-width:96vw;height:min(844px,90vh);
192
+ border-radius:24px;border:2px solid ${mix(50)};background:#000;overflow:hidden;
193
+ box-shadow:0 0 60px -12px ${mix(60)};}
194
+ .qar-preview-frame iframe{display:block;width:100%;height:100%;border:0;background:#fff;}
195
+ .qar-preview-close{position:absolute;top:8px;right:8px;display:inline-flex;align-items:center;
196
+ justify-content:center;width:28px;height:28px;border-radius:9999px;border:1px solid ${mix(60)};
197
+ background:${PANEL};color:${ACCENT};}
198
+ .qar-preview-close:hover{filter:brightness(1.2);}
199
+ `;
200
+ const STYLE_ID = "qa-review-styles";
201
+ /** Inject the overlay stylesheet once per document. */
202
+ export function ensureQAStyles() {
203
+ if (typeof document === "undefined")
204
+ return;
205
+ if (document.getElementById(STYLE_ID))
206
+ return;
207
+ const el = document.createElement("style");
208
+ el.id = STYLE_ID;
209
+ el.textContent = QA_STYLES;
210
+ document.head.appendChild(el);
211
+ }