From ad0d2393fe3209e89292641e5c2f04a45ceec04c Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 11 Jul 2026 16:14:36 -0700 Subject: [PATCH 1/3] 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 c13ed4b24b..ae8e8297b0 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 0000000000..fc0d59c086 --- /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 0000000000..901a2ff5e3 --- /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 d889e41b73..9008ec7bdb 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 34a4465565..80fb668304 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"; From aa10d49234ed3eb675232ee8e4ba567b4071bb0e Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 11 Jul 2026 16:30:13 -0700 Subject: [PATCH 2/3] fix(cli): resolve producer font-localization at runtime so vitest transform doesn't fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../utils/bundleWithLocalizedFonts.test.ts | 25 +++++++-------- .../cli/src/utils/bundleWithLocalizedFonts.ts | 31 ++++++++++++++++--- 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/utils/bundleWithLocalizedFonts.test.ts b/packages/cli/src/utils/bundleWithLocalizedFonts.test.ts index fc0d59c086..957f432c7d 100644 --- a/packages/cli/src/utils/bundleWithLocalizedFonts.test.ts +++ b/packages/cli/src/utils/bundleWithLocalizedFonts.test.ts @@ -4,11 +4,6 @@ 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(() => { @@ -16,17 +11,19 @@ afterEach(() => { }); describe("bundleWithLocalizedFonts", () => { - it("localizes fonts on top of the plain bundle", async () => { - const html = await bundleWithLocalizedFonts("/project"); - expect(injectMock).toHaveBeenCalledOnce(); + it("runs the injected font localizer over the plain bundle", async () => { + const localize = vi.fn(async (html: string) => html.replace("bundled", "bundled+fonts")); + const html = await bundleWithLocalizedFonts("/project", localize); + expect(localize).toHaveBeenCalledOnce(); + expect(localize).toHaveBeenCalledWith("bundled"); 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"); + it("returns the localizer's output verbatim (localization is the last step)", async () => { + const html = await bundleWithLocalizedFonts( + "/project", + async () => "embedded-face", + ); + expect(html).toBe("embedded-face"); }); }); diff --git a/packages/cli/src/utils/bundleWithLocalizedFonts.ts b/packages/cli/src/utils/bundleWithLocalizedFonts.ts index 901a2ff5e3..fa0bc339ba 100644 --- a/packages/cli/src/utils/bundleWithLocalizedFonts.ts +++ b/packages/cli/src/utils/bundleWithLocalizedFonts.ts @@ -16,16 +16,37 @@ * underlying injector leaves the HTML unchanged, so this never makes a bundle * worse than plain `bundleToSingleHtml`. */ -export async function bundleWithLocalizedFonts(projectDir: string): Promise { +export async function bundleWithLocalizedFonts( + projectDir: string, + // Injectable for tests. Production callers omit it and get the producer + // font-localization pass, resolved lazily at runtime (see localizeWithProducer). + localizeFonts: (html: string) => Promise = localizeWithProducer, +): Promise { const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); const html = await bundleToSingleHtml(projectDir); + return localizeFonts(html); +} + +/** + * Run the render pipeline's `injectDeterministicFontFaces` pass, resolving + * `@hyperframes/producer` at RUNTIME only. The specifier is kept out of the + * bundler's/test-runner's static module graph (`@vite-ignore` + a variable + * specifier) on purpose: the CLI test job doesn't build producer, so a static + * `import("@hyperframes/producer")` would fail Vitest's transform-time + * resolution. At runtime — the built CLI, or an installed package — producer is + * a real dependency and resolves via node_modules. + * + * Fail-open: if producer can't be resolved or a fetch layer throws, return the + * HTML unchanged so a bundle is never worse than plain `bundleToSingleHtml`. + */ +async function localizeWithProducer(html: string): Promise { try { - const { injectDeterministicFontFaces } = await import("@hyperframes/producer"); + const producerSpecifier = "@hyperframes/producer"; + const { injectDeterministicFontFaces } = (await import( + /* @vite-ignore */ producerSpecifier + )) as typeof 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; } } From fc2b905837344a1c4e4bae1ecc9b9a70af1a5c79 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 11 Jul 2026 16:39:50 -0700 Subject: [PATCH 3/3] fix(cli): distinguish producer-absent from injector failure in font localization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../utils/bundleWithLocalizedFonts.test.ts | 55 +++++++++++-- .../cli/src/utils/bundleWithLocalizedFonts.ts | 78 +++++++++++++++---- 2 files changed, 108 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/utils/bundleWithLocalizedFonts.test.ts b/packages/cli/src/utils/bundleWithLocalizedFonts.test.ts index 957f432c7d..fb7e7a7512 100644 --- a/packages/cli/src/utils/bundleWithLocalizedFonts.test.ts +++ b/packages/cli/src/utils/bundleWithLocalizedFonts.test.ts @@ -4,13 +4,18 @@ vi.mock("@hyperframes/core/compiler", () => ({ bundleToSingleHtml: vi.fn(async () => "bundled"), })); -import { bundleWithLocalizedFonts } from "./bundleWithLocalizedFonts.js"; +import { + __resetFontLocalizationWarningsForTests, + bundleWithLocalizedFonts, + localizeWithProducer, +} from "./bundleWithLocalizedFonts.js"; afterEach(() => { + __resetFontLocalizationWarningsForTests(); vi.clearAllMocks(); }); -describe("bundleWithLocalizedFonts", () => { +describe("bundleWithLocalizedFonts (call-site integration)", () => { it("runs the injected font localizer over the plain bundle", async () => { const localize = vi.fn(async (html: string) => html.replace("bundled", "bundled+fonts")); const html = await bundleWithLocalizedFonts("/project", localize); @@ -19,11 +24,45 @@ describe("bundleWithLocalizedFonts", () => { expect(html).toBe("bundled+fonts"); }); - it("returns the localizer's output verbatim (localization is the last step)", async () => { - const html = await bundleWithLocalizedFonts( - "/project", - async () => "embedded-face", - ); - expect(html).toBe("embedded-face"); + it("returns the localizer output verbatim (localization is the last step)", async () => { + const html = await bundleWithLocalizedFonts("/project", async () => "embedded"); + expect(html).toBe("embedded"); + }); +}); + +describe("localizeWithProducer", () => { + it("embeds fonts when the injector is available", async () => { + const inject = vi.fn(async (html: string) => `${html}`); + const warn = vi.fn(); + const out = await localizeWithProducer("", async () => inject, warn); + expect(out).toBe(""); + expect(warn).not.toHaveBeenCalled(); + }); + + it("fails open silently when producer is unavailable (module absent → null)", async () => { + const warn = vi.fn(); + const out = await localizeWithProducer("plain", async () => null, warn); + // Never worse than a plain bundle; benign absence is not a warning. + expect(out).toBe("plain"); + expect(warn).not.toHaveBeenCalled(); + }); + + it("fails open WITH a diagnostic when the injector itself throws", async () => { + const warn = vi.fn(); + const boom: () => Promise = () => Promise.reject(new Error("fetch layer down")); + const out = await localizeWithProducer("plain", async () => boom, warn); + expect(out).toBe("plain"); + expect(warn).toHaveBeenCalledOnce(); + expect(warn.mock.calls[0]?.[0]).toContain("fetch layer down"); + }); + + it("dedups repeated identical injector failures across re-bundles", async () => { + const warn = vi.fn(); + const boom: () => Promise = () => Promise.reject(new Error("same failure")); + for (let i = 0; i < 5; i++) { + await localizeWithProducer("", async () => boom, warn); + } + // snapshot/check re-bundle per grid point; the warning must fire once. + expect(warn).toHaveBeenCalledOnce(); }); }); diff --git a/packages/cli/src/utils/bundleWithLocalizedFonts.ts b/packages/cli/src/utils/bundleWithLocalizedFonts.ts index fa0bc339ba..46dd6a8cfa 100644 --- a/packages/cli/src/utils/bundleWithLocalizedFonts.ts +++ b/packages/cli/src/utils/bundleWithLocalizedFonts.ts @@ -1,3 +1,6 @@ +import { normalizeErrorMessage } from "./errorMessage.js"; +import { c } from "../ui/colors.js"; + /** * Bundle a project to a single HTML string AND localize its fonts — fetch and * embed `@font-face` rules for every requested family (including families @@ -11,15 +14,11 @@ * 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, // Injectable for tests. Production callers omit it and get the producer - // font-localization pass, resolved lazily at runtime (see localizeWithProducer). + // font-localization pass (see localizeWithProducer). localizeFonts: (html: string) => Promise = localizeWithProducer, ): Promise { const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); @@ -27,26 +26,71 @@ export async function bundleWithLocalizedFonts( return localizeFonts(html); } +type FontInjector = (html: string) => Promise; + /** - * Run the render pipeline's `injectDeterministicFontFaces` pass, resolving + * Load the render pipeline's `injectDeterministicFontFaces`, resolving * `@hyperframes/producer` at RUNTIME only. The specifier is kept out of the * bundler's/test-runner's static module graph (`@vite-ignore` + a variable - * specifier) on purpose: the CLI test job doesn't build producer, so a static - * `import("@hyperframes/producer")` would fail Vitest's transform-time - * resolution. At runtime — the built CLI, or an installed package — producer is - * a real dependency and resolves via node_modules. + * specifier) on purpose: the CLI test job builds with `--filter + * '!@hyperframes/producer'`, so a static `import("@hyperframes/producer")` + * would fail Vitest's transform-time resolution. At runtime — the built CLI or + * an installed package — producer is a real dependency and resolves via + * node_modules. * - * Fail-open: if producer can't be resolved or a fetch layer throws, return the - * HTML unchanged so a bundle is never worse than plain `bundleToSingleHtml`. + * Returns `null` (not a throw) when the module simply isn't available in this + * environment, so the caller can treat "producer absent" — a benign, expected + * condition — differently from "the injector itself failed". */ -async function localizeWithProducer(html: string): Promise { +async function loadFontInjector(): Promise { try { const producerSpecifier = "@hyperframes/producer"; - const { injectDeterministicFontFaces } = (await import( - /* @vite-ignore */ producerSpecifier - )) as typeof import("@hyperframes/producer"); - return await injectDeterministicFontFaces(html); + const mod = (await import(/* @vite-ignore */ producerSpecifier)) as { + injectDeterministicFontFaces?: FontInjector; + }; + return mod.injectDeterministicFontFaces ?? null; } catch { + return null; + } +} + +const warnedFontLocalizationFailures = new Set(); + +/** Reset the dedup latch — tests only. */ +export function __resetFontLocalizationWarningsForTests(): void { + warnedFontLocalizationFailures.clear(); +} + +/** + * Localize fonts via the producer injector, distinguishing two failure modes: + * + * - **Module unavailable** (`loadInjector` yields `null`): benign — producer + * isn't in this environment. Return the HTML unchanged, silently; fonts + * declared via a remote `` still load at capture time as before. + * - **Injector threw** (a fetch layer failed, a family errored past the + * injector's own per-family handling): unexpected — surface it ONCE per + * distinct message (snapshot/check re-bundle per grid point, so an + * un-deduped warning would spam), then fail open to the plain bundle. + * + * Per-family resolution failures inside a successful pass are already reported + * by the injector itself (producer's `warnUnresolvedFonts`); this layer only + * owns the module-vs-execution distinction and the dedup. + */ +export async function localizeWithProducer( + html: string, + loadInjector: () => Promise = loadFontInjector, + warn: (message: string) => void = (m) => console.warn(` ${c.warn("⚠")} ${m}`), +): Promise { + const inject = await loadInjector(); + if (!inject) return html; + try { + return await inject(html); + } catch (err) { + const message = `Font localization failed; capturing with remote/fallback fonts instead: ${normalizeErrorMessage(err)}`; + if (!warnedFontLocalizationFailures.has(message)) { + warnedFontLocalizationFailures.add(message); + warn(message); + } return html; } }