Bug
For a font family that resolves through FONT_ALIAS_MAP, the injected @font-face set can contain faces from two unrelated typefaces under a single font-family name, split by weight/style.
Authoring font-family: Helvetica today produces:
- upright 400/700/900 → embedded Inter (correct — this is the documented alias)
- 400 italic / 700 italic → real Helvetica LT Pro Italic, fetched from Google Fonts
So italic text renders in a completely different typeface from the upright text next to it, within one family name.
To be clear about what is not the complaint: the aliasing itself is intended and well documented. skills/hyperframes-creative/references/typography.md advertises Helvetica Neue / Helvetica / Arial → Inter and Garamond → EB Garamond as families you "may safely write", and the system_font_will_alias rule in packages/lint/src/rules/fonts.ts (lines 191–212) tells the author exactly what will happen: Font families will be substituted at render time: 'helvetica' → Inter. Nothing here is silent.
The defect is that the substitution is not total. The weight/style supplementation fetch runs against the authored name, which for a cross-typeface alias is a different typeface — so the family the author was told resolves to Inter quietly regains Helvetica faces at the weights/styles the bundle lacks.
Root Cause
packages/producer/src/services/deterministicFonts.ts, buildFontFaceCss() — the bundled-alias branch, lines 474–508:
const canonicalKey = FONT_ALIASES[normalizedFamily];
if (canonicalKey) {
const canonical = CANONICAL_FONTS[canonicalKey];
if (!canonical) continue;
const coveredWeights = new Set<string>();
for (const face of canonical.faces) {
const style = face.style || "normal";
const src = fontDataUri(canonical.packageName, face.weight, style);
rules.push(buildFontFaceRule(originalCaseFamily, src, face.weight, style));
coveredWeights.add(`${face.weight}:${style}`);
}
// Fetch all weights from Google Fonts and add any that aren't
// already covered by the embedded bundle. ...
const googleFaces = await fetchGoogleFont(originalCaseFamily, options, fontText); // ← line 491
for (const face of googleFaces) {
if (coveredWeights.has(`${face.weight}:${face.style}`)) continue;
rules.push(
buildFontFaceRule(originalCaseFamily, face.dataUri, face.weight, face.style, face.unicodeRange),
);
}
continue;
}
The embedded faces come from canonical (correct), but the supplementation fetch on line 491 is passed originalCaseFamily — the author's literal spelling.
FONT_ALIAS_MAP (packages/parsers/src/fontAliases.ts, lines 9–92) has 69 entries. 18 are self-referencing (inter: "inter", montserrat: "montserrat", …); for those, fetching the authored name is the same typeface and supplementation does exactly what it was written to do. The other 51 map to a different canonical (helvetica → inter, georgia → eb-garamond, noto sans → inter, times new roman → eb-garamond, …) — and for those, line 491 asks Google for the typeface the alias exists to replace.
Two things widen the blast radius:
1. No canonical bundle ships an italic face. Every entry in CANONICAL_FONTS (lines 301–374) declares weight-only faces, and line 481 defaults face.style || "normal". coveredWeights therefore only ever contains <weight>:normal keys, so every italic face Google returns for the authored name is classified "missing from the bundle" and injected. Any aliased family for which Google serves italics mixes, regardless of weights.
2. The 4xx assumption in fetchGoogleFont() is now partially stale. The comment at lines 955–962 reasons:
4xx is a *deterministic* answer from Google Fonts that this family is not served (e.g. HTTP 400 for "Segoe UI", "Arial", "Futura" — names absent from Google's catalog)
Still true for Segoe UI and Arial. But Google's css2 endpoint now serves a good number of the other alias names, so "not in Google's catalog" is no longer a reliable backstop against the aliased path fetching the wrong face.
What actually mixes today
Queried live against https://fonts.googleapis.com/css2?family=<name>:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700 with the producer's own WOFF2 user agent, then diffed the returned (weight, style) pairs against each alias's canonical bundle weights: 19 of the 51 cross-typeface aliases currently return faces the bundle does not cover, i.e. actively mix.
| authored family |
canonical |
non-canonical faces injected |
helvetica |
inter |
400i, 700i |
helvetica neue |
inter |
300, 400i, 700i |
verdana |
inter |
400i, 700i |
trebuchet ms |
inter |
400i, 700i |
calibri |
inter |
400i, 700i |
candara |
inter |
400i, 700i |
corbel |
inter |
400i, 700i |
noto sans |
inter |
100, 200, 300, 500, 600, 800, 400i, 700i |
courier new |
jetbrains-mono |
400i, 700i |
courier |
jetbrains-mono |
400i, 700i |
consolas |
jetbrains-mono |
400i, 700i |
garamond |
eb-garamond |
400i, 700i |
georgia |
eb-garamond |
400i, 700i |
palatino |
eb-garamond |
400i, 700i |
palatino linotype |
eb-garamond |
400i, 700i |
book antiqua |
eb-garamond |
400i, 700i |
cambria |
eb-garamond |
400i, 700i |
times |
eb-garamond |
400i, 700i |
times new roman |
eb-garamond |
400i, 700i |
Three of those — Helvetica, Helvetica Neue, Garamond — are names typography.md names as safe picks.
Checking the name tables of the files Google actually returns confirms these are genuinely different typefaces, not a remap onto the canonical:
family=Helvetica, italic → full name Helvetica LT Pro Italic
family=Georgia, italic → Georgia Italic
family=Verdana, italic → Verdana Italic
family=Noto Sans → served from fonts.gstatic.com/s/notosans/…, Noto Sans Italic
The other 32 cross aliases are unaffected today for two different reasons: 26 still 400 (arial, segoe ui, menlo, monaco, dejavu sans, liberation serif, sf pro*, …), and 6 are served but only at weights the bundle already covers, so nothing gets added (futura, arial black, bebas neue, avenir, tahoma, lucida sans). Both of those are properties of Google's catalog on a given day rather than of this code — the same alias can start mixing without any change here.
Note also that the pipeline's &text= subsetting doesn't suppress it. With subsetting on, family=Helvetica returns exactly 400/700 normal + 400/700 italic; the two upright faces are skipped as covered and the two Helvetica italics are injected. The subsetted case is the cleanest demonstration: 100% of the supplementary faces are the wrong typeface.
Finally, git log suggests this is an oversight rather than a decision. The supplementation was added in c8e8fdc ("fix(producer): fetch missing font weights from Google Fonts at render time"), whose message reasons purely about self-referencing canonicals — "compositions that request font weights (e.g. Montserrat 300) not included in the CANONICAL_FONTS faces array". The aliased case isn't mentioned.
Reproduction
I don't have a public repro repo pushed — the defect is statically visible in the source quoted above and needs no project-specific state, so I'd rather not burn triage time on a repo that just re-states line 491. Happy to push one if that's preferred.
From a blank project (npx hyperframes init repro --non-interactive --example blank), the composition needs only:
<style>
h1 { font-family: Helvetica; font-weight: 400; }
em { font-family: Helvetica; font-style: italic; }
</style>
<h1>Upright headline <em>with italic emphasis</em></h1>
- Render it, or call the exported entry point directly, which isolates the alias path from local system-font capture:
await injectDeterministicFontFaces(html, { allowSystemFontCapture: false })
- Inspect the injected
<style data-hyperframes-deterministic-fonts="true"> block.
- You get five
@font-face rules, all font-family: "Helvetica": 400/700/900 normal whose src data URIs are the embedded Inter bundle, plus 400/700 italic whose data URIs were fetched from fonts.googleapis.com/css2?family=Helvetica.
- On screen: the
<h1> is Inter, the <em> is Helvetica.
Substituting Georgia (→ EB Garamond upright, real Georgia italic) or Noto Sans (→ Inter at 400/700/900, real Noto Sans at 100/200/300/500/600/800 and both italics) reproduces the same shape.
I first hit this on a batch of renders with allowSystemFontCapture: false, where a Noto Sans family came out as Inter at 400/700/900 — byte-identical src prefixes to the faces emitted for a Helvetica family in the same run, i.e. literally the same embedded Inter bundle — alongside real Noto Sans at the remaining weights plus italics. That observation is from that earlier run and I haven't re-measured those exact bytes for this report; the live endpoint results, the name-table checks and the code path above are what I verified while writing it.
Expected behavior
A family listed in FONT_ALIAS_MAP resolves to exactly one typeface — the canonical it is documented and linted as aliasing to. Weight/style supplementation should extend that canonical's coverage, so Helvetica italic is Inter italic.
Actual behavior
The emitted family contains two typefaces at once — the canonical at the bundled weights, the aliased-away original at everything else. Because no canonical bundle ships italics, this hits every aliased family for which Google serves italics.
Fix
Supplement from the canonical family, not the authored one. CANONICAL_FONT_DISPLAY_NAMES (packages/parsers/src/fontAliases.ts, lines 100–119) already holds the right Google-side spelling for every canonical slug, and is already re-exported from @hyperframes/core/fonts/aliases — the module deterministicFonts.ts imports FONT_ALIAS_MAP from on line 7. So it's roughly a one-line import plus one-line change at line 491:
const canonicalDisplayName = CANONICAL_FONT_DISPLAY_NAMES[canonicalKey] ?? originalCaseFamily;
const googleFaces = await fetchGoogleFont(canonicalDisplayName, options, fontText);
buildFontFaceRule keeps emitting under originalCaseFamily, so authored CSS selectors still match — only the source of the supplementary faces changes. This preserves the original intent (a Montserrat 300 request still gets filled from Google) while making the cross-typeface case coherent: Helvetica italic comes from Inter's italic, Georgia italic from EB Garamond's italic.
Worth noting this incidentally widens correct coverage: aliased families would gain the canonical's full Google weight range instead of being stuck at the bundle's 2–3 weights while silently picking up the wrong typeface for the rest.
If fetching a canonical under an authored name feels too implicit, the narrower alternative is to skip supplementation entirely when canonicalKey !== normalizedFamily and emit only the embedded faces. Also correct, but it caps aliased families at the bundle's weights, and CSS synthesis then handles italic/missing weights.
Either way, a regression test asserting that no injected font-family ends up with faces drawn from more than one source (embedded bundle vs. a differently-named fetch) would pin this down. As far as I can tell the alias-supplementation branch has no coverage today: deterministicFonts.test.ts only asserts FONT_ALIASES map contents, and the suites that mock a Google response (-googleSubsets, -textSubset) use synthetic families (TestFam, Noto Performance Test) that aren't in FONT_ALIAS_MAP, so they exercise Path 2 rather than the alias branch's fetch.
Environment
Hand-written rather than pasted from npx hyperframes doctor: the machine I looked at this on has local paths in that output I'd rather not publish, and the finding is source analysis against main rather than a runtime capture, so the doctor block wouldn't add signal. Ask if you want the real output and I'll scrub and paste it.
Version 0.7.98 (latest at time of filing)
Node.js v22.14.0 (darwin arm64)
Source all line numbers cited against main @ 88853f1 and re-checked
via the GitHub contents API — unchanged on main as of filing
Additional context
Bug
For a font family that resolves through
FONT_ALIAS_MAP, the injected@font-faceset can contain faces from two unrelated typefaces under a singlefont-familyname, split by weight/style.Authoring
font-family: Helveticatoday produces:So italic text renders in a completely different typeface from the upright text next to it, within one family name.
To be clear about what is not the complaint: the aliasing itself is intended and well documented.
skills/hyperframes-creative/references/typography.mdadvertisesHelvetica Neue/Helvetica/Arial→ Inter andGaramond→ EB Garamond as families you "may safely write", and thesystem_font_will_aliasrule inpackages/lint/src/rules/fonts.ts(lines 191–212) tells the author exactly what will happen:Font families will be substituted at render time: 'helvetica' → Inter. Nothing here is silent.The defect is that the substitution is not total. The weight/style supplementation fetch runs against the authored name, which for a cross-typeface alias is a different typeface — so the family the author was told resolves to Inter quietly regains Helvetica faces at the weights/styles the bundle lacks.
Root Cause
packages/producer/src/services/deterministicFonts.ts,buildFontFaceCss()— the bundled-alias branch, lines 474–508:The embedded faces come from
canonical(correct), but the supplementation fetch on line 491 is passedoriginalCaseFamily— the author's literal spelling.FONT_ALIAS_MAP(packages/parsers/src/fontAliases.ts, lines 9–92) has 69 entries. 18 are self-referencing (inter: "inter",montserrat: "montserrat", …); for those, fetching the authored name is the same typeface and supplementation does exactly what it was written to do. The other 51 map to a different canonical (helvetica → inter,georgia → eb-garamond,noto sans → inter,times new roman → eb-garamond, …) — and for those, line 491 asks Google for the typeface the alias exists to replace.Two things widen the blast radius:
1. No canonical bundle ships an italic face. Every entry in
CANONICAL_FONTS(lines 301–374) declares weight-only faces, and line 481 defaultsface.style || "normal".coveredWeightstherefore only ever contains<weight>:normalkeys, so every italic face Google returns for the authored name is classified "missing from the bundle" and injected. Any aliased family for which Google serves italics mixes, regardless of weights.2. The 4xx assumption in
fetchGoogleFont()is now partially stale. The comment at lines 955–962 reasons:Still true for
Segoe UIandArial. But Google's css2 endpoint now serves a good number of the other alias names, so "not in Google's catalog" is no longer a reliable backstop against the aliased path fetching the wrong face.What actually mixes today
Queried live against
https://fonts.googleapis.com/css2?family=<name>:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700with the producer's own WOFF2 user agent, then diffed the returned(weight, style)pairs against each alias's canonical bundle weights: 19 of the 51 cross-typeface aliases currently return faces the bundle does not cover, i.e. actively mix.helveticahelvetica neueverdanatrebuchet mscalibricandaracorbelnoto sanscourier newcourierconsolasgaramondgeorgiapalatinopalatino linotypebook antiquacambriatimestimes new romanThree of those —
Helvetica,Helvetica Neue,Garamond— are namestypography.mdnames as safe picks.Checking the
nametables of the files Google actually returns confirms these are genuinely different typefaces, not a remap onto the canonical:family=Helvetica, italic → full nameHelvetica LT Pro Italicfamily=Georgia, italic →Georgia Italicfamily=Verdana, italic →Verdana Italicfamily=Noto Sans→ served fromfonts.gstatic.com/s/notosans/…,Noto Sans ItalicThe other 32 cross aliases are unaffected today for two different reasons: 26 still 400 (
arial,segoe ui,menlo,monaco,dejavu sans,liberation serif,sf pro*, …), and 6 are served but only at weights the bundle already covers, so nothing gets added (futura,arial black,bebas neue,avenir,tahoma,lucida sans). Both of those are properties of Google's catalog on a given day rather than of this code — the same alias can start mixing without any change here.Note also that the pipeline's
&text=subsetting doesn't suppress it. With subsetting on,family=Helveticareturns exactly 400/700 normal + 400/700 italic; the two upright faces are skipped as covered and the two Helvetica italics are injected. The subsetted case is the cleanest demonstration: 100% of the supplementary faces are the wrong typeface.Finally,
git logsuggests this is an oversight rather than a decision. The supplementation was added in c8e8fdc ("fix(producer): fetch missing font weights from Google Fonts at render time"), whose message reasons purely about self-referencing canonicals — "compositions that request font weights (e.g. Montserrat 300) not included in the CANONICAL_FONTS faces array". The aliased case isn't mentioned.Reproduction
I don't have a public repro repo pushed — the defect is statically visible in the source quoted above and needs no project-specific state, so I'd rather not burn triage time on a repo that just re-states line 491. Happy to push one if that's preferred.
From a blank project (
npx hyperframes init repro --non-interactive --example blank), the composition needs only:await injectDeterministicFontFaces(html, { allowSystemFontCapture: false })<style data-hyperframes-deterministic-fonts="true">block.@font-facerules, allfont-family: "Helvetica": 400/700/900normalwhosesrcdata URIs are the embedded Inter bundle, plus 400/700italicwhose data URIs were fetched fromfonts.googleapis.com/css2?family=Helvetica.<h1>is Inter, the<em>is Helvetica.Substituting
Georgia(→ EB Garamond upright, real Georgia italic) orNoto Sans(→ Inter at 400/700/900, real Noto Sans at 100/200/300/500/600/800 and both italics) reproduces the same shape.I first hit this on a batch of renders with
allowSystemFontCapture: false, where aNoto Sansfamily came out as Inter at 400/700/900 — byte-identicalsrcprefixes to the faces emitted for aHelveticafamily in the same run, i.e. literally the same embedded Inter bundle — alongside real Noto Sans at the remaining weights plus italics. That observation is from that earlier run and I haven't re-measured those exact bytes for this report; the live endpoint results, thename-table checks and the code path above are what I verified while writing it.Expected behavior
A family listed in
FONT_ALIAS_MAPresolves to exactly one typeface — the canonical it is documented and linted as aliasing to. Weight/style supplementation should extend that canonical's coverage, soHelveticaitalic is Inter italic.Actual behavior
The emitted family contains two typefaces at once — the canonical at the bundled weights, the aliased-away original at everything else. Because no canonical bundle ships italics, this hits every aliased family for which Google serves italics.
Fix
Supplement from the canonical family, not the authored one.
CANONICAL_FONT_DISPLAY_NAMES(packages/parsers/src/fontAliases.ts, lines 100–119) already holds the right Google-side spelling for every canonical slug, and is already re-exported from@hyperframes/core/fonts/aliases— the moduledeterministicFonts.tsimportsFONT_ALIAS_MAPfrom on line 7. So it's roughly a one-line import plus one-line change at line 491:buildFontFaceRulekeeps emitting underoriginalCaseFamily, so authored CSS selectors still match — only the source of the supplementary faces changes. This preserves the original intent (aMontserrat 300request still gets filled from Google) while making the cross-typeface case coherent:Helveticaitalic comes from Inter's italic,Georgiaitalic from EB Garamond's italic.Worth noting this incidentally widens correct coverage: aliased families would gain the canonical's full Google weight range instead of being stuck at the bundle's 2–3 weights while silently picking up the wrong typeface for the rest.
If fetching a canonical under an authored name feels too implicit, the narrower alternative is to skip supplementation entirely when
canonicalKey !== normalizedFamilyand emit only the embedded faces. Also correct, but it caps aliased families at the bundle's weights, and CSS synthesis then handles italic/missing weights.Either way, a regression test asserting that no injected
font-familyends up with faces drawn from more than one source (embedded bundle vs. a differently-named fetch) would pin this down. As far as I can tell the alias-supplementation branch has no coverage today:deterministicFonts.test.tsonly assertsFONT_ALIASESmap contents, and the suites that mock a Google response (-googleSubsets,-textSubset) use synthetic families (TestFam,Noto Performance Test) that aren't inFONT_ALIAS_MAP, so they exercise Path 2 rather than the alias branch's fetch.Environment
Hand-written rather than pasted from
npx hyperframes doctor: the machine I looked at this on has local paths in that output I'd rather not publish, and the finding is source analysis againstmainrather than a runtime capture, so the doctor block wouldn't add signal. Ask if you want the real output and I'll scrub and paste it.Additional context
var()parsing in the font resolver) — same file, different function, no overlap in the fix.GOOGLE_FONT_FAMILY_ALIASESmap for families that currently fail to resolve at all; it changes which namefetchGoogleFontqueries for unaliased families and does not touch theFONT_ALIAS_MAPsupplementation path, so the two shouldn't conflict.