Why the type system is built the way it is, and why every diagram is Graphviz DOT measured with Inter's own widths. Split out of WIP.md, which keeps the three faces, the hard rules and the gate under Typography.
Two neighbours: WIP.Fonts.md is how the six subset .woff2
files are produced, and WIP.A11y.md is the scan that a change in
type metrics can move.
An inline element's measured height is its font's content area, so WCAG 2.5.8
target-size results moved with whatever system-ui resolved to on the machine
running the scan --- measured at the mobile h3 size: 19px on Segoe UI, 17px on
Inter and Verdana, 16px on Arial, 15px on CI's Liberation Sans, all at the same
declared size. That is how the heading-link hit box shipped clearing the 24px
floor by 0.8px on Windows and missing it on CI. Pinning the face settles the
number at 17px everywhere.
It does not make the generous paddings unnecessary, and they must not be
trimmed back. font-display: swap means even a normal load spends its first
frames on the fallback, and a reader whose font request fails --- or who turns
webfonts off --- stays there. The rule that actually cannot be unsettled by a
font is one that meets the floor on declared size, which is what
.footer-actions > * does; prefer that shape where the layout allows it.
Changing the face broke one target-size check, on one node of 869 pages ---
read it as a warning, not a one-off. A footnote back-link is a bare inline
<a> holding a single U+21A9 with no padding, so its target is whatever box the
font gives that glyph: 12.09 x 19 on Segoe UI, 12.09 x 15 on Liberation Sans,
15.97 x 17 on Inter. None of those has ever met 24 x 24. It was passing on
axe's spacing exception --- a 24px circle centred on the target happened to
clear its neighbours --- and Inter's arrow is 3.9px wider, which moved the centre
far enough to intersect one. The fix sizes the link outright (display: inline-block; min-width/min-height: 24px) rather than restoring the clearance.
Two things to take from it. Run the full sweep after any change that moves type metrics --- the thirteen-page sample was clean through all of this, in both themes and both viewports, and would have shipped the regression. And treat a target that passes only on the spacing exception as unfixed: it is a measurement standing on the font, the surrounding layout and the viewport at once, and any of the three can move.
- docs/_sass/custom/_fonts.scss --- the
@font-facerules (as a mixin, see below) and the$tb-body-font-family/$tb-mono-font-familystacks. - docs/assets/css/just-the-docs-combined.scss
--- includes
fonts.emit-font-facesand passes the stacks into JTD's$body-font-family/$mono-font-familythrough themeta.load-css$withmap. - docs/_sass/modules-dark.scss --- passes the
same two stacks again. This is not redundant. The dark compilation
re-emits every JTD base rule under
html[data-theme="dark"], which liftsbody { font-family: ... }from (0,0,1) to (0,1,2); leave the dark side on the defaults and the site renders Inter in light mode and system fonts in dark. It is the same specificity trapmodules-dark.scss's own header warns about, and it is invisible unless you check both themes. - builder/template.mjs ---
fontPreloads()emits the two<link rel="preload">tags.crossoriginis mandatory even same-origin: font fetches are always CORS-mode, and a preload whose mode does not match the later@font-facefetch is not reused, so the file downloads twice. - docs/assets/css/print.css --- the book's own
@font-faceblock and three stacks. - builder/pdf.mjs ---
REQUIRED_FONTS, copied into the sparse_site-pdf/tree. Keep it in step with print.css's@font-faceblock.
Two rules that are easy to get wrong:
@font-faceis emitted exactly once, from the light compilation. That is why_fonts.scssexposes a mixin rather than bare CSS: the dark entry point emits its whole payload twice, once per dark selector, and an@font-facenested inside a selector is invalid.url()in both stylesheets is relative (../fonts/...), never root-absolute. The compiled CSS lands atassets/css/in all four trees, so a relative URL resolves identically online, in thefile://offline mirror, in the--baseurltree, and in the PDF source. A root-absolute path would depend on the offline rewrite catching it and would break under a base path.
Inline SVG inherits the page's font environment, so the self-hosted faces apply
to diagram labels too --- svg-inline.js puts the SVG in the DOM rather than
behind an <img src>, which would isolate it from the document's @font-face
rules. The DOT sources --- docs/assets/images/dot/ for shared diagrams,
Images/ beside a page for one that belongs to it --- and builder/gantt.mjs
name the Inter stack directly.
Every diagram is Graphviz DOT, and it measures with a font it has never seen. A committed export is only correct for the metrics the layout was computed with, so the face and the geometry are coupled whatever the renderer.
The WASM build carries no font machinery at all --- zero occurrences of
pango, fontconfig, freetype or harfbuzz --- only the built-in width tables for
the core PostScript families, and get_metrics_for_font_family() falls back to
Times for anything else. So fontname="Inter" produced byte-identical geometry
to fontname="NoSuchFontXYZ" (194.00 against Helvetica's 208.00, Courier's
254.00). Times is far narrower than Inter through the lowercase --- a is 444
per 1000 em against 557 --- so every box came out about 11% too small.
That shipped. Twenty-seven labels across the three DOT diagrams were painted
outside their boxes, by up to 17.9 user units, and every label sat right of
centre; toolchain-overview.svg had the s of "book.html + CSS + images"
clipped by the box edge. It had been that way since the diagrams were written,
through the whole font migration, and nothing reported it: axe does not
evaluate SVG <text> geometry, so the full-site sweep passed over it.
builder/dot-metrics.mjs fixes the cause. See Teaching Graphviz what Inter measures below for the mechanism, and run the gate after touching any of it:
node scripts/check_dot_fit.mjs # also runs inside check.batIt renders every committed diagram with the real webface and fails if a label sits outside the box Graphviz drew for it. With the metrics installed, the worst off-centre across all five diagrams is 1.1 units; with them reverted, the check reports the CEF diagram's three longest labels overflowing by 8.5 to 15.7 --- verified by writing a deliberately Times-measured SVG and watching it fail.
lib/common/textspan_lut.c stores widths as:
struct FontFamilyMetrics {
const char **font_name;
double units_per_em;
short widths_regular[128], widths_bold[128],
widths_italic[128], widths_bold_italic[128];
};applyInterMetrics() overwrites the Times family's four arrays with Inter's
advances after Graphviz.load(). Three facts make that work, and each was
measured rather than assumed:
- The table is reachable.
_module.HEAPU8exposes the module's 16.3 MB linear memory. - It is read on every layout, not cached. Writing a new width for
Amoved the laid-out box forAAAAfrom 43.00 pt to 80.00 pt. - Inter already routes there. The name lookup is permissive and falls back
to Times, so patching Times is what makes
fontname="Inter"correct. Nothing in the docs asks for Times; a diagram that did would now get Inter's metrics.
Widths are in the family's own em units (2048 for this table), not AFM
thousandths --- which is why a first search of the WASM for the AFM values
found nothing. builder/inter-metrics.json is in the same scale, generated by
scripts/build_dot_metrics.mjs from the committed webfonts, measured in a
browser rather than read out of the font binary: the browser's shaped advance
is the number we are trying to match.
Result across the site's diagram labels: -11.4% mean / -18.0% worst becomes +0.5% mean / +2.8% worst. The residual is kerning, which a per-character table cannot express, and it errs wide --- boxes come out slightly generous rather than slightly tight, which is the safe direction.
Why not recompile Graphviz instead. Adding an Inter entry to
all_font_metrics upstream is tidier and is upstreamable, but it needs an
emscripten toolchain plus @hpcc-js's packaging, and npm install would stop
being enough to build the docs --- which is exactly what dot.mjs's
setup-failure path exists to preserve. A binary patch of the shipped .wasm
is no better: Graphviz.load() takes no arguments, so delivering one means
monkeypatching WebAssembly.instantiate or vendoring the 800 KB generated
loader. If the source route is ever taken, inter-metrics.json is already the
table it needs.
It has to be idempotent, and that is not obvious. Graphviz.load()
memoises its module, so serve.bat hands the same instance back on every
rebuild. A second unconditional patch searches a heap where Inter's widths are
already sitting in the Times slot, finds no signature, and fails --- reading
exactly like an upstream bump, which is the wrong place to go looking.
applyInterMetrics() keeps a WeakSet of patched instances and re-verifies
instead of re-writing. Three in-process rebuilds with a .dot edit between
each now come back clean; before the WeakSet, the second one threw.
What will break it. An @hpcc-js/wasm-graphviz bump that moves the table.
That is deliberate: locateTimesFamily() requires exactly one signature match
against the published Times AFM widths and spot-checks all four arrays, and
assertMeasuresInter() lays out a real string afterwards to prove the patch
took. On failure regenerateDot emits nothing and flips the exit code --- a
stale but correct SVG beats a freshly wrong one.
custom.scss used to say .svg-container svg { width: 100% }, which stretched
every diagram to the column whatever its own size. Label size then became an
accident of how wide Graphviz happened to draw the thing --- measured against
16px body text in a 736px column:
| diagram | natural | rendered | label vs body |
|---|---|---|---|
| toolchain-overview | 636px | 736px | 1.16x |
| pdf-render-pipeline | 767px | 736px | 0.96x |
| WebView2 Monaco | 981px | 736px | 0.75x |
| scheduler-dag | 1048px | 736px | 0.70x |
| CEF Monaco | 1259px | 736px | 0.58x |
A 2x spread with no typographic intent in it, and one diagram set larger than
the prose beside it. The rule is now width: auto; max-width: 100%.
Natural size is the correct target, and not by luck. Graphviz writes labels
at fontsize=12 in a space where one user unit is one point, and sizes the root
<svg width="NNNpt"> to match --- so at 1:1 a label is 12pt, which is 16px at
96dpi, which is the site's body size. toolchain-overview lands on exactly
1.00x, not approximately.
max-width still shrinks anything too wide for the column, and that scales
geometry and labels together, so wide diagrams stay small-but-aligned; the Zoom
control is what covers reading them. Nothing else moved: the four capped
diagrams are unchanged, the Gantt chart (which has no width attribute) does
not collapse, and at mobile every diagram is capped anyway so the rule is a
no-op there.
The lesson is the same one the print stylesheet had to learn, from the opposite direction: scale the whole SVG or nothing. Stretching it up inflates the labels; shrinking the text alone unmoors them from their boxes.
Once a diagram can be narrower than the column, it also has to be centred,
in both stylesheets: an <svg> is inline-level by default, so it would sit
against the left margin with the whole shortfall pooled on the right, which
reads as a layout mistake rather than as a small diagram. display: block plus
margin-inline: auto in custom/custom.scss and print.css does it, and is a
no-op for a diagram that reaches max-width --- there is no shortfall to
distribute. Measured on toolchain-overview: 636px in a 736px column, 50px of
gap each side, on screen and on the printed page alike.
print.css carried .svg-container text { font-size: 75%; }, added to stop
diagram labels dominating the page. It is gone, and it must not come back.
It was the width-table defect arriving from the stylesheet instead. Graphviz
sizes each box to the text it measured and positions the label with
text-anchor="start" at an x computed for that size, so shrinking only the
text leaves it undersized and left of centre in a box that did not move.
Measured across the book's five diagrams: worst off-centre 15.6px with the
rule, 1.4px without.
It was also unnecessary. max-width: 100% scales any diagram wider than the
170mm text column down to fit, and that scales its labels too --- which is
four of the five. Label size against 10.5pt body text, with the rule removed:
| diagram | natural width | label | vs body |
|---|---|---|---|
| CEF Monaco | 944pt | 6.1pt | 0.58x |
| WebView2 Monaco | 736pt | 7.9pt | 0.75x |
| scheduler-dag | 786pt | 7.4pt | 0.70x |
| pdf-render-pipeline | 575pt | 10.1pt | 0.96x |
| toolchain-overview | 477pt | 12.0pt | 1.14x |
Only toolchain-overview sits above body size, because at 477pt it is the one
diagram that fits the column at natural size. The rule bought that single case
1.14x -> 1.00x and paid for it with the worst misalignment in the book.
A diagram that needs to be smaller in print is scaled whole, never by its text alone --- and one does, for a reason worth keeping straight.
Graphviz authors labels at 12pt. On the web that is the 16px body size, so a
diagram at natural size matches the prose beside it for nothing. The book sets
body text at 10.5pt, so the same diagram comes out at 1.14x the prose. It
only affects toolchain-overview, the one diagram narrow enough (477pt) to
render at natural size in the 170mm column; the other four are already scaled
down to fit and land below body size on their own.
print.css closes it with zoom: 0.875, which is 10.5/12 exactly:
| diagram | as it was | with zoom |
with max-width: 87.5% |
|---|---|---|---|
| toolchain-overview | 1.14x | 1.00x | 1.01x |
| pdf-render-pipeline | 0.96x | unchanged | 0.84x |
| WebView2 Monaco | 0.75x | unchanged | 0.65x |
| scheduler-dag | 0.70x | unchanged | 0.61x |
| CEF Monaco | 0.58x | unchanged | 0.51x |
zoom, not max-width and not transform. A max-width percentage is
relative to the column, so it shrinks the four diagrams that were already
correct. transform does not change the layout box, so paged.js would break
pages around space the diagram no longer occupies. zoom scales geometry and
labels together, relative to the diagram's own size, and does change the
layout box. The four wide diagrams are untouched by it because they are wider
than the column either way, so max-width: 100% still decides their size.
Graphviz emits <text> with no fill attribute, so it inherits SVG's initial
black. For a node label that is right: those sit on the diagrams' light amber
fill in both themes. A cluster label and an edge label sit on the page
background, and black on the dark theme's background measures 1.40:1.
This was also shipping unreported, for the same reason as the overflow: axe's
colour-contrast rule does not evaluate SVG text. custom/custom.scss now gives
g.cluster > text and g.edge > text fill: currentColor, which tracks the
body text colour without hardcoding either palette --- the same thing
admonitions.scss does for its icons. Scoped to direct children, because
node text must keep the dark ink its own light fill needs. Measured after:
15.02:1 light, 11.66:1 dark, against 19.59:1 for the node labels in both.
Do not widen that selector to all text. The node labels would go light on a
light fill in dark mode, which is the same defect with the themes swapped.
The four buttons above each inlined diagram (Download / Copy, SVG / PNG) all
route through serializeWithFonts in
docs/assets/js/svg-inline.js, which embeds the
faces the diagram paints with as data: URIs in an inline @font-face.
This is necessary because an exported diagram is cut off from the page's
@font-face rules. An SVG handed to new Image() renders in the browser's
"secure static mode", which fetches no external resource at all, and a
downloaded .svg opened somewhere else has no access to this site's
stylesheet either. Both fall back to whatever the viewer has installed.
Measured on a machine with no local Inter: an exported PNG asking for
font-family="Inter" rasterised pixel-identical to one asking for a font
that does not exist --- same ink count, same extent.
A data: URI is not an external fetch, so secure static mode permits it, and
the bytes come from the HTTP cache because the page already downloaded them.
The whole thing costs ~25 ms per click and nothing over the wire. Two details
are load-bearing:
- Only the faces the diagram actually uses are embedded, decided from the live element's computed styles rather than from the markup. Every diagram needs Inter roman; the two Monaco ones also carry italic label text, and that is a second 203 KB. An export runs ~243 KB roman-only, ~449 KB with italic.
- Font paths resolve against the script's own URL
(
document.currentScript.src), not a hard-coded root-absolute path, which is what makes them work unchanged in the offline mirror and under a--baseurldeployment. In the offline mirrorfetch()cannot read afile://URL at all, so the embed silently degrades to the old behaviour; that is the one context where an export still uses the viewer's fonts.
Never adopt a diagram tool that emits HTML-in-SVG. Chromium taints a canvas
that has had an SVG containing <foreignObject> drawn into it, and a tainted
canvas refuses toBlob() with a SecurityError --- so Download PNG and Copy
PNG cannot work on such a diagram at all, and the throw lands inside an
img.onload handler where nothing surfaces it, so the click just appears to do
nothing. Graphviz emits plain <text>, which is why all four buttons work on all
five diagrams. This is a property of the format, not of the export code, and it
is what decided the format.
Mermaid was the tool this was measured on, and the obvious workaround does not
work: flowchart: { htmlLabels: false } moves only the edge labels to <text>
and keeps node labels in foreignObject regardless, tested against Mermaid 11.
It is no longer wired up --- no dependency, no script emitted, only
code.language-mermaid rules left in the vendored theme CSS --- so a mermaid
fence renders as a code block today.
Measured through the real export path, all five rasterise: 65--403 KB per PNG,
and 6.4--13.6% more ink than the same SVG with the @font-face stripped, which
is what says the embedded Inter is being used rather than a fallback.
The failure path stays, because it is still reachable --- a hand-authored SVG
could reintroduce foreignObject: it is caught, logged, and announced through
a role="status" live region naming Download SVG as the alternative.
Driving the real buttons turned up one more silence of the same kind: the
copy-png branch called navigator.clipboard.write() with no .catch(),
unlike copy-svg beside it, so an unfocused document or a denied permission
rejected into nothing and the click looked like it had worked. It now reports
through the same live region.
The forked paged.js asserts that fonts have settled before the page-breaking
pass, and it used to reject any face not in state loaded. A CSS-connected
FontFace is only fetched when the layout demands one, so a face print.css
declares but a given render never exercises --- Cascadia Mono Italic, when no
code sample is italicised --- sits at unloaded forever and tripped it. The
assertion now rejects loading and error only; see loadFonts() in
book/lib/paged.browser.js.
The book's fallback chains are self-hosted all the way down. Source Serif 4, Inter, Georgia, ... rather than dropping straight to a system serif, and
"Cascadia Mono", monospace with no named local face. The book is rendered once,
on one machine, so a named local fallback bakes that machine's fonts into the
artifact.
Checking which fonts the PDF actually embedded is what found the remaining gaps, and it found two the CSS review had not:
- Source Serif 4 covers 603 of the codepoints this book uses against Inter's 1103, so dropping straight from it to a system serif pulled a Times New Roman subset in for seven footnote back-arrows and four U+2194. Putting Inter second in the chain fixed it.
- normalize.scss puts
pre,code,kbdandsampon genericmonospaceand JTD overrides onlycode; print.css loads no normalize but the UA default is the same. Eleven pages use<kbd>, and every one of them was setting keys in whatever the machine calledmonospace--- Consolas here. Fixed in both stylesheets;sampis included although nothing uses it yet.
What is left in 1,991 pages is three glyphs: a clock emoji, and the U+2714 and U+22EE that Authoring prints as examples of characters the subsets do not carry. Those two are deliberate and the page says so --- but they are why a font census of the book reports a Segoe UI Symbol and a Cambria Math subset, which is otherwise a mystery worth an hour.
To check what a built PDF actually embedded:
node -e "const z=require('zlib'),f=require('fs'),b=f.readFileSync('docs/_pdf/twinBASIC Book.pdf'),s=b.toString('latin1'),h={},a=t=>{for(const m of t.matchAll(/\/(BaseFont|FontFamily)\s*(?:\/|\()([\w+\-,. ]+)/g))h[m[2]]=(h[m[2]]||0)+1};a(s);for(const m of s.matchAll(/stream\r?\n/g)){const i=m.index+m[0].length,e=s.indexOf('endstream',i);try{a(z.inflateSync(b.subarray(i,e)).toString('latin1'))}catch{}}console.log(h)"A font preload is a CORS-mode fetch. Under file:// there is no origin to
match, so Chrome fails it with ERR_FAILED and logs it, while the @font-face
fetch beside it succeeds and the faces load anyway. The preload therefore buys
an offline reader nothing and costs two red lines in the console, so
stripFontPreloads in builder/offline-rewrite.mjs
removes it from that tree only.