16 KiB
Plan 27 Phase A diagnostic evidence
Date: 2026-09-06
Issue: #3377
Revision: 2a4a1583b (origin/main at run start)
Scope: Phase A, steps 1–2 only. No resolver, runtime behavior, or remote-source behavior changed.
Drift and authority
- Worktree started clean and
HEADmatchedorigin/main;git diff origin/main...HEADwas empty. - Current catalog is
packages/fonts/src/webfontlist.json. Its web font records point at both Google Fonts static assets and jsDelivr assets; “Google blocked” is not an offline proof. - Browser preview is
apps/web/src/components/typography/font-display.tsxand callsFontFace.load()against each catalog preview URL. - Browser PDF preview/download is
apps/web/src/features/resume/export/pdf-document.tsx→@reactive-resume/pdf/browser; registration ispackages/pdf/src/hooks/use-register-fonts.ts. - Server PDF is
apps/server/src/http/resume-pdf.ts→createResumePdfDownload; Playwright browser routing cannot observe that process’s outbound font fetches. - The issue is open and unmodified. PR #3455 is the approved planning PR; its plan/decision log grants execution of this bounded diagnostic and manifest evidence.
Deterministic fixture
tests/e2e/fixtures/offline-fonts.ts seeds one disposable resume after sample creation. It writes the same text into basics and summary, hides the picture, selects IBM Plex Serif 400/700 for body and heading, and marks the row public for the server-PDF surface.
The exact markers are versioned as offline-font-scripts-v1:
| Marker | Script or coverage |
|---|---|
Latin punctuation • — “quotes” € |
Latin plus General Punctuation and currency |
简体中文 |
Han / Simplified Chinese |
العربية |
Arabic |
עברית |
Hebrew |
ไทย |
Thai |
Emoji 🚀 |
Emoji |
tests/e2e/specs/offline-fonts.spec.ts is opt-in (OFFLINE_FONT_DIAGNOSTIC=1) so the normal PR E2E suite does not become network-dependent. Each surface creates a new browser context with persisted auth state, disabled service workers, and no prior browser cache. Every non-same-origin request is aborted and recorded as { hostname, path }; query strings, fragments, headers, bodies, tokens, and full URLs never enter diagnostic output. Reports are attached as JSON and emitted with the same sanitized shape.
The four surfaces are separate tests:
- Font picker preview opens Typography → Font Family and waits for lazy
FontFacepreview loads. - Builder PDF preview navigates to the builder, captures the active PDF canvas, and measures marker-local raster crops.
- Browser PDF download uses the Export dialog, rasterizes the downloaded PDF, and measures marker-local crops when generation succeeds.
- Server PDF calls the public PDF endpoint and records text-layer marker presence when generation succeeds.
Builder/browser-PDF reports keep PDF text extraction as a separate textLayerMarkers signal; it does not prove visible glyph outlines. Raster evidence attaches a rendered PNG and per-marker crop metrics, failing for blank or tofu-like visible crops. Blocked browser font requests classify browser surfaces as network-error. The server report deliberately says server-outbound-requests-unobservable-from-playwright; its cold-network gate remains unresolved because server outbound capture and verifiable restart identity require external host-level controls.
Run protocol and cold-cache boundary
Build and database setup follow tests/e2e/README.md. Run each surface in a separately restarted production server process so module-level PDF font registration state cannot leak between controls:
OFFLINE_FONT_DIAGNOSTIC=1 OFFLINE_FONT_DIAGNOSTIC_SERVER_RESTARTED=1 \
pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --grep "picker preview"
Stop and restart the production server before repeating the command with builder PDF, browser PDF, and server PDF grep patterns. The environment used for this change had no built apps/server/dist or apps/web/dist, no running PostgreSQL instance, and no production server to restart, so the cold E2E matrix was not run. This is an explicit infrastructure blocker, not a pass claim. The test records serverRestartFlag only as caller input and labels it non-proof; it does not claim a completed cold-network gate.
The current Playwright route guard cannot impose host-level egress denial on Node.js running the server. A genuinely cold server test therefore needs a separately restarted server plus host-level egress capture/deny (for example, a controlled network namespace or an approved outbound proxy). Do not infer server network behavior from an empty browser request list.
Administrator-hosted manifest proposal
This is a proposal, not an asset download. It intentionally contains only the primary family and glyph fallbacks required by the fixture and current PDF fallback map, not the full catalog.
{
"schemaVersion": "offline-fonts-v1",
"mode": "local-only",
"assetRoot": "/fonts/offline/v1",
"families": {
"IBM Plex Serif": {
"normal": { "400": "ibm-plex-serif/400.ttf", "700": "ibm-plex-serif/700.ttf" },
"italic": { "400": "ibm-plex-serif/400-italic.ttf", "700": "ibm-plex-serif/700-italic.ttf" },
"preview": "ibm-plex-serif/preview.ttf"
},
"IBM Plex Sans": {
"normal": { "400": "ibm-plex-sans/400.ttf", "700": "ibm-plex-sans/700.ttf" },
"italic": { "400": "ibm-plex-sans/400-italic.ttf", "700": "ibm-plex-sans/700-italic.ttf" },
"preview": "ibm-plex-sans/preview.ttf"
},
"Noto Serif": {
"normal": { "400": "noto-serif/400.ttf", "700": "noto-serif/700.ttf" },
"italic": { "400": "noto-serif/400-italic.ttf", "700": "noto-serif/700-italic.ttf" },
"preview": "noto-serif/preview.ttf"
},
"Noto Sans": {
"normal": { "400": "noto-sans/400.ttf", "700": "noto-sans/700.ttf" },
"italic": { "400": "noto-sans/400-italic.ttf", "700": "noto-sans/700-italic.ttf" },
"preview": "noto-sans/preview.ttf"
},
"Noto Sans SC": { "normal": { "400": "noto-sans-sc/400.ttf", "700": "noto-sans-sc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-sc/preview.ttf" },
"Noto Serif SC": { "normal": { "400": "noto-serif-sc/400.ttf", "700": "noto-serif-sc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-sc/preview.ttf" },
"Noto Sans TC": { "normal": { "400": "noto-sans-tc/400.ttf", "700": "noto-sans-tc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-tc/preview.ttf" },
"Noto Serif TC": { "normal": { "400": "noto-serif-tc/400.ttf", "700": "noto-serif-tc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-tc/preview.ttf" },
"Noto Sans JP": { "normal": { "400": "noto-sans-jp/400.ttf", "700": "noto-sans-jp/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-jp/preview.ttf" },
"Noto Serif JP": { "normal": { "400": "noto-serif-jp/400.ttf", "700": "noto-serif-jp/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-jp/preview.ttf" },
"Noto Sans KR": { "normal": { "400": "noto-sans-kr/400.ttf", "700": "noto-sans-kr/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-kr/preview.ttf" },
"Noto Serif KR": { "normal": { "400": "noto-serif-kr/400.ttf", "700": "noto-serif-kr/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-kr/preview.ttf" },
"Noto Sans Arabic": { "normal": { "400": "noto-sans-arabic/400.ttf", "700": "noto-sans-arabic/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-arabic/preview.ttf" },
"Noto Naskh Arabic": { "normal": { "400": "noto-naskh-arabic/400.ttf", "700": "noto-naskh-arabic/700.ttf" }, "italic": "reuse-normal", "preview": "noto-naskh-arabic/preview.ttf" },
"Noto Sans Hebrew": { "normal": { "400": "noto-sans-hebrew/400.ttf", "700": "noto-sans-hebrew/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-hebrew/preview.ttf" },
"Noto Sans Thai": { "normal": { "400": "noto-sans-thai/400.ttf", "700": "noto-sans-thai/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-thai/preview.ttf" },
"Noto Emoji": { "normal": { "400": "noto-emoji/400.ttf", "700": "noto-emoji/700.ttf" }, "italic": "reuse-normal", "preview": "noto-emoji/preview.ttf" }
}
}
Candidate source, license, script, and size evidence
Sizes are Content-Length bytes from a HEAD request to the exact current catalog assets on 2026-09-06. Responses reported Content-Encoding: gzip; these are compressed transfer-size estimates, not a claim about the eventual on-disk representation. Preview paths are aliases to the selected 400 face and add no extra bytes when stored once. Primary sources: IBM Plex LICENSE.txt, Noto core LICENSE, Noto CJK Sans LICENSE, and Noto Emoji font LICENSE.
| Family | Style/weights in proposal | Current catalog source version | License | Script/fallback role | Gzip bytes (selected files) | Build owner; runtime owners |
|---|---|---|---|---|---|---|
| IBM Plex Serif | normal 400/700; italic 400/700 | fonts.gstatic.com/s/ibmplexserif/v20 |
OFL 1.1, Reserved Font Name Plex |
Primary serif; Latin and punctuation stack | 294,717 | packages/fonts; apps/web FontDisplay; packages/pdf registration |
| IBM Plex Sans | normal 400/700; italic 400/700 | fonts.gstatic.com/s/ibmplexsans/v23 |
OFL 1.1, Reserved Font Name Plex |
Primary sans | 435,469 | packages/fonts; apps/web FontDisplay; packages/pdf registration |
| Noto Serif | normal 400/700; italic 400/700 | fonts.gstatic.com/s/notoserif/v33 |
OFL 1.1 | Serif punctuation fallback | 1,055,120 | packages/fonts; packages/pdf fallback registration |
| Noto Sans | normal 400/700; italic 400/700 | fonts.gstatic.com/s/notosans/v42 |
OFL 1.1 | Sans punctuation fallback | 1,236,259 | packages/fonts; packages/pdf fallback registration |
| Noto Sans SC | normal 400/700; italic reuses normal | fonts.gstatic.com/s/notosanssc/v40 |
OFL 1.1 (Noto CJK) | Simplified Han; CJK fallback | 12,766,416 | packages/fonts; packages/pdf CJK fallback |
| Noto Serif SC | normal 400/700; italic reuses normal | fonts.gstatic.com/s/notoserifsc/v35 |
OFL 1.1 (Noto CJK) | Simplified Han serif fallback | 17,350,185 | packages/fonts; packages/pdf CJK fallback |
| Noto Sans TC | normal 400/700; italic reuses normal | fonts.gstatic.com/s/notosanstc/v39 |
OFL 1.1 (Noto CJK) | Traditional Han fallback | 8,628,278 | packages/fonts; packages/pdf CJK fallback |
| Noto Serif TC | normal 400/700; italic reuses normal | fonts.gstatic.com/s/notoseriftc/v36 |
OFL 1.1 (Noto CJK) | Traditional Han serif fallback | 11,804,923 | packages/fonts; packages/pdf CJK fallback |
| Noto Sans JP | normal 400/700; italic reuses normal | fonts.gstatic.com/s/notosansjp/v56 |
OFL 1.1 (Noto CJK) | Kana and Japanese Han fallback | 6,383,035 | packages/fonts; packages/pdf CJK fallback |
| Noto Serif JP | normal 400/700; italic reuses normal | fonts.gstatic.com/s/notoserifjp/v33 |
OFL 1.1 (Noto CJK) | Kana and Japanese Han serif fallback | 8,685,862 | packages/fonts; packages/pdf CJK fallback |
| Noto Sans KR | normal 400/700; italic reuses normal | fonts.gstatic.com/s/notosanskr/v39 |
OFL 1.1 (Noto CJK) | Hangul and Korean Han fallback | 6,102,888 | packages/fonts; packages/pdf CJK fallback |
| Noto Serif KR | normal 400/700; italic reuses normal | fonts.gstatic.com/s/notoserifkr/v31 |
OFL 1.1 (Noto CJK) | Hangul and Korean Han serif fallback | 11,113,442 | packages/fonts; packages/pdf CJK fallback |
| Noto Sans Arabic | normal 400/700; italic reuses normal | fonts.gstatic.com/s/notosansarabic/v33 |
OFL 1.1 | Arabic sans fallback | 178,455 | packages/fonts; packages/pdf script fallback |
| Noto Naskh Arabic | normal 400/700; italic reuses normal | fonts.gstatic.com/s/notonaskharabic/v44 |
OFL 1.1 | Arabic serif fallback | 190,924 | packages/fonts; packages/pdf script fallback |
| Noto Sans Hebrew | normal 400/700; italic reuses normal | fonts.gstatic.com/s/notosanshebrew/v50 |
OFL 1.1 | Hebrew fallback for both serif/sans slots | 55,707 | packages/fonts; packages/pdf script fallback |
| Noto Sans Thai | normal 400/700; italic reuses normal | fonts.gstatic.com/s/notosansthai/v29 |
OFL 1.1 | Thai fallback for both serif/sans slots | 55,173 | packages/fonts; packages/pdf script fallback |
| Noto Emoji | normal 400/700; italic reuses normal | fonts.gstatic.com/s/notoemoji/v62 |
OFL 1.1 for font files; assets/tools have separate licenses | Emoji outline fallback; verify renderer support | 1,153,847 | packages/fonts; packages/pdf script fallback |
Estimated transfer size for all rows and listed styles: 87,490,700 bytes (~83.44 MiB). This confirms why a full-catalog bundle is out of scope. A later implementation should subset by declared glyph requirements or make the administrator choose fallback families; it must not silently fetch another CDN.
Source and license obligations
- Pin an upstream release/commit and retain source attribution plus the complete applicable license with hosted assets. Do not use mutable
@latestURLs as runtime sources. - IBM Plex’s license has Reserved Font Name
Plex; modified/subset outputs must follow OFL naming requirements. - Noto core, Noto CJK, and Noto Emoji font files are OFL 1.1, but Noto Emoji documents separate Apache/public-domain treatment for tools and flag image assets. Bundle only font files unless those other assets are intentionally needed and separately attributed.
- License checks are build-owner responsibility (
packages/fonts/tooling); runtime owners (apps/webandpackages/pdf) consume only the validated manifest.
Missing-family and missing-asset behavior
Local mode must resolve only same-origin administrator-hosted manifest paths. If imported resume data names an unavailable family, show an actionable missing-family error naming the family and required local asset; apply a configured local fallback only when the administrator explicitly supplied one. If a required weight/style/fallback asset is absent, fail the affected preview/export with an actionable diagnostic containing family/style/weight and local path. Never retry Google Fonts, jsDelivr, or any other remote URL in local mode.
Standard PDF families (Helvetica, Courier, Times-Roman) remain file-free. They do not prove that a document containing punctuation, CJK, Arabic, Hebrew, Thai, or emoji is network-free; the script fallback rows remain required.
Verification record
Completed read-only checks before handoff:
- CodeGraph exploration of font catalog, picker preview, browser PDF, server PDF, and existing fallback tests.
pnpm dlx @tanstack/intent@latest list: no matching local intent skill for this work.pnpm exec biome check tests/e2e/specs/offline-fonts.spec.ts tests/e2e/fixtures/offline-fonts.ts turbo.json: passed.git diff --check: passed.pnpm --filter @reactive-resume/fonts test: passed (55 tests).pnpm --filter @reactive-resume/pdf exec vitest run src/hooks/use-register-fonts.test.ts: passed (35 tests).- Web typography/regression suite: passed (940 tests across 135 files); web and server package typechecks passed.
pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --list: passed (4 diagnostic tests collected).- E2E diagnostic execution: blocked by missing build outputs and unavailable PostgreSQL/server; no success claim made.
pnpm exec turbo boundaries: passed on fresh rerun (Turbo 2.10.12, 1108 files, no issues).
The implementation intentionally stops at diagnostic fixtures and manifest evidence. Shared source resolution, asset hosting, local-mode configuration, and production behavior remain Phase A step 3+ work.