hyperframes 0.7.56 → 0.7.58

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 (29) hide show
  1. package/dist/cli.js +4595 -3123
  2. package/dist/commands/contrast-audit.browser.js +79 -19
  3. package/dist/commands/layout-audit.browser.js +490 -24
  4. package/dist/hyperframe-runtime.js +50 -30
  5. package/dist/hyperframe.manifest.json +1 -1
  6. package/dist/hyperframe.runtime.iife.js +50 -30
  7. package/dist/hyperframes-player.global.js +2 -2
  8. package/dist/hyperframes-slideshow.global.js +25 -25
  9. package/dist/skills/hyperframes-cli/SKILL.md +41 -12
  10. package/dist/skills/hyperframes-cli/references/cloud.md +123 -0
  11. package/dist/skills/hyperframes-cli/references/lint-validate-inspect.md +1 -1
  12. package/dist/skills/hyperframes-cli/references/preview-render.md +3 -3
  13. package/dist/studio/assets/hyperframes-player-CtTDO63S.js +459 -0
  14. package/dist/studio/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
  15. package/dist/studio/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
  16. package/dist/studio/assets/{index-C4csZims.js → index-DeQPzqwH.js} +1 -1
  17. package/dist/studio/assets/index-uahwWkgw.css +1 -0
  18. package/dist/studio/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  19. package/dist/studio/chunk-OBAG3GWK.js.map +1 -0
  20. package/dist/studio/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  21. package/dist/studio/index.d.ts +41 -4
  22. package/dist/studio/index.html +2 -2
  23. package/dist/studio/index.js +7436 -5423
  24. package/dist/studio/index.js.map +1 -1
  25. package/package.json +2 -1
  26. package/dist/studio/assets/hyperframes-player-BBrKzTGd.js +0 -459
  27. package/dist/studio/assets/index-D-GyYi2d.css +0 -1
  28. package/dist/studio/chunk-5QSIMBEJ.js.map +0 -1
  29. /package/dist/studio/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -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) {
@@ -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;
@@ -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",
@@ -546,7 +551,9 @@
546
551
  }
547
552
 
548
553
  function alphaFromParts(parts, index) {
549
- return parts.length > index ? parsePx(parts[index]) : 1;
554
+ if (parts.length <= index) return 1;
555
+ const raw = parts[index].trim();
556
+ return raw.endsWith("%") ? parsePx(raw) / 100 : parsePx(raw);
550
557
  }
551
558
 
552
559
  // Alpha of a CSS colour; 1 when no alpha component is present. Handles both
@@ -659,20 +666,186 @@
659
666
  }
660
667
 
661
668
  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;
669
+ let imageAlpha = 0;
670
+ if (style.backgroundImage && style.backgroundImage !== "none") {
671
+ if (style.backgroundImage.includes("url(")) return true;
672
+ // A gradient only occludes as much as its colours — a 4%-alpha grid/scrim must not count.
673
+ imageAlpha = gradientLayersAlpha(style.backgroundImage);
674
+ }
675
+ const colorValue = isTransparentColor(style.backgroundColor)
676
+ ? 0
677
+ : colorAlpha(style.backgroundColor);
678
+ // Layers composite: a 0.5 gradient over a 0.5 background colour paints at ~0.75.
679
+ return 1 - (1 - imageAlpha) * (1 - colorValue) > 0.6;
680
+ }
681
+
682
+ // background-image layers stack: two 0.5-alpha gradients paint at 1-(1-.5)^2 = .75.
683
+ function gradientLayersAlpha(backgroundImage) {
684
+ let combined = 0;
685
+ for (const layer of splitTopLevelCommas(backgroundImage)) {
686
+ combined = 1 - (1 - combined) * (1 - gradientMaxAlpha(layer));
687
+ }
688
+ return combined;
689
+ }
690
+
691
+ function splitTopLevelCommas(value) {
692
+ const parts = [];
693
+ let depth = 0;
694
+ let start = 0;
695
+ for (let i = 0; i < value.length; i += 1) {
696
+ const ch = value[i];
697
+ if (ch === "(") depth += 1;
698
+ else if (ch === ")") depth -= 1;
699
+ else if (ch === "," && depth === 0) {
700
+ parts.push(value.slice(start, i));
701
+ start = i + 1;
702
+ }
703
+ }
704
+ parts.push(value.slice(start));
705
+ return parts;
706
+ }
707
+
708
+ function gradientMaxAlpha(backgroundImage) {
709
+ // Any colour we cannot score (oklch/lab/named-colour fns/...) counts as opaque so real panels keep flagging.
710
+ const known = backgroundImage
711
+ .replace(/(?:repeating-)?(?:linear|radial|conic)-gradient\(/gi, "(")
712
+ .replace(/rgba?\([^)]*\)/gi, "");
713
+ if (/[a-z][a-z-]+\(/i.test(known)) return 1;
714
+ const colors = backgroundImage.match(/rgba?\([^)]*\)|#[0-9a-fA-F]{3,8}\b|\btransparent\b/g);
715
+ if (!colors) return 1;
716
+ let max = 0;
717
+ for (const color of colors) {
718
+ if (color === "transparent") continue;
719
+ if (color.startsWith("#")) {
720
+ const hex = color.slice(1);
721
+ max = Math.max(
722
+ max,
723
+ hex.length === 4
724
+ ? parseInt(hex[3] + hex[3], 16) / 255
725
+ : hex.length === 8
726
+ ? parseInt(hex.slice(6), 16) / 255
727
+ : 1,
728
+ );
729
+ } else {
730
+ max = Math.max(max, colorAlpha(color));
731
+ }
732
+ }
733
+ return max;
665
734
  }
666
735
 
667
736
  const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]);
668
737
  const FRAME_MEDIA_TAGS = new Set([...RASTER_TAGS, "SVG"]);
738
+ const imageAlphaCanvases = new WeakMap();
739
+
740
+ function objectPositionOffset(value, freeSpace) {
741
+ const token = String(value || "50%")
742
+ .trim()
743
+ .split(/\s+/)[0];
744
+ if (token === "left" || token === "top") return 0;
745
+ if (token === "right" || token === "bottom") return freeSpace;
746
+ if (token === "center") return freeSpace / 2;
747
+ if (token.endsWith("%")) return (freeSpace * parseFloat(token)) / 100;
748
+ const pixels = parseFloat(token);
749
+ return Number.isFinite(pixels) ? pixels : freeSpace / 2;
750
+ }
751
+
752
+ function objectPositionOffsets(value, freeX, freeY) {
753
+ const tokens = String(value || "50% 50%")
754
+ .trim()
755
+ .split(/\s+/)
756
+ .slice(0, 2);
757
+ let x = "50%";
758
+ let y = "50%";
759
+ if (tokens.length === 1) {
760
+ if (tokens[0] === "top" || tokens[0] === "bottom") y = tokens[0];
761
+ else x = tokens[0];
762
+ } else {
763
+ for (const token of tokens) {
764
+ if (token === "top" || token === "bottom") y = token;
765
+ else if (token === "left" || token === "right") x = token;
766
+ else if (x === "50%") x = token;
767
+ else y = token;
768
+ }
769
+ }
770
+ return { x: objectPositionOffset(x, freeX), y: objectPositionOffset(y, freeY) };
771
+ }
772
+
773
+ // Return the alpha painted by an <img> at a viewport point. `null` means the
774
+ // browser would not let us inspect the image (not loaded or cross-origin), in
775
+ // which case callers preserve the conservative opaque fallback.
776
+ function imageAlphaAt(element, x, y) {
777
+ const sourceWidth = element.naturalWidth;
778
+ const sourceHeight = element.naturalHeight;
779
+ const rect = element.getBoundingClientRect();
780
+ if (!sourceWidth || !sourceHeight || !rect.width || !rect.height) return null;
781
+
782
+ const style = getComputedStyle(element);
783
+ const fit = style.objectFit || "fill";
784
+ let scaleX = rect.width / sourceWidth;
785
+ let scaleY = rect.height / sourceHeight;
786
+ if (fit !== "fill") {
787
+ const contain = Math.min(scaleX, scaleY);
788
+ const cover = Math.max(scaleX, scaleY);
789
+ const scale =
790
+ fit === "cover"
791
+ ? cover
792
+ : fit === "none"
793
+ ? 1
794
+ : fit === "scale-down"
795
+ ? Math.min(1, contain)
796
+ : contain;
797
+ scaleX = scale;
798
+ scaleY = scale;
799
+ }
800
+
801
+ const paintedWidth = sourceWidth * scaleX;
802
+ const paintedHeight = sourceHeight * scaleY;
803
+ const offsets = objectPositionOffsets(
804
+ style.objectPosition,
805
+ rect.width - paintedWidth,
806
+ rect.height - paintedHeight,
807
+ );
808
+ const localX = x - rect.left - offsets.x;
809
+ const localY = y - rect.top - offsets.y;
810
+ if (localX < 0 || localY < 0 || localX >= paintedWidth || localY >= paintedHeight) return 0;
811
+
812
+ try {
813
+ let cached = imageAlphaCanvases.get(element);
814
+ const source = element.currentSrc || element.src;
815
+ if (
816
+ !cached ||
817
+ cached.width !== sourceWidth ||
818
+ cached.height !== sourceHeight ||
819
+ cached.source !== source
820
+ ) {
821
+ const canvas = document.createElement("canvas");
822
+ canvas.width = sourceWidth;
823
+ canvas.height = sourceHeight;
824
+ const context = canvas.getContext("2d", { willReadFrequently: true });
825
+ if (!context) return null;
826
+ context.drawImage(element, 0, 0, sourceWidth, sourceHeight);
827
+ cached = { context, width: sourceWidth, height: sourceHeight, source };
828
+ imageAlphaCanvases.set(element, cached);
829
+ }
830
+ const sourceX = Math.min(sourceWidth - 1, Math.max(0, Math.floor(localX / scaleX)));
831
+ const sourceY = Math.min(sourceHeight - 1, Math.max(0, Math.floor(localY / scaleY)));
832
+ return cached.context.getImageData(sourceX, sourceY, 1, 1).data[3] / 255;
833
+ } catch {
834
+ return null;
835
+ }
836
+ }
669
837
 
670
838
  // An element hides text beneath it when it paints opaque pixels at near-full
671
839
  // opacity: raster content (img/video/canvas), a background image, or a solid
672
840
  // background colour. Low-opacity overlays (grain, scrims) do not occlude.
673
- function isOpaqueOccluder(element) {
674
- if (opacityChain(element) < 0.6) return false;
841
+ function isOpaqueOccluder(element, x, y) {
842
+ const opacity = opacityChain(element);
843
+ if (opacity < 0.6) return false;
675
844
  if (IGNORE_TAGS.has(element.tagName)) return false;
845
+ if (element.tagName === "IMG") {
846
+ const alpha = imageAlphaAt(element, x, y);
847
+ if (alpha !== null) return alpha * opacity >= 0.6;
848
+ }
676
849
  if (RASTER_TAGS.has(element.tagName)) return true;
677
850
  return hasOpaqueBackground(getComputedStyle(element));
678
851
  }
@@ -722,13 +895,21 @@
722
895
  // part of a transient crossfade overlap.
723
896
  // fallow-ignore-next-line complexity
724
897
  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;
898
+ // Walk the paint-ordered stack: a transparent layer on top must not mask an opaque one below it.
899
+ const stack =
900
+ typeof document.elementsFromPoint === "function"
901
+ ? document.elementsFromPoint(x, y)
902
+ : typeof document.elementFromPoint === "function"
903
+ ? [document.elementFromPoint(x, y)].filter(Boolean)
904
+ : [];
905
+ for (const hit of stack) {
906
+ if (!isForeignElement(element, hit)) return null;
907
+ // Pair-specific exemptions excuse this hit only; keep walking for deeper occluders.
908
+ if (sharedPreserve3d(element, hit)) continue;
909
+ if (isCrossSceneTransitionOverlap(element, hit)) continue;
910
+ if (isOpaqueOccluder(hit, x, y)) return hit;
911
+ }
912
+ return null;
732
913
  }
733
914
 
734
915
  const OCCLUSION_PROBE_Y_FRACTIONS = [0.25, 0.5, 0.75];
@@ -768,6 +949,32 @@
768
949
  return { occluder, coveredFraction: round(hits / OCCLUSION_GRID_POINTS) };
769
950
  }
770
951
 
952
+ // pointer-events:none hides elements from elementFromPoint — both probed text AND occluders.
953
+ function restoreHitTesting(root) {
954
+ const restores = [];
955
+ for (const node of [root, ...root.querySelectorAll("*")]) {
956
+ if (getComputedStyle(node).pointerEvents !== "none") continue;
957
+ const previous = node.style.getPropertyValue("pointer-events");
958
+ const priority = node.style.getPropertyPriority("pointer-events");
959
+ node.style.setProperty("pointer-events", "auto", "important");
960
+ restores.push(() => {
961
+ if (previous) node.style.setProperty("pointer-events", previous, priority);
962
+ else node.style.removeProperty("pointer-events");
963
+ });
964
+ }
965
+ return () => restores.forEach((restore) => restore());
966
+ }
967
+
968
+ // No text ink is on screen while every non-whitespace text node sits at ~0 opacity (entrance not started).
969
+ function hasVisibleTextInk(element) {
970
+ const nodes = [element, ...element.querySelectorAll("*")];
971
+ for (const node of nodes) {
972
+ if (!directTextNodes(node).some((textNode) => textNode.textContent.trim())) continue;
973
+ if (opacityChain(node) >= 0.05) return true;
974
+ }
975
+ return false;
976
+ }
977
+
771
978
  // Catches the blind spot the overflow checks miss: text that fits its box
772
979
  // perfectly but is covered by a later sibling/overlay. An atomic label
773
980
  // (short, no whitespace) flags at any coverage; ordinary prose only flags
@@ -775,6 +982,7 @@
775
982
  // cover on a paragraph is usually a styling artifact, not a reading defect.
776
983
  function occludedTextIssue(element, time) {
777
984
  if (hasAllowOcclusionFlag(element)) return null;
985
+ if (!hasVisibleTextInk(element)) return null;
778
986
  const textRect = textRectFor(element);
779
987
  if (!textRect) return null;
780
988
  const text = textContentFor(element);
@@ -841,6 +1049,255 @@
841
1049
  };
842
1050
  }
843
1051
 
1052
+ // Attachment allowance: callouts/tooltips legitimately hang near (not inside) their anchor.
1053
+ const ESCAPE_INTERSECTION_FRACTION = 0.3;
1054
+ const ESCAPE_MIN_CHILD_AREA = 2500;
1055
+
1056
+ function edgeGap(child, parent) {
1057
+ const dx = Math.max(parent.left - child.right, 0, child.left - parent.right);
1058
+ const dy = Math.max(parent.top - child.bottom, 0, child.top - parent.bottom);
1059
+ return Math.sqrt(dx * dx + dy * dy);
1060
+ }
1061
+
1062
+ // An absolute element rendering far outside its offset parent was positioned in the wrong frame.
1063
+ function escapedContainerIssues(root, time) {
1064
+ const issues = [];
1065
+ const flagged = new Set();
1066
+ for (const element of Array.from(root.querySelectorAll("*"))) {
1067
+ if (!isVisibleElement(element) || hasAllowOverflowFlag(element)) continue;
1068
+ if (getComputedStyle(element).position !== "absolute") continue;
1069
+ const parent = element.offsetParent;
1070
+ if (!parent || parent === document.body || parent === root || !isVisibleElement(parent)) {
1071
+ continue;
1072
+ }
1073
+ const childRect = toRect(element.getBoundingClientRect());
1074
+ if (rectArea(childRect) < ESCAPE_MIN_CHILD_AREA) continue;
1075
+ const parentRect = toRect(parent.getBoundingClientRect());
1076
+ const visible = intersectionArea(childRect, parentRect);
1077
+ if (visible >= rectArea(childRect) * ESCAPE_INTERSECTION_FRACTION) continue;
1078
+ // Fully detached but hugging the parent = a callout/tooltip; touching yet mostly outside = drift.
1079
+ const allowance = Math.max(48, Math.min(childRect.width, childRect.height) / 2);
1080
+ if (visible <= 0 && edgeGap(childRect, parentRect) <= allowance) continue;
1081
+ flagged.add(element);
1082
+ issues.push({
1083
+ code: "escaped_container",
1084
+ severity: "warning",
1085
+ time,
1086
+ selector: selectorFor(element),
1087
+ containerSelector: selectorFor(parent),
1088
+ text: textContentFor(element),
1089
+ message:
1090
+ "Positioned element renders far outside its offset parent — its coordinates were likely computed in a different frame (canvas/viewport pixels).",
1091
+ rect: childRect,
1092
+ containerRect: parentRect,
1093
+ fixHint:
1094
+ "Compute left/top in the offset parent's frame (subtract its rect), or mark intentional placement with data-layout-allow-overflow.",
1095
+ });
1096
+ }
1097
+ return { issues, flagged };
1098
+ }
1099
+
1100
+ // A gradient reads as content when any stop is solid; all-translucent stops are glows/vignettes.
1101
+ function gradientHasOpaqueStop(image) {
1102
+ const colors = image.match(/rgba?\([^)]+\)|#[0-9a-f]{3,8}|\btransparent\b/gi) || [];
1103
+ return colors.some((color) => !/^transparent$/i.test(color) && colorAlpha(color) >= 0.6);
1104
+ }
1105
+
1106
+ function isPaintedPanel(element) {
1107
+ if (FRAME_MEDIA_TAGS.has(element.tagName.toUpperCase())) return false;
1108
+ const style = getComputedStyle(element);
1109
+ const image = style.backgroundImage || "none";
1110
+ if (image.includes("url(")) return true;
1111
+ if (image !== "none" && gradientHasOpaqueStop(image)) return true;
1112
+ if (!isTransparentColor(style.backgroundColor) && colorAlpha(style.backgroundColor) > 0.05) {
1113
+ return true;
1114
+ }
1115
+ return (
1116
+ parsePx(style.borderTopWidth) +
1117
+ parsePx(style.borderRightWidth) +
1118
+ parsePx(style.borderBottomWidth) +
1119
+ parsePx(style.borderLeftWidth) >
1120
+ 0
1121
+ );
1122
+ }
1123
+
1124
+ // Canvas-breach floor: entrance nudges stay quiet; matches the connector threshold scale.
1125
+ const PANEL_BREACH_FLOOR_PX = 24;
1126
+ const PANEL_BREACH_FLOOR_FRACTION = 0.025;
1127
+ // A hero-sized panel stuck on the edge is drift; a small painted bleed is usually decoration.
1128
+ const PANEL_HERO_AREA_FRACTION = 0.1;
1129
+
1130
+ // Painted panels breaching the canvas: text is canvas_overflow's, media is frame_out_of_frame's, panels were nobody's.
1131
+ function panelOutOfCanvasIssues(root, rootRect, time, tolerance, escapedElements) {
1132
+ const issues = [];
1133
+ const floor = Math.max(
1134
+ PANEL_BREACH_FLOOR_PX,
1135
+ Math.min(rootRect.width, rootRect.height) * PANEL_BREACH_FLOOR_FRACTION,
1136
+ );
1137
+ const rootArea = rectArea(rootRect);
1138
+ const flagged = new Set();
1139
+ for (const element of Array.from(root.querySelectorAll("*"))) {
1140
+ if (!isVisibleElement(element) || hasAllowOverflowFlag(element)) continue;
1141
+ if (escapedElements.has(element)) continue;
1142
+ // Ownership is geometric and strict-mutex: any text breach past canvas_overflow's own
1143
+ // tolerance cedes the element to canvas_overflow; in-bounds text leaves the panel finding.
1144
+ if (hasOwnTextCandidate(element)) {
1145
+ const textRect = textRectFor(element);
1146
+ if (textRect && overflowFor(textRect, rootRect, tolerance)) continue;
1147
+ }
1148
+ const rect = toRect(element.getBoundingClientRect());
1149
+ if (rectArea(rect) >= rootArea * 0.95) continue;
1150
+ // Fully off-canvas paints nothing — that is a parked entrance, not drift.
1151
+ if (intersectionArea(rect, rootRect) <= 0) continue;
1152
+ const overflow = overflowFor(rect, rootRect, floor);
1153
+ if (!overflow || !isPaintedPanel(element)) continue;
1154
+ if (element.parentElement && flagged.has(element.parentElement)) {
1155
+ flagged.add(element);
1156
+ continue;
1157
+ }
1158
+ flagged.add(element);
1159
+ issues.push({
1160
+ code: "panel_out_of_canvas",
1161
+ severity: rectArea(rect) >= rootArea * PANEL_HERO_AREA_FRACTION ? "warning" : "info",
1162
+ time,
1163
+ selector: selectorFor(element),
1164
+ containerSelector: selectorFor(root),
1165
+ text: textContentFor(element).slice(0, 48),
1166
+ message: "Painted panel extends outside the composition canvas.",
1167
+ rect,
1168
+ containerRect: rootRect,
1169
+ overflow,
1170
+ fixHint:
1171
+ "Move the panel inward, or mark intentional off-canvas animation with data-layout-allow-overflow.",
1172
+ });
1173
+ }
1174
+ return issues;
1175
+ }
1176
+
1177
+ const CONNECTOR_NAME = /\b(conn(ector)?|arrow|edge|link|flow|wire)\b/i;
1178
+ const CONNECTOR_SKIP_CONTAINERS = "defs, marker, clipPath, mask, symbol, pattern";
1179
+
1180
+ function connectorNameFor(element) {
1181
+ const className =
1182
+ typeof element.className === "string" ? element.className : element.className.baseVal || "";
1183
+ return `${element.id || ""} ${className}`;
1184
+ }
1185
+
1186
+ // Screen-space endpoints via the browser: getScreenCTM covers viewBox, preserveAspectRatio and group transforms.
1187
+ function pathScreenEndpoints(svg, path) {
1188
+ if (
1189
+ typeof path.getTotalLength !== "function" ||
1190
+ typeof path.getPointAtLength !== "function" ||
1191
+ typeof path.getScreenCTM !== "function" ||
1192
+ typeof svg.createSVGPoint !== "function"
1193
+ ) {
1194
+ return null;
1195
+ }
1196
+ let total;
1197
+ try {
1198
+ total = path.getTotalLength();
1199
+ } catch {
1200
+ return null;
1201
+ }
1202
+ if (!Number.isFinite(total) || total <= 0) return null;
1203
+ const matrix = path.getScreenCTM();
1204
+ if (!matrix) return null;
1205
+ const toScreen = (local) => {
1206
+ const point = svg.createSVGPoint();
1207
+ point.x = local.x;
1208
+ point.y = local.y;
1209
+ const mapped = point.matrixTransform(matrix);
1210
+ return { x: mapped.x, y: mapped.y };
1211
+ };
1212
+ return {
1213
+ start: toScreen(path.getPointAtLength(0)),
1214
+ end: toScreen(path.getPointAtLength(total)),
1215
+ };
1216
+ }
1217
+
1218
+ function distanceToRect(point, rect) {
1219
+ const dx = Math.max(rect.left - point.x, 0, point.x - rect.right);
1220
+ const dy = Math.max(rect.top - point.y, 0, point.y - rect.bottom);
1221
+ return Math.sqrt(dx * dx + dy * dy);
1222
+ }
1223
+
1224
+ // Solid, compact elements a connector could plausibly anchor to.
1225
+ function connectorAnchorRects(root, rootRect) {
1226
+ const compact = [];
1227
+ const painted = [];
1228
+ const rootArea = rectArea(rootRect);
1229
+ for (const element of Array.from(root.querySelectorAll("*"))) {
1230
+ // Known blind spot: anchors living inside an SVG (<image>, foreignObject) are not counted.
1231
+ if (element.closest("svg") || !isVisibleElement(element)) continue;
1232
+ const opaque =
1233
+ RASTER_TAGS.has(element.tagName) || hasOpaqueBackground(getComputedStyle(element));
1234
+ if (!opaque && !textContentFor(element)) continue;
1235
+ const rect = toRect(element.getBoundingClientRect());
1236
+ const area = rectArea(rect);
1237
+ if (area < 400) continue;
1238
+ // Containment tier: large opaque targets only — a text-bearing wrapper contains its own diagram's endpoints.
1239
+ if (opaque && area <= rootArea * 0.6) painted.push({ rect, element });
1240
+ if (area <= rootArea * 0.15) compact.push(rect);
1241
+ }
1242
+ return { compact, painted };
1243
+ }
1244
+
1245
+ function isConnectorPath(svg, path) {
1246
+ if (path.hasAttribute("marker-start") || path.hasAttribute("marker-end")) return true;
1247
+ return (
1248
+ CONNECTOR_NAME.test(connectorNameFor(svg)) || CONNECTOR_NAME.test(connectorNameFor(path))
1249
+ );
1250
+ }
1251
+
1252
+ // A connector whose BOTH endpoints land far from every anchorable element was drawn in the wrong frame.
1253
+ // min over the two endpoints is intentional: a half-attached connector is a design choice, not frame drift.
1254
+ function connectorDetachmentIssues(root, rootRect, time) {
1255
+ const issues = [];
1256
+ let anchors = null;
1257
+ const threshold = Math.max(32, Math.min(rootRect.width, rootRect.height) * 0.02);
1258
+ for (const svg of Array.from(root.querySelectorAll("svg"))) {
1259
+ if (!isVisibleElement(svg) || hasAllowOverflowFlag(svg)) continue;
1260
+ for (const path of Array.from(svg.querySelectorAll("path"))) {
1261
+ if (path.closest(CONNECTOR_SKIP_CONTAINERS)) continue;
1262
+ if (!isConnectorPath(svg, path)) continue;
1263
+ const endpoints = pathScreenEndpoints(svg, path);
1264
+ if (!endpoints) continue;
1265
+ if (anchors === null) anchors = connectorAnchorRects(root, rootRect);
1266
+ if (anchors.compact.length < 2) return issues;
1267
+ const attached = (point) =>
1268
+ anchors.painted.some(
1269
+ (anchor) => !anchor.element.contains(svg) && distanceToRect(point, anchor.rect) === 0,
1270
+ ) || anchors.compact.some((rect) => distanceToRect(point, rect) <= threshold);
1271
+ if (attached(endpoints.start) || attached(endpoints.end)) continue;
1272
+ const gap = Math.round(
1273
+ Math.min(
1274
+ Math.min(...anchors.compact.map((rect) => distanceToRect(endpoints.start, rect))),
1275
+ Math.min(...anchors.compact.map((rect) => distanceToRect(endpoints.end, rect))),
1276
+ ),
1277
+ );
1278
+ issues.push({
1279
+ code: "connector_detached",
1280
+ severity: "warning",
1281
+ time,
1282
+ selector: selectorFor(path),
1283
+ containerSelector: selectorFor(svg),
1284
+ message: `Connector path endpoints are ${gap}px from the nearest anchorable element — measured coordinates were likely drawn into an SVG with a different origin.`,
1285
+ rect: toRect({
1286
+ left: Math.min(endpoints.start.x, endpoints.end.x),
1287
+ top: Math.min(endpoints.start.y, endpoints.end.y),
1288
+ right: Math.max(endpoints.start.x, endpoints.end.x),
1289
+ bottom: Math.max(endpoints.start.y, endpoints.end.y),
1290
+ width: Math.abs(endpoints.end.x - endpoints.start.x),
1291
+ height: Math.abs(endpoints.end.y - endpoints.start.y),
1292
+ }),
1293
+ fixHint:
1294
+ "Subtract the SVG's own rect when converting measured coordinates, and keep the SVG a direct child of the stage.",
1295
+ });
1296
+ }
1297
+ }
1298
+ return issues;
1299
+ }
1300
+
844
1301
  function candidateAnchor(element) {
845
1302
  const dataAttributes = {};
846
1303
  for (const attribute of Array.from(element.attributes)) {
@@ -919,19 +1376,28 @@
919
1376
  );
920
1377
  const issues = [];
921
1378
 
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);
1379
+ const restoreHits = restoreHitTesting(root);
1380
+ try {
1381
+ for (const element of elements) {
1382
+ if (!hasOwnTextCandidate(element)) continue;
1383
+ const clipped = clippedTextIssue(element, time, tolerance);
1384
+ if (clipped) issues.push(clipped);
1385
+ issues.push(...textOverflowIssues(element, root, rootRect, time, tolerance));
1386
+ const occluded = occludedTextIssue(element, time);
1387
+ if (occluded) issues.push(occluded);
1388
+ const invisible = invisibleTextIssue(element, time);
1389
+ if (invisible) issues.push(invisible);
1390
+ }
1391
+ } finally {
1392
+ restoreHits();
931
1393
  }
932
1394
 
933
1395
  issues.push(...containerOverflowIssues(root, time, tolerance));
934
1396
  issues.push(...contentOverlapIssues(root, time));
1397
+ const escaped = escapedContainerIssues(root, time);
1398
+ issues.push(...escaped.issues);
1399
+ issues.push(...panelOutOfCanvasIssues(root, rootRect, time, tolerance, escaped.flagged));
1400
+ issues.push(...connectorDetachmentIssues(root, rootRect, time));
935
1401
  return issues;
936
1402
  };
937
1403