commit fa6d4dce4828ac8be7c972fd26f18addb92377c6
parent c7a59feef61cd820ca0d685f261f23ca6708c9e9
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 26 Sep 2026 03:08:14 -0400
plans: brand S4, as shipped — Archilyzer Media: the lockup measured (5.334 em, MEDIA 0.30 em apart), the channel's four PNGs, the opt-in umtool preset and its new-project choice; unbranded renders byte-identical (334 files); umtool e2e 175 + the two known mix.spec order failures (12/12 alone); release-10 notes S4 is outside the site rollout; editor [Unreleased] bullet
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 182 insertions(+), 0 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,5 +1,8 @@
# Changelog
+## [Unreleased]
+- **umtool's report videos can wear the Archilyzer Media brand, and the channel's YouTube picture, watermark and banner are generated.** A report video whose manifest says `"render": { "brand": "archilyzer-media" }` (umtool's **new project** form now has a brand choice, and `umtool new` takes `--brand archilyzer-media`) is drawn in the channel's slate-and-teal palette and its three faces, with a title card that carries the Archilyzer Media lockup and the found line, the mark at the head of every clip's citation line, a 20-second end card whose right half is left empty for YouTube's end-screen videos (`render.endCard` changes its length or, with `false`, drops it), and a 1280 × 720 thumbnail from a clip still and a headline (`thumbnail` in the manifest; `build-video.mjs --thumbnail` makes it alone). A manifest without the key renders exactly as before, byte for byte. `pnpm --filter yt-dlp-transcript-common exec tsx bin/brand-media.ts` writes the channel picture, the video watermark, the banner and a preview of its phone crop to `~/reports/archilyzer-media/brand/`, with an `INDEX.html` that walks through the YouTube Studio upload. The fonts (Archivo, IBM Plex Sans, IBM Plex Mono) ship in `umtool/report-to-video/fonts/` under the SIL Open Font License; nothing is installed system-wide.
+
## [0.9.0] - 2026-09-26
- **Every page now has a ground and an accent to choose, and the five theme families are gone.** The theme menu (the palette button beside the quick toggle, in the editor's sidebar and in the header of every published site, the hub and the homepage) has two groups. **Base** is System, Light, Sepia or Dark; Sepia is new, a warm paper ground for long reading. **Accent** is Signal, Brass, Vermilion, Violet, Sakura, Blue or Green, with the site's own tagged *default*; a site with a custom hex offers it first as *Site colour*. The quick toggle cycles System → Light → Sepia → Dark. A published site opens on the reader's system setting, in the accent its site form sets. The hub and the homepage open on Dark, in Signal, even with JavaScript off, and the editor follows the system, in Signal. Each accent has a value for each ground that reads at 4.5:1, and a custom hex is darkened or lightened per ground to match. A reader's accent is remembered only while it differs from the site's: picking the site's own again forgets it, so the reader follows the site if its accent changes later. Base, Archive, Selenized, Swiss and Archilyzer are gone. A choice made before this update carries over once: light stays light (Archive light becomes Sepia), dark stays dark and system stays system; the family itself is dropped. Headings are Archivo, text is IBM Plex Sans and figures are IBM Plex Mono everywhere, with one corner radius. Success, warning and other status text reads at 4.5:1 on its own tinted fill on every ground; on Light, success and warning are a shade deeper than before for it. Chart colours are fixed per ground and never follow the accent; the third is a violet, well clear of the red that marks a recording as gone. The phone's browser bar takes the page's ground, not the accent. Needs a rebuild and deploy of every site, the hub and the homepage.
- **Every site, the hub, the homepage and the editor wear the new Found-line mark, and a site's header splits its wordmark.** The mark is four transcript lines on a rounded square, the second lit and carrying a play head. The favicon, app icons and touch icon are no longer committed files: each build draws them from the mark and writes `/icons/icon.svg`, `maskable.svg`, `icon-32.png`, `icon-192.png`, `icon-512.png`, `maskable-512.png`, `apple-touch-icon.png` and `/favicon.ico` (16, 32 and 48 px). A site's icons are an ink tile lit with its own accent (Signal when it sets none; a custom colour is lightened until it reads on the tile). The hub, the homepage and the editor use the parent mark, bone on slate. The site header shows the mark and the header title split at the site's **Wordmark lead**, the lead heavy and the rest light (Jer|alyzer); with no lead the whole title is heavy. The header mark's lit line follows the reader's accent; the icons keep the site's. The footer's "Built with Archilyzer" has the small parent mark in front of it, outside the link. The homepage header shows the parent mark and "Archi|lyzer", no longer in spaced capitals. An installed app's title bar is the dark ground (`#0c0a08`) and its splash the icon's tile, where a site's used to be the old default blue. The service workers fetch icons fresh whenever the reader is online and keep a copy for offline, so an installed app picks up the new icons, and any later accent change, on its next online visit. Their shell cache is renamed to `shell-v2`: readers' offline channel downloads are kept, but the old cached pages and scripts go, so an installed app opens offline again only after one more online visit. The editor's sidebar shows the parent mark beside the admin title, and the editor now has a favicon. Needs a rebuild and deploy of every site, the hub and the homepage.
diff --git a/plans/brand-and-themes.md b/plans/brand-and-themes.md
@@ -4,6 +4,8 @@ Status: SHIPPED to main 2026-09-25 (S0 `7c9e3bdf`, S1 `575ae1d4`, S2 `b9772e53`,
`plans:` records), not rolled out. Worked on `brand/found-line` (worktree `../brand-found-line`,
pnpm wt block #1), with S1 and S2 as `brand/mark` and `brand/themes` off S0's tip. The release is
[`release-10.md`](release-10.md); the operator's runbook is `~/reports/release-10/RUNBOOK.html`.
+S4 (Archilyzer Media, the YouTube channel) was built after the release-10 cut on `brand/media`
+(worktree `../brand-media`, block #3) and is not part of the site rollout; see "Slice S4, as shipped".
Design canvas: https://claude.ai/artifact/UsUxwgRkP5a3m4jZXucAvG (boards *Family*, *In context*,
*Themes · base × accent*, and the rejected directions A–C). Read it with Artifact
@@ -1347,3 +1349,178 @@ S4 ships after release 10's cut and is **not part of the site rollout**: it touc
fixture manifest. Look at each one.
- umtool e2e if umtool's code is touched (`SONG_DIR=~/reports/quartering-uh-song/data pnpm
--filter umtool run e2e`, queued).
+
+### Slice S4, as shipped — Archilyzer Media (2026-09-26)
+
+Branch `brand/media` off `main` at `4e5630aa` (worktree `../brand-media`, port block #3). S4 gives the
+operator's YouTube channel its assets and gives umtool's report videos an opt-in brand: a pure
+`common/lib/brandMedia.ts` (the lockup with its letters as outlines, the banner, mark M2), a CLI that
+renders the channel's four PNGs and an `INDEX.html` handoff, and a report-to-video preset behind one
+manifest key. **A manifest without the key renders byte-identical** (334 files compared, below). No
+site, export, homepage or editor app file changed; `common/lib/brandIcons.ts` gained
+`renderSvgPng`, which `renderIconPng` now calls (the nine icon PNGs checked, byte-identical).
+
+**The lockup, measured rather than drawn.**
+- `common/bin/gen-media-glyphs.py` (fontTools instancer; reads the Archivo umtool vendors, or
+ `--download`) writes `common/lib/brandMediaGlyphs.ts` (do-not-edit header, font sha256): per glyph
+ the advance, the ink box and the outline (y down, baseline 0), for `Archi` 720, `lyzer` 380 and
+ `MEDIA` 700 at wdth 118, plus GPOS pair kerning inside each run (`rc` -14, `ze` -20; none in MEDIA).
+ The instanced cap height is 686 for all three weights.
+- The spec's 5.334 em is Σ advances − 0.01 em × 10 letters **without** kerning; a browser kerns, which
+ gives 5.300. `mediaLockupGeometry` keeps the spec's width (the canvas's `textLength`) and places the
+ kerned glyphs with one uniform adjustment per gap (−7.33 units), exactly what
+ `lengthAdjust="spacing"` did on the canvas. MEDIA at 1.035× then gets **0.3002 em** between letters,
+ matching "0.30 em".
+- Block = cap line to MEDIA's baseline = (0.686 + 0.30 + 0.686 × 1.035) px = 1.696 px; the mark is that
+ tall, 0.46 px from the text. At the banner's 124 px: MEDIA 128.34, text 661.4, mark 210.3, block
+ centred on the safe area at (815.6, 614.8) — the canvas had (817, 615) from hand-rounded numbers.
+ `h`, `i`, `l` rise 0.038 em above the cap line; the SVG adds that on top (`inkTop`) so nothing clips.
+- The banner's field is a **port** of the canvas generator, not a transcription: `PyRandom` is
+ CPython's MT19937 + `init_by_array` + `_randbelow`, and the test pins its first uint32s, the first
+ `choice`s and the 87 lines against the Python output of `gen_media.py`'s `banner()`.
+
+**`common/lib/brandMedia.ts`** (pure; imports `brand.ts` and the glyphs): `MEDIA_PALETTE`
+(ground `#151b20`, dim `#3f4c56`, lit/media Signal `#5fa8a0`, fg `#e7edf1`, muted `#8496a2`, amber
+`#e3b15c`), `MEDIA_MARK_PALETTE`, `MEDIA_RATIO = 1.035`, `mediaLockupGeometry(px)`,
+`mediaLockupSvg({px, palette})` (reuses `markSvg`, re-rooted as a nested `<svg>`), `MEDIA_BANNER`,
+`mediaBannerField`, `mediaBannerLayout`, `mediaBannerSvg({view?})`, `mediaMarkSvg(variant)`, and
+`mediaVideoKit()` — the JSON the umtool preset reads, since its scripts are plain `node`.
+
+**`common/bin/brand-media.ts`** (`--out`, default `~/reports/archilyzer-media/brand/`; `--video-kit`
+rewrites `umtool/report-to-video/brands/archilyzer-media.json`). Rendered through `renderSvgPng`
+(next/og) under tsx with no trouble, so rsvg-convert was not needed. Run for real:
+
+| file | IHDR | bytes |
+|---|---|---|
+| `avatar-800.png` (maskable) | 800 × 800 | 19,730 |
+| `watermark-150.png` (`any`, transparent corners) | 150 × 150 | 2,753 |
+| `banner-2560x1440.png` | 2560 × 1440 | 117,254 (limit 6 MB) |
+| `banner-safe-1546x423.png` | 1546 × 423 | 33,534 |
+
+plus `avatar.svg`, `watermark.svg`, `banner.svg`, `banner-safe.svg` (the same drawing with viewBox
+`507 508.5 1546 423`), `lockup.svg` and `INDEX.html` (previews, sizes, Customization → Branding →
+Picture / Banner image / Video watermark, then Publish). All four PNGs and the page were looked at.
+
+**The umtool preset** (`render.brand: "archilyzer-media"`; README "The Archilyzer Media preset").
+- `brand.mjs`: `resolveBrandRender` (owns `palette` and `fontRegular` = vendored Plex Mono; resolves
+ `render.endCard` — default 20 s, a number, `{seconds, url}` or `false`), `brandManifest` (appends
+ `{type:"card", id:"end", style:"end", hideRail:true, chapter:"End"}` unless the timeline has its own
+ end card; an unrelated `end` id is an error), `brandFaces`, `childOpts` (FONTCONFIG_FILE on the
+ preset's own `magick`/`rsvg-convert` children), `brandHeaderGeometry`. Every one returns its input
+ object itself when there is no brand. `brand-ids.mjs` holds the ids alone, because umtool's
+ registry is read by client components and must not pull `node:fs`.
+- The hook is **the end of `selectVariant`**, the one door the build, verify-build, compose-chrome and
+ umtool's export all use — so all of them agree the end card exists.
+- `brand-cards.mjs`: the title card (lockup top-left at the board's 34 × 2.424; the title in
+ `Archivo @wght=720,wdth=118` at 97 px, line height 1.08, from y 412; `sub` in Plex Sans; the found
+ line; a Plex Mono `meta ?? foot` line), the end card (lockup at 68 px + `archilyzer.pages.dev`,
+ centred with room for the subscribe element; a guard throws if the column reaches the right half),
+ the header mark PNG, and `renderThumbnail` (still cover-cropped into the left 711 px, the headline
+ in Archivo 800 from 78 px shrinking by 8 % until it clears the duration badge and no word overflows,
+ refused below 44 px; the mark at 29,29).
+- `render-cards.mjs`: `renderCard` routes title/end to the preset when branded; `span` takes a Pango
+ `face` (only ever passed when branded), so chapter / bullets / sources / timeline / footer labels
+ take the preset's faces with their layouts unchanged.
+- `build-video.mjs`: `headerFilters` (the unbranded tick + drawtext, verbatim; branded: two drawtexts
+ in Plex Mono 20 px, the channel drawn again in the foreground over the line's head — same glyphs,
+ same origin, no measuring); the mark (34 px in the 56 px header, at x 90 where the tick was) is the
+ LAST ffmpeg input, so every unbranded input index is unchanged; `--thumbnail` and a thumbnail after a
+ full branded build when `manifest.thumbnail` is set (`{headline, clip?, at?, still?}`; the frame
+ comes from the cached window, never the network; `.jpg` too if the PNG passes 2 MB).
+- **Surface:** umtool has no render settings UI; the new-project form is where a report video's
+ render block is born, so the brand is a select there (declared in the registry as a scaffold field
+ with `brands`, the menu renders it from data) and `umtool new --brand archilyzer-media`. No brand
+ writes the skeleton it always did.
+- **Not branded, on purpose:** the rail, `ledger`, `scroll` and `chart` SVG text stays Fira Sans —
+ `fit()` truncates against Fira's measured 0.50 em average, and swapping the face without
+ re-measuring would overrun columns. The palette does apply.
+
+**Fonts** (`umtool/report-to-video/fonts/`, committed unmodified from google/fonts; Plex carries the
+Reserved Font Name "Plex", so it may not ship subset or modified):
+
+| file | bytes | source |
+|---|---|---|
+| `Archivo[wdth,wght].ttf` | 658,596 | `ofl/archivo` @ `95f4904f` |
+| `IBMPlexSans[wdth,wght].ttf` | 537,244 | `ofl/ibmplexsans` @ `0b58fb37` |
+| `IBMPlexMono-Regular.ttf` (static, for drawtext) | 135,580 | `ofl/ibmplexmono` @ `0b58fb37` |
+| `OFL.txt` (both upstream OFL files, verbatim) | 8,846 | |
+
+1.33 MB of fonts in all. No italics, no other Plex Mono weights: nothing draws them. `fonts.conf`
+adds the directory and includes the system config (fallback for emoji / CJK, and its cache dir);
+`install-user-fonts.sh` is the no-sudo fallback.
+
+**The byte-identity proof** (`$T/s4-identity.mjs`, `$T/s4-idiff.mjs`). Before any report-to-video
+change, the harness rendered, into `$T/s4-before/`: every card, footer, rail, ledger, scroll and chart
+asset of five real manifests (`elfpire-eva-lawyer`, `quartering-christian`,
+`quartering-employee-count`, `ferret-rescue`, `destinys-child`; both variants; 70 render calls; the
+manifests read, never written) plus each resolved `render` block, and a full offline build of a
+fixture (local corpus, cached windows made with `ffmpeg -f lavfi`: title / chapter / sources cards,
+two clips with header, footer, marker and QR, an image entry, xfade, chapters) — **334 files**. The
+same harness after the preset, on the final tip `782eb48e`: **334 files, 201 raw byte-identical, 133
+identical but for ImageMagick's three `tEXt date:*` chunks, 0 different, none missing or extra.**
+- ImageMagick stamps the wall clock into every PNG and ignores `SOURCE_DATE_EPOCH`, so the control —
+ the UNCHANGED code run twice — already differs in exactly those 133 files in exactly those chunks
+ (`$T/s4-before` vs `$T/s4-before2`: 201 / 133 / 0). The diff drops only `tEXt` chunks whose key
+ starts `date:` and `tIME`; IHDR, IDAT and every other byte are compared.
+- The seven mp4s (six segments and the cut) are raw byte-identical: x264 is deterministic here.
+
+**Samples** (the branded fixture, `$T/s4-fixture/brand`, built offline): `$T/s4-sample-title.png`,
+`s4-sample-clip-header.png` (+ `-zoom`), `s4-sample-image-header.png`, `s4-sample-end.png`,
+`s4-sample-thumbnail.png`, `s4-sample-chapter.png`, `s4-sample-sources.png`. Each was looked at
+against the canvas's *in the video* board. The first header had the mark 5 px low and small beside the
+text; it is now 0.6 of the header, the capitals centred on it (drawtext `y` is the top of the tallest
+glyph, so it is set from Plex Mono's 0.74 em ascender and 0.698 em cap height).
+
+**Tests.**
+- `common/lib/brandMedia.test.ts` (7): the palette and mark M2 = `markSvg`; the measured widths
+ (5.334 / 3.953 / 686); **MEDIA's advance = the wordmark's within 0.5 %** at 18/28/64/124 px, 0.30 em
+ spacing, block ≈ 1.70×, both lines flush; **the lockup's block and ink boxes inside the banner's safe
+ area**, centred, and every field line clear of the gutter; `PyRandom` = CPython; SVG shape; **the
+ glyph file = a fresh `gen-media-glyphs.py --stdout`** (skips without python3 + fontTools).
+- `common/bin/brand-media.test.ts` (3): the asset list; `writeMediaAssets` into a temp dir — every
+ IHDR, the banner ≤ 6 MB, sources and INDEX; the committed video kit = `--video-kit`'s output.
+- `umtool/report-to-video/brand.test.mjs` (10): no brand → the same objects back from every hook,
+ `selectVariant`'s pre-preset shape, the unbranded header strings pinned literally; the preset's
+ render, `endCardConfig`, the end card rules, the branded header, the fonts and OFL, the kit; a guarded
+ render (title and end at 1920 × 1080, the end card's right half one colour, the thumbnail 1280 × 720
+ with the badge corner one colour).
+- `umtool/e2e/projects.spec.ts` +1: `umtool new --brand` writes `render.brand` (and still the palette),
+ no brand writes none, a bad brand exits non-zero; the form's brand select defaults to "no brand",
+ offers "Archilyzer Media", and creating with it writes `render.brand`.
+
+| sha | what |
+|---|---|
+| `fe053e74` | umtool: vendor Archivo, IBM Plex Sans, IBM Plex Mono Regular + `OFL.txt`, `fonts.conf`, `install-user-fonts.sh` |
+| `598fe502` | common: `brandMedia.ts`, `brandMediaGlyphs.ts` (generated), `bin/gen-media-glyphs.py`, tests |
+| `6d27814f` | common: `bin/brand-media.ts` (+ test), `renderSvgPng` in `brandIcons.ts`, the video kit JSON |
+| `a91d6527` | umtool: the report-to-video preset (`brand.mjs`, `brand-ids.mjs`, `brand-cards.mjs`, render-cards / build-video hooks, README, tests) |
+| `c2483ebf` | umtool: the brand choice in the new-project form and `umtool new --brand`, e2e +1 |
+| `782eb48e` | common: INDEX.html's timestamp says UTC |
+
+**Gates.**
+- tsc (`pnpm -r … tsc --noEmit`): clean before the common commits (59 s) and on the umtool tree (88 s);
+ common's own tsc before the two later common commits.
+- common **1,923/1,923** (1,913 + 10); editor unit **79/79**; `test:scripts` **172 + 1 skip** (162 +
+ 10); mcp **219/219**.
+- Builds: `pnpm --filter editor exec next build` ok (69 s); `pnpm --filter umtool run build` ok (22 s).
+ The umtool bundle keeps `brand.mjs`'s `import.meta.url` as the source path
+ (`file://${P("umtool/report-to-video/brand.mjs")}`), so the kit and fonts resolve in the server too.
+- umtool e2e, full suite (`SONG_DIR=~/reports/quartering-uh-song/data node scripts/worktree.mjs run --
+ pnpm --filter umtool run e2e`, from the worktree): **175 passed, 2 failed, 45 skipped**, 4.5 min
+ after 17.5 min in the queue. The new `projects.spec.ts:506` passed. The two failures are
+ `mix.spec.ts:166` and `:201`, the pair FACTS.md ("The umtool suite skips instead of going red…")
+ records as failing in every full run on main (order-dependent clip windows in the fixture); the 45
+ skips are the song-data capabilities this machine lacks.
+ Alone, `mix.spec.ts`: **12 passed**, 24 s (after 1.5 min in the queue), `:110`, `:166` and `:201`
+ included.- The identity diff: 334 files, 0 different (above).
+- Numbers tools: none.
+
+**Found and left.**
+- `render-cards.mjs` sets `-define pango:width=…`, which this ImageMagick (7.1.2) ignores; the wrap
+ comes from `-size`. Harmless, and untouched (it is in the unbranded path).
+- ImageMagick's pango coder lays out at 96 dpi: `-density 72` makes 1 pt = 1 px but wraps at ¾ of the
+ `-size` width. The preset sizes in px through `pt(px) = 0.75 px`.
+- `render.fontBold` is left as the manifest has it under the preset: only compose-chrome's HyperFrames
+ band reads it, and no Plex Mono bold is vendored.
+- umtool's project page lists the raw timeline, so the appended end card is built and chaptered but
+ not listed there.
diff --git a/plans/release-10.md b/plans/release-10.md
@@ -151,6 +151,8 @@ which passed while S3 was unmerged), and every rollout script is fail-stop: no h
failed build, no hub step after a failed sites run, a skipped site named, no restart on the pre-brand
`BUILD_ID`. Details: `brand-and-themes.md`, S3 "Review fixes".
+**S4 — Archilyzer Media** (the YouTube channel's assets and umtool's opt-in `render.brand` preset) merged after the release-10 cut on `brand/media` and is **not part of the site rollout**: it touches only `common/lib` + `common/bin` and umtool; see `brand-and-themes.md`, "Slice S4, as shipped".
+
## Rollout
Nothing is rolled out. The live :3001 editor still runs `0213f6c8` (the pre-brand build); the five