@artymclabin/qa-review 0.3.8 → 0.3.10
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 +24 -0
- package/dist/client/QAReviewOverlay.d.ts +25 -1
- package/dist/client/QAReviewOverlay.js +81 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.10 - 2026-09-19
|
|
4
|
+
|
|
5
|
+
- OUTSIDE TAP MINIMIZES THE PANEL, ON MOBILE: the only way to collapse the
|
|
6
|
+
card used to be the small minimize button in its header, and hitting a
|
|
7
|
+
small target on a phone is real friction. A pointerdown anywhere outside
|
|
8
|
+
the card now minimizes it too, scoped to mobile-width viewports (desktop
|
|
9
|
+
keeps the explicit button only, since a stray click outside a small
|
|
10
|
+
floating panel on a mouse is far more likely incidental than intentional).
|
|
11
|
+
Uses a capture-phase listener plus a contains() check against the card, so
|
|
12
|
+
dragging the card by its header (which also starts with a pointerdown,
|
|
13
|
+
from inside the card) is unaffected.
|
|
14
|
+
|
|
15
|
+
## 0.3.9 - 2026-09-13
|
|
16
|
+
|
|
17
|
+
- PICKED FORM CONTROLS ARE NAMED BY LABEL, NOT BY TAG: a form control has no
|
|
18
|
+
textContent, so the element picker fell through to `tagName.toLowerCase()`
|
|
19
|
+
for every input, textarea and select. Picking the seven link fields on the
|
|
20
|
+
GameReady pitch page produced seven identical `<input>` references, with
|
|
21
|
+
no way for the reviewer to say which row they meant. The picker now reads
|
|
22
|
+
the name a human would: own text, accessible name (aria-label /
|
|
23
|
+
aria-labelledby), the associated `<label>` (including a wrapping label),
|
|
24
|
+
placeholder, alt, title, current value, name attribute, and only then the
|
|
25
|
+
tag.
|
|
26
|
+
|
|
3
27
|
## 0.3.8 - 2026-08-09
|
|
4
28
|
|
|
5
29
|
- BACK TO REVIEW IS AN UNDO, NOT A RESTART: the finish panel's "Back to review"
|
|
@@ -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
|
|
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
|
-
/**
|
|
56
|
-
|
|
57
|
-
|
|
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).
|
|
@@ -239,6 +291,31 @@ export function QAReviewOverlay({ items, target, gateParam, stateUrl, submitUrl,
|
|
|
239
291
|
mq.addEventListener("change", onChange);
|
|
240
292
|
return () => mq.removeEventListener("change", onChange);
|
|
241
293
|
}, []);
|
|
294
|
+
// OUTSIDE TAP TO MINIMIZE (mobile only): pixel-hunting for the small
|
|
295
|
+
// minimize button is real friction on a phone, so a tap anywhere outside
|
|
296
|
+
// the card collapses it to the bubble - same result as the button, far
|
|
297
|
+
// cheaper to hit. Scoped to mobile width: on desktop a stray click outside
|
|
298
|
+
// a small floating panel is much more likely incidental than intentional.
|
|
299
|
+
// Capture phase + native listener so it fires ahead of (and regardless of)
|
|
300
|
+
// any stopPropagation on the underlying page, and a pointerdown that starts
|
|
301
|
+
// INSIDE the card (e.g. dragging the header) is excluded by the contains()
|
|
302
|
+
// check, so dragging is unaffected.
|
|
303
|
+
React.useEffect(() => {
|
|
304
|
+
if (typeof window.matchMedia !== "function")
|
|
305
|
+
return;
|
|
306
|
+
if (!active || finished || minimized || mobilePreview)
|
|
307
|
+
return;
|
|
308
|
+
const onPointerDown = (e) => {
|
|
309
|
+
if (!window.matchMedia(`(max-width: ${AUTO_BUBBLE_MAX_WIDTH_PX}px)`).matches)
|
|
310
|
+
return;
|
|
311
|
+
const target = e.target;
|
|
312
|
+
if (cardRef.current && target && !cardRef.current.contains(target)) {
|
|
313
|
+
setMinimized(true);
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
317
|
+
return () => document.removeEventListener("pointerdown", onPointerDown, true);
|
|
318
|
+
}, [active, finished, minimized, mobilePreview]);
|
|
242
319
|
// Hydrate verdicts and LAND on the first outstanding item. The DURABLE
|
|
243
320
|
// server ledger is the source of truth; localStorage is only a fast cache.
|
|
244
321
|
// 🚨 The store NEVER pre-approves (no seed). The round derives from
|
|
@@ -378,7 +455,7 @@ export function QAReviewOverlay({ items, target, gateParam, stateUrl, submitUrl,
|
|
|
378
455
|
return; // ignore the panel itself
|
|
379
456
|
e.preventDefault();
|
|
380
457
|
e.stopPropagation();
|
|
381
|
-
const ref = pickedElementRef(el
|
|
458
|
+
const ref = pickedElementRef(describePickedElement(el));
|
|
382
459
|
const ta = noteRef.current;
|
|
383
460
|
if (ta) {
|
|
384
461
|
const s = ta.selectionStart ?? note.length;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@artymclabin/qa-review",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.10",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|