hyperframes 0.6.98 → 0.6.100

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.
@@ -402,6 +402,12 @@
402
402
  return !!element.closest("[data-layout-allow-overlap]");
403
403
  }
404
404
 
405
+ function isTransparentColor(color) {
406
+ return (
407
+ !color || color === "transparent" || color === "rgba(0, 0, 0, 0)" || color.endsWith(", 0)")
408
+ );
409
+ }
410
+
405
411
  function alphaFromParts(parts, index) {
406
412
  return parts.length > index ? parsePx(parts[index]) : 1;
407
413
  }
@@ -483,6 +489,79 @@
483
489
  return issues;
484
490
  }
485
491
 
492
+ function hasOpaqueBackground(style) {
493
+ if (style.backgroundImage && style.backgroundImage !== "none") return true;
494
+ if (isTransparentColor(style.backgroundColor)) return false;
495
+ return colorAlpha(style.backgroundColor) > 0.6;
496
+ }
497
+
498
+ const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]);
499
+
500
+ // An element hides text beneath it when it paints opaque pixels at near-full
501
+ // opacity: raster content (img/video/canvas), a background image, or a solid
502
+ // background colour. Low-opacity overlays (grain, scrims) do not occlude.
503
+ function isOpaqueOccluder(element) {
504
+ if (opacityChain(element) < 0.6) return false;
505
+ if (IGNORE_TAGS.has(element.tagName)) return false;
506
+ if (RASTER_TAGS.has(element.tagName)) return true;
507
+ return hasOpaqueBackground(getComputedStyle(element));
508
+ }
509
+
510
+ function hasAllowOcclusionFlag(element) {
511
+ return !!element.closest("[data-layout-allow-occlusion]");
512
+ }
513
+
514
+ // A foreign element is one painted independently of the text — not the text
515
+ // itself, its own subtree, or an ancestor it shares a background with.
516
+ function isForeignElement(element, hit) {
517
+ return !!hit && hit !== element && !element.contains(hit) && !hit.contains(element);
518
+ }
519
+
520
+ // The opaque element painted over (x, y), or null when the topmost element
521
+ // there is related to the text or non-opaque.
522
+ function occluderAt(element, x, y) {
523
+ if (typeof document.elementFromPoint !== "function") return null;
524
+ const hit = document.elementFromPoint(x, y);
525
+ if (!isForeignElement(element, hit)) return null;
526
+ return isOpaqueOccluder(hit) ? hit : null;
527
+ }
528
+
529
+ // Sweep a grid across the text box (three rows, not just the mid-line, so
530
+ // overlays covering only part of a multi-line block are caught) and return
531
+ // the first opaque element painted over any sample point.
532
+ function firstOccluder(element, textRect) {
533
+ for (const yFraction of [0.25, 0.5, 0.75]) {
534
+ const y = textRect.top + textRect.height * yFraction;
535
+ for (const xFraction of [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97]) {
536
+ const occluder = occluderAt(element, textRect.left + textRect.width * xFraction, y);
537
+ if (occluder) return occluder;
538
+ }
539
+ }
540
+ return null;
541
+ }
542
+
543
+ // Catches the blind spot the overflow checks miss: text that fits its box
544
+ // perfectly but is covered by a later sibling/overlay.
545
+ function occludedTextIssue(element, time) {
546
+ if (hasAllowOcclusionFlag(element)) return null;
547
+ const textRect = textRectFor(element);
548
+ if (!textRect) return null;
549
+ const occluder = firstOccluder(element, textRect);
550
+ if (!occluder) return null;
551
+ return {
552
+ code: "text_occluded",
553
+ severity: "error",
554
+ time,
555
+ selector: selectorFor(element),
556
+ containerSelector: selectorFor(occluder),
557
+ text: textContentFor(element),
558
+ message: "Text is hidden beneath an opaque element.",
559
+ rect: textRect,
560
+ fixHint:
561
+ "Give the text its own zone, raise its stacking order above the covering element, or mark intentional layering with data-layout-allow-occlusion.",
562
+ };
563
+ }
564
+
486
565
  window.__hyperframesLayoutAudit = function auditLayout(options) {
487
566
  const time = options && typeof options.time === "number" ? options.time : 0;
488
567
  const tolerance =
@@ -500,6 +579,8 @@
500
579
  const clipped = clippedTextIssue(element, time, tolerance);
501
580
  if (clipped) issues.push(clipped);
502
581
  issues.push(...textOverflowIssues(element, root, rootRect, time, tolerance));
582
+ const occluded = occludedTextIssue(element, time);
583
+ if (occluded) issues.push(occluded);
503
584
  }
504
585
 
505
586
  issues.push(...containerOverflowIssues(root, time, tolerance));
@@ -0,0 +1,121 @@
1
+ // In-page motion sampler for `hyperframes inspect` motion verification (#1437).
2
+ // Runs inside the seeked, paused page (via page.evaluate). For each asserted
3
+ // selector it returns this frame's { rect, opacity, visible }; for each liveness
4
+ // scope it returns a bucketed signature of all visible elements, so the Node-side
5
+ // evaluator can detect frozen windows by comparing signatures across frames.
6
+ (function () {
7
+ const IGNORE_TAGS = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "META", "LINK"]);
8
+
9
+ function round(value) {
10
+ return Math.round(value * 100) / 100;
11
+ }
12
+
13
+ function toRect(rect) {
14
+ return {
15
+ left: round(rect.left),
16
+ top: round(rect.top),
17
+ right: round(rect.right),
18
+ bottom: round(rect.bottom),
19
+ width: round(rect.width),
20
+ height: round(rect.height),
21
+ };
22
+ }
23
+
24
+ function opacityChain(element) {
25
+ let opacity = 1;
26
+ for (let current = element; current; current = current.parentElement) {
27
+ const parsed = Number.parseFloat(getComputedStyle(current).opacity || "1");
28
+ if (Number.isFinite(parsed)) opacity *= parsed;
29
+ }
30
+ return opacity;
31
+ }
32
+
33
+ // Mirrors layout-audit.browser.js isVisibleElement.
34
+ // fallow-ignore-next-line complexity
35
+ function isVisibleElement(element) {
36
+ if (IGNORE_TAGS.has(element.tagName)) return false;
37
+ const style = getComputedStyle(element);
38
+ if (
39
+ style.display === "none" ||
40
+ style.visibility === "hidden" ||
41
+ style.visibility === "collapse"
42
+ ) {
43
+ return false;
44
+ }
45
+ if (opacityChain(element) < 0.2) return false;
46
+ const rect = element.getBoundingClientRect();
47
+ return rect.width > 0.5 && rect.height > 0.5;
48
+ }
49
+
50
+ function sampleElement(element) {
51
+ const rect = element.getBoundingClientRect();
52
+ return {
53
+ rect: toRect(rect),
54
+ opacity: round(opacityChain(element)),
55
+ visible: isVisibleElement(element),
56
+ };
57
+ }
58
+
59
+ function compositionRoot() {
60
+ return document.querySelector("[data-composition-id]") || document.body;
61
+ }
62
+
63
+ // ponytail: bucket position to 2px and opacity to 0.08 so the RFC's "moves ≥2px /
64
+ // opacity ≥0.08" thresholds fall out of bucketing. Boundary-straddling moves are
65
+ // approximate — good enough for liveness; tighten only if false negatives show up.
66
+ function elementSignature(element) {
67
+ const rect = element.getBoundingClientRect();
68
+ const bx = Math.round(rect.left / 2);
69
+ const by = Math.round(rect.top / 2);
70
+ const bw = Math.round(rect.width / 2);
71
+ const bh = Math.round(rect.height / 2);
72
+ const bo = Math.round(opacityChain(element) / 0.08);
73
+ return bx + "," + by + "," + bw + "," + bh + "," + bo;
74
+ }
75
+
76
+ function livenessSignature(root) {
77
+ if (!root) return "";
78
+ const parts = [];
79
+ // ponytail: O(DOM) × MOTION_MAX_SAMPLES (300) — fine for typical compositions;
80
+ // narrow selector (e.g. "[id],[class]") if heavy-DOM compositions slow this down.
81
+ const all = root.querySelectorAll("*");
82
+ for (const element of all) {
83
+ if (!isVisibleElement(element)) continue;
84
+ parts.push(elementSignature(element));
85
+ }
86
+ return parts.join("|");
87
+ }
88
+
89
+ function safeQuery(selector) {
90
+ try {
91
+ return document.querySelector(selector);
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ function sampleSelectors(selectors) {
98
+ const data = {};
99
+ for (const selector of selectors) {
100
+ // Multi-match selectors are rejected before this point by findAmbiguousSelectors
101
+ // in layout.ts; querySelector is safe here.
102
+ const element = safeQuery(selector);
103
+ data[selector] = element ? sampleElement(element) : null;
104
+ }
105
+ return data;
106
+ }
107
+
108
+ function sampleLiveness(scopes) {
109
+ const liveness = {};
110
+ for (const scope of scopes) {
111
+ const root = scope === "*" ? compositionRoot() : safeQuery(scope);
112
+ liveness[scope] = livenessSignature(root);
113
+ }
114
+ return liveness;
115
+ }
116
+
117
+ window.__hyperframesMotionSample = function motionSample(options) {
118
+ const { selectors = [], livenessScopes = [] } = options || {};
119
+ return { data: sampleSelectors(selectors), liveness: sampleLiveness(livenessScopes) };
120
+ };
121
+ })();