Note on provenance: this report was written by an AI coding agent (Claude) investigating a finding from another agent instance (a "frame worker" sub-agent) during a video build, at the request of a human user who is filing it on the agent's behalf. The user has not independently verified the code-level claims below beyond a light read-through — treat the diagnosis as agent-generated analysis, not a human-confirmed bug report. If it turns out not to be a real bug, it may still be useful as a concrete example of where an agent's reasoning about this codebase went right or wrong — happy to have it closed either way with a note on what it got wrong.
Summary
Building a video from the blue-professional frame-preset, a frame-worker sub-agent correctly followed the font_family_without_font_face rule in skills/hyperframes-core/references/frame-worker-core.md ("Only use fonts that ship as files with the project... Never name a font that has no file") and substituted a system-font stack, because frame.md's named fonts (Space Grotesk, Inter) had no matching .woff2/.ttf/.otf anywhere in the project, the preset's own directory, or the files staged by build-frame.mjs.
Tracing this back, it looks like a structural gap rather than a one-off: most shipped frame-presets name Google Fonts as their default identity typography, but nothing in the toolchain ever fetches those fonts as files unless the calling project happens to already have them from an unrelated brand-capture step.
What I checked
1. build-frame.mjs's font-staging logic is real and working, but scoped narrowly.
(skills/product-launch-video/scripts/build-frame.mjs, same logic duplicated in skills/faceless-explainer/scripts/build-frame.mjs and skills/pr-to-video/scripts/build-frame.mjs)
The "stage brand font files + emit @font-face" block only copies files it finds in the project's capture/assets/fonts/ or assets/fonts/:
const srcDirs = [
join(hyperframesDir, "capture/assets/fonts"),
join(hyperframesDir, "assets/fonts"),
].filter((d) => existsSync(d));
Those directories are populated by a prior brand-capture step (scraping an existing website's own font files) — not by anything related to the preset. The whole block is also gated behind if (brandFonts.length), i.e. it only runs at all when tokens.json records fonts from a captured brand. For a project with no such capture (e.g. built from a brief, not a URL), this entire section — remix and staging both — is skipped, and the log line even says so: "fonts: no brand fonts — preset fonts kept". The preset's font names survive into frame.md untouched, with nothing behind them.
Critically, the script never reads <presetDir>/<presetName>/fonts/ — the preset's own directory — at all. It only ever reads presetDir/<presetName>/FRAME.md and presetDir/<presetName>/caption-skin.html.
2. 12 of 13 shipped presets name Google Fonts and ship zero font files.
biennale-yellow: Archivo, Instrument Serif, JetBrains Mono
blockframe: Inter, Space Grotesk
blue-professional: Inter, Space Grotesk
bold-poster: Libre Baskerville, Shrikhand, Space Grotesk
broadside: Barlow, IBM Plex Mono
capsule: Bodoni Moda, Space Grotesk
cartesian: Inter, Playfair Display
cobalt-grid: DM Mono, Hanken Grotesk, Newsreader
code-editorial: EB Garamond, Inter, JetBrains Mono <- ships fonts/*.woff2 (see below)
coral: Bebas Neue, Inter
creative-mode: Archivo Black, JetBrains Mono, Space Grotesk
daisy-days: Fredoka One, Quicksand
editorial-forest: JetBrains Mono, Source Serif 4
Only code-editorial ships real .woff2 files, in its own fonts/ subfolder (Inter-{400,700}.woff2, EBGaramond-{400,700}.woff2, JetBrainsMono-{400,700}.woff2). But per point 1, nothing copies from a preset's fonts/ folder into a project — so even code-editorial's shipped files go unused by build-frame.mjs. It looks like the intended pattern (ship the font files next to the preset, one preset proves it out) exists but was never wired up on the consuming side, and the other 12 presets never got the treatment either way.
3. media-use (the skill meant to resolve/fetch all project media) has no font asset type.
Its resolve --type <type> table (skills/media-use/SKILL.md) is: bgm, sfx, image, icon, logo, voice, grade, lut. I checked scripts/resolve.mjs and scripts/lib/brand-provider.mjs for any font-fetch path under another name — the only "font" references there read an already-captured tokens.font/typography value for grade-matching purposes; there's no code path that fetches a named font's files. So a frame-worker or orchestrator has nowhere to route a "please get me this Google Font" request even if it wanted to.
4. There's a documented but narrow escape hatch that the frame-worker's contract doesn't use.
skills/hyperframes-creative/references/typography.md says the render compiler itself (not a skill script) will auto-fetch a real Google Font at build time if it's named without a matching @font-face, for local renders — but it (a) trips a font_family_without_font_face lint warning, and (b) is fail-closed (hard error) on distributed/cloud (Lambda) renders. skills/hyperframes-core/references/frame-worker-core.md's own font_family_without_font_face rule never mentions this fallback at all, and instead states the stricter "never name a font that has no file." That's arguably the correct, safer instruction given the cloud-render failure mode — but it means the only way to reliably honor a preset's typography is to ship real files, and nothing does that automatically.
Suggested fix (sketch, not a PR)
This is a design suggestion from the investigating agent, not a vetted implementation — take it as a starting point:
- Add a
font type to media-use's resolve verb: given a family name (+ optional weights), fetch the matching Google Fonts .woff2 file(s) into assets/fonts/ and emit the ledger record, mirroring the existing logo/image resolution pattern.
- Have preset adoption (
build-frame.mjs, or whichever step runs after it) call that for every fontFamily in the adopted FRAME.md that isn't already covered by a captured brand font or by the renderer's pre-bundled family list (documented in typography.md).
- Separately/optionally: either wire
build-frame.mjs to copy a preset's own fonts/ subfolder (proving out the code-editorial pattern for the other 12 presets), or drop that unused folder if the intended path is meant to go through media-use instead.
Why I'm not sending a PR
I only traced the gap; I haven't implemented or tested a fix, and didn't want to guess at API/ledger conventions I haven't fully internalized. Filing this as an issue so a maintainer can weigh in on whether the media-use route or the preset-folder route is the intended fix.
Summary
Building a video from the
blue-professionalframe-preset, a frame-worker sub-agent correctly followed thefont_family_without_font_facerule inskills/hyperframes-core/references/frame-worker-core.md("Only use fonts that ship as files with the project... Never name a font that has no file") and substituted a system-font stack, becauseframe.md's named fonts (Space Grotesk, Inter) had no matching.woff2/.ttf/.otfanywhere in the project, the preset's own directory, or the files staged bybuild-frame.mjs.Tracing this back, it looks like a structural gap rather than a one-off: most shipped frame-presets name Google Fonts as their default identity typography, but nothing in the toolchain ever fetches those fonts as files unless the calling project happens to already have them from an unrelated brand-capture step.
What I checked
1.
build-frame.mjs's font-staging logic is real and working, but scoped narrowly.(
skills/product-launch-video/scripts/build-frame.mjs, same logic duplicated inskills/faceless-explainer/scripts/build-frame.mjsandskills/pr-to-video/scripts/build-frame.mjs)The "stage brand font files + emit @font-face" block only copies files it finds in the project's
capture/assets/fonts/orassets/fonts/:Those directories are populated by a prior brand-capture step (scraping an existing website's own font files) — not by anything related to the preset. The whole block is also gated behind
if (brandFonts.length), i.e. it only runs at all whentokens.jsonrecords fonts from a captured brand. For a project with no such capture (e.g. built from a brief, not a URL), this entire section — remix and staging both — is skipped, and the log line even says so:"fonts: no brand fonts — preset fonts kept". The preset's font names survive intoframe.mduntouched, with nothing behind them.Critically, the script never reads
<presetDir>/<presetName>/fonts/— the preset's own directory — at all. It only ever readspresetDir/<presetName>/FRAME.mdandpresetDir/<presetName>/caption-skin.html.2. 12 of 13 shipped presets name Google Fonts and ship zero font files.
Only
code-editorialships real.woff2files, in its ownfonts/subfolder (Inter-{400,700}.woff2,EBGaramond-{400,700}.woff2,JetBrainsMono-{400,700}.woff2). But per point 1, nothing copies from a preset'sfonts/folder into a project — so evencode-editorial's shipped files go unused bybuild-frame.mjs. It looks like the intended pattern (ship the font files next to the preset, one preset proves it out) exists but was never wired up on the consuming side, and the other 12 presets never got the treatment either way.3.
media-use(the skill meant to resolve/fetch all project media) has nofontasset type.Its
resolve --type <type>table (skills/media-use/SKILL.md) is:bgm,sfx,image,icon,logo,voice,grade,lut. I checkedscripts/resolve.mjsandscripts/lib/brand-provider.mjsfor any font-fetch path under another name — the only "font" references there read an already-capturedtokens.font/typographyvalue for grade-matching purposes; there's no code path that fetches a named font's files. So a frame-worker or orchestrator has nowhere to route a "please get me this Google Font" request even if it wanted to.4. There's a documented but narrow escape hatch that the frame-worker's contract doesn't use.
skills/hyperframes-creative/references/typography.mdsays the render compiler itself (not a skill script) will auto-fetch a real Google Font at build time if it's named without a matching@font-face, for local renders — but it (a) trips afont_family_without_font_facelint warning, and (b) is fail-closed (hard error) on distributed/cloud (Lambda) renders.skills/hyperframes-core/references/frame-worker-core.md's ownfont_family_without_font_facerule never mentions this fallback at all, and instead states the stricter "never name a font that has no file." That's arguably the correct, safer instruction given the cloud-render failure mode — but it means the only way to reliably honor a preset's typography is to ship real files, and nothing does that automatically.Suggested fix (sketch, not a PR)
This is a design suggestion from the investigating agent, not a vetted implementation — take it as a starting point:
fonttype tomedia-use'sresolveverb: given a family name (+ optional weights), fetch the matching Google Fonts.woff2file(s) intoassets/fonts/and emit the ledger record, mirroring the existinglogo/imageresolution pattern.build-frame.mjs, or whichever step runs after it) call that for everyfontFamilyin the adoptedFRAME.mdthat isn't already covered by a captured brand font or by the renderer's pre-bundled family list (documented intypography.md).build-frame.mjsto copy a preset's ownfonts/subfolder (proving out thecode-editorialpattern for the other 12 presets), or drop that unused folder if the intended path is meant to go throughmedia-useinstead.Why I'm not sending a PR
I only traced the gap; I haven't implemented or tested a fix, and didn't want to guess at API/ledger conventions I haven't fully internalized. Filing this as an issue so a maintainer can weigh in on whether the
media-useroute or the preset-folder route is the intended fix.