hyperframes 0.7.49 → 0.7.51

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.
@@ -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 (opacityChain(element) < 0.2) return false;
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 textContentFor(element) {
163
- return (element.innerText || element.textContent || "").replace(/\s+/g, " ").trim();
201
+ function directTextNodes(element) {
202
+ return Array.from(element.childNodes).filter((node) => node.nodeType === 3);
164
203
  }
165
204
 
166
- function hasOwnTextCandidate(element) {
167
- const text = textContentFor(element);
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 textRectFor(element) {
176
- const range = document.createRange();
177
- range.selectNodeContents(element);
178
- const rects = Array.from(range.getClientRects()).filter(
179
- (rect) => rect.width > 0.5 && rect.height > 0.5,
180
- );
181
- range.detach();
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, not error: must not fail the exit code (ok = errorCount === 0)
571
- // for compositions that intentionally layer text. Re-promote once the
572
- // data-layout-allow-overlap opt-out is widely adopted.
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) and return
672
- // the first opaque element painted over any sample point.
673
- function firstOccluder(element, textRect) {
674
- for (const yFraction of [0.25, 0.5, 0.75]) {
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 [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97]) {
677
- const occluder = occluderAt(element, textRect.left + textRect.width * xFraction, y);
678
- if (occluder) return occluder;
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 null;
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 occluder = firstOccluder(element, textRect);
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: textContentFor(element),
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(isVisibleElement);
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
  })();