hyperframes 0.6.98 → 0.6.100
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 +10442 -2819
- package/dist/commands/layout-audit.browser.js +81 -0
- package/dist/commands/motion-sample.browser.js +121 -0
- package/dist/hyperframe-runtime.js +17 -17
- package/dist/hyperframe.manifest.json +1 -1
- package/dist/hyperframe.runtime.iife.js +17 -17
- package/dist/skills/hyperframes/SKILL.md +168 -0
- package/dist/skills/hyperframes-cli/SKILL.md +2 -2
- package/dist/skills/hyperframes-cli/references/init-and-scaffold.md +1 -1
- package/dist/skills/hyperframes-cli/references/lint-validate-inspect.md +28 -0
- package/dist/studio/assets/index-BkT9VKwz.js +296 -0
- package/dist/studio/assets/{index-D-bS9Dxx.js → index-CKWBqyRd.js} +1 -1
- package/dist/studio/assets/{index-D-ET9M0b.js → index-gpSohHUn.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
- package/dist/studio/assets/index-Ce3pBm_I.js +0 -252
|
@@ -402,6 +402,12 @@
|
|
|
402
402
|
return !!element.closest("[data-layout-allow-overlap]");
|
|
403
403
|
}
|
|
404
404
|
|
|
405
|
+
function isTransparentColor(color) {
|
|
406
|
+
return (
|
|
407
|
+
!color || color === "transparent" || color === "rgba(0, 0, 0, 0)" || color.endsWith(", 0)")
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
405
411
|
function alphaFromParts(parts, index) {
|
|
406
412
|
return parts.length > index ? parsePx(parts[index]) : 1;
|
|
407
413
|
}
|
|
@@ -483,6 +489,79 @@
|
|
|
483
489
|
return issues;
|
|
484
490
|
}
|
|
485
491
|
|
|
492
|
+
function hasOpaqueBackground(style) {
|
|
493
|
+
if (style.backgroundImage && style.backgroundImage !== "none") return true;
|
|
494
|
+
if (isTransparentColor(style.backgroundColor)) return false;
|
|
495
|
+
return colorAlpha(style.backgroundColor) > 0.6;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]);
|
|
499
|
+
|
|
500
|
+
// An element hides text beneath it when it paints opaque pixels at near-full
|
|
501
|
+
// opacity: raster content (img/video/canvas), a background image, or a solid
|
|
502
|
+
// background colour. Low-opacity overlays (grain, scrims) do not occlude.
|
|
503
|
+
function isOpaqueOccluder(element) {
|
|
504
|
+
if (opacityChain(element) < 0.6) return false;
|
|
505
|
+
if (IGNORE_TAGS.has(element.tagName)) return false;
|
|
506
|
+
if (RASTER_TAGS.has(element.tagName)) return true;
|
|
507
|
+
return hasOpaqueBackground(getComputedStyle(element));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function hasAllowOcclusionFlag(element) {
|
|
511
|
+
return !!element.closest("[data-layout-allow-occlusion]");
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// A foreign element is one painted independently of the text — not the text
|
|
515
|
+
// itself, its own subtree, or an ancestor it shares a background with.
|
|
516
|
+
function isForeignElement(element, hit) {
|
|
517
|
+
return !!hit && hit !== element && !element.contains(hit) && !hit.contains(element);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// The opaque element painted over (x, y), or null when the topmost element
|
|
521
|
+
// there is related to the text or non-opaque.
|
|
522
|
+
function occluderAt(element, x, y) {
|
|
523
|
+
if (typeof document.elementFromPoint !== "function") return null;
|
|
524
|
+
const hit = document.elementFromPoint(x, y);
|
|
525
|
+
if (!isForeignElement(element, hit)) return null;
|
|
526
|
+
return isOpaqueOccluder(hit) ? hit : null;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Sweep a grid across the text box (three rows, not just the mid-line, so
|
|
530
|
+
// overlays covering only part of a multi-line block are caught) and return
|
|
531
|
+
// the first opaque element painted over any sample point.
|
|
532
|
+
function firstOccluder(element, textRect) {
|
|
533
|
+
for (const yFraction of [0.25, 0.5, 0.75]) {
|
|
534
|
+
const y = textRect.top + textRect.height * yFraction;
|
|
535
|
+
for (const xFraction of [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97]) {
|
|
536
|
+
const occluder = occluderAt(element, textRect.left + textRect.width * xFraction, y);
|
|
537
|
+
if (occluder) return occluder;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Catches the blind spot the overflow checks miss: text that fits its box
|
|
544
|
+
// perfectly but is covered by a later sibling/overlay.
|
|
545
|
+
function occludedTextIssue(element, time) {
|
|
546
|
+
if (hasAllowOcclusionFlag(element)) return null;
|
|
547
|
+
const textRect = textRectFor(element);
|
|
548
|
+
if (!textRect) return null;
|
|
549
|
+
const occluder = firstOccluder(element, textRect);
|
|
550
|
+
if (!occluder) return null;
|
|
551
|
+
return {
|
|
552
|
+
code: "text_occluded",
|
|
553
|
+
severity: "error",
|
|
554
|
+
time,
|
|
555
|
+
selector: selectorFor(element),
|
|
556
|
+
containerSelector: selectorFor(occluder),
|
|
557
|
+
text: textContentFor(element),
|
|
558
|
+
message: "Text is hidden beneath an opaque element.",
|
|
559
|
+
rect: textRect,
|
|
560
|
+
fixHint:
|
|
561
|
+
"Give the text its own zone, raise its stacking order above the covering element, or mark intentional layering with data-layout-allow-occlusion.",
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
486
565
|
window.__hyperframesLayoutAudit = function auditLayout(options) {
|
|
487
566
|
const time = options && typeof options.time === "number" ? options.time : 0;
|
|
488
567
|
const tolerance =
|
|
@@ -500,6 +579,8 @@
|
|
|
500
579
|
const clipped = clippedTextIssue(element, time, tolerance);
|
|
501
580
|
if (clipped) issues.push(clipped);
|
|
502
581
|
issues.push(...textOverflowIssues(element, root, rootRect, time, tolerance));
|
|
582
|
+
const occluded = occludedTextIssue(element, time);
|
|
583
|
+
if (occluded) issues.push(occluded);
|
|
503
584
|
}
|
|
504
585
|
|
|
505
586
|
issues.push(...containerOverflowIssues(root, time, tolerance));
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// In-page motion sampler for `hyperframes inspect` motion verification (#1437).
|
|
2
|
+
// Runs inside the seeked, paused page (via page.evaluate). For each asserted
|
|
3
|
+
// selector it returns this frame's { rect, opacity, visible }; for each liveness
|
|
4
|
+
// scope it returns a bucketed signature of all visible elements, so the Node-side
|
|
5
|
+
// evaluator can detect frozen windows by comparing signatures across frames.
|
|
6
|
+
(function () {
|
|
7
|
+
const IGNORE_TAGS = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "META", "LINK"]);
|
|
8
|
+
|
|
9
|
+
function round(value) {
|
|
10
|
+
return Math.round(value * 100) / 100;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function toRect(rect) {
|
|
14
|
+
return {
|
|
15
|
+
left: round(rect.left),
|
|
16
|
+
top: round(rect.top),
|
|
17
|
+
right: round(rect.right),
|
|
18
|
+
bottom: round(rect.bottom),
|
|
19
|
+
width: round(rect.width),
|
|
20
|
+
height: round(rect.height),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function opacityChain(element) {
|
|
25
|
+
let opacity = 1;
|
|
26
|
+
for (let current = element; current; current = current.parentElement) {
|
|
27
|
+
const parsed = Number.parseFloat(getComputedStyle(current).opacity || "1");
|
|
28
|
+
if (Number.isFinite(parsed)) opacity *= parsed;
|
|
29
|
+
}
|
|
30
|
+
return opacity;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Mirrors layout-audit.browser.js isVisibleElement.
|
|
34
|
+
// fallow-ignore-next-line complexity
|
|
35
|
+
function isVisibleElement(element) {
|
|
36
|
+
if (IGNORE_TAGS.has(element.tagName)) return false;
|
|
37
|
+
const style = getComputedStyle(element);
|
|
38
|
+
if (
|
|
39
|
+
style.display === "none" ||
|
|
40
|
+
style.visibility === "hidden" ||
|
|
41
|
+
style.visibility === "collapse"
|
|
42
|
+
) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
if (opacityChain(element) < 0.2) return false;
|
|
46
|
+
const rect = element.getBoundingClientRect();
|
|
47
|
+
return rect.width > 0.5 && rect.height > 0.5;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sampleElement(element) {
|
|
51
|
+
const rect = element.getBoundingClientRect();
|
|
52
|
+
return {
|
|
53
|
+
rect: toRect(rect),
|
|
54
|
+
opacity: round(opacityChain(element)),
|
|
55
|
+
visible: isVisibleElement(element),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function compositionRoot() {
|
|
60
|
+
return document.querySelector("[data-composition-id]") || document.body;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ponytail: bucket position to 2px and opacity to 0.08 so the RFC's "moves ≥2px /
|
|
64
|
+
// opacity ≥0.08" thresholds fall out of bucketing. Boundary-straddling moves are
|
|
65
|
+
// approximate — good enough for liveness; tighten only if false negatives show up.
|
|
66
|
+
function elementSignature(element) {
|
|
67
|
+
const rect = element.getBoundingClientRect();
|
|
68
|
+
const bx = Math.round(rect.left / 2);
|
|
69
|
+
const by = Math.round(rect.top / 2);
|
|
70
|
+
const bw = Math.round(rect.width / 2);
|
|
71
|
+
const bh = Math.round(rect.height / 2);
|
|
72
|
+
const bo = Math.round(opacityChain(element) / 0.08);
|
|
73
|
+
return bx + "," + by + "," + bw + "," + bh + "," + bo;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function livenessSignature(root) {
|
|
77
|
+
if (!root) return "";
|
|
78
|
+
const parts = [];
|
|
79
|
+
// ponytail: O(DOM) × MOTION_MAX_SAMPLES (300) — fine for typical compositions;
|
|
80
|
+
// narrow selector (e.g. "[id],[class]") if heavy-DOM compositions slow this down.
|
|
81
|
+
const all = root.querySelectorAll("*");
|
|
82
|
+
for (const element of all) {
|
|
83
|
+
if (!isVisibleElement(element)) continue;
|
|
84
|
+
parts.push(elementSignature(element));
|
|
85
|
+
}
|
|
86
|
+
return parts.join("|");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function safeQuery(selector) {
|
|
90
|
+
try {
|
|
91
|
+
return document.querySelector(selector);
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function sampleSelectors(selectors) {
|
|
98
|
+
const data = {};
|
|
99
|
+
for (const selector of selectors) {
|
|
100
|
+
// Multi-match selectors are rejected before this point by findAmbiguousSelectors
|
|
101
|
+
// in layout.ts; querySelector is safe here.
|
|
102
|
+
const element = safeQuery(selector);
|
|
103
|
+
data[selector] = element ? sampleElement(element) : null;
|
|
104
|
+
}
|
|
105
|
+
return data;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function sampleLiveness(scopes) {
|
|
109
|
+
const liveness = {};
|
|
110
|
+
for (const scope of scopes) {
|
|
111
|
+
const root = scope === "*" ? compositionRoot() : safeQuery(scope);
|
|
112
|
+
liveness[scope] = livenessSignature(root);
|
|
113
|
+
}
|
|
114
|
+
return liveness;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
window.__hyperframesMotionSample = function motionSample(options) {
|
|
118
|
+
const { selectors = [], livenessScopes = [] } = options || {};
|
|
119
|
+
return { data: sampleSelectors(selectors), liveness: sampleLiveness(livenessScopes) };
|
|
120
|
+
};
|
|
121
|
+
})();
|