hyperframes 0.8.72 → 0.8.74
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 +2618 -1582
- package/dist/commands/layout-audit.browser.js +116 -0
- package/dist/fontLocalizeCli.js +731 -391
- package/dist/hyperframe-runtime.js +17 -17
- package/dist/hyperframe.manifest.json +1 -1
- package/dist/hyperframe.runtime.iife.js +17 -17
- package/dist/hyperframes-player.global.js +3 -3
- package/dist/renderSetupWorker.js +242 -0
- package/dist/skills/hyperframes-cli/references/lint-validate-inspect.md +1 -1
- package/dist/studio/assets/hyperframes-player-Bs9qG2hg.js +464 -0
- package/dist/studio/assets/{index-DpVD1igA.js → index-B86QTmD2.js} +1 -1
- package/dist/studio/assets/{index-BqUGSWqN.js → index-DhTyI9zH.js} +173 -173
- package/dist/studio/assets/{index-HuYqM2Fi.js → index-vccPjvCl.js} +1 -1
- package/dist/studio/index.d.ts +14 -1
- package/dist/studio/index.html +1 -1
- package/dist/studio/index.js +1434 -1332
- package/dist/studio/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/studio/assets/hyperframes-player-jTC1EDAI.js +0 -464
|
@@ -27409,6 +27409,7 @@ function containingCallOffset(script, tokenOffset) {
|
|
|
27409
27409
|
}
|
|
27410
27410
|
return start;
|
|
27411
27411
|
}
|
|
27412
|
+
var INSPECTION_VIEWPORT_CAP = 4096;
|
|
27412
27413
|
var MAX_COMPOSITION_LINES = 300;
|
|
27413
27414
|
var MAX_TIMED_ELEMENTS_PER_TRACK = 3;
|
|
27414
27415
|
var TRACK_DENSITY_EXEMPT_TAGS = /* @__PURE__ */ new Set(["audio", "script", "style", "video"]);
|
|
@@ -27531,6 +27532,81 @@ function rootClassStyledSelectors(styles, rootClasses) {
|
|
|
27531
27532
|
}
|
|
27532
27533
|
return offenders;
|
|
27533
27534
|
}
|
|
27535
|
+
var ZOOM_DECLARATION = /(?<![\w-])zoom\s*:\s*([^;}]+)/gi;
|
|
27536
|
+
function isIdentityZoom(rawValue) {
|
|
27537
|
+
const value = rawValue.trim().replace(/!\s*important\s*$/i, "").trim().toLowerCase();
|
|
27538
|
+
return value === "" || value === "normal" || value === "unset" || value === "initial" || value === "revert" || value === "1" || value === "1.0" || value === "100%";
|
|
27539
|
+
}
|
|
27540
|
+
function firstRescalingZoom(css) {
|
|
27541
|
+
ZOOM_DECLARATION.lastIndex = 0;
|
|
27542
|
+
let match;
|
|
27543
|
+
while ((match = ZOOM_DECLARATION.exec(css)) !== null) {
|
|
27544
|
+
const value = (match[1] ?? "").trim();
|
|
27545
|
+
if (!isIdentityZoom(value)) return value.replace(/!\s*important\s*$/i, "").trim();
|
|
27546
|
+
}
|
|
27547
|
+
return null;
|
|
27548
|
+
}
|
|
27549
|
+
function targetsCanvasRoot(selector, rootId, rootClasses) {
|
|
27550
|
+
const leftmost = selector.trim().split(/[\s>+~]+/)[0] ?? "";
|
|
27551
|
+
const bare = leftmost.toLowerCase();
|
|
27552
|
+
if (bare === "html" || bare === "body" || bare === ":root" || bare === "*") return true;
|
|
27553
|
+
if (rootId && leftmostCompoundId(selector) === rootId) return true;
|
|
27554
|
+
return leftmostCompoundClasses(selector).some((cls) => rootClasses.includes(cls));
|
|
27555
|
+
}
|
|
27556
|
+
function inlineCanvasZoomHits(rootTag, tags) {
|
|
27557
|
+
const hits = [];
|
|
27558
|
+
const htmlTag = findHtmlTag(tags);
|
|
27559
|
+
const bodyTag = tags.find((tag) => tag.name.toLowerCase() === "body");
|
|
27560
|
+
for (const [label, tag] of [
|
|
27561
|
+
["the root element's inline style", rootTag],
|
|
27562
|
+
["<html>'s inline style", htmlTag],
|
|
27563
|
+
["<body>'s inline style", bodyTag]
|
|
27564
|
+
]) {
|
|
27565
|
+
if (!tag) continue;
|
|
27566
|
+
const inline = readAttr(tag.raw, "style");
|
|
27567
|
+
const value = inline ? firstRescalingZoom(inline) : null;
|
|
27568
|
+
if (value) hits.push({ where: label, value, snippet: truncateSnippet(tag.raw) });
|
|
27569
|
+
}
|
|
27570
|
+
return hits;
|
|
27571
|
+
}
|
|
27572
|
+
function firstCanvasSelector(header, rootId, rootClasses) {
|
|
27573
|
+
for (const selector of header.split(",")) {
|
|
27574
|
+
const trimmed = selector.trim();
|
|
27575
|
+
if (trimmed && targetsCanvasRoot(trimmed, rootId, rootClasses)) return trimmed;
|
|
27576
|
+
}
|
|
27577
|
+
return null;
|
|
27578
|
+
}
|
|
27579
|
+
function canvasZoomInRule(header, body, rootId, rootClasses) {
|
|
27580
|
+
const trimmedHeader = header.trim();
|
|
27581
|
+
if (!trimmedHeader || trimmedHeader.startsWith("@")) return null;
|
|
27582
|
+
const value = firstRescalingZoom(body);
|
|
27583
|
+
if (!value) return null;
|
|
27584
|
+
const selector = firstCanvasSelector(trimmedHeader, rootId, rootClasses);
|
|
27585
|
+
if (!selector) return null;
|
|
27586
|
+
return {
|
|
27587
|
+
where: `\`${selector}\``,
|
|
27588
|
+
value,
|
|
27589
|
+
snippet: truncateSnippet(`${selector} { zoom: ${value} }`)
|
|
27590
|
+
};
|
|
27591
|
+
}
|
|
27592
|
+
function stylesheetCanvasZoomHits(styles, rootId, rootClasses) {
|
|
27593
|
+
const hits = [];
|
|
27594
|
+
for (const style of styles) {
|
|
27595
|
+
const ruleWithBody = /([^{}]+)\{([^{}]*)\}/g;
|
|
27596
|
+
let match;
|
|
27597
|
+
while ((match = ruleWithBody.exec(stripCssComments(style.content))) !== null) {
|
|
27598
|
+
const hit = canvasZoomInRule(match[1] ?? "", match[2] ?? "", rootId, rootClasses);
|
|
27599
|
+
if (hit) hits.push(hit);
|
|
27600
|
+
}
|
|
27601
|
+
}
|
|
27602
|
+
return hits;
|
|
27603
|
+
}
|
|
27604
|
+
function canvasZoomHits(rootTag, tags, styles, rootId, rootClasses) {
|
|
27605
|
+
return [
|
|
27606
|
+
...inlineCanvasZoomHits(rootTag, tags),
|
|
27607
|
+
...stylesheetCanvasZoomHits(styles, rootId, rootClasses)
|
|
27608
|
+
];
|
|
27609
|
+
}
|
|
27534
27610
|
function collectDeclaredVariableIds(htmlTagRaw) {
|
|
27535
27611
|
const declared = /* @__PURE__ */ new Set();
|
|
27536
27612
|
const raw = readJsonAttr(htmlTagRaw, "data-composition-variables");
|
|
@@ -27569,6 +27645,29 @@ function isInsideInertTemplate(tag, tags) {
|
|
|
27569
27645
|
(candidate) => candidate.name === "template" && candidate.closeIndex != null && tag.index > candidate.index && tag.index < candidate.closeIndex
|
|
27570
27646
|
);
|
|
27571
27647
|
}
|
|
27648
|
+
var NEGATIVE_Z_INDEX = /(?<![\w-])z-index\s*:\s*-(?!0(?:\s*[;}]|\s*$))([\d.]+)/g;
|
|
27649
|
+
function cssOwnerSelector(content, matchIndex) {
|
|
27650
|
+
const blockStart = content.lastIndexOf("{", matchIndex);
|
|
27651
|
+
if (blockStart === -1) return void 0;
|
|
27652
|
+
const previousBlockEnd = content.lastIndexOf("}", blockStart);
|
|
27653
|
+
const lines = content.slice(previousBlockEnd + 1, blockStart).trim().split("\n");
|
|
27654
|
+
return lines[lines.length - 1]?.trim() || void 0;
|
|
27655
|
+
}
|
|
27656
|
+
function elementSelector(tag) {
|
|
27657
|
+
const elementId = readAttr(tag.raw, "id");
|
|
27658
|
+
return elementId ? `#${elementId}` : void 0;
|
|
27659
|
+
}
|
|
27660
|
+
function negativeZIndexFinding(match, selector) {
|
|
27661
|
+
const level = match[1] ?? "";
|
|
27662
|
+
return {
|
|
27663
|
+
code: "negative_z_index",
|
|
27664
|
+
severity: "warning",
|
|
27665
|
+
...selector ? { selector } : {},
|
|
27666
|
+
message: `\`z-index: -${level}\` paints the element behind its nearest stacking context's own content. With no stacking-context ancestor that context is the composition root itself, so an opaque background there hides it entirely: in the DOM, laid out, and absent from the picture. A transparent root leaves it visible. Siblings at \`z-index: 0\` or above are unaffected.`,
|
|
27667
|
+
fixHint: "Give the element's parent a stacking context - `isolation: isolate` is the cheapest, and `transform`, `filter`, `opacity` below 1, `contain: paint` and `will-change` all work too. The negative-z child then paints above that parent's background and renders normally. Or express paint order through DOM order - an earlier sibling paints behind a later one - and raise the elements that should sit in front rather than lowering this one.",
|
|
27668
|
+
snippet: truncateSnippet(match[0])
|
|
27669
|
+
};
|
|
27670
|
+
}
|
|
27572
27671
|
var compositionRules = [
|
|
27573
27672
|
// duplicate_composition_id catches meta-tag/root collisions that create duplicate composition entries.
|
|
27574
27673
|
({ tags }) => {
|
|
@@ -27859,6 +27958,59 @@ var compositionRules = [
|
|
|
27859
27958
|
}
|
|
27860
27959
|
];
|
|
27861
27960
|
},
|
|
27961
|
+
// negative_z_index
|
|
27962
|
+
// An element at a negative z-index is silently absent from both `snapshot`
|
|
27963
|
+
// and `render`, while siblings differing only in the sign of z-index render
|
|
27964
|
+
// exactly (heygen-com/hyperframes#4366). lint, validate and render all exit 0
|
|
27965
|
+
// and report nothing, so the first suspicion falls on the author's own CSS.
|
|
27966
|
+
// NOT A RENDERER DEFECT -- ORDINARY CSS PAINTING ORDER, measured at 0.8.72.
|
|
27967
|
+
// The element is PAINTED; it is simply painted beneath something opaque. Remove
|
|
27968
|
+
// every opaque background above it and the same `z-index: -1` band renders at
|
|
27969
|
+
// full coverage, identically to the same band with `z-index` deleted. A negative-z
|
|
27970
|
+
// descendant paints at step 2 of its nearest stacking context -- above that
|
|
27971
|
+
// context root's own background, but below the context's positioned in-flow
|
|
27972
|
+
// content -- so a `position: relative` composition root with an opaque background
|
|
27973
|
+
// paints over it. That is the shape #4366 reports; nothing is dropped and the
|
|
27974
|
+
// renderer has no say in it.
|
|
27975
|
+
//
|
|
27976
|
+
// Kept as a warning because authors hit it and nothing explains why: the element
|
|
27977
|
+
// is in the DOM, laid out, and invisible. The message therefore names the CAUSE
|
|
27978
|
+
// and the remedy rather than implying the tool failed.
|
|
27979
|
+
//
|
|
27980
|
+
// THE CONDITION IS LOAD-BEARING AND THE MESSAGE STATES IT. The element is only
|
|
27981
|
+
// dropped when its nearest ancestor stacking context is the composition root,
|
|
27982
|
+
// which is the shape #4366 reports. Give any ancestor a stacking context and it
|
|
27983
|
+
// renders correctly: measured at 0.8.72 on ONE frame carrying the same
|
|
27984
|
+
// `z-index: -1` band under six triggers -- isolation:isolate, transform,
|
|
27985
|
+
// opacity below 1, filter, contain:paint, will-change -- all six PRESENT at full
|
|
27986
|
+
// coverage, against the no-stacking-context control ABSENT at zero coverage.
|
|
27987
|
+
// That is ordinary CSS painting order: a negative-z child paints above its
|
|
27988
|
+
// stacking context root's own background, so only the root case is hidden.
|
|
27989
|
+
//
|
|
27990
|
+
// This rule matches CSS TEXT and does not resolve the cascade, so it cannot know
|
|
27991
|
+
// whether an ancestor forms a stacking context and fires on both shapes. That is
|
|
27992
|
+
// why the message is CONDITIONAL rather than an assertion of absence: an
|
|
27993
|
+
// unconditional "silently dropped" is false for every isolated case, and a lint
|
|
27994
|
+
// message that overclaims is how authors learn to disregard the rule.
|
|
27995
|
+
({ tags, styles }) => {
|
|
27996
|
+
const findings = [];
|
|
27997
|
+
for (const style of styles) {
|
|
27998
|
+
const content = stripCssComments(style.content);
|
|
27999
|
+
NEGATIVE_Z_INDEX.lastIndex = 0;
|
|
28000
|
+
let match;
|
|
28001
|
+
while ((match = NEGATIVE_Z_INDEX.exec(content)) !== null) {
|
|
28002
|
+
findings.push(negativeZIndexFinding(match, cssOwnerSelector(content, match.index)));
|
|
28003
|
+
}
|
|
28004
|
+
}
|
|
28005
|
+
for (const tag of tags) {
|
|
28006
|
+
const inline = readAttr(tag.raw, "style");
|
|
28007
|
+
if (!inline) continue;
|
|
28008
|
+
NEGATIVE_Z_INDEX.lastIndex = 0;
|
|
28009
|
+
const match = NEGATIVE_Z_INDEX.exec(inline);
|
|
28010
|
+
if (match) findings.push(negativeZIndexFinding(match, elementSelector(tag)));
|
|
28011
|
+
}
|
|
28012
|
+
return findings;
|
|
28013
|
+
},
|
|
27862
28014
|
// requestanimationframe_in_composition
|
|
27863
28015
|
({ scripts, rawSource, options }) => {
|
|
27864
28016
|
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
|
|
@@ -28278,6 +28430,96 @@ ${allInlineStyles}`.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
|
28278
28430
|
fixHint: `${splitTarget} and concat the pieces (FFmpeg or the runtime's slideshow) so each capture only sees a small subset of heavy overlays at once. Even hidden overlays (opacity:0 / visibility:hidden) contribute \u2014 either remove truly unused ones from the source or scope them into their own per-transition sub-composition. If an overlay is genuinely inert for the whole clip, use display:none so it never enters the render tree. Field ref ts=1784040753 (#hyperframes-cli-feedback).`
|
|
28279
28431
|
}
|
|
28280
28432
|
];
|
|
28433
|
+
},
|
|
28434
|
+
// root_zoom_rescales_a_fixed_canvas
|
|
28435
|
+
//
|
|
28436
|
+
// The capture frame is sized from the root's data-width/data-height, which are
|
|
28437
|
+
// LAYOUT pixels. CSS `zoom` on the canvas itself (the root, or html/body above
|
|
28438
|
+
// it) rescales the painted content inside that frame without changing the frame,
|
|
28439
|
+
// so the two disagree and the author gets neither of the two things they might
|
|
28440
|
+
// have meant.
|
|
28441
|
+
//
|
|
28442
|
+
// Measured at 0.8.71 on an 800x400 composition holding two 100x100 boxes at
|
|
28443
|
+
// left:100 and left:600, rendered to a PNG sequence and the boxes located by
|
|
28444
|
+
// colour:
|
|
28445
|
+
//
|
|
28446
|
+
// root zoom:0.5 canvas stays 800x400; boxes paint at (50,25) and (300,25),
|
|
28447
|
+
// each 50x50 -- the composition occupies the top-left quarter
|
|
28448
|
+
// and the rest of every frame is dead space.
|
|
28449
|
+
// root zoom:2 canvas stays 800x400; the left:100 box paints at (200,100)
|
|
28450
|
+
// at 200x200, and the left:600 box renders ZERO PIXELS --
|
|
28451
|
+
// scaled to x=1200, outside a frame that is still 800 wide.
|
|
28452
|
+
//
|
|
28453
|
+
// The second case is the one worth an error: content that is inside the declared
|
|
28454
|
+
// composition silently does not exist in the output, with nothing else in lint,
|
|
28455
|
+
// check or the render log naming it.
|
|
28456
|
+
//
|
|
28457
|
+
// Deliberately NOT flagged: `zoom` on a descendant. Measured in the same run --
|
|
28458
|
+
// a child at left:100 with zoom:2 paints at (200,100) at 200x200, which is
|
|
28459
|
+
// exactly what CSS `zoom` specifies. It is honoured, not ignored, so a rule that
|
|
28460
|
+
// fired on every `zoom` would be flagging correct authoring.
|
|
28461
|
+
({ rootTag, tags, styles }) => {
|
|
28462
|
+
if (!rootTag) return [];
|
|
28463
|
+
const rootId = readAttr(rootTag.raw, "id");
|
|
28464
|
+
const rootClasses = (readAttr(rootTag.raw, "class") || "").split(/\s+/).filter(Boolean);
|
|
28465
|
+
const hits = canvasZoomHits(rootTag, tags, styles, rootId, rootClasses);
|
|
28466
|
+
const hit = hits[0];
|
|
28467
|
+
if (!hit) return [];
|
|
28468
|
+
const enlarging = !hit.value.startsWith("-") && parseFloat(hit.value) > (hit.value.includes("%") ? 100 : 1);
|
|
28469
|
+
return [
|
|
28470
|
+
{
|
|
28471
|
+
code: "root_zoom_rescales_a_fixed_canvas",
|
|
28472
|
+
severity: "error",
|
|
28473
|
+
message: `\`zoom: ${hit.value}\` on ${hit.where} rescales the painted composition inside a frame that does not rescale with it. The capture frame is sized from the root's data-width/data-height in LAYOUT pixels, and \`zoom\` changes only what is painted inside it, so ` + (enlarging ? `content past the frame's edge renders zero pixels \u2014 it is inside the declared composition and absent from the output, with nothing else reporting it.` : `the composition paints into part of the frame and the remainder of every output frame is dead space.`),
|
|
28474
|
+
fixHint: `Author the composition at its real size \u2014 set data-width/data-height (and the root's width/height) to the dimensions you want \u2014 and drop the canvas-level \`zoom\`. To scale the OUTPUT without changing layout, pass \`--output-resolution\` to render, which supersamples. \`zoom\` on descendants is fine and is not flagged: it is honoured exactly as specified.`,
|
|
28475
|
+
snippet: hit.snippet
|
|
28476
|
+
}
|
|
28477
|
+
];
|
|
28478
|
+
},
|
|
28479
|
+
// composition_exceeds_inspection_viewport_cap
|
|
28480
|
+
//
|
|
28481
|
+
// packages/cli/src/utils/compositionViewport.ts caps a parsed data-width /
|
|
28482
|
+
// data-height at MAX_VIEWPORT_DIMENSION (4096) with a plain Math.min and no
|
|
28483
|
+
// warning. captureCompositionFrame resolves its viewport through that helper,
|
|
28484
|
+
// and check, validate, snapshot, compare and layout all capture through it;
|
|
28485
|
+
// layout and motionShot carry their own independent Math.min(..., 4096).
|
|
28486
|
+
//
|
|
28487
|
+
// `render` does NOT go through it, and that asymmetry is the whole finding.
|
|
28488
|
+
// Measured at 0.8.71 on a 5000x400 composition with 100x100 boxes at left:100
|
|
28489
|
+
// and left:4500:
|
|
28490
|
+
//
|
|
28491
|
+
// render -> 5000x400 output, both boxes present.
|
|
28492
|
+
// snapshot -> 4096x400 frame, the left:4500 box ABSENT.
|
|
28493
|
+
//
|
|
28494
|
+
// So the video is correct and the tools an author would reach for to check it
|
|
28495
|
+
// silently cannot see the last 904 px. The failure direction is the awkward
|
|
28496
|
+
// one: `check` comes back clean on a region it never rendered, and someone
|
|
28497
|
+
// debugging a missing element through `snapshot` chases a difference that
|
|
28498
|
+
// exists only in the instrument.
|
|
28499
|
+
//
|
|
28500
|
+
// A warning, not an error: the deliverable is fine, and a composition wider
|
|
28501
|
+
// than 4096 can be entirely deliberate.
|
|
28502
|
+
({ rootTag }) => {
|
|
28503
|
+
if (!rootTag) return [];
|
|
28504
|
+
const over = [];
|
|
28505
|
+
for (const attr of ["data-width", "data-height"]) {
|
|
28506
|
+
const raw = readAttr(rootTag.raw, attr);
|
|
28507
|
+
if (!raw) continue;
|
|
28508
|
+
const value = Number.parseInt(raw, 10);
|
|
28509
|
+
if (Number.isFinite(value) && value > INSPECTION_VIEWPORT_CAP) {
|
|
28510
|
+
over.push(`${attr}=${value}`);
|
|
28511
|
+
}
|
|
28512
|
+
}
|
|
28513
|
+
if (over.length === 0) return [];
|
|
28514
|
+
return [
|
|
28515
|
+
{
|
|
28516
|
+
code: "composition_exceeds_inspection_viewport_cap",
|
|
28517
|
+
severity: "warning",
|
|
28518
|
+
message: `${over.join(" and ")} exceeds the ${INSPECTION_VIEWPORT_CAP}px viewport cap that check, validate, snapshot, compare and layout clamp to. render is unaffected and produces the full size, so the video is correct \u2014 but every inspection command captures a ${INSPECTION_VIEWPORT_CAP}px-wide frame, and anything beyond that is missing from what they report. Measured at 0.8.71: a 5000x400 composition renders 5000x400 with all content, while snapshot returns 4096x400 with the element at left:4500 absent.`,
|
|
28519
|
+
fixHint: `Keep the authored size if the output needs it, and verify the region past ${INSPECTION_VIEWPORT_CAP}px from a render rather than from check/snapshot/compare \u2014 a clean inspection result does not cover it. If the large canvas is only there to gain resolution, author at the layout size and pass \`--output-resolution\` to render instead, which supersamples without changing layout.`,
|
|
28520
|
+
snippet: truncateSnippet(rootTag.raw)
|
|
28521
|
+
}
|
|
28522
|
+
];
|
|
28281
28523
|
}
|
|
28282
28524
|
];
|
|
28283
28525
|
var CLIP_MEDIA_TAGS = /* @__PURE__ */ new Set(["img", "video", "audio"]);
|
|
@@ -50,7 +50,7 @@ One command, one Chrome boot. `check` runs the linter first and skips the browse
|
|
|
50
50
|
|
|
51
51
|
Every finding carries a selector, the element's `data-*` identity, the composition source file, a bbox, and the sample time: jump straight from the JSON to the HTML you must edit and re-run.
|
|
52
52
|
|
|
53
|
-
**Severity is persistence-aware.** A dynamic issue observed at a single grid sample (an entrance/exit transient) demotes to info and never gates. Issues held across samples gate the exit code, a held `content_overlap` is an error, and a held, partially-visible `canvas_overflow` breaching ≥5% of the canvas promotes to warning. Coordinate-frame findings (`escaped_container`, `panel_out_of_canvas`, `connector_detached`) flag geometry computed in one frame but rendered in another — an element far outside its offset parent, a painted panel stuck across the canvas edge, a connector line detached from every node. If a 3s+ composition shows zero geometry change across every sample, `check` fails with `sweep_static`: a frozen timeline makes every green verdict unreliable, so it refuses to pass. The fingerprint includes per-element opacity, so opacity-only reveals (code typing, staggered fades) count as motion — but only while they're still in flight at the sampled times. The classic trap is a reveal that completes early and then holds a static frame for the rest of the duration: every sample lands on the settled state and the run fails. Spread the reveal across the timeline or keep one continuously animated element alive (a blinking caret is idiomatic for code typing) — don't bolt on a slow position drift just to appease the check.
|
|
53
|
+
**Severity is persistence-aware.** A dynamic issue observed at a single grid sample (an entrance/exit transient) demotes to info and never gates. Issues held across samples gate the exit code, a held `content_overlap` is an error, and a held, partially-visible `canvas_overflow` breaching ≥5% of the canvas promotes to warning. Coordinate-frame findings (`escaped_container`, `panel_out_of_canvas`, `connector_detached`) flag geometry computed in one frame but rendered in another — an element far outside its offset parent, a painted panel stuck across the canvas edge, a connector line detached from every node. Text drawn into a `<canvas>` has no DOM box, so `canvas_overflow` cannot see it; `canvas_content_at_edge` warns when a canvas's pixels show sharp content (drawn text, hard shapes) along the frame edge — mark intentional full-bleed art (particles, photos) with `data-layout-allow-overflow`. If a 3s+ composition shows zero geometry change across every sample, `check` fails with `sweep_static`: a frozen timeline makes every green verdict unreliable, so it refuses to pass. The fingerprint includes per-element opacity, so opacity-only reveals (code typing, staggered fades) count as motion — but only while they're still in flight at the sampled times. The classic trap is a reveal that completes early and then holds a static frame for the rest of the duration: every sample lands on the settled state and the run fails. Spread the reveal across the timeline or keep one continuously animated element alive (a blinking caret is idiomatic for code typing) — don't bolt on a slow position drift just to appease the check.
|
|
54
54
|
|
|
55
55
|
**Escape hatches** (mark intent in the HTML, then re-run):
|
|
56
56
|
|