@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 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 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).
@@ -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.textContent, el.tagName);
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.8",
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": {