@artymclabin/qa-review 0.3.7 → 0.3.8

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
@@ -147,6 +147,8 @@ export function QAReviewOverlay({ items, target, gateParam, stateUrl, submitUrl,
147
147
  const [finished, setFinished] = React.useState(false);
148
148
  const [copied, setCopied] = React.useState(false);
149
149
  const [refCopied, setRefCopied] = React.useState(false);
150
+ /** Reference lines collected by right-clicking "Copy ref". See `appendRef`. */
151
+ const [refBuffer, setRefBuffer] = React.useState([]);
150
152
  const [save, setSave] = React.useState({ status: "idle" });
151
153
  // Minimized-to-bubble state (replaces the old 3s peek).
152
154
  const [minimized, setMinimized] = React.useState(false);
@@ -701,17 +703,56 @@ export function QAReviewOverlay({ items, target, gateParam, stateUrl, submitUrl,
701
703
  window.prompt("Copy QA results:", json);
702
704
  });
703
705
  }, [buildPayload]);
706
+ /**
707
+ * Write `text` to the clipboard, falling back to a prompt when the API is
708
+ * unavailable (insecure origin, permission denied). One place, so the single
709
+ * and the collected copy cannot diverge.
710
+ */
711
+ const writeClipboard = React.useCallback((text, label) => {
712
+ navigator.clipboard.writeText(text).then(() => {
713
+ setRefCopied(true);
714
+ window.setTimeout(() => setRefCopied(false), 1500);
715
+ }, () => {
716
+ window.prompt(label, text);
717
+ });
718
+ }, []);
704
719
  const copyRef = React.useCallback(() => {
705
720
  if (!current)
706
721
  return;
707
722
  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);
723
+ // A plain click REPLACES, and therefore also ends a collection run - so the
724
+ // way out of a half-built list is the same button you started with.
725
+ setRefBuffer([]);
726
+ writeClipboard(line, "Copy QA reference:");
727
+ }, [current, target, writeClipboard]);
728
+ /**
729
+ * 🚨 COLLECT SEVERAL REFERENCES, PASTE THEM ONCE (Arty 2026-08-09: "qa
730
+ * interactive component - should have right click option on 'copy ref' button,
731
+ * to include an 'append to current clipboard'. Which should pretty much let us
732
+ * collect multiple references together to be able to paste them all in one.
733
+ * This way I would be able to copy multiple references in a single sweep and
734
+ * then paste them all together into claude code").
735
+ *
736
+ * 🚨 IT ACCUMULATES IN THE PANEL, NOT BY READING THE CLIPBOARD.
737
+ * `navigator.clipboard.readText()` is the obvious implementation and the wrong
738
+ * one: it needs a separate permission that Firefox does not grant to a page at
739
+ * all, and on a grant it would append to WHATEVER is on the clipboard -
740
+ * including a password manager's payload or the pasta the operator copied a
741
+ * minute ago off this very board. Keeping the list in the panel means the
742
+ * button can only ever append references it produced itself.
743
+ */
744
+ const appendRef = React.useCallback(() => {
745
+ if (!current)
746
+ return;
747
+ const line = formatQARef(target, current.id, current.title);
748
+ setRefBuffer((prev) => {
749
+ // Right-clicking the same item twice is a slip, not a request for a
750
+ // duplicate - so it is idempotent rather than additive.
751
+ const next = prev.includes(line) ? prev : [...prev, line];
752
+ writeClipboard(next.join("\n"), "Copy QA references:");
753
+ return next;
713
754
  });
714
- }, [current, target]);
755
+ }, [current, target, writeClipboard]);
715
756
  // Persist the completed run as a session snapshot via the submit endpoint.
716
757
  const saveToDb = React.useCallback(async () => {
717
758
  if (!submitUrl)
@@ -767,8 +808,15 @@ export function QAReviewOverlay({ items, target, gateParam, stateUrl, submitUrl,
767
808
  ? "Saved to database ✓"
768
809
  : "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
810
  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);
811
+ // 🚨 THE LAST ITEM, NOT THE FIRST (Arty 2026-08-09: "Clicking
812
+ // on 'Back to review' in the QA interactive panel should go to
813
+ // the last item and not to the first item - as if it was undo
814
+ // instead of restart"). You press this having just finished,
815
+ // because of something about the item you just judged. Landing
816
+ // on item 1 of forty makes the button a restart, and getting
817
+ // back to where you were means clicking Next until you arrive.
818
+ setIndex(Math.max(0, roundTotal - 1));
819
+ }, 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
820
  }
773
821
  if (!current)
774
822
  return null;
@@ -821,7 +869,16 @@ export function QAReviewOverlay({ items, target, gateParam, stateUrl, submitUrl,
821
869
  : { top: 24, left: 16 }),
822
870
  width: CARD_W,
823
871
  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
872
+ }, 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) => {
873
+ e.preventDefault();
874
+ appendRef();
875
+ }, 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
876
+ ? refBuffer.length > 1
877
+ ? `Copied ${refBuffer.length}!`
878
+ : "Copied!"
879
+ : refBuffer.length > 0
880
+ ? `Copy ref (${refBuffer.length} collected)`
881
+ : "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
882
  .split("\n")
826
883
  .map((line) => line.trim())
827
884
  .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.8",
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
+ }