Skip to content

fix(fonts): alias weight/style supplementation fetches the authored family, mixing two typefaces under one font-family #3083

Description

@akzarma

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>
  1. Render it, or call the exported entry point directly, which isolates the alias path from local system-font capture:
    await injectDeterministicFontFaces(html, { allowSystemFontCapture: false })
  2. Inspect the injected <style data-hyperframes-deterministic-fonts="true"> block.
  3. 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.
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions