Skip to content

fix(cli): localize remote fonts in snapshot/check capture to match render#2264

Merged
vanceingalls merged 3 commits into
mainfrom
07-11-fix-cli-localize-remote-fonts-in-audit-capture
Jul 12, 2026
Merged

fix(cli): localize remote fonts in snapshot/check capture to match render#2264
vanceingalls merged 3 commits into
mainfrom
07-11-fix-cli-localize-remote-fonts-in-audit-capture

Conversation

@vanceingalls

Copy link
Copy Markdown
Collaborator

What

Snapshot and check now localize fonts before capture — fetch and embed an @font-face (data URI) for every requested family, including families declared only via a remote <link> (e.g. Google Fonts) — reusing the exact injectDeterministicFontFaces pass the render pipeline already runs. New shared helper bundleWithLocalizedFonts(projectDir) wraps bundleToSingleHtml + the injector; snapshot.ts and both bundling sites in checkBrowser.ts use it.

Why

Wild report (CLI 0.7.53, macOS): hyperframes snapshot rendered League Gothic as an un-condensed fallback sans, while the actual render/draft-render embedded the font correctly.

Root cause (structural, not timing): the render path localizes fonts in its compile stage (deterministicFonts.ts — fetches from Google Fonts, embeds as data URIs). The snapshot/check path uses core's bundleToSingleHtml, which inlines only local CSS (htmlBundler.ts) and leaves remote font <link>s as-is — so a snapshot depends on loading the remote font at capture time. When that remote load loses the race against the capture (slow CDN / memory pressure / cold cache), Chrome paints the fallback family. This fix removes the remote dependency entirely, matching render.

Fail-open: if a family can't be fetched (offline, unknown font) the injector leaves the HTML unchanged, so a bundle is never worse than plain bundleToSingleHtml.

Validation

  • Deterministic: ran bundleWithLocalizedFonts on a composition that declares League Gothic only via a Google Fonts <link> — the bundled HTML now contains a data-hyperframes-deterministic-fonts <style> with an embedded @font-face data URI for League Gothic (previously only the remote <link> was present). RESULT: PASS.
  • End-to-end: hyperframes snapshot of that composition renders condensed League Gothic correctly (visually confirmed), Fonts: 2 loaded.
  • Unit test for the helper's happy path + fail-open contract; existing snapshot, captureCompositionFrame, and checkBrowser suites green; full monorepo build clean.

Notes

Applied to the two reported paths (snapshot, check). Other audit bundling sites (compare, layout, motionShot, grade-compare, validate) still use plain bundleToSingleHtml; extending them to the shared helper is a straightforward follow-up if desired — kept this PR scoped to the reported paths.

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — fix(cli): localize remote fonts in snapshot/check capture

SSOT check: clean. Font localization stays canonical in injectDeterministicFontFaces (producer); the new bundleWithLocalizedFonts helper composes it with bundleToSingleHtml — no decision duplication. All three call sites (snapshot, two in checkBrowser) use the same shared helper.

Fail-open layering is correct: injectDeterministicFontFaces itself is fail-open per-family (failClosedFontFetch defaults false). The wrapper's catch covers the orthogonal case — producer not installed (lightweight CLI) or a catastrophic injector failure. Two levels, two different failure modes. Good.

Re-export from producer/src/index.ts: injectDeterministicFontFaces was already exported from deterministicFonts.ts — this just promotes it to the package's public API. Correct pattern: CLI consumes the public barrel, not a deep import.

Tests: happy path (bundle + fonts compose) and fail-open (injector throws → plain bundle returned). Both test the contract, not the implementation. Clean.

Scope: PR explicitly notes other audit paths (compare, layout, motionShot, grade-compare, validate) are left on plain bundleToSingleHtml — documented follow-up, not a gap.

LGTM — no issues found.

— Miga

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: bundleWithLocalizedFonts catches every error from the dynamic @hyperframes/producer import and injector call with one empty catch. This collapses producer-module-unavailable and per-family/injector failures into the same silent fallback, provides no useful/deduped warning, and can mask unrelated runtime bugs. Narrow the module-missing catch separately from the injector call; preserve injector per-family degradation semantics and emit an actionable non-secret warning for module absence/failure. Add tests for successful localization, per-family degradation, producer-unavailable fallback, and both snapshot/check call-site integration.

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at ad0d239.

Clean, minimal fix for a real symptom. The shape is right: the audit paths now run the exact same injectDeterministicFontFaces pass as the render compile stage, so a snapshot/check can't diverge from a render on font embedding. The helper composes cleanly on top of bundleToSingleHtml, and all three reported call sites (snapshot.ts + both checkBrowser.ts entry points) route through it. No duplication.

The two-level fail-open (per-family inside the injector via warnUnresolvedFonts + a wrapper catch) means a bundle is never worse than plain bundleToSingleHtml — the invariant the PR promises. Tests cover both contracts.

A couple of small things worth thinking about, non-blocking:

Concerns

  • packages/cli/src/utils/bundleWithLocalizedFonts.ts:26 — the outer catch is silent. If the injector throws for any reason (unexpected fetch layer, HTML shape it can't parse, future code path that regresses), the bundle silently degrades and the user sees exactly the fallback-sans symptom that motivated this fix, with no diagnostic signal. Since producer is workspace:* in the CLI package — always present at runtime — the catch is defending against a narrow set of unexpected throws rather than "producer missing." Consider a defaultLogger.warn (or plain console.warn if pulling the logger is scope-creep) inside the catch so any regression of this shape surfaces in CLI output instead of vanishing. The next wild report would land with a hint attached.

Questions

  • HF#2251 interaction#2251 added a post-seek font-subset-load settle before audit screenshots to protect against the remote-font race. With this change, the CLI paths embed @font-face as data URIs at bundle time, so the subset load happens at initial parse rather than post-seek. Is the settle mechanism still guarding a distinct scenario on the snapshot/check paths (variable subsets, dynamic family declarations), or is it now effectively redundant on these paths and only load-bearing on other audit sites that still use plain bundleToSingleHtml? Not asking you to change anything — just curious whether the two mechanisms have overlapping coverage worth documenting.

What I didn't verify

  • Behavior on compositions declaring a family via BOTH a remote <link> and an explicit @font-faceextractExistingFontFaces skips already-declared families, so the injector should no-op on the family, but I didn't trace it end-to-end.
  • The follow-up audit paths you flagged (compare, layout, motionShot, grade-compare, validate) — agree these are a natural follow-up; leaving them out of this PR is the right scope.

LGTM from my side once the observability question is either addressed or explicitly deferred.

Review by Rames D Jusso

…nsform doesn't fail

The CLI test job builds with --filter '!@hyperframes/producer', and render.ts
imports producer only as a type — so a static import("@hyperframes/producer")
in the font-localization helper failed Vitest's transform-time module
resolution ("Failed to resolve entry for package"), breaking checkBrowser
tests and the helper's own test. Keep the specifier out of the static module
graph (@vite-ignore + variable specifier) so it resolves at runtime only:
production/installed CLI has producer in node_modules and localizes fonts;
the test env fail-opens to the plain bundle. Localizer is now injectable so
the helper's unit tests cover it without needing producer resolvable.

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at aa10d49: the injectable localizer improves testability, but production localizeWithProducer still wraps both dynamic module resolution and injectDeterministicFontFaces in one broad silent catch. This still cannot distinguish producer-unavailable from injector/per-family failure and has no useful/deduped warning. The new tests only cover success/output; they do not cover producer-unavailable, per-family degradation, or snapshot/check integration. Narrow the module-missing vs injector handling, add non-secret diagnostics, and add the requested regression coverage before approval. Required CI is still pending.

…ocalization

Addresses review on #2264: the localization helper had one broad catch around
both dynamic producer resolution and injector execution, so it couldn't tell a
benign 'producer not in this environment' from a real injector/fetch failure,
and emitted no diagnostic. Split into loadFontInjector() (returns null when the
module is absent — silent fail-open) and localizeWithProducer() (warns ONCE per
distinct message when the injector itself throws, then fails open). Per-family
resolution failures remain the injector's own responsibility (producer's
warnUnresolvedFonts). The localizer seam is injectable; tests now cover success,
producer-unavailable, injector-throw, warn dedup, and call-site integration.
@miguel-heygen miguel-heygen dismissed stale reviews from themself July 11, 2026 23:41

Superseded by aa10d49 and fc2b905 fixes.

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed current head fc2b905. Module-unavailable and injector-throw paths are now distinct; injector failures emit normalized, deduplicated non-secret warnings and fail open; tests cover successful injection, module absence, injector failure/dedup, and helper integration. Required CI is green. LGTM pending independent approval.

@vanceingalls vanceingalls merged commit 1a9c1b6 into main Jul 12, 2026
50 checks passed
@vanceingalls vanceingalls deleted the 07-11-fix-cli-localize-remote-fonts-in-audit-capture branch July 12, 2026 00:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants