hyperframes 0.7.49 → 0.7.50
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 +3825 -1757
- package/dist/commands/contrast-audit.browser.js +16 -0
- package/dist/commands/layout-audit.browser.js +212 -29
- package/dist/hyperframe-runtime.js +16 -16
- package/dist/hyperframe.manifest.json +1 -1
- package/dist/hyperframe.runtime.iife.js +16 -16
- package/dist/skills/hyperframes/SKILL.md +1 -1
- package/dist/skills/hyperframes-cli/SKILL.md +12 -15
- package/dist/skills/hyperframes-cli/references/lint-validate-inspect.md +67 -51
- package/dist/studio/assets/{index-HvbbjzDK.js → index-CWraNKMN.js} +3 -3
- package/dist/studio/assets/{index-BYSlsVGG.js → index-CdhJLcJc.js} +1 -1
- package/dist/studio/assets/{index-369h3UdR.js → index-VhK_L4WR.js} +1 -1
- package/dist/studio/index.html +1 -1
- package/dist/templates/_shared/AGENTS.md +2 -2
- package/dist/templates/_shared/CLAUDE.md +2 -2
- package/package.json +1 -1
|
@@ -158,6 +158,22 @@ window.__contrastAuditPrepare = function () {
|
|
|
158
158
|
}
|
|
159
159
|
if (!hasText) continue;
|
|
160
160
|
|
|
161
|
+
// Same decorative opt-out the layout audit honors: text marked (or inside)
|
|
162
|
+
// data-layout-ignore is set dressing, not copy a viewer must read —
|
|
163
|
+
// deliberately dim rail labels, ghost typography, texture text.
|
|
164
|
+
if (el.closest && el.closest("[data-layout-ignore]")) continue;
|
|
165
|
+
|
|
166
|
+
// Text that has (nearly) left the canvas — a cursor exiting the frame, an
|
|
167
|
+
// element parked off-screen — is not readable content, and sampling its
|
|
168
|
+
// clamped edge reads whatever pixels happen to sit at the border (the
|
|
169
|
+
// classic false "white-on-white"). Require a minimally-visible on-canvas
|
|
170
|
+
// intersection before judging contrast; the layout audit separately owns
|
|
171
|
+
// off-canvas detection as its own finding class.
|
|
172
|
+
var vis = el.getBoundingClientRect();
|
|
173
|
+
var onX = Math.min(vis.right, window.innerWidth) - Math.max(vis.left, 0);
|
|
174
|
+
var onY = Math.min(vis.bottom, window.innerHeight) - Math.max(vis.top, 0);
|
|
175
|
+
if (onX < 8 || onY < 8) continue;
|
|
176
|
+
|
|
161
177
|
var cs = getComputedStyle(el);
|
|
162
178
|
if (cs.visibility === "hidden" || cs.display === "none") continue;
|
|
163
179
|
if (parseFloat(cs.opacity) <= 0.01) continue;
|
|
@@ -81,6 +81,22 @@
|
|
|
81
81
|
return `${selectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
function uniqueSelectorFor(element) {
|
|
85
|
+
const preferred = selectorFor(element);
|
|
86
|
+
try {
|
|
87
|
+
if (document.querySelectorAll(preferred).length === 1) return preferred;
|
|
88
|
+
} catch {
|
|
89
|
+
// Fall through to a structural selector.
|
|
90
|
+
}
|
|
91
|
+
const parent = element.parentElement;
|
|
92
|
+
if (!parent) return preferred;
|
|
93
|
+
const siblings = Array.from(parent.children).filter(
|
|
94
|
+
(child) => child.tagName === element.tagName,
|
|
95
|
+
);
|
|
96
|
+
const index = siblings.indexOf(element) + 1;
|
|
97
|
+
return `${uniqueSelectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`;
|
|
98
|
+
}
|
|
99
|
+
|
|
84
100
|
function hasIgnoreFlag(element) {
|
|
85
101
|
return !!element.closest("[data-layout-ignore], [data-layout-check='ignore']");
|
|
86
102
|
}
|
|
@@ -98,6 +114,14 @@
|
|
|
98
114
|
return opacity;
|
|
99
115
|
}
|
|
100
116
|
|
|
117
|
+
function hasOpacityBelow(element, floor) {
|
|
118
|
+
for (let current = element; current; current = current.parentElement) {
|
|
119
|
+
const parsed = Number.parseFloat(getComputedStyle(current).opacity || "1");
|
|
120
|
+
if (Number.isFinite(parsed) && parsed < floor) return true;
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
101
125
|
// A clip-path can shrink an element's painted region to nothing (e.g. a
|
|
102
126
|
// typewriter span pre-reveal at `inset(0 100% 0 0)`, or `circle(0px)`) while
|
|
103
127
|
// its layout box, opacity, visibility and display all still read as present.
|
|
@@ -142,9 +166,20 @@
|
|
|
142
166
|
return !paintsAnyProbePoint(element, rect);
|
|
143
167
|
}
|
|
144
168
|
|
|
145
|
-
function isVisibleElement(element) {
|
|
169
|
+
function isVisibleElement(element, opacityFloor, probeClipPath) {
|
|
146
170
|
if (IGNORE_TAGS.has(element.tagName)) return false;
|
|
147
171
|
if (hasIgnoreFlag(element)) return false;
|
|
172
|
+
if (
|
|
173
|
+
opacityFloor != null &&
|
|
174
|
+
typeof element.checkVisibility === "function" &&
|
|
175
|
+
!element.checkVisibility({
|
|
176
|
+
opacityProperty: true,
|
|
177
|
+
visibilityProperty: true,
|
|
178
|
+
contentVisibilityAuto: true,
|
|
179
|
+
})
|
|
180
|
+
) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
148
183
|
const style = getComputedStyle(element);
|
|
149
184
|
if (
|
|
150
185
|
style.display === "none" ||
|
|
@@ -153,32 +188,57 @@
|
|
|
153
188
|
) {
|
|
154
189
|
return false;
|
|
155
190
|
}
|
|
156
|
-
if (
|
|
191
|
+
if (
|
|
192
|
+
opacityFloor == null ? opacityChain(element) < 0.2 : hasOpacityBelow(element, opacityFloor)
|
|
193
|
+
) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
157
196
|
const rect = element.getBoundingClientRect();
|
|
158
197
|
if (rect.width <= 0.5 || rect.height <= 0.5) return false;
|
|
159
|
-
return !isClippedAway(element);
|
|
198
|
+
return probeClipPath === false || !isClippedAway(element);
|
|
160
199
|
}
|
|
161
200
|
|
|
162
|
-
function
|
|
163
|
-
return (element.
|
|
201
|
+
function directTextNodes(element) {
|
|
202
|
+
return Array.from(element.childNodes).filter((node) => node.nodeType === 3);
|
|
164
203
|
}
|
|
165
204
|
|
|
166
|
-
function
|
|
167
|
-
const
|
|
205
|
+
function textContentFor(element, ownTextOnly) {
|
|
206
|
+
const content = ownTextOnly
|
|
207
|
+
? directTextNodes(element)
|
|
208
|
+
.map((node) => node.textContent || "")
|
|
209
|
+
.join("")
|
|
210
|
+
: element.innerText || element.textContent || "";
|
|
211
|
+
return content.replace(/\s+/g, " ").trim();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function hasOwnTextCandidate(element, directOnly) {
|
|
215
|
+
const text = textContentFor(element, directOnly);
|
|
168
216
|
if (!text) return false;
|
|
217
|
+
if (directOnly) return true;
|
|
169
218
|
for (const child of Array.from(element.children)) {
|
|
170
219
|
if (isVisibleElement(child) && textContentFor(child)) return false;
|
|
171
220
|
}
|
|
172
221
|
return true;
|
|
173
222
|
}
|
|
174
223
|
|
|
175
|
-
function
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
224
|
+
function textClientRects(element, directOnly) {
|
|
225
|
+
const subjects = directOnly ? directTextNodes(element) : [element];
|
|
226
|
+
const rects = [];
|
|
227
|
+
for (const subject of subjects) {
|
|
228
|
+
const range = document.createRange();
|
|
229
|
+
range.selectNodeContents(subject);
|
|
230
|
+
rects.push(
|
|
231
|
+
...Array.from(range.getClientRects()).filter(
|
|
232
|
+
(rect) => rect.width > 0.5 && rect.height > 0.5,
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
range.detach();
|
|
236
|
+
}
|
|
237
|
+
return rects;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function textRectFor(element, directOnly) {
|
|
241
|
+
const rects = textClientRects(element, directOnly);
|
|
182
242
|
if (rects.length === 0) return null;
|
|
183
243
|
|
|
184
244
|
const union = rects.reduce(
|
|
@@ -567,9 +627,12 @@
|
|
|
567
627
|
const area = intersectionArea(a.rect, b.rect);
|
|
568
628
|
if (area <= Math.min(rectArea(a.rect), rectArea(b.rect)) * 0.2) return null;
|
|
569
629
|
return {
|
|
570
|
-
// Warning
|
|
571
|
-
//
|
|
572
|
-
//
|
|
630
|
+
// Warning at the per-sample level: a single-sample overlap is usually an
|
|
631
|
+
// entrance/exit transient (two blocks crossing mid-animation), not a real
|
|
632
|
+
// collision. `collapseStaticLayoutIssues` (utils/layoutAudit.ts) re-promotes
|
|
633
|
+
// this to error once the SAME overlap is held across >= 2 adjacent samples
|
|
634
|
+
// (or ~500ms of timeline) — a persistence-tiered replacement for the old
|
|
635
|
+
// "re-promote once data-layout-allow-overlap is widely adopted" plan (#U10).
|
|
573
636
|
code: "content_overlap",
|
|
574
637
|
severity: "warning",
|
|
575
638
|
time,
|
|
@@ -602,6 +665,7 @@
|
|
|
602
665
|
}
|
|
603
666
|
|
|
604
667
|
const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]);
|
|
668
|
+
const FRAME_MEDIA_TAGS = new Set([...RASTER_TAGS, "SVG"]);
|
|
605
669
|
|
|
606
670
|
// An element hides text beneath it when it paints opaque pixels at near-full
|
|
607
671
|
// opacity: raster content (img/video/canvas), a background image, or a solid
|
|
@@ -667,42 +731,135 @@
|
|
|
667
731
|
return hit;
|
|
668
732
|
}
|
|
669
733
|
|
|
734
|
+
const OCCLUSION_PROBE_Y_FRACTIONS = [0.25, 0.5, 0.75];
|
|
735
|
+
const OCCLUSION_PROBE_X_FRACTIONS = [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97];
|
|
736
|
+
const OCCLUSION_GRID_POINTS =
|
|
737
|
+
OCCLUSION_PROBE_Y_FRACTIONS.length * OCCLUSION_PROBE_X_FRACTIONS.length;
|
|
738
|
+
|
|
739
|
+
// Short, atomic text (a label/button/word, no whitespace) reads as a single
|
|
740
|
+
// unit — ANY covered probe point changes what it says, so flag at any hit
|
|
741
|
+
// (the pre-#U10 behaviour). Longer prose survives a nibbled edge; only flag
|
|
742
|
+
// once a real share of it is covered — see `occludedTextIssue`.
|
|
743
|
+
const ATOMIC_LABEL_MAX_CHARS = 16;
|
|
744
|
+
const PROSE_COVERAGE_FLOOR = 0.15;
|
|
745
|
+
|
|
746
|
+
function isAtomicLabel(text) {
|
|
747
|
+
return text.length > 0 && text.length <= ATOMIC_LABEL_MAX_CHARS && !/\s/.test(text);
|
|
748
|
+
}
|
|
749
|
+
|
|
670
750
|
// Sweep a grid across the text box (three rows, not just the mid-line, so
|
|
671
|
-
// overlays covering only part of a multi-line block are caught)
|
|
672
|
-
//
|
|
673
|
-
|
|
674
|
-
|
|
751
|
+
// overlays covering only part of a multi-line block are caught). Unlike a
|
|
752
|
+
// first-hit scan, this keeps sampling every point so it can report what
|
|
753
|
+
// fraction of the box is actually covered — a corner nibble on a paragraph
|
|
754
|
+
// reads very differently from a label buried under an overlay. Still
|
|
755
|
+
// returns the first opaque element found, for `containerSelector`.
|
|
756
|
+
function occlusionCoverage(element, textRect) {
|
|
757
|
+
let occluder = null;
|
|
758
|
+
let hits = 0;
|
|
759
|
+
for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) {
|
|
675
760
|
const y = textRect.top + textRect.height * yFraction;
|
|
676
|
-
for (const xFraction of
|
|
677
|
-
const
|
|
678
|
-
if (
|
|
761
|
+
for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) {
|
|
762
|
+
const hit = occluderAt(element, textRect.left + textRect.width * xFraction, y);
|
|
763
|
+
if (!hit) continue;
|
|
764
|
+
hits += 1;
|
|
765
|
+
if (!occluder) occluder = hit;
|
|
679
766
|
}
|
|
680
767
|
}
|
|
681
|
-
return
|
|
768
|
+
return { occluder, coveredFraction: round(hits / OCCLUSION_GRID_POINTS) };
|
|
682
769
|
}
|
|
683
770
|
|
|
684
771
|
// Catches the blind spot the overflow checks miss: text that fits its box
|
|
685
|
-
// perfectly but is covered by a later sibling/overlay.
|
|
772
|
+
// perfectly but is covered by a later sibling/overlay. An atomic label
|
|
773
|
+
// (short, no whitespace) flags at any coverage; ordinary prose only flags
|
|
774
|
+
// once coveredFraction clears PROSE_COVERAGE_FLOOR, since a sliver of edge
|
|
775
|
+
// cover on a paragraph is usually a styling artifact, not a reading defect.
|
|
686
776
|
function occludedTextIssue(element, time) {
|
|
687
777
|
if (hasAllowOcclusionFlag(element)) return null;
|
|
688
778
|
const textRect = textRectFor(element);
|
|
689
779
|
if (!textRect) return null;
|
|
690
|
-
const
|
|
780
|
+
const text = textContentFor(element);
|
|
781
|
+
const { occluder, coveredFraction } = occlusionCoverage(element, textRect);
|
|
691
782
|
if (!occluder) return null;
|
|
783
|
+
if (!isAtomicLabel(text) && coveredFraction < PROSE_COVERAGE_FLOOR) return null;
|
|
692
784
|
return {
|
|
693
785
|
code: "text_occluded",
|
|
694
786
|
severity: "error",
|
|
695
787
|
time,
|
|
696
788
|
selector: selectorFor(element),
|
|
697
789
|
containerSelector: selectorFor(occluder),
|
|
698
|
-
text
|
|
790
|
+
text,
|
|
699
791
|
message: "Text is hidden beneath an opaque element.",
|
|
700
792
|
rect: textRect,
|
|
793
|
+
coveredFraction,
|
|
701
794
|
fixHint:
|
|
702
795
|
"Give the text its own zone, raise its stacking order above the covering element, or mark intentional layering with data-layout-allow-occlusion.",
|
|
703
796
|
};
|
|
704
797
|
}
|
|
705
798
|
|
|
799
|
+
function candidateAnchor(element) {
|
|
800
|
+
const dataAttributes = {};
|
|
801
|
+
for (const attribute of Array.from(element.attributes)) {
|
|
802
|
+
if (attribute.name.startsWith("data-")) dataAttributes[attribute.name] = attribute.value;
|
|
803
|
+
}
|
|
804
|
+
const source = element
|
|
805
|
+
.closest("[data-composition-file]")
|
|
806
|
+
?.getAttribute("data-composition-file");
|
|
807
|
+
return {
|
|
808
|
+
selector: uniqueSelectorFor(element),
|
|
809
|
+
dataAttributes,
|
|
810
|
+
sourceFile: source || "index.html",
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function geometryCandidate(element, kind, rect, elementRect, rootRect, tolerance) {
|
|
815
|
+
const tag = element.tagName.toLowerCase();
|
|
816
|
+
const text = kind === "text" ? textContentFor(element, true) : tag;
|
|
817
|
+
const overflow = kind === "media" ? overflowFor(elementRect, rootRect, tolerance) : null;
|
|
818
|
+
return {
|
|
819
|
+
kind,
|
|
820
|
+
tag,
|
|
821
|
+
text,
|
|
822
|
+
rect,
|
|
823
|
+
elementRect,
|
|
824
|
+
...candidateAnchor(element),
|
|
825
|
+
...(overflow ? { overflow } : {}),
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
window.__hyperframesGeometryCandidates = function collectGeometryCandidates(options) {
|
|
830
|
+
const includeText = options?.text === true;
|
|
831
|
+
const includeMedia = options?.media === true;
|
|
832
|
+
if (!includeText && !includeMedia) return [];
|
|
833
|
+
const tolerance = typeof options?.tolerance === "number" ? options.tolerance : 2;
|
|
834
|
+
const root =
|
|
835
|
+
document.querySelector("[data-composition-id][data-width][data-height]") ||
|
|
836
|
+
document.querySelector("[data-composition-id]") ||
|
|
837
|
+
document.body;
|
|
838
|
+
const rootRect = rootRectFor(root);
|
|
839
|
+
const candidates = [];
|
|
840
|
+
for (const element of Array.from(document.querySelectorAll("body *"))) {
|
|
841
|
+
if (element.closest('[data-composition-id="captions"], .caption-layer, #caption-stage')) {
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
if (!isVisibleElement(element, 0.05, false)) continue;
|
|
845
|
+
const elementRect = toRect(element.getBoundingClientRect());
|
|
846
|
+
if (includeText && hasOwnTextCandidate(element, true)) {
|
|
847
|
+
const rect = textRectFor(element, true);
|
|
848
|
+
if (rect) {
|
|
849
|
+
candidates.push(
|
|
850
|
+
geometryCandidate(element, "text", rect, elementRect, rootRect, tolerance),
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
if (includeMedia && FRAME_MEDIA_TAGS.has(element.tagName.toUpperCase())) {
|
|
855
|
+
candidates.push(
|
|
856
|
+
geometryCandidate(element, "media", elementRect, elementRect, rootRect, tolerance),
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
return candidates;
|
|
861
|
+
};
|
|
862
|
+
|
|
706
863
|
window.__hyperframesLayoutAudit = function auditLayout(options) {
|
|
707
864
|
const time = options && typeof options.time === "number" ? options.time : 0;
|
|
708
865
|
const tolerance =
|
|
@@ -712,7 +869,9 @@
|
|
|
712
869
|
document.querySelector("[data-composition-id]") ||
|
|
713
870
|
document.body;
|
|
714
871
|
const rootRect = rootRectFor(root);
|
|
715
|
-
const elements = Array.from(root.querySelectorAll("*")).filter(
|
|
872
|
+
const elements = Array.from(root.querySelectorAll("*")).filter((element) =>
|
|
873
|
+
isVisibleElement(element),
|
|
874
|
+
);
|
|
716
875
|
const issues = [];
|
|
717
876
|
|
|
718
877
|
for (const element of elements) {
|
|
@@ -728,4 +887,28 @@
|
|
|
728
887
|
issues.push(...contentOverlapIssues(root, time));
|
|
729
888
|
return issues;
|
|
730
889
|
};
|
|
890
|
+
|
|
891
|
+
// Frozen-sweep guard (#U10, checkPipeline.ts): a compact per-sample
|
|
892
|
+
// fingerprint of every visible element's box + opacity, in DOM order. Node
|
|
893
|
+
// calls this once per seeked grid point and compares the strings across the
|
|
894
|
+
// whole run — if every sample produces the identical string, the seek never
|
|
895
|
+
// actually moved anything and the whole audit run is unreliable. Deliberately
|
|
896
|
+
// a single opaque string (not a structured array) since Node only ever needs
|
|
897
|
+
// equality, not per-element diffing.
|
|
898
|
+
window.__hyperframesLayoutGeometry = function collectLayoutGeometry() {
|
|
899
|
+
const root =
|
|
900
|
+
document.querySelector("[data-composition-id][data-width][data-height]") ||
|
|
901
|
+
document.querySelector("[data-composition-id]") ||
|
|
902
|
+
document.body;
|
|
903
|
+
const elements = Array.from(root.querySelectorAll("*")).filter((element) =>
|
|
904
|
+
isVisibleElement(element),
|
|
905
|
+
);
|
|
906
|
+
return elements
|
|
907
|
+
.map((element) => {
|
|
908
|
+
const rect = toRect(element.getBoundingClientRect());
|
|
909
|
+
const opacity = round(opacityChain(element));
|
|
910
|
+
return `${rect.left},${rect.top},${rect.width},${rect.height},${opacity}`;
|
|
911
|
+
})
|
|
912
|
+
.join("|");
|
|
913
|
+
};
|
|
731
914
|
})();
|