From ad0d2393fe3209e89292641e5c2f04a45ceec04c Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 11 Jul 2026 16:14:36 -0700 Subject: [PATCH] fix(cli): localize remote fonts in snapshot/check capture to match render --- packages/cli/src/commands/snapshot.ts | 6 ++-- .../utils/bundleWithLocalizedFonts.test.ts | 32 +++++++++++++++++++ .../cli/src/utils/bundleWithLocalizedFonts.ts | 31 ++++++++++++++++++ packages/cli/src/utils/checkBrowser.ts | 8 ++--- packages/producer/src/index.ts | 9 ++++++ 5 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/utils/bundleWithLocalizedFonts.test.ts create mode 100644 packages/cli/src/utils/bundleWithLocalizedFonts.ts diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index c13ed4b24..ae8e8297b 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -193,11 +193,13 @@ async function captureSnapshots( zoomScale?: number; }, ): Promise { - const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); + const { bundleWithLocalizedFonts } = await import("../utils/bundleWithLocalizedFonts.js"); const numFrames = opts.frames ?? 5; - const html = await bundleToSingleHtml(projectDir); + // Localize fonts (embed remote @font-face as data URIs, matching the render + // path) so snapshots render the real font instead of a fallback sans. + const html = await bundleWithLocalizedFonts(projectDir); const server = await serveStaticProjectHtml(projectDir, html); const savedPaths: string[] = []; diff --git a/packages/cli/src/utils/bundleWithLocalizedFonts.test.ts b/packages/cli/src/utils/bundleWithLocalizedFonts.test.ts new file mode 100644 index 000000000..fc0d59c08 --- /dev/null +++ b/packages/cli/src/utils/bundleWithLocalizedFonts.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@hyperframes/core/compiler", () => ({ + bundleToSingleHtml: vi.fn(async () => "bundled"), +})); + +const injectMock = vi.fn(async (html: string) => html.replace("bundled", "bundled+fonts")); +vi.mock("@hyperframes/producer", () => ({ + injectDeterministicFontFaces: (html: string) => injectMock(html), +})); + +import { bundleWithLocalizedFonts } from "./bundleWithLocalizedFonts.js"; + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("bundleWithLocalizedFonts", () => { + it("localizes fonts on top of the plain bundle", async () => { + const html = await bundleWithLocalizedFonts("/project"); + expect(injectMock).toHaveBeenCalledOnce(); + expect(html).toBe("bundled+fonts"); + }); + + it("falls open to the plain bundle when font localization throws", async () => { + injectMock.mockRejectedValueOnce(new Error("offline / fetch layer unavailable")); + const html = await bundleWithLocalizedFonts("/project"); + // Never worse than a plain bundleToSingleHtml — the remote still + // loads at capture time as before. + expect(html).toBe("bundled"); + }); +}); diff --git a/packages/cli/src/utils/bundleWithLocalizedFonts.ts b/packages/cli/src/utils/bundleWithLocalizedFonts.ts new file mode 100644 index 000000000..901a2ff5e --- /dev/null +++ b/packages/cli/src/utils/bundleWithLocalizedFonts.ts @@ -0,0 +1,31 @@ +/** + * Bundle a project to a single HTML string AND localize its fonts — fetch and + * embed `@font-face` rules for every requested family (including families + * declared only via a remote ``, e.g. Google Fonts) as data URIs. + * + * Why the audit/snapshot paths need this: core's `bundleToSingleHtml` inlines + * only LOCAL stylesheets and leaves remote font ``s as-is, so a snapshot + * depends on loading the remote font at capture time. The render pipeline + * instead localizes fonts in its compile stage, which is why a render embeds + * (say) League Gothic correctly while a snapshot of the same composition can + * fall back to an un-styled system sans when the remote font loses the race + * against the capture. Running the SAME localization the render path uses makes + * snapshot/check captures font-faithful and deterministic — no network race. + * + * Fail-open: if a family can't be fetched (offline, unknown font), the + * underlying injector leaves the HTML unchanged, so this never makes a bundle + * worse than plain `bundleToSingleHtml`. + */ +export async function bundleWithLocalizedFonts(projectDir: string): Promise { + const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); + const html = await bundleToSingleHtml(projectDir); + try { + const { injectDeterministicFontFaces } = await import("@hyperframes/producer"); + return await injectDeterministicFontFaces(html); + } catch { + // Producer/font localization unavailable (or a fetch layer threw) — fall + // back to the plain bundle. Fonts declared via remote still load at + // capture time as before; we just lose the deterministic embed. + return html; + } +} diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index d889e41b7..9008ec7bd 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -88,8 +88,8 @@ export async function runBrowserCheck( motion: MotionSpecResolution, runGrid: RunAuditGrid, ): Promise { - const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); - const html = await bundleToSingleHtml(project.dir); + const { bundleWithLocalizedFonts } = await import("./bundleWithLocalizedFonts.js"); + const html = await bundleWithLocalizedFonts(project.dir); const server = await serveStaticProjectHtml(project.dir, html, "Failed to bind check server"); const drafts: RuntimeDraft[] = []; let currentTime = 0; @@ -145,8 +145,8 @@ export async function captureFindingCrops( requests: CheckFindingCropRequest[], ): Promise { if (requests.length === 0) return []; - const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); - const html = await bundleToSingleHtml(project.dir); + const { bundleWithLocalizedFonts } = await import("./bundleWithLocalizedFonts.js"); + const html = await bundleWithLocalizedFonts(project.dir); const server = await serveStaticProjectHtml(project.dir, html, "Failed to bind check server"); let chromeBrowser: import("puppeteer-core").Browser | undefined; const written: string[] = []; diff --git a/packages/producer/src/index.ts b/packages/producer/src/index.ts index 34a446556..80fb66830 100644 --- a/packages/producer/src/index.ts +++ b/packages/producer/src/index.ts @@ -86,6 +86,15 @@ export { // ── Utilities ─────────────────────────────────────────────────────────────── export { normalizeErrorMessage } from "./utils/errorMessage.js"; +// Font localization: fetch + embed @font-face rules for requested families +// (including those declared only via a remote ) so a bundled composition +// renders with the real font instead of a fallback, regardless of network +// timing. The render pipeline runs this in its compile stage; the CLI audit +// paths (snapshot/check) reuse it so their captures match the render. +export { + injectDeterministicFontFaces, + type InjectDeterministicFontFacesOptions, +} from "./services/deterministicFonts.js"; export { quantizeTimeToFrame } from "./utils/parityContract.js"; export { resolveRenderPaths, type RenderPaths } from "./utils/paths.js";