hyperframes 0.7.56 → 0.7.57

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.
@@ -131,6 +131,12 @@ window.__contrastAuditPrepare = function () {
131
131
  return !paintsAnyProbePoint(el, rect);
132
132
  }
133
133
 
134
+ function isIntentionallyOccluded(el, rect) {
135
+ if (typeof document.elementFromPoint !== "function") return false;
136
+ if (!el.closest || !el.closest("[data-layout-allow-occlusion]")) return false;
137
+ return !paintsAnyProbePoint(el, rect);
138
+ }
139
+
134
140
  var out = [];
135
141
  var restores = [];
136
142
  // Registered BEFORE the walk starts (not after it finishes) and pushed to
@@ -200,6 +206,10 @@ window.__contrastAuditPrepare = function () {
200
206
  if (rect.width < 8 || rect.height < 8) continue;
201
207
  if (rect.right <= 0 || rect.bottom <= 0) continue;
202
208
  if (isClippedAway(el, rect)) continue;
209
+ // The layout audit's explicit occlusion opt-out means this text is allowed
210
+ // to sit behind another scene. Skip contrast only while every probe point
211
+ // is actually covered; the same copy is audited normally when visible.
212
+ if (isIntentionallyOccluded(el, rect)) continue;
203
213
 
204
214
  // For SVG text, `fill` is the paint that's actually rendered; `color` is
205
215
  // frequently just the inherited/initial value and unrelated to what's on
@@ -546,7 +546,9 @@
546
546
  }
547
547
 
548
548
  function alphaFromParts(parts, index) {
549
- return parts.length > index ? parsePx(parts[index]) : 1;
549
+ if (parts.length <= index) return 1;
550
+ const raw = parts[index].trim();
551
+ return raw.endsWith("%") ? parsePx(raw) / 100 : parsePx(raw);
550
552
  }
551
553
 
552
554
  // Alpha of a CSS colour; 1 when no alpha component is present. Handles both
@@ -659,9 +661,71 @@
659
661
  }
660
662
 
661
663
  function hasOpaqueBackground(style) {
662
- if (style.backgroundImage && style.backgroundImage !== "none") return true;
663
- if (isTransparentColor(style.backgroundColor)) return false;
664
- return colorAlpha(style.backgroundColor) > 0.6;
664
+ let imageAlpha = 0;
665
+ if (style.backgroundImage && style.backgroundImage !== "none") {
666
+ if (style.backgroundImage.includes("url(")) return true;
667
+ // A gradient only occludes as much as its colours — a 4%-alpha grid/scrim must not count.
668
+ imageAlpha = gradientLayersAlpha(style.backgroundImage);
669
+ }
670
+ const colorValue = isTransparentColor(style.backgroundColor)
671
+ ? 0
672
+ : colorAlpha(style.backgroundColor);
673
+ // Layers composite: a 0.5 gradient over a 0.5 background colour paints at ~0.75.
674
+ return 1 - (1 - imageAlpha) * (1 - colorValue) > 0.6;
675
+ }
676
+
677
+ // background-image layers stack: two 0.5-alpha gradients paint at 1-(1-.5)^2 = .75.
678
+ function gradientLayersAlpha(backgroundImage) {
679
+ let combined = 0;
680
+ for (const layer of splitTopLevelCommas(backgroundImage)) {
681
+ combined = 1 - (1 - combined) * (1 - gradientMaxAlpha(layer));
682
+ }
683
+ return combined;
684
+ }
685
+
686
+ function splitTopLevelCommas(value) {
687
+ const parts = [];
688
+ let depth = 0;
689
+ let start = 0;
690
+ for (let i = 0; i < value.length; i += 1) {
691
+ const ch = value[i];
692
+ if (ch === "(") depth += 1;
693
+ else if (ch === ")") depth -= 1;
694
+ else if (ch === "," && depth === 0) {
695
+ parts.push(value.slice(start, i));
696
+ start = i + 1;
697
+ }
698
+ }
699
+ parts.push(value.slice(start));
700
+ return parts;
701
+ }
702
+
703
+ function gradientMaxAlpha(backgroundImage) {
704
+ // Any colour we cannot score (oklch/lab/named-colour fns/...) counts as opaque so real panels keep flagging.
705
+ const known = backgroundImage
706
+ .replace(/(?:repeating-)?(?:linear|radial|conic)-gradient\(/gi, "(")
707
+ .replace(/rgba?\([^)]*\)/gi, "");
708
+ if (/[a-z][a-z-]+\(/i.test(known)) return 1;
709
+ const colors = backgroundImage.match(/rgba?\([^)]*\)|#[0-9a-fA-F]{3,8}\b|\btransparent\b/g);
710
+ if (!colors) return 1;
711
+ let max = 0;
712
+ for (const color of colors) {
713
+ if (color === "transparent") continue;
714
+ if (color.startsWith("#")) {
715
+ const hex = color.slice(1);
716
+ max = Math.max(
717
+ max,
718
+ hex.length === 4
719
+ ? parseInt(hex[3] + hex[3], 16) / 255
720
+ : hex.length === 8
721
+ ? parseInt(hex.slice(6), 16) / 255
722
+ : 1,
723
+ );
724
+ } else {
725
+ max = Math.max(max, colorAlpha(color));
726
+ }
727
+ }
728
+ return max;
665
729
  }
666
730
 
667
731
  const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]);
@@ -722,13 +786,21 @@
722
786
  // part of a transient crossfade overlap.
723
787
  // fallow-ignore-next-line complexity
724
788
  function occluderAt(element, x, y) {
725
- if (typeof document.elementFromPoint !== "function") return null;
726
- const hit = document.elementFromPoint(x, y);
727
- if (!isForeignElement(element, hit)) return null;
728
- if (sharedPreserve3d(element, hit)) return null;
729
- if (!isOpaqueOccluder(hit)) return null;
730
- if (isCrossSceneTransitionOverlap(element, hit)) return null;
731
- return hit;
789
+ // Walk the paint-ordered stack: a transparent layer on top must not mask an opaque one below it.
790
+ const stack =
791
+ typeof document.elementsFromPoint === "function"
792
+ ? document.elementsFromPoint(x, y)
793
+ : typeof document.elementFromPoint === "function"
794
+ ? [document.elementFromPoint(x, y)].filter(Boolean)
795
+ : [];
796
+ for (const hit of stack) {
797
+ if (!isForeignElement(element, hit)) return null;
798
+ // Pair-specific exemptions excuse this hit only; keep walking for deeper occluders.
799
+ if (sharedPreserve3d(element, hit)) continue;
800
+ if (isCrossSceneTransitionOverlap(element, hit)) continue;
801
+ if (isOpaqueOccluder(hit)) return hit;
802
+ }
803
+ return null;
732
804
  }
733
805
 
734
806
  const OCCLUSION_PROBE_Y_FRACTIONS = [0.25, 0.5, 0.75];
@@ -768,6 +840,32 @@
768
840
  return { occluder, coveredFraction: round(hits / OCCLUSION_GRID_POINTS) };
769
841
  }
770
842
 
843
+ // pointer-events:none hides elements from elementFromPoint — both probed text AND occluders.
844
+ function restoreHitTesting(root) {
845
+ const restores = [];
846
+ for (const node of [root, ...root.querySelectorAll("*")]) {
847
+ if (getComputedStyle(node).pointerEvents !== "none") continue;
848
+ const previous = node.style.getPropertyValue("pointer-events");
849
+ const priority = node.style.getPropertyPriority("pointer-events");
850
+ node.style.setProperty("pointer-events", "auto", "important");
851
+ restores.push(() => {
852
+ if (previous) node.style.setProperty("pointer-events", previous, priority);
853
+ else node.style.removeProperty("pointer-events");
854
+ });
855
+ }
856
+ return () => restores.forEach((restore) => restore());
857
+ }
858
+
859
+ // No text ink is on screen while every non-whitespace text node sits at ~0 opacity (entrance not started).
860
+ function hasVisibleTextInk(element) {
861
+ const nodes = [element, ...element.querySelectorAll("*")];
862
+ for (const node of nodes) {
863
+ if (!directTextNodes(node).some((textNode) => textNode.textContent.trim())) continue;
864
+ if (opacityChain(node) >= 0.05) return true;
865
+ }
866
+ return false;
867
+ }
868
+
771
869
  // Catches the blind spot the overflow checks miss: text that fits its box
772
870
  // perfectly but is covered by a later sibling/overlay. An atomic label
773
871
  // (short, no whitespace) flags at any coverage; ordinary prose only flags
@@ -775,6 +873,7 @@
775
873
  // cover on a paragraph is usually a styling artifact, not a reading defect.
776
874
  function occludedTextIssue(element, time) {
777
875
  if (hasAllowOcclusionFlag(element)) return null;
876
+ if (!hasVisibleTextInk(element)) return null;
778
877
  const textRect = textRectFor(element);
779
878
  if (!textRect) return null;
780
879
  const text = textContentFor(element);
@@ -841,6 +940,255 @@
841
940
  };
842
941
  }
843
942
 
943
+ // Attachment allowance: callouts/tooltips legitimately hang near (not inside) their anchor.
944
+ const ESCAPE_INTERSECTION_FRACTION = 0.3;
945
+ const ESCAPE_MIN_CHILD_AREA = 2500;
946
+
947
+ function edgeGap(child, parent) {
948
+ const dx = Math.max(parent.left - child.right, 0, child.left - parent.right);
949
+ const dy = Math.max(parent.top - child.bottom, 0, child.top - parent.bottom);
950
+ return Math.sqrt(dx * dx + dy * dy);
951
+ }
952
+
953
+ // An absolute element rendering far outside its offset parent was positioned in the wrong frame.
954
+ function escapedContainerIssues(root, time) {
955
+ const issues = [];
956
+ const flagged = new Set();
957
+ for (const element of Array.from(root.querySelectorAll("*"))) {
958
+ if (!isVisibleElement(element) || hasAllowOverflowFlag(element)) continue;
959
+ if (getComputedStyle(element).position !== "absolute") continue;
960
+ const parent = element.offsetParent;
961
+ if (!parent || parent === document.body || parent === root || !isVisibleElement(parent)) {
962
+ continue;
963
+ }
964
+ const childRect = toRect(element.getBoundingClientRect());
965
+ if (rectArea(childRect) < ESCAPE_MIN_CHILD_AREA) continue;
966
+ const parentRect = toRect(parent.getBoundingClientRect());
967
+ const visible = intersectionArea(childRect, parentRect);
968
+ if (visible >= rectArea(childRect) * ESCAPE_INTERSECTION_FRACTION) continue;
969
+ // Fully detached but hugging the parent = a callout/tooltip; touching yet mostly outside = drift.
970
+ const allowance = Math.max(48, Math.min(childRect.width, childRect.height) / 2);
971
+ if (visible <= 0 && edgeGap(childRect, parentRect) <= allowance) continue;
972
+ flagged.add(element);
973
+ issues.push({
974
+ code: "escaped_container",
975
+ severity: "warning",
976
+ time,
977
+ selector: selectorFor(element),
978
+ containerSelector: selectorFor(parent),
979
+ text: textContentFor(element),
980
+ message:
981
+ "Positioned element renders far outside its offset parent — its coordinates were likely computed in a different frame (canvas/viewport pixels).",
982
+ rect: childRect,
983
+ containerRect: parentRect,
984
+ fixHint:
985
+ "Compute left/top in the offset parent's frame (subtract its rect), or mark intentional placement with data-layout-allow-overflow.",
986
+ });
987
+ }
988
+ return { issues, flagged };
989
+ }
990
+
991
+ // A gradient reads as content when any stop is solid; all-translucent stops are glows/vignettes.
992
+ function gradientHasOpaqueStop(image) {
993
+ const colors = image.match(/rgba?\([^)]+\)|#[0-9a-f]{3,8}|\btransparent\b/gi) || [];
994
+ return colors.some((color) => !/^transparent$/i.test(color) && colorAlpha(color) >= 0.6);
995
+ }
996
+
997
+ function isPaintedPanel(element) {
998
+ if (FRAME_MEDIA_TAGS.has(element.tagName.toUpperCase())) return false;
999
+ const style = getComputedStyle(element);
1000
+ const image = style.backgroundImage || "none";
1001
+ if (image.includes("url(")) return true;
1002
+ if (image !== "none" && gradientHasOpaqueStop(image)) return true;
1003
+ if (!isTransparentColor(style.backgroundColor) && colorAlpha(style.backgroundColor) > 0.05) {
1004
+ return true;
1005
+ }
1006
+ return (
1007
+ parsePx(style.borderTopWidth) +
1008
+ parsePx(style.borderRightWidth) +
1009
+ parsePx(style.borderBottomWidth) +
1010
+ parsePx(style.borderLeftWidth) >
1011
+ 0
1012
+ );
1013
+ }
1014
+
1015
+ // Canvas-breach floor: entrance nudges stay quiet; matches the connector threshold scale.
1016
+ const PANEL_BREACH_FLOOR_PX = 24;
1017
+ const PANEL_BREACH_FLOOR_FRACTION = 0.025;
1018
+ // A hero-sized panel stuck on the edge is drift; a small painted bleed is usually decoration.
1019
+ const PANEL_HERO_AREA_FRACTION = 0.1;
1020
+
1021
+ // Painted panels breaching the canvas: text is canvas_overflow's, media is frame_out_of_frame's, panels were nobody's.
1022
+ function panelOutOfCanvasIssues(root, rootRect, time, tolerance, escapedElements) {
1023
+ const issues = [];
1024
+ const floor = Math.max(
1025
+ PANEL_BREACH_FLOOR_PX,
1026
+ Math.min(rootRect.width, rootRect.height) * PANEL_BREACH_FLOOR_FRACTION,
1027
+ );
1028
+ const rootArea = rectArea(rootRect);
1029
+ const flagged = new Set();
1030
+ for (const element of Array.from(root.querySelectorAll("*"))) {
1031
+ if (!isVisibleElement(element) || hasAllowOverflowFlag(element)) continue;
1032
+ if (escapedElements.has(element)) continue;
1033
+ // Ownership is geometric and strict-mutex: any text breach past canvas_overflow's own
1034
+ // tolerance cedes the element to canvas_overflow; in-bounds text leaves the panel finding.
1035
+ if (hasOwnTextCandidate(element)) {
1036
+ const textRect = textRectFor(element);
1037
+ if (textRect && overflowFor(textRect, rootRect, tolerance)) continue;
1038
+ }
1039
+ const rect = toRect(element.getBoundingClientRect());
1040
+ if (rectArea(rect) >= rootArea * 0.95) continue;
1041
+ // Fully off-canvas paints nothing — that is a parked entrance, not drift.
1042
+ if (intersectionArea(rect, rootRect) <= 0) continue;
1043
+ const overflow = overflowFor(rect, rootRect, floor);
1044
+ if (!overflow || !isPaintedPanel(element)) continue;
1045
+ if (element.parentElement && flagged.has(element.parentElement)) {
1046
+ flagged.add(element);
1047
+ continue;
1048
+ }
1049
+ flagged.add(element);
1050
+ issues.push({
1051
+ code: "panel_out_of_canvas",
1052
+ severity: rectArea(rect) >= rootArea * PANEL_HERO_AREA_FRACTION ? "warning" : "info",
1053
+ time,
1054
+ selector: selectorFor(element),
1055
+ containerSelector: selectorFor(root),
1056
+ text: textContentFor(element).slice(0, 48),
1057
+ message: "Painted panel extends outside the composition canvas.",
1058
+ rect,
1059
+ containerRect: rootRect,
1060
+ overflow,
1061
+ fixHint:
1062
+ "Move the panel inward, or mark intentional off-canvas animation with data-layout-allow-overflow.",
1063
+ });
1064
+ }
1065
+ return issues;
1066
+ }
1067
+
1068
+ const CONNECTOR_NAME = /\b(conn(ector)?|arrow|edge|link|flow|wire)\b/i;
1069
+ const CONNECTOR_SKIP_CONTAINERS = "defs, marker, clipPath, mask, symbol, pattern";
1070
+
1071
+ function connectorNameFor(element) {
1072
+ const className =
1073
+ typeof element.className === "string" ? element.className : element.className.baseVal || "";
1074
+ return `${element.id || ""} ${className}`;
1075
+ }
1076
+
1077
+ // Screen-space endpoints via the browser: getScreenCTM covers viewBox, preserveAspectRatio and group transforms.
1078
+ function pathScreenEndpoints(svg, path) {
1079
+ if (
1080
+ typeof path.getTotalLength !== "function" ||
1081
+ typeof path.getPointAtLength !== "function" ||
1082
+ typeof path.getScreenCTM !== "function" ||
1083
+ typeof svg.createSVGPoint !== "function"
1084
+ ) {
1085
+ return null;
1086
+ }
1087
+ let total;
1088
+ try {
1089
+ total = path.getTotalLength();
1090
+ } catch {
1091
+ return null;
1092
+ }
1093
+ if (!Number.isFinite(total) || total <= 0) return null;
1094
+ const matrix = path.getScreenCTM();
1095
+ if (!matrix) return null;
1096
+ const toScreen = (local) => {
1097
+ const point = svg.createSVGPoint();
1098
+ point.x = local.x;
1099
+ point.y = local.y;
1100
+ const mapped = point.matrixTransform(matrix);
1101
+ return { x: mapped.x, y: mapped.y };
1102
+ };
1103
+ return {
1104
+ start: toScreen(path.getPointAtLength(0)),
1105
+ end: toScreen(path.getPointAtLength(total)),
1106
+ };
1107
+ }
1108
+
1109
+ function distanceToRect(point, rect) {
1110
+ const dx = Math.max(rect.left - point.x, 0, point.x - rect.right);
1111
+ const dy = Math.max(rect.top - point.y, 0, point.y - rect.bottom);
1112
+ return Math.sqrt(dx * dx + dy * dy);
1113
+ }
1114
+
1115
+ // Solid, compact elements a connector could plausibly anchor to.
1116
+ function connectorAnchorRects(root, rootRect) {
1117
+ const compact = [];
1118
+ const painted = [];
1119
+ const rootArea = rectArea(rootRect);
1120
+ for (const element of Array.from(root.querySelectorAll("*"))) {
1121
+ // Known blind spot: anchors living inside an SVG (<image>, foreignObject) are not counted.
1122
+ if (element.closest("svg") || !isVisibleElement(element)) continue;
1123
+ const opaque =
1124
+ RASTER_TAGS.has(element.tagName) || hasOpaqueBackground(getComputedStyle(element));
1125
+ if (!opaque && !textContentFor(element)) continue;
1126
+ const rect = toRect(element.getBoundingClientRect());
1127
+ const area = rectArea(rect);
1128
+ if (area < 400) continue;
1129
+ // Containment tier: large opaque targets only — a text-bearing wrapper contains its own diagram's endpoints.
1130
+ if (opaque && area <= rootArea * 0.6) painted.push({ rect, element });
1131
+ if (area <= rootArea * 0.15) compact.push(rect);
1132
+ }
1133
+ return { compact, painted };
1134
+ }
1135
+
1136
+ function isConnectorPath(svg, path) {
1137
+ if (path.hasAttribute("marker-start") || path.hasAttribute("marker-end")) return true;
1138
+ return (
1139
+ CONNECTOR_NAME.test(connectorNameFor(svg)) || CONNECTOR_NAME.test(connectorNameFor(path))
1140
+ );
1141
+ }
1142
+
1143
+ // A connector whose BOTH endpoints land far from every anchorable element was drawn in the wrong frame.
1144
+ // min over the two endpoints is intentional: a half-attached connector is a design choice, not frame drift.
1145
+ function connectorDetachmentIssues(root, rootRect, time) {
1146
+ const issues = [];
1147
+ let anchors = null;
1148
+ const threshold = Math.max(32, Math.min(rootRect.width, rootRect.height) * 0.02);
1149
+ for (const svg of Array.from(root.querySelectorAll("svg"))) {
1150
+ if (!isVisibleElement(svg) || hasAllowOverflowFlag(svg)) continue;
1151
+ for (const path of Array.from(svg.querySelectorAll("path"))) {
1152
+ if (path.closest(CONNECTOR_SKIP_CONTAINERS)) continue;
1153
+ if (!isConnectorPath(svg, path)) continue;
1154
+ const endpoints = pathScreenEndpoints(svg, path);
1155
+ if (!endpoints) continue;
1156
+ if (anchors === null) anchors = connectorAnchorRects(root, rootRect);
1157
+ if (anchors.compact.length < 2) return issues;
1158
+ const attached = (point) =>
1159
+ anchors.painted.some(
1160
+ (anchor) => !anchor.element.contains(svg) && distanceToRect(point, anchor.rect) === 0,
1161
+ ) || anchors.compact.some((rect) => distanceToRect(point, rect) <= threshold);
1162
+ if (attached(endpoints.start) || attached(endpoints.end)) continue;
1163
+ const gap = Math.round(
1164
+ Math.min(
1165
+ Math.min(...anchors.compact.map((rect) => distanceToRect(endpoints.start, rect))),
1166
+ Math.min(...anchors.compact.map((rect) => distanceToRect(endpoints.end, rect))),
1167
+ ),
1168
+ );
1169
+ issues.push({
1170
+ code: "connector_detached",
1171
+ severity: "warning",
1172
+ time,
1173
+ selector: selectorFor(path),
1174
+ containerSelector: selectorFor(svg),
1175
+ message: `Connector path endpoints are ${gap}px from the nearest anchorable element — measured coordinates were likely drawn into an SVG with a different origin.`,
1176
+ rect: toRect({
1177
+ left: Math.min(endpoints.start.x, endpoints.end.x),
1178
+ top: Math.min(endpoints.start.y, endpoints.end.y),
1179
+ right: Math.max(endpoints.start.x, endpoints.end.x),
1180
+ bottom: Math.max(endpoints.start.y, endpoints.end.y),
1181
+ width: Math.abs(endpoints.end.x - endpoints.start.x),
1182
+ height: Math.abs(endpoints.end.y - endpoints.start.y),
1183
+ }),
1184
+ fixHint:
1185
+ "Subtract the SVG's own rect when converting measured coordinates, and keep the SVG a direct child of the stage.",
1186
+ });
1187
+ }
1188
+ }
1189
+ return issues;
1190
+ }
1191
+
844
1192
  function candidateAnchor(element) {
845
1193
  const dataAttributes = {};
846
1194
  for (const attribute of Array.from(element.attributes)) {
@@ -919,19 +1267,28 @@
919
1267
  );
920
1268
  const issues = [];
921
1269
 
922
- for (const element of elements) {
923
- if (!hasOwnTextCandidate(element)) continue;
924
- const clipped = clippedTextIssue(element, time, tolerance);
925
- if (clipped) issues.push(clipped);
926
- issues.push(...textOverflowIssues(element, root, rootRect, time, tolerance));
927
- const occluded = occludedTextIssue(element, time);
928
- if (occluded) issues.push(occluded);
929
- const invisible = invisibleTextIssue(element, time);
930
- if (invisible) issues.push(invisible);
1270
+ const restoreHits = restoreHitTesting(root);
1271
+ try {
1272
+ for (const element of elements) {
1273
+ if (!hasOwnTextCandidate(element)) continue;
1274
+ const clipped = clippedTextIssue(element, time, tolerance);
1275
+ if (clipped) issues.push(clipped);
1276
+ issues.push(...textOverflowIssues(element, root, rootRect, time, tolerance));
1277
+ const occluded = occludedTextIssue(element, time);
1278
+ if (occluded) issues.push(occluded);
1279
+ const invisible = invisibleTextIssue(element, time);
1280
+ if (invisible) issues.push(invisible);
1281
+ }
1282
+ } finally {
1283
+ restoreHits();
931
1284
  }
932
1285
 
933
1286
  issues.push(...containerOverflowIssues(root, time, tolerance));
934
1287
  issues.push(...contentOverlapIssues(root, time));
1288
+ const escaped = escapedContainerIssues(root, time);
1289
+ issues.push(...escaped.issues);
1290
+ issues.push(...panelOutOfCanvasIssues(root, rootRect, time, tolerance, escaped.flagged));
1291
+ issues.push(...connectorDetachmentIssues(root, rootRect, time));
935
1292
  return issues;
936
1293
  };
937
1294