fix(cli): localize remote fonts in snapshot/check capture to match render#2264
Conversation
miga-heygen
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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 outercatchis 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 isworkspace:*in the CLI package — always present at runtime — the catch is defending against a narrow set of unexpected throws rather than "producer missing." Consider adefaultLogger.warn(or plainconsole.warnif 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-faceas 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 plainbundleToSingleHtml? 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-face—extractExistingFontFacesskips 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.
…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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
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 exactinjectDeterministicFontFacespass the render pipeline already runs. New shared helperbundleWithLocalizedFonts(projectDir)wrapsbundleToSingleHtml+ the injector; snapshot.ts and both bundling sites in checkBrowser.ts use it.Why
Wild report (CLI 0.7.53, macOS):
hyperframes snapshotrendered 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'sbundleToSingleHtml, 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
bundleWithLocalizedFontson a composition that declares League Gothic only via a Google Fonts<link>— the bundled HTML now contains adata-hyperframes-deterministic-fonts<style>with an embedded@font-facedata URI for League Gothic (previously only the remote<link>was present). RESULT: PASS.hyperframes snapshotof that composition renders condensed League Gothic correctly (visually confirmed), Fonts: 2 loaded.snapshot,captureCompositionFrame, andcheckBrowsersuites 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.