commit b5d9f7b35cec1d36fb4d90fcd1f22d88e0243819
parent 4132b68c9c04c7df92ddd5b4de3668f06c2cbb10
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 03:33:45 -0400
plans: slice O5 as shipped — umtool brand fonts: Plex Sans for the rail / ledger / scroll / chart fitted by the face's own advances, Plex Mono Bold vendored, the unbranded render byte-identical (334 files, 0 different)
The record: why a table rather than an average (Fira's 0.50 re-measured
at 0.457 em; even it overruns given real text; Plex 0 over in the proof),
what shipped, the provenance of IBMPlexMono-Bold.ttf, the proof sheet
and overrun check, the identity proof with its control, the gates
(test:scripts 185 + 1, umtool build, umtool e2e 175/2/45 with mix.spec
alone 12/12), the bite table, and what is left (Fira's own overruns,
the first scroll head beside DATE, STATE.md and S4's record for the
integration pass).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
| M | plans/release-11.md | | | 171 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
1 file changed, 171 insertions(+), 0 deletions(-)
diff --git a/plans/release-11.md b/plans/release-11.md
@@ -663,6 +663,177 @@ Gates on the merged tree, from the worktree root (`o6-gateR.log`, `o6-e2e-editor
ENVIRONMENT.md regenerated fails exactly the mention test (1); an undeclared
`ARCHILYZER_NEW_KNOB` in the entrypoint fails 1; all-absent ids not refused fails 1.
+### Slice O5, as shipped — umtool brand fonts (2026-09-28)
+
+Branch `r11/umtool-fonts` off `main` `2162db92`, fast-forwarded to `main` `cb9d02b2` (O4, O3, O6
+checkpoint A) before any change; worktree `/home/user/Projects/r11-umtool-fonts`, port block #8
+(umtool e2e 3851, stub 3852). One Opus implementer; scratch files `o5-*` in the job's
+`tmp/overnight`. Everything is under `umtool/report-to-video/**`; nothing in `umtool/app/**`.
+
+**Why.** S4 left two things for later ([`brand-and-themes.md`](brand-and-themes.md), "Slice S4, as
+shipped"): the rail, `ledger`, `scroll` and `chart` SVG text stayed Fira Sans under the brand,
+because `fit()` truncates against Fira's 0.50 em average; and `render.fontBold` stayed whatever the
+manifest said, because no bold Plex Mono was vendored. O5 does both, and an unbranded render stays
+byte-identical.
+
+**The measurement, and why the brand's metric is a table rather than an average.** Nothing
+recorded how 0.50 was measured, so O5 fixed a method and checked that it gives Fira's own number:
+the mean advance per character (fontTools hmtx; Fira from the system files fontconfig resolves, Plex
+Sans from the vendored variable font instanced at wdth 100) over the text these assets draw. The
+`fit()` corpus is the five S4 manifests' track labels, log and scroll labels, roster lines, column
+heads and quotes, read and never written (`o5-corpus.json`, 144 strings, all from
+`quartering-employee-count`, the only one with a rail).
+
+| face | weight | `fit()` strings | p95 of a string's own mean | every SVG text run | capitals |
+|---|---|---|---|---|---|
+| Fira Sans | 400 | 0.457 em | 0.492 | 0.463 | 0.549 |
+| Fira Sans | 700 | 0.462 | 0.497 | 0.525 | 0.550 |
+| IBM Plex Sans | 400 | 0.456 | 0.497 | 0.469 | 0.590 |
+| IBM Plex Sans | 700 | 0.479 | 0.521 | 0.577 | 0.613 |
+
+So 0.50 is Fira's mean plus the "hair" its comment describes, just over its p95. By the same method
+Plex Sans Regular gets 0.50 too, and Bold 0.53. **But an average cannot keep the promise the slice
+makes, that a branded column never overruns** (`o5-stress.log`). Every real string (87, 77 over 20
+characters) was pushed through every call site's size, budget, weight and letter-spacing:
+- **Fira's own 0.50 overruns** the scroll's bold, letter-spaced column head in 40 of 87 runs (up
+ to 11 %) and the rail's track label once;
+- Plex at 0.50 / 0.53 overruns the head in 35 of 87 runs;
+- uppercased, every site overruns in both faces at every average tried (0.50 to 0.55).
+
+None of the manifests' labels comes near a budget as written (the tightest is a wrapped quote line
+at 95 %), which is why this never showed. So the preset's metric is **the face's own advances**:
+- `face-metrics.mjs` is generated by `fonts/gen-face-metrics.py` (fontTools 4.65.0, instancer)
+ from the vendored `IBMPlexSans[wdth,wght].ttf`. It holds the advance of all 891 characters the
+ font maps at wght 400 and 700, wdth 100 (11.5 kB, do-not-edit header, the font's sha256).
+- `fit()` keeps the longest prefix whose width, ellipsis, weight and letter-spacing included, is
+ inside the budget.
+- Fira keeps its 0.50 average and its exact character counts. It is the system's font, so a table
+ of its advances could not be pinned anyway.
+- Kerning is ignored. Over the proof's 24 Plex runs, the plain sum was at or above the rendered ink
+ width of every one (by 0.19 px at the least), so the sum is the conservative side.
+- A character Plex does not map (emoji, CJK) counts 1.3 em.
+
+**What shipped.**
+- **`svg-faces.mjs`:** `FIRA_SANS` (`{family, em: 0.5}`), `IBM_PLEX_SANS` (`{family, advances:
+ {regular, bold}}`, built from the table), `textWidth(text, size, face, {weight, ls})`, and
+ `FALLBACK_EM`. `brand.mjs` gains `brandSvgFace(render)`: null without a brand, the preset's face
+ with one.
+- **`render-cards.mjs`:**
+ - `fit(text, size, maxPx, face = FIRA_SANS, run)` and `wrapPx(…, face)` are exported. An
+ average face runs the old code path verbatim. A measured face fits by width, and `wrapPx`
+ wraps by width and fits every line, so a lone overlong word is cut too.
+ - `svgTextIn(face)` sets each asset's family. The rail (chrome, log, tally, roster, the QR
+ tile), the ledger card, the scroll and the chart take `svgFaceOf(render)`.
+ - The scroll head fits with `{weight: "bold", ls: 0.6}`, and the ledger's scope pill is the
+ label's measured width + 22 (Fira: `length × 7.6 + 22`, unchanged).
+ - `rasterize()` hands rsvg-convert `childOpts(render, …)`, which passes the options through
+ unchanged without a brand. Without it a branded SVG found no Plex (it is not a system font)
+ and fell back to another sans, silently.
+- **IBM Plex Mono Bold, vendored:**
+
+ | | |
+ |---|---|
+ | file | `fonts/IBMPlexMono-Bold.ttf`, unmodified (Reserved Font Name "Plex") |
+ | source | google/fonts `ofl/ibmplexmono` at `0b58fb37` (`0b58fb370093f9a9f4ff785d94405710b79de67c`), the commit S4 took Regular from; Regular there is byte-identical to the vendored one |
+ | bytes | 137,784 |
+ | sha256 | `ac27abd6450a64dd94467580a02fe6235156d5b92f2926ebbc8e7489df64e0be` (git blob `17b406d2`, as upstream lists it) |
+ | licence | `OFL.txt` already holds Plex's OFL verbatim; unchanged |
+
+ `fonts.conf`'s relative `<dir>` picks it up: under `FONTCONFIG_FILE`, `IBM Plex Mono:bold` now
+ resolves to it (it resolved to Regular, emboldened, before), and without it the system still
+ answers Noto. `resolveBrandRender` sets `fontBold` to it beside `fontRegular`, so compose-chrome's
+ HyperFrames band sets its bold in the brand's mono. The fonts are about 1.5 MB in all.
+- **README:** the preset's "owns" list; the "Not branded yet" paragraph goes; the fonts paragraph;
+ "Why a table, not an average".
+
+**Shown.**
+- **`o5-fit-proof.png`** (+ `.tsv`): every `fit()` call site, three cases each (the site's longest
+ real label; the longest real string anywhere, a 143-character quote; and the site's label in
+ capitals), fitted in both faces inside a box exactly its budget wide. Each fitted run is then rendered
+ **alone** through rsvg-convert and its ink measured:
+ - **Plex: 24 runs, 9 truncated, 0 over budget, widest 98.9 %.**
+ - Fira (the unchanged path): 1 over, the scroll head at 152 px in 140.
+- **`o5-overrun.py`** computes every `<text>` run's extent from its face's real advances (anchor,
+ weight and letter-spacing aware) over the SVG sources the renderers write. It flags a run that
+ leaves its canvas or tally lane, or overlaps another run on its line. **0 problems** in all 27
+ SVGs of each set: unbranded, branded before, branded after (1,552–1,554 runs). It catches a
+ synthetic collision.
+- **Branded before/after** (`o5-brandshots.mjs`: the five manifests with `render.brand` and
+ `endCard: false` set in memory, 19 render calls). Every asset differs, as it should. Two ledger
+ quotes that Fira wrapped onto a stranded last word (L03 "employees", L06 "now") are one line in
+ Plex, and the pills hug their labels.
+ - Looked at: the ledger cards, the rail chrome, the log, the tally strip, the QR tile, the scroll
+ and the chart. No clipped glyph and no overrun.
+ - "IMPLIED" clears its delta triangle by 21.4 px (Fira 28), and "≈" and "→" are in Plex.
+ - `o5-compare.png`: a ledger row, the scroll heads and the chart's end labels, before and after.
+- **The branded chapter kicker** (Pango, the `label` face in bold) now draws the real Plex Mono Bold,
+ where it drew an emboldened Regular before. It is the only change on that card: 276 px inside the
+ kicker's 189 × 24 box, and it looks the same at 24 px.
+
+**The byte-identity proof** (S4's harness, copied as `o5-identity.mjs` / `o5-idiff.mjs`, paths
+adapted; S4's fixture copied to `o5-fixture`):
+- **BEFORE** was rendered on `cb9d02b2` before any change, into `o5-before/`. It covers every card,
+ footer, rail, ledger, scroll and chart asset of the five manifests in both variants (70 render
+ calls, the resolved `render` blocks included), plus a full offline fixture build.
+- **Control** (the same code twice, `o5-before` vs `o5-before2`): 334 files, 201 raw identical, 133
+ identical but for ImageMagick's `tEXt date:*` chunks, 0 different.
+- **AFTER, on the final code tip `216533ea`** (`o5-after-2`): **334 files, 201 raw identical, 133
+ identical but for the date chunks, 0 different, none missing or extra.** The fixture's cut is the
+ same 3,380,693 bytes, and the mp4s are raw-identical. The same result on the pre-commit tree
+ (`o5-after-1`).
+- The five manifests' sha256 were the same after every run (`o5-manifests-sha-before.txt`).
+
+| sha | what |
+|---|---|
+| `b9933d71` | `umtool:` vendor IBM Plex Mono Bold; `resolveBrandRender` sets `fontBold`; brand tests (the file, the value, fontconfig resolves it) |
+| `2223ba07` | `umtool:` Plex Sans for the rail / ledger / scroll / chart under the preset — `svg-faces.mjs`, `face-metrics.mjs` + `fonts/gen-face-metrics.py`, `fit()`/`wrapPx()` take the face, the pill, `rasterize()` gets `childOpts`, `brandSvgFace`; `svg-faces.test.mjs` (7) and a scroll-card render test |
+| `b555af1a` | `umtool:` test that the preset's rsvg-convert children get `FONTCONFIG_FILE` (an `RSVG_BIN` stub); fc-match's escaped paths unescaped |
+| `216533ea` | `umtool:` README — the preset's faces, "Why a table, not an average", Plex Mono Bold; `svg-faces.mjs`'s header says what was rendered |
+| _this_ | `plans:` this record |
+
+**Gates** (from the worktree root; logs `o5-*.log`):
+- **tsc clean** before every commit: 66 s, 32 s, 38 s and 30 s. The `.mjs` files are `allowJs`
+ without `checkJs`, so it resolves them but does not check them.
+- **`test:scripts` 185 + 1 skip** (`main` `cb9d02b2`: 175 + 1; +10 = `svg-faces.test.mjs` 7 +
+ `brand.test.mjs` 3). Common, editor unit and mcp not run: nothing outside umtool changed.
+- **umtool build** (`pnpm --filter umtool run build`): ok, 17 s.
+- **umtool e2e, full** (`SONG_DIR=~/reports/quartering-uh-song/data node scripts/worktree.mjs run --
+ pnpm --filter umtool run e2e`, on `2223ba07`, no queue wait): **175 passed, 2 failed, 45 skipped,
+ 4.4 min**, which is release 10's count. The two failures are `mix.spec.ts:166` and `:201`, the
+ known order-dependent pair; the skips are the song-data capabilities this machine lacks.
+ **`mix.spec.ts` alone: 12 passed** (26 s), both included. The two later commits are a test, the
+ README and a comment, so it was not re-run.
+- **Changelog:** umtool has none (`umtool/CHANGELOG.md` does not exist), so nothing was added.
+
+**They bite** (`o5-bite.py`: scratch copies of `report-to-video/`, the worktree untouched; `svg-faces`
++ `brand` tests, 21):
+
+| variant | result |
+|---|---|
+| the tip, unmutated (control) | 21/21 |
+| `cb9d02b2` (the old code) | both files fail at import (`MONO_BOLD_FONT_FILE`, `fit` not exported) |
+| `b9933d71` (bold in, measured face not yet) | both fail at import (`brandSvgFace`) |
+| `fit()` counts for every face | 4 fail: Plex inside its budget, measured not counted, wrap, the scroll render |
+| the scroll head fitted without its face | 1 fails: the scroll render (`THE QUARTERING · MED…` in Plex) |
+| `wrapPx()` counts for every face | 1 fails: wrap |
+| `rasterize()` without `childOpts` | 1 fails: the rsvg env test |
+| `fontBold` not set | 1 fails: the preset's render |
+| `IBMPlexMono-Bold.ttf` removed | 2 fail: the vendored faces, `fonts.conf` resolution |
+
+**Found and left**
+- **Fira's unbranded path can overrun its own columns** given long enough text (the scroll head
+ above; uppercased text at every site). None of the real manifests' labels comes near a budget,
+ and the byte-identity rule forbids changing it. If it ever matters, the fix is a Fira table like
+ Plex's, and that has to be pinned to one Fira build.
+- **The scroll's first column head** is fitted to the column pitch (140 px) and ignores the `DATE`
+ head to its left. "THE QUARTERING" clears `DATE` by 8.4 px of advance in Plex (23 in Fira), and a
+ head near 140 px would reach it in either face. Not hit by any manifest.
+- The 1.3 em fallback for unmapped characters is a bound, not a measurement: nothing rendered an
+ emoji or CJK label through a fallback font.
+- **`plans/STATE.md`** ("New lows", l. 154–155) lists both of this slice's items as open. **S4's
+ record** in `brand-and-themes.md` (l. 1451–1453, 1541) states them as they were. Neither file is
+ this slice's to edit, so they are left for the integration pass.
+
## Rollout
Nothing is rolled out tonight. The morning runbook lists what is owed: the :3001 editor restart,