Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,7 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o
| `--caption-zone "<x0=..;y0=..;x1=..;y1=..>"` | Opt-in band gate: flags content whose center sits inside the fractional band (optional `severity`, `seek`) |
| `--frame-check` | Opt-in media out-of-frame detection (img/svg/video/canvas) |

Contrast failures are **errors** and include the sampled fg/bg colors, measured vs required ratio, and a suggested compliant color. Severity is persistence-aware: single-sample transients demote to info, held findings gate the exit code, and a frozen timeline on a 3s+ composition fails with `sweep_static`.
Contrast failures are **errors** and include the sampled fg/bg colors, measured vs required ratio, and a suggested compliant color. Severity is persistence-aware: single-sample transients demote to info, held findings gate the exit code, and a frozen timeline on a 3s+ composition fails with `sweep_static` — unless the root declares `data-layout-allow-static`, which reports it at info instead.

### `beats`

Expand Down
31 changes: 31 additions & 0 deletions packages/cli/src/commands/check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ function fakeDriver(overrides: Partial<CheckAuditDriver> = {}): CheckAuditDriver
getDuration: vi.fn(async () => 9),
getTransitionBoundaries: vi.fn(async () => []),
getCanvas: vi.fn(async () => ({ width: 1920, height: 1080 })),
hasStaticOptOut: vi.fn(async () => false),
findAmbiguousSelectors: vi.fn(async (_selectors: string[]) => []),
seek: vi.fn(async (_time: number) => undefined),
seekGeometry: vi.fn(async (_time: number) => undefined),
Expand Down Expand Up @@ -1095,6 +1096,36 @@ describe("check pipeline", () => {
).toBe(true);
});

it("reports at info, without gating, when the root declares data-layout-allow-static", async () => {
// A held title card had no passing spelling before. The claim is the author's and can be
// wrong, so the record survives at info rather than vanishing.
const driver = fakeDriver({
getDuration: vi.fn(async () => 6),
collectLayoutGeometry: vi.fn(async () => "frozen"),
hasStaticOptOut: vi.fn(async () => true),
});
const { report } = await runScenario(driver);

const sweep = report.layout.findings.find((finding) => finding.code === "sweep_static");
expect(sweep?.severity).toBe("info");
expect(sweep?.message).toContain("data-layout-allow-static");
expect(report.ok).toBe(true);
});

it("keeps the unqualified wording when the root does not declare the opt-out", async () => {
const driver = fakeDriver({
getDuration: vi.fn(async () => 6),
collectLayoutGeometry: vi.fn(async () => "frozen"),
hasStaticOptOut: vi.fn(async () => false),
});
const { report } = await runScenario(driver);

const sweep = report.layout.findings.find((finding) => finding.code === "sweep_static");
expect(sweep?.severity).toBe("error");
expect(sweep?.message).toContain("did not advance");
expect(sweep?.message).not.toContain("data-layout-allow-static");
});

it("does not flag a 1.5s static title card — too short for the guard to apply", async () => {
const driver = fakeDriver({
getDuration: vi.fn(async () => 1.5),
Expand Down
17 changes: 14 additions & 3 deletions packages/cli/src/commands/layout-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -1471,11 +1471,22 @@
}
}

window.__hyperframesLayoutGeometry = function collectLayoutGeometry() {
const root =
/** The composition root, resolved identically for the fingerprint and the opt-out that excuses it. */
function compositionRoot() {
return (
document.querySelector("[data-composition-id][data-width][data-height]") ||
document.querySelector("[data-composition-id]") ||
document.body;
document.body
);
}

/** Root-only: a held title card declares itself motionless, so the frozen-sweep guard reports without gating. */
window.__hyperframesStaticOptOut = function collectStaticOptOut() {
return compositionRoot().hasAttribute("data-layout-allow-static");
};

window.__hyperframesLayoutGeometry = function collectLayoutGeometry() {
const root = compositionRoot();
const elements = Array.from(root.querySelectorAll("*")).filter((element) =>
isVisibleElement(element),
);
Expand Down
44 changes: 44 additions & 0 deletions packages/cli/src/commands/layout-audit.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,50 @@ describe("layout-audit.browser", () => {
expect(textOverflowCodes()).toEqual([]);
});

// Root-only by design: a stray attribute on a child must not excuse a frozen sweep for the
// whole run, and the root is resolved exactly as the fingerprint resolves it.
describe("static opt-out", () => {
function optOutFor(html: string): boolean {
document.body.innerHTML = html;
installAuditScript();
const probe = Reflect.get(window, "__hyperframesStaticOptOut");
if (typeof probe !== "function") throw new Error("probe missing");
return Reflect.apply(probe, window, []) === true;
}

it("is satisfied by the attribute on the composition root", () => {
expect(
optOutFor(
`<div id="root" data-composition-id="main" data-width="640" data-height="360" data-layout-allow-static></div>`,
),
).toBe(true);
});

it("is NOT satisfied by the attribute on a child", () => {
expect(
optOutFor(
`<div id="root" data-composition-id="main" data-width="640" data-height="360"><div data-layout-allow-static></div></div>`,
),
).toBe(false);
});

it("is NOT satisfied by the attribute on a nested composition host", () => {
expect(
optOutFor(
`<div id="root" data-composition-id="main" data-width="640" data-height="360"><div data-composition-id="scene" data-width="640" data-height="360" data-layout-allow-static></div></div>`,
),
).toBe(false);
});

it("is absent when nothing declares it", () => {
expect(
optOutFor(
`<div id="root" data-composition-id="main" data-width="640" data-height="360"></div>`,
),
).toBe(false);
});
});

it("does not flag glyph-ink vertical spill within the font-metric band on a non-clipping box", () => {
// A painted, non-clipping caption-word-like box whose glyph ink (text rect) exceeds its snug
// line-height box by a few px vertically — normal typography, nothing is clipped. (fontSize
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/utils/checkBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud
getTransitionBoundaries: () => collectTweenBoundaries(page),
getCanvas: () =>
page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })),
hasStaticOptOut: () => evaluateStaticOptOut(page),
findAmbiguousSelectors: (selectors) => findAmbiguousSelectors(page, selectors),
seek: async (time) => {
setTime(time);
Expand Down Expand Up @@ -483,6 +484,14 @@ async function collectOverlap(page: Page, time: number): Promise<AnchoredLayoutI
return anchorLayoutIssues(page, raw.flatMap(parseLayoutIssue));
}

async function evaluateStaticOptOut(page: Page): Promise<boolean> {
return page.evaluate(() => {
const probe = Reflect.get(window, "__hyperframesStaticOptOut");
if (typeof probe !== "function") return false;
return Reflect.apply(probe, window, []) === true;
});
}

async function collectLayoutGeometry(page: Page): Promise<string> {
return page.evaluate(() => {
const geometry = Reflect.get(window, "__hyperframesLayoutGeometry");
Expand Down
19 changes: 14 additions & 5 deletions packages/cli/src/utils/checkPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,11 +473,17 @@ const ZERO_LAYOUT_RECT: LayoutRect = {
* short (<3s) compositions, single-sample runs (nothing to compare), and
* runs where a `motion_frozen` finding already reported the same underlying
* symptom (no double-reporting the one thing that's wrong).
*
* A root marked `data-layout-allow-static` is a held title card or logo lockup
* — motionless on purpose, and previously unpassable. That claim is the
* author's and can be wrong, so the finding is still emitted, at `info`: it
* keeps the record that every other verdict came from one repeated frame.
*/
function detectSweepStatic(
duration: number,
geometrySignatures: string[],
motionIssues: AnchoredLayoutIssue[],
staticOptOut: boolean,
): AnchoredLayoutIssue[] {
if (duration < SWEEP_STATIC_MIN_DURATION_SEC) return [];
if (geometrySignatures.length < 2) return [];
Expand All @@ -487,17 +493,19 @@ function detectSweepStatic(
return [
{
code: "sweep_static",
severity: "error",
severity: staticOptOut ? "info" : "error",
time: 0,
selector: "[data-composition-id]",
dataAttributes: {},
sourceFile: "index.html",
bbox: ZERO_BBOX,
rect: ZERO_LAYOUT_RECT,
message:
"Timeline did not advance under seek; every green verdict on this run is unreliable.",
fixHint:
"Confirm the composition seeks a paused GSAP/CSS timeline under `data-*` timing attributes rather than only autoplaying.",
message: staticOptOut
? "No geometry change across the sampled grid; this composition declares data-layout-allow-static, so every other verdict reflects a single frame."
: "Timeline did not advance under seek; every green verdict on this run is unreliable.",
fixHint: staticOptOut
? "Remove data-layout-allow-static if this composition is meant to animate."
: "Confirm the composition seeks a paused GSAP/CSS timeline under `data-*` timing attributes rather than only autoplaying.",
},
];
}
Expand Down Expand Up @@ -1012,6 +1020,7 @@ export async function runAuditGrid(
grid.duration,
collected.geometrySignatures,
motionIssues,
await driver.hasStaticOptOut(),
);
const rotationFindings = detectRotationPivotDrift(
collected.rotationSamples,
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/utils/checkTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ export interface CheckAuditDriver {
getDuration(): Promise<number>;
getTransitionBoundaries(): Promise<number[]>;
getCanvas(): Promise<Canvas>;
/** Whether the composition root declares it is deliberately motionless, so the frozen-sweep guard reports without gating. */
hasStaticOptOut(): Promise<boolean>;
findAmbiguousSelectors(selectors: string[]): Promise<AnchoredLayoutIssue[]>;
seek(time: number): Promise<void>;
/** Settle-free seek for the geometry-only dense content_overlap pass; only collectOverlap consumes it, and getBoundingClientRect is valid synchronously after setTime. */
Expand Down
4 changes: 2 additions & 2 deletions skills-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@
"files": 121
},
"hyperframes-cli": {
"hash": "a4db0693ff88e760",
"hash": "cba64cc502702676",
"files": 11
},
"hyperframes-core": {
"hash": "773fc6d15d9e87b3",
"hash": "6c4061695aa3f09d",
"files": 19
},
"hyperframes-creative": {
Expand Down
3 changes: 2 additions & 1 deletion skills/hyperframes-cli/references/lint-validate-inspect.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,12 @@ One command, one Chrome boot. `check` runs the linter first and skips the browse

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.

**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.
**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. A piece that is motionless _by design_ is the exception: mark the root `data-layout-allow-static` and `sweep_static` drops to info, keeping the record that every other verdict came from one repeated frame.

**Escape hatches** (mark intent in the HTML, then re-run):

- `data-layout-allow-overflow` — overflow is intentional (entrance/exit travel).
- `data-layout-allow-static` — on the composition root: this piece is deliberately motionless (held title card, logo lockup), so `sweep_static` reports at info instead of failing.
- `data-layout-allow-overlap` — deliberate text layering (e.g. a demo cursor label over a heading).
- `data-layout-allow-occlusion` — an element is meant to cover text.
- `data-layout-ignore` — decorative element that should never be audited.
Expand Down
1 change: 1 addition & 0 deletions skills/hyperframes-core/references/data-attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ See `sub-compositions.md` for the full wiring pattern.

- `id="root"` — template convention used by scaffolds and the transition catalog so CSS can target the composition root with `#root` instead of `[data-composition-id="main"]`. Not required by the runtime, but consistent with the rest of the ecosystem.
- `class="clip"` — required runtime visibility marker on visible timed elements (`<div>`, `<img>`, …). See Clip Attributes above.
- `data-layout-allow-static` — on the composition root only: declares the piece deliberately motionless (held title card, logo lockup). `sweep_static` then reports at info instead of failing the run. Layout-audit only; no render-path effect. Remove it if the composition is meant to animate.
- `data-layout-allow-overflow` — tells `hyperframes check` that overflow on this element (or its descendants) is intentional. Notes:
- The `check` layout audit measures `getBoundingClientRect` at sampled timestamps, not rendered pixels. `overflow: hidden` clips the visual but does **not** suppress a layout finding. This attribute is the escape hatch; CSS overflow is not.
- Can be set on the composition **root** as well as on any child. When the cited offender is `div.<comp>-root inside div.<comp>-root` (the root reports its own children's union as overflowing), the fix goes on the root, not on individual text descendants — shrinking font sizes will not converge.
Expand Down
Loading