hyperframes 0.7.21 → 0.7.23

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.
@@ -45,6 +45,45 @@ window.__contrastAudit = async function (imgBase64, time) {
45
45
  return s[Math.floor(s.length / 2)];
46
46
  }
47
47
 
48
+ function hasClipPath(el) {
49
+ for (var ce = el; ce; ce = ce.parentElement) {
50
+ var cp = getComputedStyle(ce).clipPath;
51
+ if (cp && cp !== "none") return true;
52
+ }
53
+ return false;
54
+ }
55
+
56
+ var CLIP_PROBE_COLS = [0.05, 0.25, 0.5, 0.75, 0.95];
57
+ var CLIP_PROBE_ROWS = [0.25, 0.5, 0.75];
58
+
59
+ function paintsAnyProbePoint(el, rect) {
60
+ // Keep probe resolution aligned with layout-audit.browser.js. Edge strips
61
+ // narrower than the nearest probe point are treated as clipped away to
62
+ // avoid noisy typewriter pre-reveal contrast reports.
63
+ for (var ci = 0; ci < CLIP_PROBE_COLS.length; ci++) {
64
+ for (var ri = 0; ri < CLIP_PROBE_ROWS.length; ri++) {
65
+ var x = rect.left + rect.width * CLIP_PROBE_COLS[ci];
66
+ var y = rect.top + rect.height * CLIP_PROBE_ROWS[ri];
67
+ var hit = document.elementFromPoint(x, y);
68
+ if (hit === el || el.contains(hit)) return true;
69
+ }
70
+ }
71
+ return false;
72
+ }
73
+
74
+ // A clip-path can shrink an element's painted region to nothing (a typewriter
75
+ // span pre-reveal at `inset(0 100% 0 0)`, or `circle(0px)`) while its box and
76
+ // colours read normally; it then paints zero pixels and measures a meaningless
77
+ // background-on-background ratio. clip-path drives hit-testing, so a fully
78
+ // clipped element is unreachable by elementFromPoint across its box. Probe only
79
+ // when a clip-path is in effect (self or ancestor) so genuinely-occluded but
80
+ // unclipped text is not skipped.
81
+ function isClippedAway(el, rect) {
82
+ if (typeof document.elementFromPoint !== "function") return false;
83
+ if (!hasClipPath(el)) return false;
84
+ return !paintsAnyProbePoint(el, rect);
85
+ }
86
+
48
87
  // Decode screenshot into canvas pixel data
49
88
  var img = new Image();
50
89
  await new Promise(function (resolve) {
@@ -110,6 +149,7 @@ window.__contrastAudit = async function (imgBase64, time) {
110
149
  var rect = el.getBoundingClientRect();
111
150
  if (rect.width < 8 || rect.height < 8) continue;
112
151
  if (rect.right <= 0 || rect.bottom <= 0 || rect.left >= w || rect.top >= h) continue;
152
+ if (isClippedAway(el, rect)) continue;
113
153
 
114
154
  var fg = parseColor(cs.color);
115
155
  if (fg[3] <= 0.01) continue;
@@ -98,6 +98,50 @@
98
98
  return opacity;
99
99
  }
100
100
 
101
+ // A clip-path can shrink an element's painted region to nothing (e.g. a
102
+ // typewriter span pre-reveal at `inset(0 100% 0 0)`, or `circle(0px)`) while
103
+ // its layout box, opacity, visibility and display all still read as present.
104
+ // Such an element paints zero pixels, so flagging it for overlap/occlusion is
105
+ // a false positive. clip-path also drives hit-testing, so an element clipped
106
+ // to nothing is unreachable by elementFromPoint anywhere in its box; only run
107
+ // the probe when a clip-path is actually in effect (self or ancestor) to avoid
108
+ // mistaking a genuinely-occluded element for a clipped one.
109
+ function hasClipPath(element) {
110
+ for (let current = element; current; current = current.parentElement) {
111
+ const clip = getComputedStyle(current).clipPath;
112
+ if (clip && clip !== "none") return true;
113
+ }
114
+ return false;
115
+ }
116
+
117
+ const CLIP_PROBE_COLS = [0.05, 0.25, 0.5, 0.75, 0.95];
118
+ const CLIP_PROBE_ROWS = [0.25, 0.5, 0.75];
119
+
120
+ function paintsAnyProbePoint(element, rect) {
121
+ // Probe resolution intentionally treats edge strips narrower than the
122
+ // nearest probe point as clipped away. That avoids noisy reports for
123
+ // typewriter pre-reveal states; if a real visible-strip bug appears, add
124
+ // edge probes here before widening the audit surface.
125
+ for (const fx of CLIP_PROBE_COLS) {
126
+ for (const fy of CLIP_PROBE_ROWS) {
127
+ const hit = document.elementFromPoint(
128
+ rect.left + rect.width * fx,
129
+ rect.top + rect.height * fy,
130
+ );
131
+ if (hit === element || element.contains(hit)) return true;
132
+ }
133
+ }
134
+ return false;
135
+ }
136
+
137
+ function isClippedAway(element) {
138
+ if (typeof document.elementFromPoint !== "function") return false;
139
+ if (!hasClipPath(element)) return false;
140
+ const rect = element.getBoundingClientRect();
141
+ if (rect.width <= 0.5 || rect.height <= 0.5) return false;
142
+ return !paintsAnyProbePoint(element, rect);
143
+ }
144
+
101
145
  function isVisibleElement(element) {
102
146
  if (IGNORE_TAGS.has(element.tagName)) return false;
103
147
  if (hasIgnoreFlag(element)) return false;
@@ -111,7 +155,8 @@
111
155
  }
112
156
  if (opacityChain(element) < 0.2) return false;
113
157
  const rect = element.getBoundingClientRect();
114
- return rect.width > 0.5 && rect.height > 0.5;
158
+ if (rect.width <= 0.5 || rect.height <= 0.5) return false;
159
+ return !isClippedAway(element);
115
160
  }
116
161
 
117
162
  function textContentFor(element) {
@@ -473,11 +518,37 @@
473
518
  return a.contains(b) || b.contains(a);
474
519
  }
475
520
 
521
+ function isInFlow(element) {
522
+ const position = getComputedStyle(element).position;
523
+ return position === "static" || position === "relative" || position === "sticky";
524
+ }
525
+
526
+ function nearestFlexGridAncestor(element) {
527
+ for (let parent = element.parentElement; parent; parent = parent.parentElement) {
528
+ const display = getComputedStyle(parent).display;
529
+ if (display.includes("flex") || display.includes("grid")) return parent;
530
+ }
531
+ return null;
532
+ }
533
+
534
+ // Two in-flow text blocks governed by the same flex/grid container are placed
535
+ // by the layout engine, which reserves space for each — they cannot visually
536
+ // collide. Any measured text-rect overlap between them is line-box / leading
537
+ // slop (tight stacks, number lockups, super/subscript units), not a collision.
538
+ // A real overlap bug needs free positioning (absolute/fixed), which keeps a
539
+ // different formatting context and is still flagged.
540
+ function isManagedFlowOverlap(a, b) {
541
+ if (!isInFlow(a) || !isInFlow(b)) return false;
542
+ const container = nearestFlexGridAncestor(a);
543
+ return !!container && container === nearestFlexGridAncestor(b);
544
+ }
545
+
476
546
  // Two solid text blocks whose boxes overlap by more than a fifth of the
477
547
  // smaller block read as a collision — unreadable, and invisible to the
478
548
  // overflow checks, which only compare an element against its container.
479
549
  function overlapIssue(a, b, time) {
480
550
  if (isNested(a.element, b.element)) return null;
551
+ if (isManagedFlowOverlap(a.element, b.element)) return null;
481
552
  const area = intersectionArea(a.rect, b.rect);
482
553
  if (area <= Math.min(rectArea(a.rect), rectArea(b.rect)) * 0.2) return null;
483
554
  return {
@@ -537,13 +608,30 @@
537
608
  return !!hit && hit !== element && !element.contains(hit) && !hit.contains(element);
538
609
  }
539
610
 
611
+ // During a scene-to-scene crossfade the incoming scene paints over the
612
+ // outgoing scene's still-visible text at >= 0.6 opacity — and `--at-transitions`
613
+ // samples exactly that midpoint. That overlap is the transition doing its job,
614
+ // not an occlusion bug. Detect it: the occluder lives in a DIFFERENT composition
615
+ // mount ([data-composition-id]) than the text, and at least one of the two scenes
616
+ // is mid-fade (effective opacity < 1). Two fully-settled scenes overlapping
617
+ // (both opacity 1) is NOT suppressed — that is a real layering bug.
618
+ function isCrossSceneTransitionOverlap(textEl, occluder) {
619
+ const textScene = textEl.closest("[data-composition-id]");
620
+ const occluderScene = occluder.closest("[data-composition-id]");
621
+ if (!textScene || !occluderScene || textScene === occluderScene) return false;
622
+ return Math.min(opacityChain(textScene), opacityChain(occluderScene)) < 0.999;
623
+ }
624
+
540
625
  // The opaque element painted over (x, y), or null when the topmost element
541
- // there is related to the text or non-opaque.
626
+ // there is related to the text, non-opaque, or a transient crossfade overlap.
627
+ // fallow-ignore-next-line complexity
542
628
  function occluderAt(element, x, y) {
543
629
  if (typeof document.elementFromPoint !== "function") return null;
544
630
  const hit = document.elementFromPoint(x, y);
545
631
  if (!isForeignElement(element, hit)) return null;
546
- return isOpaqueOccluder(hit) ? hit : null;
632
+ if (!isOpaqueOccluder(hit)) return null;
633
+ if (isCrossSceneTransitionOverlap(element, hit)) return null;
634
+ return hit;
547
635
  }
548
636
 
549
637
  // Sweep a grid across the text box (three rows, not just the mid-line, so