Skip to content
Merged
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
19 changes: 19 additions & 0 deletions packages/cli/src/commands/contrast-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,25 @@ window.__contrastAudit = async function (imgBase64, time) {
var cs = getComputedStyle(el);
if (cs.visibility === "hidden" || cs.display === "none") continue;
if (parseFloat(cs.opacity) <= 0.01) continue;
// Also skip when an ANCESTOR is effectively invisible (opacity≈0 / hidden / display:none).
// Karaoke captions keep every word at opacity 1 but toggle the GROUP's opacity per beat,
// so an inactive word's OWN opacity is 1 — only an ancestor reveals it's hidden. Without
// this, the hidden caption words flood the audit with false ~1:1 contrast warnings.
var anc = el.parentElement,
ancHidden = false;
while (anc && anc !== document.body) {
var acs = getComputedStyle(anc);
if (
acs.visibility === "hidden" ||
acs.display === "none" ||
parseFloat(acs.opacity) <= 0.01
) {
ancHidden = true;
break;
}
anc = anc.parentElement;
}
if (ancHidden) continue;
var rect = el.getBoundingClientRect();
if (rect.width < 8 || rect.height < 8) continue;
if (rect.right <= 0 || rect.bottom <= 0 || rect.left >= w || rect.top >= h) continue;
Expand Down
27 changes: 22 additions & 5 deletions packages/cli/src/commands/layout-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,18 @@
return Math.round(value * 100) / 100;
}

function overflowFor(subject, container, tolerance) {
function overflowFor(subject, container, tolerance, vTolerance) {
// Horizontal axis uses `tolerance`; vertical axis uses `vTolerance` (defaults to the same).
// A separate vertical tolerance lets text overflow checks absorb glyph ink that exceeds a
// snug line-height — see textOverflowIssues.
if (vTolerance == null) vTolerance = tolerance;
const overflow = {};
if (subject.left < container.left - tolerance)
overflow.left = round(container.left - subject.left);
if (subject.right > container.right + tolerance)
overflow.right = round(subject.right - container.right);
if (subject.top < container.top - tolerance) overflow.top = round(container.top - subject.top);
if (subject.bottom > container.bottom + tolerance)
if (subject.top < container.top - vTolerance) overflow.top = round(container.top - subject.top);
if (subject.bottom > container.bottom + vTolerance)
overflow.bottom = round(subject.bottom - container.bottom);
return Object.keys(overflow).length > 0 ? overflow : null;
}
Expand Down Expand Up @@ -319,9 +323,22 @@

const container = nearestConstraint(element, root, rootRect);
const containerRect = container === root ? rootRect : toRect(container.getBoundingClientRect());
const containerOverflow = overflowFor(textRect, containerRect, tolerance);
// Glyph ink (ascenders / descenders / accents / heavy display faces) routinely exceeds a
// snug line-height box by a few px, proportional to font size. When the constraining box
// does NOT clip, that vertical spill is normal typography — it shows in the padding, nothing
// is hidden — not a layout defect (it false-flagged caption words). Allow a font-metric
// vertical tolerance there; keep it tight when the box actually clips (a real cut-off) and
// always tight horizontally (too-wide text is a real wrap/legibility issue).
const elementStyle = getComputedStyle(element);
const containerClips = clipsOverflow(
container === root ? getComputedStyle(root) : getComputedStyle(container),
);
const verticalTolerance = containerClips
? tolerance
: Math.max(tolerance, parsePx(elementStyle.fontSize) * 0.2);
const containerOverflow = overflowFor(textRect, containerRect, tolerance, verticalTolerance);
if (containerOverflow && !hasAllowOverflowFlag(element)) {
const style = getComputedStyle(element);
const style = elementStyle;
issues.push({
code: "text_box_overflow",
severity: "error",
Expand Down
36 changes: 36 additions & 0 deletions packages/cli/src/commands/layout-audit.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,42 @@ describe("layout-audit.browser", () => {

expect(runAudit()).toEqual([]);
});

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
// 36 → vertical tolerance ~7.2px; the ink spills ~5px each side, well within it.)
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="bubble"><div id="headline">crews,</div></div>
</div>
`;
installGeometry({
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
bubble: rect({ left: 80, top: 120, width: 400, height: 80 }),
text: rect({ left: 100, top: 115, width: 300, height: 90 }),
});
installAuditScript();

expect(runAudit().some((issue) => issue.code === "text_box_overflow")).toBe(false);
});

it("still flags vertical text overflow beyond the font-metric band", () => {
// Ink is 40px / 80px beyond the box — far past the ~7px font-metric band: a real overflow.
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="bubble"><div id="headline">two crammed lines</div></div>
</div>
`;
installGeometry({
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
bubble: rect({ left: 80, top: 120, width: 400, height: 80 }),
text: rect({ left: 100, top: 80, width: 300, height: 200 }),
});
installAuditScript();

expect(runAudit().some((issue) => issue.code === "text_box_overflow")).toBe(true);
});
});

describe("layout-audit.browser content overlap", () => {
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/lint/rules/captions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,26 @@ describe("caption rules", () => {
expect(finding).toBeUndefined();
});

it("does not warn on a content frame that only mentions karaoke in a comment", async () => {
const html = `<template id="06-one-platform-template">
<div id="root" data-composition-id="06-one-platform" data-width="1920" data-height="1080">
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
// "Minutes, not weeks" lands with a karaoke-style keyword glow
SCREENS.forEach(function (s, i) {
var el = document.getElementById("screen-" + i);
tl.to(el, { y: -40, opacity: 0, duration: 0.3 }, i * 1.3);
});
window.__timelines["06-one-platform"] = tl;
</script>
</div>
</template>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
expect(finding).toBeUndefined();
});

it("warns when caption group has nowrap without max-width", async () => {
const html = `
<html><body>
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/lint/rules/captions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,17 @@ function extractArrayLiteral(src: string, varMatch: RegExpExecArray): string | n

export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// caption_exit_missing_hard_kill
({ scripts }) => {
({ scripts, styles, options, rootCompositionId }) => {
const findings: HyperframeLintFinding[] = [];
// Only the ACTUAL captions composition. A content frame that merely mentions
// "karaoke" / "caption-*" in a comment (or uses an unrelated forEach + opacity:0
// screen-swap) is NOT captions — gating here prevents the false positive that fired
// on a content frame whose only caption signal was a descriptive comment.
const isCaptionComposition =
Boolean(options.filePath && /caption/i.test(options.filePath)) ||
rootCompositionId === "captions" ||
styles.some((s) => /\.caption[-_]?(?:group|word|line|block)\b|\.cg-/.test(s.content));
if (!isCaptionComposition) return findings;
for (const script of scripts) {
const content = script.content;
const hasExitTween = /\.to\s*\([^,]+,\s*\{[^}]*opacity\s*:\s*0/.test(content);
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/lint/rules/media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,4 +220,32 @@ describe("media rules", () => {
const finding = result.findings.find((f) => f.code === "imperative_media_control");
expect(finding).toBeUndefined();
});

it("flags <video> inside a sub-composition (media must be a host-root child)", async () => {
const html = `<template id="scene-template">
<div id="root" data-composition-id="scene" data-width="1920" data-height="1080">
<video id="v1" src="clip.mp4" data-start="0" data-duration="5" muted playsinline></video>
<script>window.__timelines = window.__timelines || {}; window.__timelines["scene"] = gsap.timeline({ paused: true });</script>
</div>
</template>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
const finding = result.findings.find((f) => f.code === "media_in_subcomposition");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("v1");
expect(finding?.message).toContain("sub-composition");
});

it("does not flag media in a host-root (non-sub) composition", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="v1" src="clip.mp4" data-start="0" data-duration="5" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_in_subcomposition");
expect(finding).toBeUndefined();
});
});
23 changes: 23 additions & 0 deletions packages/core/src/lint/rules/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,29 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
return findings;
},

// media_in_subcomposition — <video>/<audio> only render as a DIRECT child of the host
// root (index.html). Inside a sub-composition <template> the runtime never seeks/decodes
// them, so they render BLANK/black in preview and renders — and the other lint/validate
// passes otherwise miss it (only a per-frame snapshot reveals the blank panel).
({ tags, options }) => {
const findings: HyperframeLintFinding[] = [];
if (!options.isSubComposition) return findings;
for (const tag of tags) {
if (tag.name !== "video" && tag.name !== "audio") continue;
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "media_in_subcomposition",
severity: "error",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> is inside a sub-composition. The runtime only drives media that is a DIRECT child of the host root (index.html); media inside a sub-comp <template> is never seeked/decoded and renders BLANK/black in preview and renders.`,
elementId,
fixHint:
"Move the media OUT of the sub-composition: place the <video>/<audio> as a direct child of #root in index.html, positioned over the scene, and drive any per-scene motion on the MAIN timeline at global time (a sub-comp timeline cannot reach host elements). See composition-patterns.md archetype B.",
snippet: truncateSnippet(tag.raw),
});
}
return findings;
},

// self_closing_media_tag
({ source }) => {
const findings: HyperframeLintFinding[] = [];
Expand Down
12 changes: 6 additions & 6 deletions skills-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"files": 144
},
"faceless-explainer": {
"hash": "d6beeca6029b815a",
"hash": "e50186bf73e5092e",
"files": 17
},
"general-video": {
Expand All @@ -18,8 +18,8 @@
"files": 1
},
"hyperframes-animation": {
"hash": "ced7bd16b3cdb376",
"files": 108
"hash": "57458f4308708e21",
"files": 115
},
"hyperframes-cli": {
"hash": "ed24d781c2ae462b",
Expand All @@ -34,7 +34,7 @@
"files": 67
},
"hyperframes-media": {
"hash": "ce80c5a15ecaf0fc",
"hash": "096b2ab0a43b05dd",
"files": 40
},
"hyperframes-registry": {
Expand All @@ -54,11 +54,11 @@
"files": 132
},
"pr-to-video": {
"hash": "d7acaeb6281f99ac",
"hash": "f8f7691af263bf03",
"files": 21
},
"product-launch-video": {
"hash": "5669874d3bdd5bd4",
"hash": "dedfc10e340df06c",
"files": 18
},
"remotion-to-hyperframes": {
Expand Down
2 changes: 1 addition & 1 deletion skills/faceless-explainer/references/visual-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Entrance: the snowball seats upper-left on a dim hill gradient. Development: it
```

- **`effects`** — name atomic effect **ids** from `hyperframes-animation`'s rules index. **Cite ≥3 when you name no `blueprint`** (the worker composes them into the beat; fewer than 3 reads as generic motion); 1+ as accents when a blueprint already carries the choreography. With no blueprint, those **≥3 effects are the shot's phases** — your note must **sequence them** (one enters, one develops, one emphasizes), not list them as a flat set that all fires at entry. You cite; the worker **reads the recipe body and reproduces it** (not a name-guess).
- **`blueprint`** — name **one** multi-phase blueprint id from `hyperframes-animation/blueprints-index.md` when a frame's beat wants a proven multi-phase shape. Two postures — both require the worker to read the recipe body (and run its `examples/<id>.html`) first:
- **`blueprint`** — name **one** multi-phase blueprint id from `hyperframes-animation/blueprints-index.md` when a frame's beat wants a proven multi-phase shape. Two postures — both require the worker to read the recipe body first:
- **Reproduce** — the blueprint fits the beat cleanly and the frame's content maps onto its slots; write the composition note shot-by-shot to match.
- **Adapt** — the blueprint is the right _structure_ but the content / beat doesn't fit its exact form (or you want a fresher surface). Lead the note with a **`Base / Keep / Depart`** line — `Base:` the blueprint id · `Keep:` its **signature** move (never drop this) · `Depart:` what you change and why. Adapt may **extend or vary, never reduce below the shot model** — never flatten a multi-phase blueprint into a single entrance.

Expand Down
6 changes: 3 additions & 3 deletions skills/hyperframes-animation/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ Pick 2-4 rules from `rules-index.md`, glue them together with a single paused GS

## Load a blueprint when

- The scene matches an existing pre-designed multi-phase template (brand-reveal, social-proof, demo-page-scroll-spotlight, etc.) and reusing its phase pipeline saves real authoring time
- The scene matches an existing pre-designed multi-phase template (brand-reveal, social-proof, etc.) and reusing its phase pipeline saves real authoring time
- You want runnable ground-truth code for a complex 4-5 phase choreography

Blueprints live in `blueprints-index.md`. Each entry points to `blueprints/<id>.md` (recipe) and `examples/<id>.html` (runnable sample). Do not read it speculatively; load it when you've already decided you need scene-level orchestration.
Blueprints live in `blueprints-index.md`. Each entry points to `blueprints/<id>.md` (recipe). Do not read it speculatively; load it when you've already decided you need scene-level orchestration.

## Routing

Expand All @@ -27,7 +27,7 @@ Blueprints live in `blueprints-index.md`. Each entry points to `blueprints/<id>.
| Pick an atomic motion pattern by trigger / tag | `rules-index.md` |
| Read one rule's full HTML / CSS / GSAP recipe | `rules/<name>.md` |
| Pick a multi-phase scene template | `blueprints-index.md` |
| Read one blueprint's full recipe | `blueprints/<id>.md` + `examples/<id>.html` |
| Read one blueprint's full recipe | `blueprints/<id>.md` |
| Author a scene transition (CSS-driven, between two clips) | `transitions/overview.md`, `transitions/catalog.md` |
| Look up a broader motion-design technique | `techniques.md` |
| Analyze an existing composition's animation map | `scripts/animation-map.mjs` |
Expand Down
Loading
Loading