hyperframes 0.6.99 → 0.6.101

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
  }
@@ -458,13 +464,16 @@
458
464
  const area = intersectionArea(a.rect, b.rect);
459
465
  if (area <= Math.min(rectArea(a.rect), rectArea(b.rect)) * 0.2) return null;
460
466
  return {
467
+ // Warning, not error: must not fail the exit code (ok = errorCount === 0)
468
+ // for compositions that intentionally layer text. Re-promote once the
469
+ // data-layout-allow-overlap opt-out is widely adopted.
461
470
  code: "content_overlap",
462
- severity: "error",
471
+ severity: "warning",
463
472
  time,
464
473
  selector: selectorFor(a.element),
465
474
  containerSelector: selectorFor(b.element),
466
475
  text: textContentFor(a.element),
467
- message: "Two text blocks overlap and render unreadable.",
476
+ message: "Two text blocks overlap and may render unreadable.",
468
477
  rect: a.rect,
469
478
  fixHint:
470
479
  "Give each block its own zone, or mark intentional layering with data-layout-allow-overlap.",
@@ -483,6 +492,79 @@
483
492
  return issues;
484
493
  }
485
494
 
495
+ function hasOpaqueBackground(style) {
496
+ if (style.backgroundImage && style.backgroundImage !== "none") return true;
497
+ if (isTransparentColor(style.backgroundColor)) return false;
498
+ return colorAlpha(style.backgroundColor) > 0.6;
499
+ }
500
+
501
+ const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]);
502
+
503
+ // An element hides text beneath it when it paints opaque pixels at near-full
504
+ // opacity: raster content (img/video/canvas), a background image, or a solid
505
+ // background colour. Low-opacity overlays (grain, scrims) do not occlude.
506
+ function isOpaqueOccluder(element) {
507
+ if (opacityChain(element) < 0.6) return false;
508
+ if (IGNORE_TAGS.has(element.tagName)) return false;
509
+ if (RASTER_TAGS.has(element.tagName)) return true;
510
+ return hasOpaqueBackground(getComputedStyle(element));
511
+ }
512
+
513
+ function hasAllowOcclusionFlag(element) {
514
+ return !!element.closest("[data-layout-allow-occlusion]");
515
+ }
516
+
517
+ // A foreign element is one painted independently of the text — not the text
518
+ // itself, its own subtree, or an ancestor it shares a background with.
519
+ function isForeignElement(element, hit) {
520
+ return !!hit && hit !== element && !element.contains(hit) && !hit.contains(element);
521
+ }
522
+
523
+ // The opaque element painted over (x, y), or null when the topmost element
524
+ // there is related to the text or non-opaque.
525
+ function occluderAt(element, x, y) {
526
+ if (typeof document.elementFromPoint !== "function") return null;
527
+ const hit = document.elementFromPoint(x, y);
528
+ if (!isForeignElement(element, hit)) return null;
529
+ return isOpaqueOccluder(hit) ? hit : null;
530
+ }
531
+
532
+ // Sweep a grid across the text box (three rows, not just the mid-line, so
533
+ // overlays covering only part of a multi-line block are caught) and return
534
+ // the first opaque element painted over any sample point.
535
+ function firstOccluder(element, textRect) {
536
+ for (const yFraction of [0.25, 0.5, 0.75]) {
537
+ const y = textRect.top + textRect.height * yFraction;
538
+ for (const xFraction of [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97]) {
539
+ const occluder = occluderAt(element, textRect.left + textRect.width * xFraction, y);
540
+ if (occluder) return occluder;
541
+ }
542
+ }
543
+ return null;
544
+ }
545
+
546
+ // Catches the blind spot the overflow checks miss: text that fits its box
547
+ // perfectly but is covered by a later sibling/overlay.
548
+ function occludedTextIssue(element, time) {
549
+ if (hasAllowOcclusionFlag(element)) return null;
550
+ const textRect = textRectFor(element);
551
+ if (!textRect) return null;
552
+ const occluder = firstOccluder(element, textRect);
553
+ if (!occluder) return null;
554
+ return {
555
+ code: "text_occluded",
556
+ severity: "error",
557
+ time,
558
+ selector: selectorFor(element),
559
+ containerSelector: selectorFor(occluder),
560
+ text: textContentFor(element),
561
+ message: "Text is hidden beneath an opaque element.",
562
+ rect: textRect,
563
+ fixHint:
564
+ "Give the text its own zone, raise its stacking order above the covering element, or mark intentional layering with data-layout-allow-occlusion.",
565
+ };
566
+ }
567
+
486
568
  window.__hyperframesLayoutAudit = function auditLayout(options) {
487
569
  const time = options && typeof options.time === "number" ? options.time : 0;
488
570
  const tolerance =
@@ -500,6 +582,8 @@
500
582
  const clipped = clippedTextIssue(element, time, tolerance);
501
583
  if (clipped) issues.push(clipped);
502
584
  issues.push(...textOverflowIssues(element, root, rootRect, time, tolerance));
585
+ const occluded = occludedTextIssue(element, time);
586
+ if (occluded) issues.push(occluded);
503
587
  }
504
588
 
505
589
  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
+ })();