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.
- package/dist/cli.js +4595 -3123
- package/dist/commands/contrast-audit.browser.js +79 -19
- package/dist/commands/layout-audit.browser.js +490 -24
- package/dist/hyperframe-runtime.js +50 -30
- package/dist/hyperframe.manifest.json +1 -1
- package/dist/hyperframe.runtime.iife.js +50 -30
- package/dist/hyperframes-player.global.js +2 -2
- package/dist/hyperframes-slideshow.global.js +25 -25
- package/dist/skills/hyperframes-cli/SKILL.md +41 -12
- package/dist/skills/hyperframes-cli/references/cloud.md +123 -0
- package/dist/skills/hyperframes-cli/references/lint-validate-inspect.md +1 -1
- package/dist/skills/hyperframes-cli/references/preview-render.md +3 -3
- package/dist/studio/assets/hyperframes-player-CtTDO63S.js +459 -0
- package/dist/studio/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
- package/dist/studio/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
- package/dist/studio/assets/{index-C4csZims.js → index-DeQPzqwH.js} +1 -1
- package/dist/studio/assets/index-uahwWkgw.css +1 -0
- package/dist/studio/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
- package/dist/studio/chunk-OBAG3GWK.js.map +1 -0
- package/dist/studio/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
- package/dist/studio/index.d.ts +41 -4
- package/dist/studio/index.html +2 -2
- package/dist/studio/index.js +7436 -5423
- package/dist/studio/index.js.map +1 -1
- package/package.json +2 -1
- package/dist/studio/assets/hyperframes-player-BBrKzTGd.js +0 -459
- package/dist/studio/assets/index-D-GyYi2d.css +0 -1
- package/dist/studio/chunk-5QSIMBEJ.js.map +0 -1
- /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
|
-
!
|
|
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 && !
|
|
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
|
-
|
|
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
|
-
|
|
663
|
-
if (
|
|
664
|
-
|
|
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
|
-
|
|
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
|
-
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
923
|
-
|
|
924
|
-
const
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
|