Skip to content

fix(viewer): embed the viewer font so delivered pages stay self-contained - #256

Open
276970789 wants to merge 1 commit into
tt-a1i:mainfrom
276970789:embed-viewer-font
Open

fix(viewer): embed the viewer font so delivered pages stay self-contained#256
276970789 wants to merge 1 commit into
tt-a1i:mainfrom
276970789:embed-viewer-font

Conversation

@276970789

@276970789 276970789 commented Sep 1, 2026

Copy link
Copy Markdown

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.com and fonts.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.json on main readerWidth diagramWidth
fonts fetched 1068 1038
fonts unreachable 1088 1058

The 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.html no longer links a font stylesheet. It embeds the woff2 subsets Google Fonts served for JetBrains Mono at service revision v24 (Google's identifier for the hosted files, not an upstream JetBrains release) as data: 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-face per unicode range, not per weight. The file Google serves is variable over wght 400–800 (I checked: fvar present, 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:

  • No --embed-fonts flag (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.
  • Unicode ranges the subsets do not cover — CJK, for one — still fall through to the monospace stack, exactly as before.
  • Website pages (docs/index.html, docs/guide.html, docs/start.html, docs/gallery.html) and scripts/*-template.html still 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.html is a frozen 2.11.0 artifact with its own generator, and experiments/mco-showcase/ is scratch output; neither is part of the delivery chain the tests maintain.
  • The desktop-reader-browser golden 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 reach fonts.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 delivered examples/web-app.html at showcase goes 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 to latin + latin-ext saves 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.zip goes 77 → 78 files and 1,321,533 → 1,846,385 bytes (+524,852, +39.7%), from the font payload carried once in assets/template.html plus 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 — the latin + latin-ext variant 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: from font-src will now reject the embedded faces, where before it would have needed https://fonts.gstatic.com in 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 unmodified main before the fix and passes after:

# before the fix (clean main, test file copied in)
$ node --test test/offline-self-containment.test.mjs
✖ the viewer template carries its own font instead of linking a third party
    AssertionError: template must not reference an external origin
    actual: [ 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap',
              'https://fonts.gstatic.com' ]
✖ a freshly delivered artifact of every type reaches no external origin
✖ every checked-in viewer artifact carries its font and reaches no external origin
ℹ pass 0   ℹ fail 3

# on this branch
$ node --test test/offline-self-containment.test.mjs
✔ the viewer template carries its own font instead of linking a third party (9.2ms)
✔ a freshly delivered artifact of every type reaches no external origin (2107.7ms)
✔ every checked-in viewer artifact carries its font and reaches no external origin (221.2ms)
ℹ pass 3   ℹ fail 0

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. readerLayout is 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.html has no generator meta at all and would be skipped, even though a compare artifact is delivered to a viewer like any other, while docs/gallery.html and docs/guide.html declare archify gallery / archify guide and are served pages that link a stylesheet on purpose.

Once the compare artifact was in the set I found a second problem. compare embeds each side as an escaped srcdoc document, so its subresources sit in the file as &lt;link href=&quot;https://…. Scanning the raw text calls such a page clean no matter what the embedded viewers link: on main, examples/checkout-platform-delta.html carries six Google Fonts references that no plain grep for <link href="http will find. So the test scans each artifact twice, as-is and with entities decoded. It now reports the main copy 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:

$ ARCHIFY_CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
    node --test test/desktop-reader-browser.test.mjs
✔ production showcase is readable in the real 1440 by 900 adaptive reader (13249.2ms)
ℹ pass 1   ℹ fail 0   ℹ skipped 0

Full suite from the rebased head (42fe7ac), from archify/, on the Node version the repo pins for packaging:

$ node --version   # v22.23.2
$ ARCHIFY_CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" npm test
# tests 1029   # pass 1025   # fail 0   # skipped 4

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.mjs reports two ERR_FS_EISDIR failures 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:

"validation": { "checksPassed": 9, "checkCount": 9, "compositionProfile": "showcase",
                "compositionStatus": "pass", "errors": 0, "warnings": 0 }
"artifact":   { "bytes": 807304,
                "sha256": "596cd485f06faf39115875614532aae690c1966bced3552406dad425b14ee8ce" }

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:

# main
2  https://fonts.googleapis.com
1  https://fonts.gstatic.com
43 http://www.w3.org        <- namespace, not a request

# this branch
0  external hosts
43 http://www.w3.org        <- namespace, not a request

visual-check on the delivered artifact: readability: pass at 1440×900, 1600×1000, 1920×1080, and 2048×1320; scrollHeight equals viewport height at all four; 4 screenshots captured; receipt remains visualReview: "pending".

Visual evidence

visual review: passed. Six screenshots are attached in a comment below — three states, light and dark, all at 1440×900:

  1. main, fonts fetched
  2. main, fonts unreachable
  3. this branch

They are visual-check captures, which wait for Archify.readerLayout.whenStable(). A raw --screenshot fires 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:

pair 1440×900 light 1440×900 dark
main fonts fetched vs this branch 0.02% 0.02%
main fonts fetched vs main fonts unreachable 8.66% 10.73%
main fonts unreachable vs this branch 8.66% 10.74%

The 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 direct render-architecture.mjs calls 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.html
  • examples/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.json
  • docs/gallery.html, docs/gallery/manifest.json, and all 11 files under docs/gallery/artifacts/
  • docs/cases/mco-runtime.architecture.html — re-rendered with ARCHIFY_REPO_ROOT at the pinned revision 9f1a1cf1afdc04d7b5406782b40dfec76d9bc798
  • docs/assets/archify-live-proof.gif, docs/assets/archify-live-proof.json — 54 frames / 5.4s. No byte size quoted on purpose: repeated build:readme-showcase runs 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 artifact
  • THIRD_PARTY_NOTICES.md and archify/THIRD_PARTY_NOTICES.md — JetBrains Mono provenance, SIL OFL 1.1, and the packaged license path; the notice contract now enforces all five disclosures
  • archify.zip — 78 files, 1,846,385 bytes, byte-reproducible under Node 22

New tracked file: archify/assets/JetBrainsMono-OFL.txt. The latest main third-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, and examples/rag-pipeline.html. render-examples.mjs's TARGETS covers the other five under examples/, and nothing in scripts/ 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. Extending render-examples.mjs to own them would fix it properly, but that is a separate change and I have not made it.

Checklist

  • I used a minimal focused change and preserved existing typed JSON behavior unless the issue requires a contract change.
  • I ran the relevant targeted tests and npm test in archify/.
  • I added or updated a regression test for behavioral changes.
  • I checked generated artifacts and package freshness when their sources changed.
  • I removed secrets, private repository content, and customer data from fixtures and screenshots.

@276970789

Copy link
Copy Markdown
Author

Screenshots for the Visual evidence section above. visual review: passed.

All six are visual-check captures of examples/web-app.architecture.json at --quality showcase, same Chrome, 1440×900. visual-check waits for Archify.readerLayout.whenStable(); a raw --screenshot fires mid-settle and reports roughly 19% difference between two runs of the same file, so it is not usable for this comparison.

The pair to look at is 1 vs 2 — that is main with and without the font request succeeding, and it is what a producer and a receiver of the same file can each see today. 3 is this branch, which reproduces 1 without the request.

1. main, fonts fetched

light dark

2. main, fonts unreachable (--host-resolver-rules="MAP * ~NOTFOUND") — readerWidth 1068 → 1088, diagramWidth 1038 → 1058, and the diagram repaints at a different scale. Both states report readability: pass.

light dark

3. this branch — identical with the network blocked.

light dark

Pixel deltas are in the PR body: 3 vs 1 is 0.02% (288 of 1,296,000 pixels, glyph edges only), 1 vs 2 is 8.66% light and 10.73% dark.

The files are pinned to a commit on my fork so the links do not move. Happy to re-attach them as uploads if you would rather not have the PR depend on an external host — I could not attach binaries through the API.

@JINITAIMEI121

Copy link
Copy Markdown

Hi! Same exercise, other side: I verified this branch locally too (macOS arm64, Node v24.19.0, head 4df3e7e, opened against 2bfb471).

Verification results

  • npm test at head: 1020 tests, 0 failures — including the new archify/test/offline-self-containment.test.mjs.
  • node bin/archify.mjs deliver dataflow examples/product-analytics.dataflow.json out.html --quality showcase → 9/9 artifact checks; the delivered HTML has zero fonts.googleapis.com/fonts.gstatic.com references and 6 embedded data:font/woff2 unicode-range faces.
  • assets/JetBrainsMono-OFL.txt ships with the embedded bytes — necessary for embedding, good to see.

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 local()-only fallback (#249), layout depends on each reader's locally installed fonts, which breaks producer/receiver reproducibility. The digests-pinned-bytes detail is what makes it defensible. Nice work.

…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
@276970789

Copy link
Copy Markdown
Author

@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 main (06dd052); the new head is 42fe7ac. The generated artifacts were rebuilt from the latest templates rather than keeping the conflicted copies.

I also addressed the disclosure/licensing points introduced on recent main:

  • CHANGELOG.md now calls out the approximately +92 KB cost per delivered artifact.
  • JetBrains Mono is covered by both repository and packaged THIRD_PARTY_NOTICES.md files, and the notice contract enforces its provenance, SIL OFL 1.1 license, and packaged license path.
  • The Node 22 full suite is green: 1029 tests, 1025 passed, 0 failed, 4 skipped, with Chrome enabled.
  • The rebased archify.zip contains 78 files and is byte-reproducible at 1,846,385 bytes.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Delivered HTML fetches Google Fonts, so an offline or air-gapped viewer silently loses the typography

2 participants