@artymclabin/qa-review 0.3.7 → 0.3.9

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 CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.8 - 2026-08-09
4
+
5
+ - BACK TO REVIEW IS AN UNDO, NOT A RESTART: the finish panel's "Back to review"
6
+ sent the reviewer to item 1. You press it having just finished, because of
7
+ something about the item you just judged, so on a forty-item board getting
8
+ back to where you were meant clicking Next thirty-nine times. It now opens the
9
+ LAST item of the round.
10
+ - COLLECT SEVERAL REFERENCES FOR ONE PASTE: right-clicking "Copy ref" ADDS this
11
+ item's reference line to the ones already collected and puts the whole list on
12
+ the clipboard, so a sweep of a board can be pasted into a chat in one go. A
13
+ plain click still copies just this item, and also ends the collection, so
14
+ there is a way out of a half-built list. The button reports how many are
15
+ collected. Right-clicking the same item twice is idempotent.
16
+ It does NOT call navigator.clipboard.readText(): appending to "whatever is on
17
+ the clipboard" would need a permission Firefox never grants a page, and on a
18
+ grant it would happily append a password manager's payload or whatever the
19
+ reviewer copied a minute earlier. The list lives in the panel, so the button
20
+ can only ever append references it produced itself.
21
+
3
22
  ## 0.3.7 - 2026-07-30
4
23
 
5
24
  - MOBILE PREVIEW SCROLLS TO THE ITEM: the phone-frame iframe loaded each page
package/README.md CHANGED
@@ -1,177 +1,177 @@
1
- # qa-review
2
-
3
- Interactive on-page QA review for React apps: a spotlight walkthrough overlay
4
- that steps a reviewer through the elements of a real rendered page
5
- (approve / reject / note / undo / design-variation picking), plus a durable
6
- server-side verdict ledger with a self-provisioning Postgres store.
7
-
8
- Built for "founder reviews the page element by element" workflows: the reviewer
9
- opens the live page, the overlay dims everything except the current item, and
10
- every verdict is persisted immediately - refreshes, storage wipes, and device
11
- switches never lose progress.
12
-
13
- ## Features
14
-
15
- - **Spotlight walkthrough** - dims the page except the current `[data-qa]`
16
- target; card shows title/subtitle + Approve/Reject/Prev/Next.
17
- - **Round-based review** - the set of actionable (not-yet-approved) items is
18
- frozen at load with a fixed denominator ("1 of N"), so approved items never
19
- re-appear mid-round.
20
- - **Durable verdict ledger** - verdicts are saved per item as you go
21
- (localStorage cache + server ledger). No bulk reset exists; a single item is
22
- re-queued by invalidating just it (`verdict: null`).
23
- - **Undo, Peek (3s clean-page view), element-picker notes, keyboard driving**
24
- (A/R/arrows/U/Esc), draggable panel.
25
- - **Design variations** - an item can expose N live-swappable variants; the
26
- chosen variant is recorded with the approval.
27
- - **Session snapshots** - optional "Save review to database" submit that stores
28
- the full run (summary + per-item results) for auditability.
29
- - **BYO auth** - the server handlers take an `authorize(req)` callback; plug in
30
- any gate (SSO, password, none for local tools). Fail-closed.
31
- - **Self-provisioning Postgres storage** - the adapter creates its own
32
- `qa_review_*` tables on first use (versioned, advisory-locked). A `site`
33
- scope column lets one database serve many installs.
34
- - **Cross-page journey** - an ordered multi-page review: finishing a page
35
- navigates immediately to the next page with pending items (activation query
36
- params survive the hop); a completion panel shows only when the whole
37
- journey is clean.
38
- - **Task items** - selectorless items render as a centered card with an
39
- optional action-link button, for visit-this-page checks and decisions.
40
- - **NOT-ALTERED poka-yoke** - every verdict stores a content fingerprint; a
41
- re-shown rejected item whose content still hashes identical gets a
42
- system-computed "NOT ALTERED since your rejection" badge.
43
- - **Device-split approvals** - items can require per-device sign-off
44
- (PC/mobile); approved only when every required device approved. Plain
45
- historical approvals are grandfathered as fully approved.
46
- - **Sub-highlights** - `highlightWords` marks specific words inside the
47
- spotlighted element, replacing "where to look" prose.
48
- - **Codenames** - a deterministic two-word codename per item ("red-apple")
49
- with a Copy-ref button and an exported resolver, so humans and agents can
50
- reference items by name. Included in state GET responses.
51
- - **Minimize bubble** - the panel collapses to a draggable floating bubble
52
- (mouse + touch); tap to restore.
53
- - **No CSS toolchain required** - the overlay injects its own stylesheet;
54
- brand colors come from a small theme prop.
55
-
56
- ## Install
57
-
58
- ```bash
59
- npm install @artymclabin/qa-review
60
- # or
61
- pnpm add @artymclabin/qa-review
62
- ```
63
-
64
- Install straight from git if you want an unreleased commit:
65
-
66
- ```bash
67
- npm install github:ArtyMcLabin/qa-review
68
- ```
69
-
70
- For CI environments without registry access, vendor a tarball:
71
-
72
- ```bash
73
- # in this repo
74
- npm pack # -> artymclabin-qa-review-<version>.tgz
75
- # in the consumer
76
- pnpm add ./vendor/artymclabin-qa-review-<version>.tgz
77
- ```
78
-
79
- Peer dependencies: `react`, `react-dom`, `lucide-react`.
80
-
81
- ## Quick start (Next.js App Router)
82
-
83
- ### 1. Server: mount the handlers
84
-
85
- ```ts
86
- // src/lib/qa-review.ts
87
- import { createQAReviewHandlers } from "@artymclabin/qa-review/server";
88
-
89
- export const qaHandlers = createQAReviewHandlers({
90
- site: "example-site", // scope for this install (one DB can serve many)
91
- authorize: async (req) => {
92
- const user = await verifyMySession(req); // your gate; null -> 401
93
- return user ? { reviewer: user.name, displayName: user.name } : null;
94
- },
95
- });
96
- ```
97
-
98
- ```ts
99
- // src/app/api/qa/state/route.ts
100
- import { qaHandlers } from "@/lib/qa-review";
101
- export const runtime = "nodejs";
102
- export const dynamic = "force-dynamic";
103
- export const GET = qaHandlers.stateGET;
104
- export const POST = qaHandlers.statePOST;
105
- ```
106
-
107
- Mount `submitPOST`, `sessionsGET`, and `accessGET` the same way on their own
108
- routes as needed.
109
-
110
- ### 2. Database
111
-
112
- Set the connection string (any Postgres - local, Neon, Supabase, RDS):
113
-
114
- ```bash
115
- QA_REVIEW_DATABASE_URL=postgresql://user:password@db.example.com/mydb
116
- ```
117
-
118
- No migrations to write: on the first request the adapter provisions
119
- `qa_review_state` (the per-item ledger), `qa_review_sessions` (session
120
- snapshots), and `qa_review_migrations` (its own version bookkeeping).
121
-
122
- ### 3. Client: mark targets and mount the overlay
123
-
124
- ```tsx
125
- // Any element you want reviewed gets a stable [data-qa] anchor:
126
- <section data-qa="hero">...</section>
127
- ```
128
-
129
- ```tsx
130
- "use client";
131
- import { QAReviewOverlay } from "@artymclabin/qa-review";
132
-
133
- const ITEMS = [
134
- { id: "hero", title: "Hero headline", selector: '[data-qa="hero"]' },
135
- { id: "pricing", title: "Pricing table", sub: "Check the currency.", selector: '[data-qa="pricing"]' },
136
- ];
137
-
138
- export function PageQA() {
139
- return (
140
- <QAReviewOverlay
141
- items={ITEMS}
142
- target="example-site:/pricing" // one ledger bucket per reviewed surface
143
- gateParam="qaReview" // omit if activation is gated upstream
144
- submitUrl="/api/qa/submit" // omit to hide the session-save button
145
- theme={{ accent: "#ffde4d" }}
146
- />
147
- );
148
- }
149
- ```
150
-
151
- ## API sketch
152
-
153
- ```ts
154
- // client
155
- import {
156
- QAReviewOverlay, // the overlay component
157
- createQAStore, // per-target verdict store (localStorage + server mirror)
158
- targetFromLocation, // ?target= override helper (E2E isolation)
159
- } from "@artymclabin/qa-review";
160
-
161
- // server
162
- import {
163
- createQAReviewHandlers, // { stateGET, statePOST, submitPOST, sessionsGET, accessGET }
164
- createPostgresStorage, // default storage; implement QAReviewStorage to swap
165
- } from "@artymclabin/qa-review/server";
166
- ```
167
-
168
- State endpoint semantics (the ledger):
169
-
170
- - `GET ?target=...` -> `{ ok, verdicts: { itemId: { verdict, note, variant } } }`
171
- - `POST { target, itemId, verdict | note | variant }` -> merge-upsert one item
172
- - `POST { target, itemId, verdict: null }` -> delete that one item
173
- (re-queues it on the next round). There is deliberately **no reset-all**.
174
-
175
- ## License
176
-
177
- MIT
1
+ # qa-review
2
+
3
+ Interactive on-page QA review for React apps: a spotlight walkthrough overlay
4
+ that steps a reviewer through the elements of a real rendered page
5
+ (approve / reject / note / undo / design-variation picking), plus a durable
6
+ server-side verdict ledger with a self-provisioning Postgres store.
7
+
8
+ Built for "founder reviews the page element by element" workflows: the reviewer
9
+ opens the live page, the overlay dims everything except the current item, and
10
+ every verdict is persisted immediately - refreshes, storage wipes, and device
11
+ switches never lose progress.
12
+
13
+ ## Features
14
+
15
+ - **Spotlight walkthrough** - dims the page except the current `[data-qa]`
16
+ target; card shows title/subtitle + Approve/Reject/Prev/Next.
17
+ - **Round-based review** - the set of actionable (not-yet-approved) items is
18
+ frozen at load with a fixed denominator ("1 of N"), so approved items never
19
+ re-appear mid-round.
20
+ - **Durable verdict ledger** - verdicts are saved per item as you go
21
+ (localStorage cache + server ledger). No bulk reset exists; a single item is
22
+ re-queued by invalidating just it (`verdict: null`).
23
+ - **Undo, Peek (3s clean-page view), element-picker notes, keyboard driving**
24
+ (A/R/arrows/U/Esc), draggable panel.
25
+ - **Design variations** - an item can expose N live-swappable variants; the
26
+ chosen variant is recorded with the approval.
27
+ - **Session snapshots** - optional "Save review to database" submit that stores
28
+ the full run (summary + per-item results) for auditability.
29
+ - **BYO auth** - the server handlers take an `authorize(req)` callback; plug in
30
+ any gate (SSO, password, none for local tools). Fail-closed.
31
+ - **Self-provisioning Postgres storage** - the adapter creates its own
32
+ `qa_review_*` tables on first use (versioned, advisory-locked). A `site`
33
+ scope column lets one database serve many installs.
34
+ - **Cross-page journey** - an ordered multi-page review: finishing a page
35
+ navigates immediately to the next page with pending items (activation query
36
+ params survive the hop); a completion panel shows only when the whole
37
+ journey is clean.
38
+ - **Task items** - selectorless items render as a centered card with an
39
+ optional action-link button, for visit-this-page checks and decisions.
40
+ - **NOT-ALTERED poka-yoke** - every verdict stores a content fingerprint; a
41
+ re-shown rejected item whose content still hashes identical gets a
42
+ system-computed "NOT ALTERED since your rejection" badge.
43
+ - **Device-split approvals** - items can require per-device sign-off
44
+ (PC/mobile); approved only when every required device approved. Plain
45
+ historical approvals are grandfathered as fully approved.
46
+ - **Sub-highlights** - `highlightWords` marks specific words inside the
47
+ spotlighted element, replacing "where to look" prose.
48
+ - **Codenames** - a deterministic two-word codename per item ("red-apple")
49
+ with a Copy-ref button and an exported resolver, so humans and agents can
50
+ reference items by name. Included in state GET responses.
51
+ - **Minimize bubble** - the panel collapses to a draggable floating bubble
52
+ (mouse + touch); tap to restore.
53
+ - **No CSS toolchain required** - the overlay injects its own stylesheet;
54
+ brand colors come from a small theme prop.
55
+
56
+ ## Install
57
+
58
+ ```bash
59
+ npm install @artymclabin/qa-review
60
+ # or
61
+ pnpm add @artymclabin/qa-review
62
+ ```
63
+
64
+ Install straight from git if you want an unreleased commit:
65
+
66
+ ```bash
67
+ npm install github:ArtyMcLabin/qa-review
68
+ ```
69
+
70
+ For CI environments without registry access, vendor a tarball:
71
+
72
+ ```bash
73
+ # in this repo
74
+ npm pack # -> artymclabin-qa-review-<version>.tgz
75
+ # in the consumer
76
+ pnpm add ./vendor/artymclabin-qa-review-<version>.tgz
77
+ ```
78
+
79
+ Peer dependencies: `react`, `react-dom`, `lucide-react`.
80
+
81
+ ## Quick start (Next.js App Router)
82
+
83
+ ### 1. Server: mount the handlers
84
+
85
+ ```ts
86
+ // src/lib/qa-review.ts
87
+ import { createQAReviewHandlers } from "@artymclabin/qa-review/server";
88
+
89
+ export const qaHandlers = createQAReviewHandlers({
90
+ site: "example-site", // scope for this install (one DB can serve many)
91
+ authorize: async (req) => {
92
+ const user = await verifyMySession(req); // your gate; null -> 401
93
+ return user ? { reviewer: user.name, displayName: user.name } : null;
94
+ },
95
+ });
96
+ ```
97
+
98
+ ```ts
99
+ // src/app/api/qa/state/route.ts
100
+ import { qaHandlers } from "@/lib/qa-review";
101
+ export const runtime = "nodejs";
102
+ export const dynamic = "force-dynamic";
103
+ export const GET = qaHandlers.stateGET;
104
+ export const POST = qaHandlers.statePOST;
105
+ ```
106
+
107
+ Mount `submitPOST`, `sessionsGET`, and `accessGET` the same way on their own
108
+ routes as needed.
109
+
110
+ ### 2. Database
111
+
112
+ Set the connection string (any Postgres - local, Neon, Supabase, RDS):
113
+
114
+ ```bash
115
+ QA_REVIEW_DATABASE_URL=postgresql://user:password@db.example.com/mydb
116
+ ```
117
+
118
+ No migrations to write: on the first request the adapter provisions
119
+ `qa_review_state` (the per-item ledger), `qa_review_sessions` (session
120
+ snapshots), and `qa_review_migrations` (its own version bookkeeping).
121
+
122
+ ### 3. Client: mark targets and mount the overlay
123
+
124
+ ```tsx
125
+ // Any element you want reviewed gets a stable [data-qa] anchor:
126
+ <section data-qa="hero">...</section>
127
+ ```
128
+
129
+ ```tsx
130
+ "use client";
131
+ import { QAReviewOverlay } from "@artymclabin/qa-review";
132
+
133
+ const ITEMS = [
134
+ { id: "hero", title: "Hero headline", selector: '[data-qa="hero"]' },
135
+ { id: "pricing", title: "Pricing table", sub: "Check the currency.", selector: '[data-qa="pricing"]' },
136
+ ];
137
+
138
+ export function PageQA() {
139
+ return (
140
+ <QAReviewOverlay
141
+ items={ITEMS}
142
+ target="example-site:/pricing" // one ledger bucket per reviewed surface
143
+ gateParam="qaReview" // omit if activation is gated upstream
144
+ submitUrl="/api/qa/submit" // omit to hide the session-save button
145
+ theme={{ accent: "#ffde4d" }}
146
+ />
147
+ );
148
+ }
149
+ ```
150
+
151
+ ## API sketch
152
+
153
+ ```ts
154
+ // client
155
+ import {
156
+ QAReviewOverlay, // the overlay component
157
+ createQAStore, // per-target verdict store (localStorage + server mirror)
158
+ targetFromLocation, // ?target= override helper (E2E isolation)
159
+ } from "@artymclabin/qa-review";
160
+
161
+ // server
162
+ import {
163
+ createQAReviewHandlers, // { stateGET, statePOST, submitPOST, sessionsGET, accessGET }
164
+ createPostgresStorage, // default storage; implement QAReviewStorage to swap
165
+ } from "@artymclabin/qa-review/server";
166
+ ```
167
+
168
+ State endpoint semantics (the ledger):
169
+
170
+ - `GET ?target=...` -> `{ ok, verdicts: { itemId: { verdict, note, variant } } }`
171
+ - `POST { target, itemId, verdict | note | variant }` -> merge-upsert one item
172
+ - `POST { target, itemId, verdict: null }` -> delete that one item
173
+ (re-queues it on the next round). There is deliberately **no reset-all**.
174
+
175
+ ## License
176
+
177
+ MIT
@@ -4,7 +4,31 @@ import { type QAJourneyConfig } from "./journey.js";
4
4
  /** Was the pointer gesture a click (vs a drag)? Exported for tests. */
5
5
  export declare function isClickGesture(dx: number, dy: number): boolean;
6
6
  /** Note-reference text for a picked element: « label » (truncated) or tag. */
7
- export declare function pickedElementRef(textContent: string | null, tagName: string): string;
7
+ export interface PickedElementDescriptor {
8
+ textContent?: string | null;
9
+ tagName: string;
10
+ /** aria-label, or the aria-labelledby target's text. */
11
+ ariaLabel?: string | null;
12
+ /** Text of an associated <label>, whether wrapping or `for=`-linked. */
13
+ labelText?: string | null;
14
+ placeholder?: string | null;
15
+ alt?: string | null;
16
+ title?: string | null;
17
+ /** Current value, for a control the reviewer has already filled in. */
18
+ value?: string | null;
19
+ name?: string | null;
20
+ }
21
+ /**
22
+ * 🚨 A form control has no `textContent`, so reading only that made every input,
23
+ * textarea and select come out as its bare tag name: a reviewer picking seven link
24
+ * fields got seven « input » refs with no way to say which row they meant. The
25
+ * order below is "what a human would call it" - its own text, then the accessible
26
+ * name, then the label a designer wrote, then what it holds - and the tag name only
27
+ * when the element genuinely has no name at all.
28
+ */
29
+ export declare function pickedElementRef(el: PickedElementDescriptor): string;
30
+ /** Read a live DOM element into the descriptor above. */
31
+ export declare function describePickedElement(el: Element): PickedElementDescriptor;
8
32
  /**
9
33
  * Element-pick mode semantics (0.3.1): LEFT click picks and exits the mode;
10
34
  * RIGHT click picks and STAYS for multi-select (context menu suppressed).
@@ -52,11 +52,63 @@ const CLICK_DRAG_THRESHOLD_PX = 6;
52
52
  export function isClickGesture(dx, dy) {
53
53
  return Math.hypot(dx, dy) < CLICK_DRAG_THRESHOLD_PX;
54
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();
55
+ /**
56
+ * 🚨 A form control has no `textContent`, so reading only that made every input,
57
+ * textarea and select come out as its bare tag name: a reviewer picking seven link
58
+ * fields got seven « input » refs with no way to say which row they meant. The
59
+ * order below is "what a human would call it" - its own text, then the accessible
60
+ * name, then the label a designer wrote, then what it holds - and the tag name only
61
+ * when the element genuinely has no name at all.
62
+ */
63
+ export function pickedElementRef(el) {
64
+ const clean = (v) => (v || "").replace(/\s+/g, " ").trim();
65
+ const label = [
66
+ clean(el.textContent),
67
+ clean(el.ariaLabel),
68
+ clean(el.labelText),
69
+ clean(el.placeholder),
70
+ clean(el.alt),
71
+ clean(el.title),
72
+ clean(el.value),
73
+ clean(el.name),
74
+ ]
75
+ .find((c) => c.length > 0)
76
+ ?.slice(0, 48) || el.tagName.toLowerCase();
58
77
  return ` «${label}» `;
59
78
  }
79
+ /** Read a live DOM element into the descriptor above. */
80
+ export function describePickedElement(el) {
81
+ const labelledBy = el.getAttribute("aria-labelledby");
82
+ const labelledByText = labelledBy
83
+ ? labelledBy
84
+ .split(/\s+/)
85
+ .map((id) => el.ownerDocument?.getElementById(id)?.textContent ?? "")
86
+ .join(" ")
87
+ : null;
88
+ // A control's label is either an ancestor <label> or one pointing at its id.
89
+ let labelText = null;
90
+ const id = el.getAttribute("id");
91
+ if (id) {
92
+ const escaped = id.replace(/["\\]/g, "\\$&");
93
+ labelText = el.ownerDocument?.querySelector(`label[for="${escaped}"]`)?.textContent ?? null;
94
+ }
95
+ if (!labelText)
96
+ labelText = el.closest("label")?.textContent ?? null;
97
+ const isControl = ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName);
98
+ return {
99
+ // A control's own textContent is empty anyway; skipping it explicitly keeps the
100
+ // intent readable rather than relying on that emptiness.
101
+ textContent: isControl ? null : el.textContent,
102
+ tagName: el.tagName,
103
+ ariaLabel: el.getAttribute("aria-label") || labelledByText,
104
+ labelText,
105
+ placeholder: el.getAttribute("placeholder"),
106
+ alt: el.getAttribute("alt"),
107
+ title: el.getAttribute("title"),
108
+ value: isControl ? (el.value ?? null) : null,
109
+ name: el.getAttribute("name"),
110
+ };
111
+ }
60
112
  /**
61
113
  * Element-pick mode semantics (0.3.1): LEFT click picks and exits the mode;
62
114
  * RIGHT click picks and STAYS for multi-select (context menu suppressed).
@@ -147,6 +199,8 @@ export function QAReviewOverlay({ items, target, gateParam, stateUrl, submitUrl,
147
199
  const [finished, setFinished] = React.useState(false);
148
200
  const [copied, setCopied] = React.useState(false);
149
201
  const [refCopied, setRefCopied] = React.useState(false);
202
+ /** Reference lines collected by right-clicking "Copy ref". See `appendRef`. */
203
+ const [refBuffer, setRefBuffer] = React.useState([]);
150
204
  const [save, setSave] = React.useState({ status: "idle" });
151
205
  // Minimized-to-bubble state (replaces the old 3s peek).
152
206
  const [minimized, setMinimized] = React.useState(false);
@@ -376,7 +430,7 @@ export function QAReviewOverlay({ items, target, gateParam, stateUrl, submitUrl,
376
430
  return; // ignore the panel itself
377
431
  e.preventDefault();
378
432
  e.stopPropagation();
379
- const ref = pickedElementRef(el.textContent, el.tagName);
433
+ const ref = pickedElementRef(describePickedElement(el));
380
434
  const ta = noteRef.current;
381
435
  if (ta) {
382
436
  const s = ta.selectionStart ?? note.length;
@@ -701,17 +755,56 @@ export function QAReviewOverlay({ items, target, gateParam, stateUrl, submitUrl,
701
755
  window.prompt("Copy QA results:", json);
702
756
  });
703
757
  }, [buildPayload]);
758
+ /**
759
+ * Write `text` to the clipboard, falling back to a prompt when the API is
760
+ * unavailable (insecure origin, permission denied). One place, so the single
761
+ * and the collected copy cannot diverge.
762
+ */
763
+ const writeClipboard = React.useCallback((text, label) => {
764
+ navigator.clipboard.writeText(text).then(() => {
765
+ setRefCopied(true);
766
+ window.setTimeout(() => setRefCopied(false), 1500);
767
+ }, () => {
768
+ window.prompt(label, text);
769
+ });
770
+ }, []);
704
771
  const copyRef = React.useCallback(() => {
705
772
  if (!current)
706
773
  return;
707
774
  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);
775
+ // A plain click REPLACES, and therefore also ends a collection run - so the
776
+ // way out of a half-built list is the same button you started with.
777
+ setRefBuffer([]);
778
+ writeClipboard(line, "Copy QA reference:");
779
+ }, [current, target, writeClipboard]);
780
+ /**
781
+ * 🚨 COLLECT SEVERAL REFERENCES, PASTE THEM ONCE (Arty 2026-08-09: "qa
782
+ * interactive component - should have right click option on 'copy ref' button,
783
+ * to include an 'append to current clipboard'. Which should pretty much let us
784
+ * collect multiple references together to be able to paste them all in one.
785
+ * This way I would be able to copy multiple references in a single sweep and
786
+ * then paste them all together into claude code").
787
+ *
788
+ * 🚨 IT ACCUMULATES IN THE PANEL, NOT BY READING THE CLIPBOARD.
789
+ * `navigator.clipboard.readText()` is the obvious implementation and the wrong
790
+ * one: it needs a separate permission that Firefox does not grant to a page at
791
+ * all, and on a grant it would append to WHATEVER is on the clipboard -
792
+ * including a password manager's payload or the pasta the operator copied a
793
+ * minute ago off this very board. Keeping the list in the panel means the
794
+ * button can only ever append references it produced itself.
795
+ */
796
+ const appendRef = React.useCallback(() => {
797
+ if (!current)
798
+ return;
799
+ const line = formatQARef(target, current.id, current.title);
800
+ setRefBuffer((prev) => {
801
+ // Right-clicking the same item twice is a slip, not a request for a
802
+ // duplicate - so it is idempotent rather than additive.
803
+ const next = prev.includes(line) ? prev : [...prev, line];
804
+ writeClipboard(next.join("\n"), "Copy QA references:");
805
+ return next;
713
806
  });
714
- }, [current, target]);
807
+ }, [current, target, writeClipboard]);
715
808
  // Persist the completed run as a session snapshot via the submit endpoint.
716
809
  const saveToDb = React.useCallback(async () => {
717
810
  if (!submitUrl)
@@ -767,8 +860,15 @@ export function QAReviewOverlay({ items, target, gateParam, stateUrl, submitUrl,
767
860
  ? "Saved to database ✓"
768
861
  : "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
862
  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);
863
+ // 🚨 THE LAST ITEM, NOT THE FIRST (Arty 2026-08-09: "Clicking
864
+ // on 'Back to review' in the QA interactive panel should go to
865
+ // the last item and not to the first item - as if it was undo
866
+ // instead of restart"). You press this having just finished,
867
+ // because of something about the item you just judged. Landing
868
+ // on item 1 of forty makes the button a restart, and getting
869
+ // back to where you were means clicking Next until you arrive.
870
+ setIndex(Math.max(0, roundTotal - 1));
871
+ }, className: "qar-btn-outline", "data-qatip": "Go back to the last item you reviewed", children: "Back to review" })), _jsx("button", { type: "button", onClick: exit, className: "qar-btn-ghost", children: "Exit QA mode" })] })] }) }), document.body);
772
872
  }
773
873
  if (!current)
774
874
  return null;
@@ -821,7 +921,16 @@ export function QAReviewOverlay({ items, target, gateParam, stateUrl, submitUrl,
821
921
  : { top: 24, left: 16 }),
822
922
  width: CARD_W,
823
923
  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
924
+ }, 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, onContextMenu: (e) => {
925
+ e.preventDefault();
926
+ appendRef();
927
+ }, className: "qar-pick-btn", "data-qatip": "Click: copy this item's reference line. Right-click: ADD it to the ones already collected, so several can be pasted at once.", children: [_jsx(ClipboardCopy, { size: 12 }), " ", refCopied
928
+ ? refBuffer.length > 1
929
+ ? `Copied ${refBuffer.length}!`
930
+ : "Copied!"
931
+ : refBuffer.length > 0
932
+ ? `Copy ref (${refBuffer.length} collected)`
933
+ : "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
934
  .split("\n")
826
935
  .map((line) => line.trim())
827
936
  .filter(Boolean)
@@ -6,23 +6,46 @@ export interface RevisitInfo {
6
6
  prevVerdict?: string;
7
7
  /** The reviewer's note from that prior verdict. */
8
8
  prevNote?: string;
9
+ /** Review round the prior verdict was given in. Absent on pre-0.4.0 rows. */
10
+ prevRound?: number;
9
11
  }
10
12
  export interface RevisitDisplay {
11
13
  /** Prominent "Back for review: ..." headline (reason, or generic change note). */
12
14
  headline: string | null;
13
- /** "Your last verdict: reject - "note"" context line. */
15
+ /** "Your last verdict: reject" - WITHOUT the note (see priorNote). */
14
16
  prior: string | null;
17
+ /**
18
+ * The prior note, kept separate so the card can render it COLLAPSED. It used
19
+ * to be inlined into `prior` and repeated again under the NOT-ALTERED badge,
20
+ * so a reviewer read their own note up to three times per card (it also sits
21
+ * in the textarea below). Arty, 2026-08-04: "don't show a copy of it there
22
+ * since the note appears at the bottom anyway ... make the previous rejection
23
+ * note expandable in case I still want to review it."
24
+ */
25
+ priorNote: string | null;
15
26
  /** Show the prominent NOT-ALTERED badge (prior REJECT + identical content). */
16
27
  notAltered: boolean;
17
28
  }
18
29
  /**
19
30
  * Pure display logic for a re-queued item. Rules:
31
+ * - SAME ROUND -> nothing at all. Walking back over your own verdicts inside
32
+ * one pass is navigation, not a revisit.
20
33
  * - a provided reason headlines as "Back for review: <reason>";
21
34
  * - no reason but a CHANGED fingerprint -> generic "Content changed since
22
35
  * your last review.";
23
- * - a prior verdict renders as context ("Your last verdict: ...") with the
24
- * saved note;
36
+ * - a prior verdict renders as context ("Your last verdict: reject"), with the
37
+ * note handed back separately for collapsed rendering;
25
38
  * - prior REJECT + UNCHANGED fingerprint keeps the system-computed
26
39
  * NOT-ALTERED badge (reason or not - the reviewer must see nothing moved).
40
+ *
41
+ * `currentRound` is the round being reviewed right now. When either side is
42
+ * unknown the comparison is skipped and the old behaviour stands, so ledgers
43
+ * written before 0.4.0 keep working.
27
44
  */
28
- export declare function describeRevisit(info: RevisitInfo | undefined, fpStatus: FingerprintStatus | null): RevisitDisplay | null;
45
+ export declare function describeRevisit(info: RevisitInfo | undefined, fpStatus: FingerprintStatus | null, currentRound?: number): RevisitDisplay | null;
46
+ /**
47
+ * True when a stored verdict belongs to the round being reviewed right now.
48
+ * Unknown on either side = "cannot tell", which must NOT suppress the badge - a
49
+ * missing round means a pre-0.4.0 row, and those are genuinely from earlier.
50
+ */
51
+ export declare function isSameRound(prevRound?: number, currentRound?: number): boolean;
@@ -1,27 +1,44 @@
1
1
  "use client";
2
2
  /**
3
3
  * Pure display logic for a re-queued item. Rules:
4
+ * - SAME ROUND -> nothing at all. Walking back over your own verdicts inside
5
+ * one pass is navigation, not a revisit.
4
6
  * - a provided reason headlines as "Back for review: <reason>";
5
7
  * - no reason but a CHANGED fingerprint -> generic "Content changed since
6
8
  * your last review.";
7
- * - a prior verdict renders as context ("Your last verdict: ...") with the
8
- * saved note;
9
+ * - a prior verdict renders as context ("Your last verdict: reject"), with the
10
+ * note handed back separately for collapsed rendering;
9
11
  * - prior REJECT + UNCHANGED fingerprint keeps the system-computed
10
12
  * NOT-ALTERED badge (reason or not - the reviewer must see nothing moved).
13
+ *
14
+ * `currentRound` is the round being reviewed right now. When either side is
15
+ * unknown the comparison is skipped and the old behaviour stands, so ledgers
16
+ * written before 0.4.0 keep working.
11
17
  */
12
- export function describeRevisit(info, fpStatus) {
18
+ export function describeRevisit(info, fpStatus, currentRound) {
13
19
  if (!info)
14
20
  return null;
21
+ if (isSameRound(info.prevRound, currentRound))
22
+ return null;
15
23
  const headline = info.reason
16
24
  ? `Back for review: ${info.reason}`
17
25
  : fpStatus === "changed"
18
26
  ? "Content changed since your last review."
19
27
  : null;
20
- const prior = info.prevVerdict
21
- ? `Your last verdict: ${info.prevVerdict}${info.prevNote ? ` - "${info.prevNote}"` : ""}`
22
- : null;
28
+ const prior = info.prevVerdict ? `Your last verdict: ${info.prevVerdict}` : null;
29
+ const priorNote = info.prevNote ?? null;
23
30
  const notAltered = fpStatus === "unchanged" && info.prevVerdict === "reject";
24
31
  if (!headline && !prior && !notAltered)
25
32
  return null;
26
- return { headline, prior, notAltered };
33
+ return { headline, prior, priorNote, notAltered };
34
+ }
35
+ /**
36
+ * True when a stored verdict belongs to the round being reviewed right now.
37
+ * Unknown on either side = "cannot tell", which must NOT suppress the badge - a
38
+ * missing round means a pre-0.4.0 row, and those are genuinely from earlier.
39
+ */
40
+ export function isSameRound(prevRound, currentRound) {
41
+ if (typeof prevRound !== "number" || typeof currentRound !== "number")
42
+ return false;
43
+ return prevRound === currentRound;
27
44
  }
package/package.json CHANGED
@@ -1,61 +1,61 @@
1
- {
2
- "name": "@artymclabin/qa-review",
3
- "version": "0.3.7",
4
- "description": "Interactive on-page QA review overlay (spotlight walkthrough, approve/reject verdict ledger) with a framework-agnostic server handler factory and a self-provisioning Postgres store.",
5
- "license": "MIT",
6
- "repository": {
7
- "type": "git",
8
- "url": "git+https://github.com/ArtyMcLabin/qa-review.git"
9
- },
10
- "type": "module",
11
- "sideEffects": false,
12
- "main": "./dist/client/index.js",
13
- "types": "./dist/client/index.d.ts",
14
- "exports": {
15
- ".": {
16
- "types": "./dist/client/index.d.ts",
17
- "default": "./dist/client/index.js"
18
- },
19
- "./client": {
20
- "types": "./dist/client/index.d.ts",
21
- "default": "./dist/client/index.js"
22
- },
23
- "./server": {
24
- "types": "./dist/server/index.d.ts",
25
- "default": "./dist/server/index.js"
26
- }
27
- },
28
- "publishConfig": {
29
- "access": "public"
30
- },
31
- "files": [
32
- "dist",
33
- "README.md",
34
- "CHANGELOG.md",
35
- "LICENSE"
36
- ],
37
- "scripts": {
38
- "build": "tsc -p tsconfig.build.json",
39
- "test": "vitest run",
40
- "prepare": "npm run build"
41
- },
42
- "peerDependencies": {
43
- "lucide-react": ">=0.400.0",
44
- "react": ">=18",
45
- "react-dom": ">=18"
46
- },
47
- "dependencies": {
48
- "postgres": "^3.4.5"
49
- },
50
- "devDependencies": {
51
- "@types/node": "^22",
52
- "@types/react": "^19",
53
- "@types/react-dom": "^19",
54
- "jsdom": "^26",
55
- "lucide-react": "^1.25.0",
56
- "react": "^19.2.0",
57
- "react-dom": "^19.2.0",
58
- "typescript": "^5",
59
- "vitest": "^3"
60
- }
61
- }
1
+ {
2
+ "name": "@artymclabin/qa-review",
3
+ "version": "0.3.9",
4
+ "description": "Interactive on-page QA review overlay (spotlight walkthrough, approve/reject verdict ledger) with a framework-agnostic server handler factory and a self-provisioning Postgres store.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/ArtyMcLabin/qa-review.git"
9
+ },
10
+ "type": "module",
11
+ "sideEffects": false,
12
+ "main": "./dist/client/index.js",
13
+ "types": "./dist/client/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/client/index.d.ts",
17
+ "default": "./dist/client/index.js"
18
+ },
19
+ "./client": {
20
+ "types": "./dist/client/index.d.ts",
21
+ "default": "./dist/client/index.js"
22
+ },
23
+ "./server": {
24
+ "types": "./dist/server/index.d.ts",
25
+ "default": "./dist/server/index.js"
26
+ }
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "README.md",
34
+ "CHANGELOG.md",
35
+ "LICENSE"
36
+ ],
37
+ "scripts": {
38
+ "build": "tsc -p tsconfig.build.json",
39
+ "test": "vitest run",
40
+ "prepare": "npm run build"
41
+ },
42
+ "peerDependencies": {
43
+ "lucide-react": ">=0.400.0",
44
+ "react": ">=18",
45
+ "react-dom": ">=18"
46
+ },
47
+ "dependencies": {
48
+ "postgres": "^3.4.5"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^22",
52
+ "@types/react": "^19",
53
+ "@types/react-dom": "^19",
54
+ "jsdom": "^26",
55
+ "lucide-react": "^1.25.0",
56
+ "react": "^19.2.0",
57
+ "react-dom": "^19.2.0",
58
+ "typescript": "^5",
59
+ "vitest": "^3"
60
+ }
61
+ }