From c9e114cbad9f310c9db62f93beb1605b6732096f Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Thu, 7 May 2026 11:58:12 -0700 Subject: [PATCH 01/88] feat(core): add font lint rules (google_fonts_import, font_family_without_font_face) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new lint rules that catch the #1 composition quality failure: - google_fonts_import: warns on fonts.googleapis.com in or @import - font_family_without_font_face: warns when font-family CSS has no matching @font-face Verified against 3 baseline sites (framer, raycast, workos) — correctly detects all font loading failures found in manual A/B testing. --- packages/core/src/lint/hyperframeLinter.ts | 2 + packages/core/src/lint/rules/fonts.test.ts | 100 +++++++++++++++++++ packages/core/src/lint/rules/fonts.ts | 110 +++++++++++++++++++++ 3 files changed, 212 insertions(+) create mode 100644 packages/core/src/lint/rules/fonts.test.ts create mode 100644 packages/core/src/lint/rules/fonts.ts diff --git a/packages/core/src/lint/hyperframeLinter.ts b/packages/core/src/lint/hyperframeLinter.ts index e50518b69..ff76579ba 100644 --- a/packages/core/src/lint/hyperframeLinter.ts +++ b/packages/core/src/lint/hyperframeLinter.ts @@ -8,6 +8,7 @@ import { captionRules } from "./rules/captions"; import { compositionRules } from "./rules/composition"; import { adapterRules } from "./rules/adapters"; import { textureRules } from "./rules/textures"; +import { fontRules } from "./rules/fonts"; const ALL_RULES = [ ...coreRules, @@ -17,6 +18,7 @@ const ALL_RULES = [ ...compositionRules, ...adapterRules, ...textureRules, + ...fontRules, ]; export function lintHyperframeHtml( diff --git a/packages/core/src/lint/rules/fonts.test.ts b/packages/core/src/lint/rules/fonts.test.ts new file mode 100644 index 000000000..9b2879fdb --- /dev/null +++ b/packages/core/src/lint/rules/fonts.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from "vitest"; +import { lintHyperframeHtml } from "../hyperframeLinter.js"; + +function findByCode(html: string, code: string, isSubComposition = true) { + const result = lintHyperframeHtml(html, { isSubComposition }); + return result.findings.filter((f) => f.code === code); +} + +describe("font rules", () => { + describe("google_fonts_import", () => { + it("flags @import url with fonts.googleapis.com", () => { + const html = `
+ +
`; + const findings = findByCode(html, "google_fonts_import"); + expect(findings).toHaveLength(1); + expect(findings[0]!.severity).toBe("warning"); + }); + + it("flags to fonts.googleapis.com", () => { + const html = `
+ +
`; + const findings = findByCode(html, "google_fonts_import"); + expect(findings).toHaveLength(1); + }); + + it("does not flag local @font-face usage", () => { + const html = `
+ +
`; + const findings = findByCode(html, "google_fonts_import"); + expect(findings).toHaveLength(0); + }); + }); + + describe("font_family_without_font_face", () => { + it("flags font-family used without @font-face", () => { + const html = `
+ +
`; + const findings = findByCode(html, "font_family_without_font_face"); + expect(findings).toHaveLength(1); + expect(findings[0]!.message).toContain("gt walsheim"); + }); + + it("does not flag when @font-face is declared", () => { + const html = `
+ +
`; + const findings = findByCode(html, "font_family_without_font_face"); + expect(findings).toHaveLength(0); + }); + + it("does not flag generic font families", () => { + const html = `
+ +
`; + const findings = findByCode(html, "font_family_without_font_face"); + expect(findings).toHaveLength(0); + }); + + it("reports multiple missing families in one finding", () => { + const html = `
+ +
`; + const findings = findByCode(html, "font_family_without_font_face"); + expect(findings).toHaveLength(1); + expect(findings[0]!.message).toContain("aeonik"); + expect(findings[0]!.message).toContain("ibm plex mono"); + }); + + it("is case-insensitive when matching @font-face to font-family", () => { + const html = `
+ +
`; + const findings = findByCode(html, "font_family_without_font_face"); + expect(findings).toHaveLength(0); + }); + + it("ignores font-family inside @font-face blocks", () => { + const html = `
+ +
`; + const findings = findByCode(html, "font_family_without_font_face"); + expect(findings).toHaveLength(0); + }); + }); +}); diff --git a/packages/core/src/lint/rules/fonts.ts b/packages/core/src/lint/rules/fonts.ts new file mode 100644 index 000000000..bae76a286 --- /dev/null +++ b/packages/core/src/lint/rules/fonts.ts @@ -0,0 +1,110 @@ +import type { LintContext, HyperframeLintFinding } from "../context"; + +const GENERIC_FAMILIES = new Set([ + "serif", + "sans-serif", + "monospace", + "cursive", + "fantasy", + "system-ui", + "ui-serif", + "ui-sans-serif", + "ui-monospace", + "ui-rounded", + "math", + "emoji", + "fangsong", + "inherit", + "initial", + "unset", + "revert", +]); + +function extractFontFaceFamilies(styles: Array<{ content: string }>): Set { + const families = new Set(); + const fontFaceRe = /@font-face\s*\{[^}]*\}/gi; + const familyRe = /font-family\s*:\s*(['"]?)([^;'"]+)\1/i; + for (const style of styles) { + let match: RegExpExecArray | null; + while ((match = fontFaceRe.exec(style.content)) !== null) { + const familyMatch = match[0].match(familyRe); + if (familyMatch?.[2]) { + families.add(familyMatch[2].trim().toLowerCase()); + } + } + } + return families; +} + +function extractUsedFontFamilies(styles: Array<{ content: string }>): string[] { + const used: string[] = []; + const seen = new Set(); + const propRe = /font-family\s*:\s*([^;}{]+)/gi; + for (const style of styles) { + const withoutFontFace = style.content.replace(/@font-face\s*\{[^}]*\}/gi, ""); + let match: RegExpExecArray | null; + while ((match = propRe.exec(withoutFontFace)) !== null) { + const stack = match[1]!; + for (const part of stack.split(",")) { + const name = part + .trim() + .replace(/^['"]|['"]$/g, "") + .trim() + .toLowerCase(); + if (name && !GENERIC_FAMILIES.has(name) && !seen.has(name)) { + seen.add(name); + used.push(name); + } + } + } + } + return used; +} + +export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ + // google_fonts_import + ({ styles, source }) => { + const findings: HyperframeLintFinding[] = []; + const googleFontsInLink = /]*fonts\.googleapis\.com[^>]*>/i.test(source); + const googleFontsInImport = styles.some((s) => + /@import\s+url\s*\(\s*['"]?[^)]*fonts\.googleapis\.com/i.test(s.content), + ); + + if (googleFontsInLink || googleFontsInImport) { + findings.push({ + code: "google_fonts_import", + severity: "warning", + message: + "Composition loads fonts from fonts.googleapis.com. External font requests " + + "fail in sandboxed/offline renders and add latency. Use local @font-face " + + "declarations with captured .woff2 files instead.", + fixHint: + "Replace the Google Fonts or @import with @font-face { font-family: '...'; " + + "src: url('../capture/assets/fonts/Font.woff2'); } pointing to captured font files.", + }); + } + return findings; + }, + + // font_family_without_font_face + ({ styles }) => { + const findings: HyperframeLintFinding[] = []; + const declared = extractFontFaceFamilies(styles); + const used = extractUsedFontFamilies(styles); + + const undeclared = used.filter((name) => !declared.has(name)); + if (undeclared.length === 0) return findings; + + findings.push({ + code: "font_family_without_font_face", + severity: "warning", + message: + `Font ${undeclared.length === 1 ? "family" : "families"} used without @font-face declaration: ${undeclared.join(", ")}. ` + + "Without local @font-face, text renders in the browser's fallback font, producing incorrect typography in the video.", + fixHint: + "Add @font-face { font-family: '...'; src: url('../capture/assets/fonts/...woff2'); } " + + "for each font family, pointing to the captured .woff2 files.", + }); + return findings; + }, +]; From 9458eb3f111f0bbc8e9ebab0d01f601e8d8f6638 Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Thu, 7 May 2026 11:59:38 -0700 Subject: [PATCH 02/88] feat(skills): pipeline quality improvements for website-to-hyperframes Step 4 (storyboard): - Add Capabilities Audit section (HTML-in-Canvas, texture mask text, SFX, rembg) Step 6 (build): - Pre-generated @font-face block instruction for sub-agent dispatch - Per-composition lint gate after each beat - Asset cross-reference as blocking check - Depth Layers vocabulary (background/midground/foreground) - SFX wiring convention with track index allocation - Animation density minimum (15+ GSAP calls) with production reference - Easing variety check (3+ distinct easings) techniques.md: - Add Easing Vocabulary section with full GSAP palette and mood mapping --- skills/hyperframes/references/techniques.md | 21 ++++++ .../references/step-4-storyboard.md | 11 +++ .../references/step-6-build.md | 75 +++++++++++++++++-- 3 files changed, 99 insertions(+), 8 deletions(-) diff --git a/skills/hyperframes/references/techniques.md b/skills/hyperframes/references/techniques.md index b460fa845..b0c8a6b31 100644 --- a/skills/hyperframes/references/techniques.md +++ b/skills/hyperframes/references/techniques.md @@ -376,6 +376,27 @@ Keep text/logo intensity subtle (≤5% scale, ≤30% glow) — audio-reactive mo --- +## Easing Vocabulary + +GSAP offers a deep easing library. Every composition should use at least 3 different easings — using `power2.out` for everything produces flat, monotonous motion. Think of easings as tone of voice: a video that only whispers is boring; one that varies between whisper, normal, and punch is engaging. + +**The full palette** (each family has `.in`, `.out`, `.inOut` variants): + +| Family | Character | Typical use | +| -------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `power1`–`power4` | Gentle (1) to aggressive (4) acceleration curves | General purpose. power2 is the workhorse, power4 for dramatic snaps | +| `back(N)` | Overshoot then settle. N controls how far past the target (1=subtle, 4=wild) | Logo reveals, badge pops, card entrances. `back.out(2.5)` for playful, `back.out(1.2)` for elegant | +| `elastic(amp, freq)` | Spring bounce. amp=magnitude, freq=oscillation speed | Panel scatter, energetic drops, fun reveals | +| `bounce` | Ball-drop bouncing | Physical interactions, icons landing, score counters | +| `expo` | Extreme acceleration curve (much steeper than power4) | Premium/luxury reveals, dramatic entrances | +| `sine` | Smooth, organic, no hard edges | Ambient float, breathing, Ken Burns, anything that loops. `.inOut` for yoyo motion | +| `circ` | Circular acceleration (starts very fast, ends very gentle or vice versa) | Camera moves, scene transitions, orbital motion | +| `steps(N)` | Discrete N-step jumps, no interpolation | Typing effects, cursor blink, counter ticks, retro/digital aesthetics | + +**Mood mapping:** Match easing intensity to the beat's emotional content. A calm brand insight gets `sine.inOut` drifts. A big stat reveal gets `power4.out` snap. A playful feature showcase gets `back.out(2)` bounces. The storyboard's mood description should guide easing choice. + +--- + ## When to Use What | Video energy | Techniques to combine | diff --git a/skills/website-to-hyperframes/references/step-4-storyboard.md b/skills/website-to-hyperframes/references/step-4-storyboard.md index 6db26bea4..f400784cb 100644 --- a/skills/website-to-hyperframes/references/step-4-storyboard.md +++ b/skills/website-to-hyperframes/references/step-4-storyboard.md @@ -36,6 +36,17 @@ Apple keynote register — economy of words, silence between sentences is a feat --- +## Capabilities Audit + +Before writing any beats, check what tools are available beyond the standard techniques: + +1. **HTML-in-Canvas**: The `drawElementImage` Chrome API captures live DOM into a `` as a GPU-accelerated texture. This lets you render any HTML/CSS through WebGL shaders, map it onto 3D geometry, or apply post-processing — all at 60fps. HyperFrames auto-enables this during renders. Read `docs/guides/html-in-canvas.mdx` for the API and patterns. Pre-built VFX blocks (liquid-glass, device mockups, shatter, portal, magnetic) are available via `npx hyperframes add html-in-canvas`, but you can also build custom effects from scratch using the raw API. +2. **Texture mask text**: If the `texture-mask-text` component is installed, typographic hero beats can use texture-filled text (wood, stone, metal). +3. **SFX files**: Check if `sfx/` directory exists. If SFX files are available, plan sound cues per beat — whoosh on transitions, pop on element reveals, typing on code sequences. +4. **Remove-background**: If person footage exists, `hyperframes remove-background` can isolate subjects for text-behind-person compositing. + +--- + ## Asset Audit Before writing any beats, audit every captured asset. Print this table: diff --git a/skills/website-to-hyperframes/references/step-6-build.md b/skills/website-to-hyperframes/references/step-6-build.md index 3a028e1a6..514edaca0 100644 --- a/skills/website-to-hyperframes/references/step-6-build.md +++ b/skills/website-to-hyperframes/references/step-6-build.md @@ -10,9 +10,20 @@ **Split the work: spawn a sub-agent for each beat.** By this step your context is full of captured data, DESIGN.md, SCRIPT, STORYBOARD, and transcript. Building compositions on top of all that means the detailed rules below compete with thousands of tokens of prior work. Each sub-agent gets a fresh context focused on one beat — dramatically better output. +**Before dispatching sub-agents, prepare two things:** + +**1. Build the @font-face block.** Sub-agents will not figure out font-file-to-family mapping on their own (proven by testing — 2 out of 3 sites ship with zero @font-face when agents are left to do it themselves). Build it for them: + +1. Read DESIGN.md to get font family names and weights +2. Run `ls capture/assets/fonts/` to get the `.woff2` filenames +3. Map each filename to its family and weight (e.g., `Inter-Medium.woff2` → Inter, weight 500) +4. Write the complete `@font-face` CSS block and save it for pasting into every sub-agent prompt + +**2. Build the asset inventory for this beat.** For each beat, list the exact assets the storyboard assigned with their `../capture/assets/` paths. This is what gets pasted into the sub-agent prompt. + **How to dispatch each sub-agent:** -Pass file PATHS, not file contents. The #1 failure mode is reading an asset file and pasting its SVG/image data into the sub-agent prompt. The sub-agent then uses inline content instead of referencing the file on disk. Same with fonts — pass the local woff2 path, don't substitute Google Fonts. +Pass file PATHS, not file contents. The #1 failure mode is reading an asset file and pasting its SVG/image data into the sub-agent prompt. ``` Build the composition for beat 1. Save to compositions/beat-1-hook.html. @@ -23,17 +34,26 @@ STORYBOARD for this beat: ASSETS — reference by path, do NOT read/inline the file contents: - Logo: (top-left, 40x40px) - Hero image: (full-bleed background) -- Noise texture: ../capture/assets/noise.png (full-frame overlay, 3% opacity) -FONTS — use @font-face with the captured font files, NOT Google Fonts: -@font-face { font-family: 'BrandFont'; src: url('../capture/assets/fonts/BrandFont-Regular.woff2'); } +FONTS — paste this COMPLETE @font-face block into your `; const findings = findByCode(html, "font_family_without_font_face"); expect(findings).toHaveLength(1); expect(findings[0]!.message).toContain("aeonik"); - expect(findings[0]!.message).toContain("ibm plex mono"); + expect(findings[0]!.message).toContain("feature deck"); + }); + + it("does not flag fonts the producer has pre-bundled", () => { + const html = `
+ +
`; + const findings = findByCode(html, "font_family_without_font_face"); + expect(findings).toHaveLength(0); + }); + + it("still flags Google-Fonts-only fonts not pre-bundled", () => { + const html = `
+ +
`; + const findings = findByCode(html, "font_family_without_font_face"); + expect(findings).toHaveLength(1); + expect(findings[0]!.message).toContain("geist"); }); it("is case-insensitive when matching @font-face to font-family", () => { diff --git a/packages/core/src/lint/rules/fonts.ts b/packages/core/src/lint/rules/fonts.ts index bae76a286..c8b794e16 100644 --- a/packages/core/src/lint/rules/fonts.ts +++ b/packages/core/src/lint/rules/fonts.ts @@ -20,6 +20,43 @@ const GENERIC_FAMILIES = new Set([ "revert", ]); +// Fonts pre-bundled as data URIs in the producer (deterministicFonts.ts FONT_ALIASES). +// These render correctly without @font-face — the producer injects them automatically. +// Must match the keys in packages/producer/src/services/deterministicFonts.ts exactly. +const PRODUCER_BUNDLED_FONTS = new Set([ + "inter", + "helvetica neue", + "helvetica", + "arial", + "helvetica bold", + "montserrat", + "futura", + "din alternate", + "arial black", + "outfit", + "nunito", + "oswald", + "bebas neue", + "league gothic", + "archivo black", + "space mono", + "ibm plex mono", + "jetbrains mono", + "courier new", + "courier", + "eb garamond", + "garamond", + "playfair display", + "source code pro", + "noto sans jp", + "noto sans japanese", + "roboto", + "open sans", + "lato", + "poppins", + "segoe ui", +]); + function extractFontFaceFamilies(styles: Array<{ content: string }>): Set { const families = new Set(); const fontFaceRe = /@font-face\s*\{[^}]*\}/gi; @@ -92,7 +129,9 @@ export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ const declared = extractFontFaceFamilies(styles); const used = extractUsedFontFamilies(styles); - const undeclared = used.filter((name) => !declared.has(name)); + const undeclared = used.filter( + (name) => !declared.has(name) && !PRODUCER_BUNDLED_FONTS.has(name), + ); if (undeclared.length === 0) return findings; findings.push({ @@ -100,7 +139,8 @@ export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ severity: "warning", message: `Font ${undeclared.length === 1 ? "family" : "families"} used without @font-face declaration: ${undeclared.join(", ")}. ` + - "Without local @font-face, text renders in the browser's fallback font, producing incorrect typography in the video.", + "These are not in the auto-resolved font list, so the renderer cannot supply them automatically. " + + "Text will fall back to a generic font, producing incorrect typography in the video.", fixHint: "Add @font-face { font-family: '...'; src: url('../capture/assets/fonts/...woff2'); } " + "for each font family, pointing to the captured .woff2 files.", From a8ece0dacd201fd6a0e58f46986ef1737da57092 Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Sat, 9 May 2026 17:31:00 -0700 Subject: [PATCH 07/88] fix(skills): address 4 root causes from transcript analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Capabilities Discovery: replace descriptive guidance with executable commands (npx hyperframes list, ls registry/blocks/). Agent was told to "check" but never ran the commands — now it has exact bash to run and paste results. 2. SFX decoupled from storyboard: removed SFX listing from Step 4 Capabilities Audit. SFX are now wired in Step 6 Audio Wiring only, matched to the storyboard's creative moments. Prevents creative direction from revolving around the SFX inventory. 3. Font @font-face: rewritten to explain the auto-resolve system. Common fonts (Inter, Roboto, etc.) skip @font-face. Brand fonts use tokens.json for family mapping. Hashed filenames = Google Fonts subsets that auto-resolve. Prevents agents from giving up on hashed filenames. 4. SFX wiring table: added moment-type → SFX mapping so agents match sound to emotion instead of listing files arbitrarily. --- .../references/step-4-storyboard.md | 24 ++++++--- .../references/step-6-build.md | 53 ++++++++++++------- 2 files changed, 52 insertions(+), 25 deletions(-) diff --git a/skills/website-to-hyperframes/references/step-4-storyboard.md b/skills/website-to-hyperframes/references/step-4-storyboard.md index dd99a5586..14e349046 100644 --- a/skills/website-to-hyperframes/references/step-4-storyboard.md +++ b/skills/website-to-hyperframes/references/step-4-storyboard.md @@ -36,14 +36,26 @@ Apple keynote register — economy of words, silence between sentences is a feat --- -## Capabilities Audit +## Capabilities Discovery -Before writing any beats, check what tools are available beyond the standard techniques: +Before writing any beats, run these commands and paste the output below the Global Direction section. This tells you what's available beyond the standard techniques. -1. **HTML-in-Canvas**: The `drawElementImage` Chrome API captures live DOM into a `` as a GPU-accelerated texture. This lets you render any HTML/CSS through WebGL shaders, map it onto 3D geometry, or apply post-processing — all at 60fps. HyperFrames auto-enables this during renders. Read `docs/guides/html-in-canvas.mdx` for the API and patterns. Pre-built VFX blocks are available via `npx hyperframes add --tag html-in-canvas` (installs all: vfx-liquid-glass, vfx-iphone-device, vfx-liquid-background, vfx-magnetic, vfx-portal, vfx-shatter, vfx-text-cursor), but you can also build custom effects from scratch using the raw API. -2. **Texture mask text**: If the `texture-mask-text` component is installed, typographic hero beats can use texture-filled text (wood, stone, metal). -3. **SFX files**: Check if `sfx/` directory exists. If SFX files are available, plan sound cues per beat — whoosh on transitions, pop on element reveals, typing on code sequences. -4. **Remove-background**: If person footage exists, `hyperframes remove-background` can isolate subjects for text-behind-person compositing. +```bash +# 1. Check installed registry blocks (VFX, transitions, components) +npx hyperframes list --installed 2>/dev/null || echo "No blocks installed" + +# 2. Check available shader transitions +ls registry/blocks/ 2>/dev/null | grep -E 'chromatic|cinematic|cross-warp|domain-warp|flash|glitch|gravitational|light-leak|ridged|ripple|sdf|swirl|thermal|whip' || echo "No shader transitions in registry" + +# 3. Check available VFX blocks +ls registry/blocks/ 2>/dev/null | grep vfx || echo "No VFX blocks in registry" +``` + +If VFX blocks are available (vfx-liquid-glass, vfx-iphone-device, vfx-shatter, vfx-portal, etc.), use them for hero treatments instead of basic perspective tilt. Install any you want with `npx hyperframes add `. If shader transitions are in the registry, use them between beats instead of basic blur/fade — install with `npx hyperframes add `. + +**HTML-in-Canvas** (available in all HyperFrames renders): The `drawElementImage` Chrome API captures live DOM into a `` as a GPU-accelerated texture. You can render any HTML/CSS through WebGL shaders, map it onto 3D geometry, or apply post-processing — even without VFX blocks. Read `docs/guides/html-in-canvas.mdx` for the API. + +Do NOT list SFX files in the storyboard. SFX are wired in Step 6 (Audio Wiring) after compositions are built — matched to the creative direction, not the other way around. --- diff --git a/skills/website-to-hyperframes/references/step-6-build.md b/skills/website-to-hyperframes/references/step-6-build.md index c0ce4f0a0..518aeec1c 100644 --- a/skills/website-to-hyperframes/references/step-6-build.md +++ b/skills/website-to-hyperframes/references/step-6-build.md @@ -12,12 +12,14 @@ **Before dispatching sub-agents, prepare two things:** -**1. Build the @font-face block.** Sub-agents will not figure out font-file-to-family mapping on their own (proven by testing — 2 out of 3 sites ship with zero @font-face when agents are left to do it themselves). Build it for them: +**1. Build the @font-face block for brand fonts.** Common fonts (Inter, Roboto, JetBrains Mono, Poppins, Lato, etc.) are auto-resolved by the HyperFrames renderer at render time — you do NOT need @font-face for those. But brand-specific fonts captured from the website (GT Walsheim, Aeonik, Feature Deck, etc.) need explicit @font-face because the renderer can't find them. -1. Read DESIGN.md to get font family names and weights -2. Run `ls capture/assets/fonts/` to get the `.woff2` filenames -3. Map each filename to its family and weight (e.g., `Inter-Medium.woff2` → Inter, weight 500) -4. Write the complete `@font-face` CSS block and save it for pasting into every sub-agent prompt +To build the block: + +1. Read `capture/extracted/tokens.json` — it has the font families and weights extracted from the site +2. Run `ls capture/assets/fonts/` — check if any filenames contain recognizable font names (e.g., `gt-walsheim-medium.woff2`). Hashed filenames (like `NGSnv5HMAFg6IuG.woff2`) are Google Fonts subsets that the renderer fetches automatically — skip those. +3. For each brand font with a recognizable filename, write an @font-face rule mapping it to the family name from tokens.json +4. If ALL font files are hashed, the fonts are likely all from Google Fonts and will be auto-resolved. Skip the @font-face block entirely. **2. Build the asset inventory for this beat.** For each beat, list the exact assets the storyboard assigned with their `../capture/assets/` paths. This is what gets pasted into the sub-agent prompt. @@ -185,28 +187,41 @@ In the root `index.html`: - **Narration**: `