hyperframes 0.7.57 → 0.7.59

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.
Files changed (44) hide show
  1. package/dist/cli.js +4218 -2643
  2. package/dist/commands/contrast-audit.browser.js +69 -19
  3. package/dist/commands/layout-audit.browser.js +168 -35
  4. package/dist/hyperframe-runtime.js +27 -27
  5. package/dist/hyperframe.manifest.json +1 -1
  6. package/dist/hyperframe.runtime.iife.js +27 -27
  7. package/dist/hyperframes-player.global.js +1 -1
  8. package/dist/skills/hyperframes/SKILL.md +103 -124
  9. package/dist/skills/hyperframes/references/capability-menu.md +47 -0
  10. package/dist/skills/hyperframes/references/pitch-round.md +40 -0
  11. package/dist/skills/hyperframes/references/route-briefs.md +85 -0
  12. package/dist/skills/hyperframes/references/skill-lifecycle.md +33 -0
  13. package/dist/skills/hyperframes/references/workflow-catalog.md +70 -0
  14. package/dist/skills/hyperframes-cli/SKILL.md +99 -109
  15. package/dist/skills/hyperframes-cli/references/beats.md +19 -0
  16. package/dist/skills/hyperframes-cli/references/cloud.md +8 -3
  17. package/dist/skills/hyperframes-cli/references/cloudrun.md +62 -0
  18. package/dist/skills/hyperframes-cli/references/compare-and-batch.md +105 -0
  19. package/dist/skills/hyperframes-cli/references/lambda.md +77 -24
  20. package/dist/skills/hyperframes-cli/references/lint-validate-inspect.md +3 -3
  21. package/dist/skills/hyperframes-cli/references/preview-render.md +7 -3
  22. package/dist/skills/hyperframes-cli/references/upgrade-info-misc.md +2 -0
  23. package/dist/studio/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-Bm07FLkl.js} +1 -1
  24. package/dist/studio/assets/{index-KsfE1bUu.js → index-B995FG46.js} +1 -1
  25. package/dist/studio/assets/index-CrkAdJkb.js +426 -0
  26. package/dist/studio/assets/index-Dj5p8U_A.css +1 -0
  27. package/dist/studio/assets/{index-Bf1x1y8H.js → index-FvzmPhfG.js} +1 -1
  28. package/dist/studio/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  29. package/dist/studio/chunk-OBAG3GWK.js.map +1 -0
  30. package/dist/studio/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  31. package/dist/studio/index.d.ts +27 -7
  32. package/dist/studio/index.html +2 -2
  33. package/dist/studio/index.js +18029 -11563
  34. package/dist/studio/index.js.map +1 -1
  35. package/dist/studio/styles/tailwind-preset.d.ts +5 -0
  36. package/dist/studio/styles/tailwind-preset.js +8 -1
  37. package/dist/studio/styles/tailwind-preset.js.map +1 -1
  38. package/dist/templates/_shared/AGENTS.md +5 -4
  39. package/dist/templates/_shared/CLAUDE.md +5 -4
  40. package/package.json +2 -2
  41. package/dist/studio/assets/index-DfmYkU44.js +0 -423
  42. package/dist/studio/assets/index-_pqzyxB1.css +0 -1
  43. package/dist/studio/chunk-5QSIMBEJ.js.map +0 -1
  44. /package/dist/studio/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -58,28 +58,29 @@ window.__contrastAuditPrepare = function () {
58
58
  }
59
59
 
60
60
  function parseColor(c) {
61
- var m = c.match(/rgba?\(([^)]+)\)/);
62
- if (!m) return [0, 0, 0, 1];
63
- var p = m[1].split(",").map(function (s) {
64
- return parseFloat(s.trim());
65
- });
66
- return [p[0], p[1], p[2], p[3] != null ? p[3] : 1];
67
- }
68
-
69
- // Like parseColor, but returns null instead of defaulting to black when the
70
- // value isn't a solid rgb()/rgba() color — e.g. SVG paint keywords such as
71
- // "none"/"context-fill", or a gradient/pattern reference like
72
- // 'url("#grad")'. Callers should fall back to another source of truth
73
- // rather than trust a fabricated black.
74
- function tryParseSolidColor(c) {
75
- var m = c.match(/rgba?\(([^)]+)\)/);
76
- if (!m) return null;
61
+ var m = (c || "").match(/^rgba?\(([^)]+)\)$/i);
62
+ if (!m) {
63
+ var mix = (c || "").match(
64
+ /^color-mix\(\s*in\s+srgb\s*,\s*(rgba?\([^)]+\))\s+(\d+(?:\.\d+)?|\.\d+)%\s*,\s*(rgba?\([^)]+\))\s+(\d+(?:\.\d+)?|\.\d+)%\s*\)$/i,
65
+ );
66
+ if (!mix) return null;
67
+ var first = parseColor(mix[1]);
68
+ var second = parseColor(mix[3]);
69
+ var firstWeight = parseFloat(mix[2]);
70
+ var secondWeight = parseFloat(mix[4]);
71
+ var weightTotal = firstWeight + secondWeight;
72
+ if (!first || !second || !isFinite(weightTotal) || weightTotal <= 0) return null;
73
+ return first.map(function (channel, index) {
74
+ return (channel * firstWeight + second[index] * secondWeight) / weightTotal;
75
+ });
76
+ }
77
77
  var p = m[1].split(",").map(function (s) {
78
78
  return parseFloat(s.trim());
79
79
  });
80
80
  if (
81
+ (p.length !== 3 && p.length !== 4) ||
81
82
  p.some(function (v) {
82
- return isNaN(v);
83
+ return !isFinite(v);
83
84
  })
84
85
  )
85
86
  return null;
@@ -218,8 +219,12 @@ window.__contrastAuditPrepare = function () {
218
219
  // `color` rather than crashing parseColor or reporting a fabricated
219
220
  // black.
220
221
  var isSvgText = isSvgTextElement(el);
221
- var fg = isSvgText ? tryParseSolidColor(cs.fill) || parseColor(cs.color) : parseColor(cs.color);
222
+ var fg = isSvgText ? parseColor(cs.fill) || parseColor(cs.color) : parseColor(cs.color);
223
+ if (!fg) continue;
222
224
  if (fg[3] <= 0.01) continue;
225
+ var strokeWidth = parseFloat(cs.webkitTextStrokeWidth || "0");
226
+ var stroke = strokeWidth > 0 ? parseColor(cs.webkitTextStrokeColor || "") : null;
227
+ if (stroke && stroke[3] <= 0.01) stroke = null;
223
228
 
224
229
  var fontSize = parseFloat(cs.fontSize);
225
230
  var fontWeight = Number(cs.fontWeight) || 400;
@@ -251,6 +256,13 @@ window.__contrastAuditPrepare = function () {
251
256
  origFillPriority = el.style.getPropertyPriority("fill");
252
257
  el.style.setProperty("fill", "transparent", "important");
253
258
  }
259
+ var origStrokeColor = null,
260
+ origStrokeColorPriority = null;
261
+ if (stroke) {
262
+ origStrokeColor = el.style.getPropertyValue("-webkit-text-stroke-color");
263
+ origStrokeColorPriority = el.style.getPropertyPriority("-webkit-text-stroke-color");
264
+ el.style.setProperty("-webkit-text-stroke-color", "transparent", "important");
265
+ }
254
266
  restores.push({
255
267
  el: el,
256
268
  origTransition: origTransition,
@@ -259,6 +271,9 @@ window.__contrastAuditPrepare = function () {
259
271
  origColorPriority: origColorPriority,
260
272
  origFill: origFill,
261
273
  origFillPriority: origFillPriority,
274
+ origStrokeColor: origStrokeColor,
275
+ origStrokeColorPriority: origStrokeColorPriority,
276
+ hasStroke: !!stroke,
262
277
  isSvgText: isSvgText,
263
278
  });
264
279
 
@@ -266,6 +281,7 @@ window.__contrastAuditPrepare = function () {
266
281
  selector: selectorOf(el),
267
282
  text: (el.textContent || "").trim().slice(0, 50),
268
283
  fg: fg,
284
+ stroke: stroke,
269
285
  fontSize: fontSize,
270
286
  fontWeight: fontWeight,
271
287
  large: large,
@@ -287,6 +303,15 @@ function __contrastAuditRestoreAll() {
287
303
  if (r.origFill) r.el.style.setProperty("fill", r.origFill, r.origFillPriority);
288
304
  else r.el.style.removeProperty("fill");
289
305
  }
306
+ if (r.hasStroke) {
307
+ if (r.origStrokeColor)
308
+ r.el.style.setProperty(
309
+ "-webkit-text-stroke-color",
310
+ r.origStrokeColor,
311
+ r.origStrokeColorPriority,
312
+ );
313
+ else r.el.style.removeProperty("-webkit-text-stroke-color");
314
+ }
290
315
  if (r.origTransition)
291
316
  r.el.style.setProperty("transition", r.origTransition, r.origTransitionPriority);
292
317
  else r.el.style.removeProperty("transition");
@@ -371,17 +396,25 @@ window.__contrastAuditFinish = async function (imgBase64, time, candidates) {
371
396
  var stepY = Math.max(1, Math.floor((y1 - y0) / 6));
372
397
  var rr = [],
373
398
  gg = [],
374
- bb = [];
399
+ bb = [],
400
+ aa = [];
375
401
  for (var y = y0; y <= y1; y += stepY) {
376
402
  for (var x = x0; x <= x1; x += stepX) {
377
403
  var idx = (y * w + x) * 4;
378
404
  rr.push(px[idx]);
379
405
  gg.push(px[idx + 1]);
380
406
  bb.push(px[idx + 2]);
407
+ aa.push(px[idx + 3]);
381
408
  }
382
409
  }
383
410
  if (rr.length === 0) continue;
384
411
 
412
+ // A transparent sampled backdrop is supplied by the downstream editor,
413
+ // not this composition. WCAG contrast cannot be inferred until that live
414
+ // video/image is known, so do not invent an opaque browser default and
415
+ // hard-fail an otherwise valid alpha overlay.
416
+ if (median(aa) < 255) continue;
417
+
385
418
  var bgR = median(rr),
386
419
  bgG = median(gg),
387
420
  bgB = median(bb);
@@ -394,6 +427,23 @@ window.__contrastAuditFinish = async function (imgBase64, time, candidates) {
394
427
 
395
428
  var ratio = +wcagRatio(compR, compG, compB, bgR, bgG, bgB).toFixed(2);
396
429
 
430
+ // A solid text stroke is part of the visible glyph paint. Use whichever
431
+ // of fill or stroke provides the stronger edge contrast, rather than
432
+ // failing readable outlined captions based on fill alone.
433
+ if (c.stroke) {
434
+ var stroke = c.stroke;
435
+ var strokeR = Math.round(stroke[0] * stroke[3] + bgR * (1 - stroke[3]));
436
+ var strokeG = Math.round(stroke[1] * stroke[3] + bgG * (1 - stroke[3]));
437
+ var strokeB = Math.round(stroke[2] * stroke[3] + bgB * (1 - stroke[3]));
438
+ var strokeRatio = +wcagRatio(strokeR, strokeG, strokeB, bgR, bgG, bgB).toFixed(2);
439
+ if (strokeRatio > ratio) {
440
+ compR = strokeR;
441
+ compG = strokeG;
442
+ compB = strokeB;
443
+ ratio = strokeRatio;
444
+ }
445
+ }
446
+
397
447
  out.push({
398
448
  time: time,
399
449
  selector: c.selector,
@@ -105,6 +105,10 @@
105
105
  return !!element.closest("[data-layout-allow-overflow]");
106
106
  }
107
107
 
108
+ function hasTextClipOptOut(element) {
109
+ return hasAllowOverflowFlag(element) || element.hasAttribute("data-layout-bleed");
110
+ }
111
+
108
112
  function opacityChain(element) {
109
113
  let opacity = 1;
110
114
  for (let current = element; current; current = current.parentElement) {
@@ -215,10 +219,10 @@
215
219
  const text = textContentFor(element, directOnly);
216
220
  if (!text) return false;
217
221
  if (directOnly) return true;
218
- for (const child of Array.from(element.children)) {
219
- if (isVisibleElement(child) && textContentFor(child)) return false;
220
- }
221
- return true;
222
+ // Aggregate text may come exclusively from descendants (including hidden
223
+ // captions). The container itself does not paint that text and must not be
224
+ // audited as though it did.
225
+ return textContentFor(element, true).length > 0;
222
226
  }
223
227
 
224
228
  function textClientRects(element, directOnly) {
@@ -394,6 +398,7 @@
394
398
  }
395
399
 
396
400
  function clippedTextIssue(element, time, tolerance) {
401
+ if (hasTextClipOptOut(element)) return null;
397
402
  const style = getComputedStyle(element);
398
403
  if (!clipsOverflow(style)) return null;
399
404
  const overflowX = element.scrollWidth - element.clientWidth;
@@ -431,9 +436,9 @@
431
436
  }
432
437
 
433
438
  function textOverflowIssues(element, root, rootRect, time, tolerance) {
434
- const textRect = textRectFor(element);
439
+ const textRect = textRectFor(element, true);
435
440
  if (!textRect) return [];
436
- const text = textContentFor(element);
441
+ const text = textContentFor(element, true);
437
442
  const selector = selectorFor(element);
438
443
  const issues = [];
439
444
 
@@ -455,7 +460,7 @@
455
460
  const containerOverflow = overflowFor(textRect, containerRect, tolerance, verticalTolerance);
456
461
  if (
457
462
  containerOverflow &&
458
- !hasAllowOverflowFlag(element) &&
463
+ !hasTextClipOptOut(element) &&
459
464
  !clippedByAncestor(element, container)
460
465
  ) {
461
466
  const style = elementStyle;
@@ -481,7 +486,7 @@
481
486
  }
482
487
 
483
488
  const canvasOverflow = overflowFor(textRect, rootRect, tolerance);
484
- if (canvasOverflow && !hasAllowOverflowFlag(element)) {
489
+ if (canvasOverflow && !hasTextClipOptOut(element)) {
485
490
  issues.push({
486
491
  code: "canvas_overflow",
487
492
  severity: "info",
@@ -566,7 +571,7 @@
566
571
  // (low colour alpha) is decorative and exempt, as are elements opted out with
567
572
  // data-layout-allow-overlap.
568
573
  function isSolidTextBlock(element) {
569
- if (!isVisibleElement(element) || !hasOwnTextCandidate(element)) return false;
574
+ if (!isVisibleElement(element) || !hasOwnTextCandidate(element, true)) return false;
570
575
  if (hasAllowOverlapFlag(element)) return false;
571
576
  return colorAlpha(getComputedStyle(element).color) >= 0.35;
572
577
  }
@@ -575,8 +580,9 @@
575
580
  const blocks = [];
576
581
  for (const element of Array.from(root.querySelectorAll("*"))) {
577
582
  if (!isSolidTextBlock(element)) continue;
578
- const rect = textRectFor(element);
579
- if (rect) blocks.push({ element, rect });
583
+ const rects = textClientRects(element, true);
584
+ const rect = textRectFor(element, true);
585
+ if (rect) blocks.push({ element, rect, rects });
580
586
  }
581
587
  return blocks;
582
588
  }
@@ -591,6 +597,18 @@
591
597
  return overlapX > 0 && overlapY > 0 ? overlapX * overlapY : 0;
592
598
  }
593
599
 
600
+ function rectsArea(rects) {
601
+ return rects.reduce((total, rect) => total + rectArea(rect), 0);
602
+ }
603
+
604
+ function fragmentIntersectionArea(a, b) {
605
+ let total = 0;
606
+ for (const aRect of a) {
607
+ for (const bRect of b) total += intersectionArea(aRect, bRect);
608
+ }
609
+ return total;
610
+ }
611
+
594
612
  function isNested(a, b) {
595
613
  return a.contains(b) || b.contains(a);
596
614
  }
@@ -626,8 +644,8 @@
626
644
  function overlapIssue(a, b, time) {
627
645
  if (isNested(a.element, b.element)) return null;
628
646
  if (isManagedFlowOverlap(a.element, b.element)) return null;
629
- const area = intersectionArea(a.rect, b.rect);
630
- if (area <= Math.min(rectArea(a.rect), rectArea(b.rect)) * 0.2) return null;
647
+ const area = fragmentIntersectionArea(a.rects, b.rects);
648
+ if (area <= Math.min(rectsArea(a.rects), rectsArea(b.rects)) * 0.2) return null;
631
649
  return {
632
650
  // Warning at the per-sample level: a single-sample overlap is usually an
633
651
  // entrance/exit transient (two blocks crossing mid-animation), not a real
@@ -730,13 +748,117 @@
730
748
 
731
749
  const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]);
732
750
  const FRAME_MEDIA_TAGS = new Set([...RASTER_TAGS, "SVG"]);
751
+ const imageAlphaCanvases = new WeakMap();
752
+
753
+ function objectPositionOffset(value, freeSpace) {
754
+ const token = String(value || "50%")
755
+ .trim()
756
+ .split(/\s+/)[0];
757
+ if (token === "left" || token === "top") return 0;
758
+ if (token === "right" || token === "bottom") return freeSpace;
759
+ if (token === "center") return freeSpace / 2;
760
+ if (token.endsWith("%")) return (freeSpace * parseFloat(token)) / 100;
761
+ const pixels = parseFloat(token);
762
+ return Number.isFinite(pixels) ? pixels : freeSpace / 2;
763
+ }
764
+
765
+ function objectPositionOffsets(value, freeX, freeY) {
766
+ const tokens = String(value || "50% 50%")
767
+ .trim()
768
+ .split(/\s+/)
769
+ .slice(0, 2);
770
+ let x = "50%";
771
+ let y = "50%";
772
+ if (tokens.length === 1) {
773
+ if (tokens[0] === "top" || tokens[0] === "bottom") y = tokens[0];
774
+ else x = tokens[0];
775
+ } else {
776
+ for (const token of tokens) {
777
+ if (token === "top" || token === "bottom") y = token;
778
+ else if (token === "left" || token === "right") x = token;
779
+ else if (x === "50%") x = token;
780
+ else y = token;
781
+ }
782
+ }
783
+ return { x: objectPositionOffset(x, freeX), y: objectPositionOffset(y, freeY) };
784
+ }
785
+
786
+ // Return the alpha painted by an <img> at a viewport point. `null` means the
787
+ // browser would not let us inspect the image (not loaded or cross-origin), in
788
+ // which case callers preserve the conservative opaque fallback.
789
+ function imageAlphaAt(element, x, y) {
790
+ const sourceWidth = element.naturalWidth;
791
+ const sourceHeight = element.naturalHeight;
792
+ const rect = element.getBoundingClientRect();
793
+ if (!sourceWidth || !sourceHeight || !rect.width || !rect.height) return null;
794
+
795
+ const style = getComputedStyle(element);
796
+ const fit = style.objectFit || "fill";
797
+ let scaleX = rect.width / sourceWidth;
798
+ let scaleY = rect.height / sourceHeight;
799
+ if (fit !== "fill") {
800
+ const contain = Math.min(scaleX, scaleY);
801
+ const cover = Math.max(scaleX, scaleY);
802
+ const scale =
803
+ fit === "cover"
804
+ ? cover
805
+ : fit === "none"
806
+ ? 1
807
+ : fit === "scale-down"
808
+ ? Math.min(1, contain)
809
+ : contain;
810
+ scaleX = scale;
811
+ scaleY = scale;
812
+ }
813
+
814
+ const paintedWidth = sourceWidth * scaleX;
815
+ const paintedHeight = sourceHeight * scaleY;
816
+ const offsets = objectPositionOffsets(
817
+ style.objectPosition,
818
+ rect.width - paintedWidth,
819
+ rect.height - paintedHeight,
820
+ );
821
+ const localX = x - rect.left - offsets.x;
822
+ const localY = y - rect.top - offsets.y;
823
+ if (localX < 0 || localY < 0 || localX >= paintedWidth || localY >= paintedHeight) return 0;
824
+
825
+ try {
826
+ let cached = imageAlphaCanvases.get(element);
827
+ const source = element.currentSrc || element.src;
828
+ if (
829
+ !cached ||
830
+ cached.width !== sourceWidth ||
831
+ cached.height !== sourceHeight ||
832
+ cached.source !== source
833
+ ) {
834
+ const canvas = document.createElement("canvas");
835
+ canvas.width = sourceWidth;
836
+ canvas.height = sourceHeight;
837
+ const context = canvas.getContext("2d", { willReadFrequently: true });
838
+ if (!context) return null;
839
+ context.drawImage(element, 0, 0, sourceWidth, sourceHeight);
840
+ cached = { context, width: sourceWidth, height: sourceHeight, source };
841
+ imageAlphaCanvases.set(element, cached);
842
+ }
843
+ const sourceX = Math.min(sourceWidth - 1, Math.max(0, Math.floor(localX / scaleX)));
844
+ const sourceY = Math.min(sourceHeight - 1, Math.max(0, Math.floor(localY / scaleY)));
845
+ return cached.context.getImageData(sourceX, sourceY, 1, 1).data[3] / 255;
846
+ } catch {
847
+ return null;
848
+ }
849
+ }
733
850
 
734
851
  // An element hides text beneath it when it paints opaque pixels at near-full
735
852
  // opacity: raster content (img/video/canvas), a background image, or a solid
736
853
  // background colour. Low-opacity overlays (grain, scrims) do not occlude.
737
- function isOpaqueOccluder(element) {
738
- if (opacityChain(element) < 0.6) return false;
854
+ function isOpaqueOccluder(element, x, y) {
855
+ const opacity = opacityChain(element);
856
+ if (opacity < 0.6) return false;
739
857
  if (IGNORE_TAGS.has(element.tagName)) return false;
858
+ if (element.tagName === "IMG") {
859
+ const alpha = imageAlphaAt(element, x, y);
860
+ if (alpha !== null) return alpha * opacity >= 0.6;
861
+ }
740
862
  if (RASTER_TAGS.has(element.tagName)) return true;
741
863
  return hasOpaqueBackground(getComputedStyle(element));
742
864
  }
@@ -798,7 +920,7 @@
798
920
  // Pair-specific exemptions excuse this hit only; keep walking for deeper occluders.
799
921
  if (sharedPreserve3d(element, hit)) continue;
800
922
  if (isCrossSceneTransitionOverlap(element, hit)) continue;
801
- if (isOpaqueOccluder(hit)) return hit;
923
+ if (isOpaqueOccluder(hit, x, y)) return hit;
802
924
  }
803
925
  return null;
804
926
  }
@@ -819,25 +941,32 @@
819
941
  return text.length > 0 && text.length <= ATOMIC_LABEL_MAX_CHARS && !/\s/.test(text);
820
942
  }
821
943
 
822
- // Sweep a grid across the text box (three rows, not just the mid-line, so
823
- // overlays covering only part of a multi-line block are caught). Unlike a
944
+ // Sweep a grid across each painted text fragment (three rows, not just the
945
+ // mid-line, so overlays covering only part of a multi-line block are caught).
946
+ // Sampling fragments instead of their union avoids probing empty line gaps.
947
+ // Unlike a
824
948
  // first-hit scan, this keeps sampling every point so it can report what
825
949
  // fraction of the box is actually covered — a corner nibble on a paragraph
826
950
  // reads very differently from a label buried under an overlay. Still
827
951
  // returns the first opaque element found, for `containerSelector`.
828
- function occlusionCoverage(element, textRect) {
952
+ function occlusionCoverage(element, textRects) {
829
953
  let occluder = null;
830
954
  let hits = 0;
831
- for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) {
832
- const y = textRect.top + textRect.height * yFraction;
833
- for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) {
834
- const hit = occluderAt(element, textRect.left + textRect.width * xFraction, y);
835
- if (!hit) continue;
836
- hits += 1;
837
- if (!occluder) occluder = hit;
955
+ for (const textRect of textRects) {
956
+ for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) {
957
+ const y = textRect.top + textRect.height * yFraction;
958
+ for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) {
959
+ const hit = occluderAt(element, textRect.left + textRect.width * xFraction, y);
960
+ if (!hit) continue;
961
+ hits += 1;
962
+ if (!occluder) occluder = hit;
963
+ }
838
964
  }
839
965
  }
840
- return { occluder, coveredFraction: round(hits / OCCLUSION_GRID_POINTS) };
966
+ return {
967
+ occluder,
968
+ coveredFraction: round(hits / (OCCLUSION_GRID_POINTS * textRects.length)),
969
+ };
841
970
  }
842
971
 
843
972
  // pointer-events:none hides elements from elementFromPoint — both probed text AND occluders.
@@ -874,10 +1003,14 @@
874
1003
  function occludedTextIssue(element, time) {
875
1004
  if (hasAllowOcclusionFlag(element)) return null;
876
1005
  if (!hasVisibleTextInk(element)) return null;
877
- const textRect = textRectFor(element);
1006
+ const textRect = textRectFor(element, true);
878
1007
  if (!textRect) return null;
879
- const text = textContentFor(element);
880
- const { occluder, coveredFraction } = occlusionCoverage(element, textRect);
1008
+ const textRects = textClientRects(element, true);
1009
+ const text = textContentFor(element, true);
1010
+ const { occluder, coveredFraction } = occlusionCoverage(
1011
+ element,
1012
+ textRects.length > 0 ? textRects : [textRect],
1013
+ );
881
1014
  if (!occluder) return null;
882
1015
  if (!isAtomicLabel(text) && coveredFraction < PROSE_COVERAGE_FLOOR) return null;
883
1016
  return {
@@ -907,9 +1040,9 @@
907
1040
  // paints the glyphs; a `background-clip: text` with no gradient/image and no
908
1041
  // opaque background-color paints nothing, so it stays reportable.
909
1042
  function invisibleTextIssue(element, time) {
910
- const textRect = textRectFor(element);
1043
+ const textRect = textRectFor(element, true);
911
1044
  if (!textRect) return null;
912
- const text = textContentFor(element);
1045
+ const text = textContentFor(element, true);
913
1046
  if (!text) return null;
914
1047
  const cs = getComputedStyle(element);
915
1048
  // Vendor computed-style props are read by property (camelCase), matching
@@ -1032,8 +1165,8 @@
1032
1165
  if (escapedElements.has(element)) continue;
1033
1166
  // Ownership is geometric and strict-mutex: any text breach past canvas_overflow's own
1034
1167
  // tolerance cedes the element to canvas_overflow; in-bounds text leaves the panel finding.
1035
- if (hasOwnTextCandidate(element)) {
1036
- const textRect = textRectFor(element);
1168
+ if (hasOwnTextCandidate(element, true)) {
1169
+ const textRect = textRectFor(element, true);
1037
1170
  if (textRect && overflowFor(textRect, rootRect, tolerance)) continue;
1038
1171
  }
1039
1172
  const rect = toRect(element.getBoundingClientRect());
@@ -1263,7 +1396,7 @@
1263
1396
  document.body;
1264
1397
  const rootRect = rootRectFor(root);
1265
1398
  const elements = Array.from(root.querySelectorAll("*")).filter((element) =>
1266
- isVisibleElement(element),
1399
+ isVisibleElement(element, 0.05),
1267
1400
  );
1268
1401
  const issues = [];
1269
1402