@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 +19 -0
- package/README.md +177 -177
- package/dist/client/QAReviewOverlay.js +66 -9
- package/dist/client/revisit.d.ts +27 -4
- package/dist/client/revisit.js +24 -7
- package/package.json +61 -61
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
-
|
|
771
|
-
|
|
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,
|
|
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)
|
package/dist/client/revisit.d.ts
CHANGED
|
@@ -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 -
|
|
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:
|
|
24
|
-
*
|
|
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;
|
package/dist/client/revisit.js
CHANGED
|
@@ -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:
|
|
8
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|