fix(viewer): embed the viewer font so delivered pages stay self-contained - #256
fix(viewer): embed the viewer font so delivered pages stay self-contained#256276970789 wants to merge 1 commit into
Conversation
|
Hi! Same exercise, other side: I verified this branch locally too (macOS arm64, Node v24.19.0, head Verification results
Trade-off worth stating in the PR body The embedded subsets add ~92 KB per delivered artifact in my test (720,653 B for #249's no-embed output vs 812,578 B here, same input). That looks like a fair price for reproducible geometry, but it affects every future artifact, so CHANGELOG/docs should call it out. Housekeeping The branch is currently unmergeable against recent main — a rebase will also let the regenerated goldens settle against the newest template. Between the two approaches I'd favor embedding for the reason in the description: with |
…ined A delivered page linked fonts.googleapis.com and fonts.gstatic.com, so every viewer's browser reached a third party to render the artifact as designed. The consequence is larger than the reported typography loss. The adaptive reader measures real font metrics rather than assuming them, so the resolved column width depends on that request succeeding: the same file resolves a 960px reader column with the face loaded and 987px without it, and the diagram repaints at the matching scale. Both states report readability: pass, so nothing flags it. The producer sees one page and the receiver sees another. Embed the woff2 subsets Google Fonts serves for JetBrains Mono as data: URIs, one @font-face per unicode range over the variable wght 400-800 axis. The bytes are identical to what the linked stylesheet resolved to, so typography and geometry are unchanged and now reproducible offline. No local() source is declared first on purpose: resolving an installed copy of unknown version puts the metrics, and so the column width, back at the mercy of the viewer's machine. The template comment publishes a sha256 per subset and a test enforces that those digests match what ships. Add test/offline-self-containment.test.mjs. It selects artifacts by the presence of Archify.readerLayout rather than a generator meta match, because readerLayout is the code that measures font metrics: the compare artifact carries no generator meta yet is delivered like any other page, while the site pages declare one and link a stylesheet on purpose. Each artifact is scanned both as-is and with entities decoded, since compare embeds each side as an escaped srcdoc whose subresources would otherwise read as clean. Both artifact cases assert the six faces are present, because reaching no third party by dropping the typography satisfies the offline check and still hands the receiver a different page. JetBrains Mono is under the SIL Open Font License 1.1; the notice and full license text ship at assets/JetBrainsMono-OFL.txt rather than in the template comment, so a grep over a delivered page reports zero external hosts. Closes tt-a1i#242
4df3e7e to
42fe7ac
Compare
|
@JINITAIMEI121 Thank you for the independent verification and for calling out both the rebase and artifact-size trade-off. I have now rebased this branch onto current I also addressed the disclosure/licensing points introduced on recent
The PR body has been refreshed with the current measurements and generated-artifact list. Thanks again — your check caught exactly the integration work this branch needed. |






Problem and value
Closes #242.
On the overlap with #249, first. That PR is open against this issue and I only found it after finishing here, so this is not a fork of it — the two arrived at different fixes independently. #249 declares
local('JetBrains Mono')as the source. I tried that first and measured it: on a machine without the font installed it reproduces the same layout drift the linked stylesheet caused, because the reader resolves its column width from real font metrics (numbers below). It removes the request without making the page reproducible, which is the part of #242 that I think actually matters. I would rather put the measurements in front of you than argue about it, so everything here is reproducible from the commands quoted. If you prefer that PR's shape, the test file and the artifact-freshness findings in this one are worth taking regardless of which fix lands.A delivered page linked
fonts.googleapis.comandfonts.gstatic.com, so every viewer's browser reached a third party in order to render the artifact as designed.While reproducing it I found the consequence is bigger than the typography loss the issue reports. The adaptive reader measures real font metrics rather than assuming them, so the resolved column width depends on that request succeeding. Same file, same Chrome, same 1440×900 viewport, measured through
visual-check:examples/web-app.architecture.jsononmainreaderWidthdiagramWidthThe diagram then repaints at a different scale: 8.66% of pixels differ in light theme, 10.73% in dark. Both states report
readability: pass, so nothing flags it. The producer sees one page, the receiver sees another, and the difference is invisible to both.The issue points out that the SVG export path is already clean, which is what makes this fixable in one place rather than inherent.
Scope
What changed:
archify/assets/template.htmlno longer links a font stylesheet. It embeds the woff2 subsets Google Fonts served for JetBrains Mono at service revisionv24(Google's identifier for the hosted files, not an upstream JetBrains release) asdata:URIs. The bytes are identical to what the linked stylesheet resolved to, so typography and geometry are unchanged and now reproducible offline. The header comment publishes the sha256 of each subset so anyone can trace the bytes upstream, and a new test enforces that those digests match what actually ships.Two things I'd look at closely:
One
@font-faceper unicode range, not per weight. The file Google serves is variable overwght 400–800(I checked:fvarpresent, one axis), and the same URL backs all four declared weights upstream. So six faces cover every weight instead of twenty-four.No
local()source declared first. That was my first attempt, option 2 in the issue, and it just trades one nondeterminism for another: resolving an installed copy of unknown version puts the metrics, and so the column width, back at the mercy of the viewer's machine. On a machine without JetBrains Mono installed it reproduced the same 987-vs-960 drift the linked stylesheet caused. Embedding is what actually makes the geometry reproducible.What deliberately did not change:
--embed-fontsflag (option 1 in the issue). A flag leaves the default artifact reaching a third party, and self-containment is asserted in the README and in tests rather than opted into.docs/index.html,docs/guide.html,docs/start.html,docs/gallery.html) andscripts/*-template.htmlstill link Google Fonts. Those are pages served from the site, not artifacts handed to a viewer, and Delivered HTML fetches Google Fonts, so an offline or air-gapped viewer silently loses the typography #242 is about the delivered file. Say the word if you want them in scope.experiments/is untouched.experiments/visual-evolution/prototype.htmlis a frozen 2.11.0 artifact with its own generator, andexperiments/mco-showcase/is scratch output; neither is part of the delivery chain the tests maintain.desktop-reader-browsergolden expectations are untouched (readerWidth === 960,diagramWidth === 930). Embedding the exact upstream bytes reproduces those numbers with the network blocked, which is the point — the previous values were only reachable on a machine that could reachfonts.gstatic.com.No unrelated changes: confirmed. Every regenerated file is listed below and follows from the template change alone.
Stability impact
Compatibility and migration risk: None for typed JSON, schemas, CLI, or receipts. No renderer or validator logic changed. Against the rebased
main(06dd052), the deliveredexamples/web-app.htmlatshowcasegoes 715,216 → 807,304 bytes (+92,088, +12.9%). The embedded payload is 66,164 raw bytes across six subsets, 88,228 base64 characters. If you would rather pay less, dropping tolatin+latin-extsaves 41KB per artifact and is a two-line change — I kept all six so that no unicode range regresses.Renderer, validator, package, or generated-artifact risk: after rebasing,
archify.zipgoes 77 → 78 files and 1,321,533 → 1,846,385 bytes (+524,852, +39.7%), from the font payload carried once inassets/template.htmlplus the license text. Rebuilt with Node 22 as required; a fresh rebuild is byte-identical (ba0d7bba71ba49e2c92d925e1608d8ce13f8a91d5312a963e1714a86ff947fe7). This is the biggest single cost of the change, so flag it if package size is budgeted somewhere I did not find — thelatin+latin-extvariant above would bring it down to roughly +265KB.Licensing: JetBrains Mono is under the SIL Open Font License 1.1, which permits bundling in a larger work. The full license text ships with the Skill at
archify/assets/JetBrainsMono-OFL.txt. The upstream project URL is kept in that file rather than in the template comment so that a grep over a delivered page still reports zero external hosts — the acceptance check the reporter asked for.Failure behavior and rollback path: This removes a failure mode rather than adding one, since there is no longer a request that can fail or be blocked. One degradation path remains and is unchanged in effect: if a face fails to decode, the stack falls through to system monospace, exactly as it did when the fetch failed. One trigger is genuinely new. A host page under a strict CSP that omits
data:fromfont-srcwill now reject the embedded faces, where before it would have neededhttps://fonts.gstatic.comin the same directive. The requirement moves rather than appears, and it moves off the network. Rollback is reverting this commit and re-running the builders listed below.Tests run
New regression test,
archify/test/offline-self-containment.test.mjs(3 tests). It fails on unmodifiedmainbefore the fix and passes after:The three cover the template, a freshly delivered artifact of all five types at
--quality showcase, and all 28 checked-in artifacts that carry the reader shell. Only subresource positions count as external:<link href>,src,srcset,@import,url(), including protocol-relative hosts. A repository-backed diagram cites source lines as anchors the reader clicks, and the SVG namespace is an identifier. Neither is a request the page makes on open.Three decisions in that test that I would rather explain than have you reverse-engineer.
It selects artifacts by the presence of
Archify.readerLayout, not by a<meta name="generator">match.readerLayoutis the code that measures font metrics to resolve the column width, so its presence is exactly what makes a page sensitive to which face loaded. Matching the generator meta gets the set wrong both ways:examples/checkout-platform-delta.htmlhas no generator meta at all and would be skipped, even though a compare artifact is delivered to a viewer like any other, whiledocs/gallery.htmlanddocs/guide.htmldeclarearchify gallery/archify guideand are served pages that link a stylesheet on purpose.Once the compare artifact was in the set I found a second problem.
compareembeds each side as an escapedsrcdocdocument, so its subresources sit in the file as<link href="https://…. Scanning the raw text calls such a page clean no matter what the embedded viewers link: onmain,examples/checkout-platform-delta.htmlcarries six Google Fonts references that no plain grep for<link href="httpwill find. So the test scans each artifact twice, as-is and with entities decoded. It now reports themaincopy as['https://fonts.googleapis.com/…', 'https://fonts.gstatic.com']where the raw scan reported[].Both artifact cases also assert the six faces are present. "Reaches no external origin" is satisfied by dropping the typography just as well as by embedding it, and an artifact rendered from a
local()-only template contacts nobody while still resolving its metrics off whatever the viewer happens to have installed — which is the failure this PR exists to remove. The offline assertion alone would let a stale artifact through. The face assertion is what makes re-rendering non-optional.Real browser gate, unchanged expectations:
Full suite from the rebased head (
42fe7ac), fromarchify/, on the Node version the repo pins for packaging:This clean full run followed a targeted 9/9 pass of
test/semantic-radar.test.mjs. Every font/self-containment, notice, golden-render, package-smoke, archive, and real-browser test passed.Unrelated aside, noted only so the Node 22 pin above does not look like cherry-picking: on Node 25 (non-LTS, outside the matrix)
test/clean-skill-staging.test.mjsreports twoERR_FS_EISDIRfailures in its own symlink probe, identically on an unmodified checkout and on this branch. Nothing to do with this change; I can open a separate issue if it is useful.Delivery receipt,
deliver architecture examples/web-app.architecture.json --quality showcase --json:That output is byte-identical and sha-identical to the checked-in
examples/web-app.html, so the artifact in this diff is reproducible from its authoritative input rather than hand-edited.The reporter's acceptance check — one grep over the delivered file:
visual-checkon the delivered artifact:readability: passat 1440×900, 1600×1000, 1920×1080, and 2048×1320;scrollHeightequals viewport height at all four; 4 screenshots captured; receipt remainsvisualReview: "pending".Visual evidence
visual review: passed. Six screenshots are attached in a comment below — three states, light and dark, all at 1440×900:
main, fonts fetchedmain, fonts unreachableThey are
visual-checkcaptures, which wait forArchify.readerLayout.whenStable(). A raw--screenshotfires mid-settle and is not comparable — it reports a difference of about 19% between two runs of the same file.Pixel comparison, counting pixels whose max channel delta exceeds 8:
mainfonts fetched vs this branchmainfonts fetched vsmainfonts unreachablemainfonts unreachable vs this branchThe 0.02% is 288 of 1,296,000 pixels in light (max channel delta 54) and 279 in dark (delta 51), confined to glyph edges — subpixel antialiasing, no layout shift. So the branch reproduces the fetched-fonts rendering exactly, and does so without the fetch. The delivered page is pixel-identical with the network blocked (
--host-resolver-rules="MAP * ~NOTFOUND"): 0.00% difference.Generated artifacts
Rebuilt from the template change with
render-examples.mjs,build-gallery.mjs,build-guide.mjs,build-start.mjs,build-readme-showcase.mjs,build-zip.sh(Node 22), and directrender-architecture.mjscalls for the five artifacts no builder owns (see the note at the end of this section):archify/examples/—dataflow-product-analytics.html,lifecycle-agent-run.html,sequence-cache-miss-request.html,web-app-rendered.html,workflow-agent-tool-call-rendered.htmlexamples/—archify-repo.html,archify-repo-grid.html,dataflow-product-analytics.html,lifecycle-agent-run.html,maka-architecture.html,rag-pipeline.html,sequence-cache-miss-request.html,web-app.html,web-app-rendered.html,workflow-agent-tool-call-rendered.html,checkout-platform-delta.html,checkout-platform-delta.receipt.jsondocs/gallery.html,docs/gallery/manifest.json, and all 11 files underdocs/gallery/artifacts/docs/cases/mco-runtime.architecture.html— re-rendered withARCHIFY_REPO_ROOTat the pinned revision9f1a1cf1afdc04d7b5406782b40dfec76d9bc798docs/assets/archify-live-proof.gif,docs/assets/archify-live-proof.json— 54 frames / 5.4s. No byte size quoted on purpose: repeatedbuild:readme-showcaseruns on identical inputs gave me GIFs tens of KB apart, so any number would describe one run rather than this change. Tell me if you would rather I left it alone entirely.CHANGELOG.md—[Unreleased]now discloses self-contained viewer typography and the approximate +92KB cost per delivered artifactTHIRD_PARTY_NOTICES.mdandarchify/THIRD_PARTY_NOTICES.md— JetBrains Mono provenance, SIL OFL 1.1, and the packaged license path; the notice contract now enforces all five disclosuresarchify.zip— 78 files, 1,846,385 bytes, byte-reproducible under Node 22New tracked file:
archify/assets/JetBrainsMono-OFL.txt. The latestmainthird-party-notice mechanism now covers this bundled font in both the repository and packaged Skill copies.One thing I hit that might deserve its own issue. Five checked-in artifacts are not regenerated by any script:
examples/web-app.html,examples/archify-repo.html,examples/archify-repo-grid.html,examples/maka-architecture.html, andexamples/rag-pipeline.html.render-examples.mjs'sTARGETScovers the other five underexamples/, and nothing inscripts/names these, so a template change quietly leaves them behind. They have to be re-rendered by hand from their sibling.architecture.json, which is what I did here. The new checked-in-artifact test at least fails now when any of them drifts, instead of the drift sitting there unnoticed. Extendingrender-examples.mjsto own them would fix it properly, but that is a separate change and I have not made it.Checklist
npm testinarchify/.