commit e4008ed555308f33a98a47860fa595fb514e46d0
parent 2046a23e0bc5a92320e13dc52f39a4dd5e847792
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 05:41:42 -0400
plans: FACTS — release 11 (O1–O6) verified at 2046a23e; dated amendments for the E2E_ names and PUBLISH.md; the integration gate's findings
A "Release 11 — slices O1–O6" section: archilyzer doctor/run/mcp and pnpm
archilyzer, every bin a subcommand, common/lib/ports.mjs, envVars.ts and the
generated ENVIRONMENT.md, the E2E_ renames and E2E_SERVER_ENV, PUBLISH.md, the
homepage jobs and ops verbs, JobLane under Strict Mode, normalizeSocialSvg's
single-colour rule, fittedHex as perBaseColor's only production caller,
--chart-6, the forced-colours outline, the homepage's synthetic e2e summary,
report-to-video's per-character fit (with the O5 review's kerning and
FALLBACK_EM caveats), and pointers to O3's section. Dated notes where an older
fact named EDITOR_TEST_ROUTES, TWO_ORIGIN_REBUILD, DEPLOY_CLOUDFLARE.md or the
dev:test wiring. The gate's findings: transcript-source.spec.ts:76 is a
full-suite flake (alone 9/9), the umtool mix pair as before.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
| M | plans/FACTS.md | | | 268 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- |
1 file changed, 266 insertions(+), 2 deletions(-)
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -983,6 +983,9 @@ seed again (`plans/tools/implementer-rules.md`); the export config re-copies `sw
- Binary env vars are wired in `editor/package.json`'s `dev:test` **and** `start:test`
scripts — a single line duplicated verbatim, differing only in `next dev` vs `next start`.
**Adding one env var means editing both.**
+ **AMENDED 2026-09-28 (release 11 O6-B):** that holds for the paths and binaries. A TEST-ONLY
+ variable no longer goes there: it is `E2E_`-prefixed and set in `editor/playwright.config.ts`'
+ `E2E_SERVER_ENV` (`:37-44`, the webServer's `env`) — see "Release 11 — slices O1–O6".
- Route-stubbing scaffold: `editor/e2e/export-player-platform-cache.spec.ts:39-111`.
### Known-failing on base — not regressions
@@ -5251,6 +5254,11 @@ Three branches off `4ac8ceda`, merged in order: `1a011d96` alone, then `tags/rul
- `EDITOR_TEST_ROUTES=1` is set by `dev:test` and `start:test` only
(`editor/package.json:8-9`), so a production `start` serves 404 for all of them. It is a
404 and not a 403 on purpose: the route does not admit it exists.
+- **AMENDED 2026-09-28 (release 11 O6-B): the variable is `E2E_TEST_ROUTES`** (`_guard.ts:25`), and
+ it is set by `editor/playwright.config.ts`' `E2E_SERVER_ENV` (`:38`), NOT by `dev:test` /
+ `start:test` any more. A hand-started `dev:test` therefore serves 404 on every `/api/test/*`; a
+ server reused under `E2E_PORT_CHECK=0` must be started with `E2E_SERVER_ENV`'s values
+ (WORKTREES.md). A production `start` still serves 404.
### S0-pause — the four legacy pause fields are DELETED, and `held` defaults per lane
@@ -5376,7 +5384,8 @@ a branch complaint. `site-publish-preview.spec.ts` covers the control itself.
**Who can open a preview is a Cloudflare project setting, not ours** — *preview
deployment access*, **public by default**. Documented in `DEPLOY_CLOUDFLARE.md` →
-"Preview deployments".
+"Preview deployments". *(2026-09-28, release 11 O6: that file is gone; the section is
+`PUBLISH.md` → "Preview deployments", `:89`.)*
### Deploy-only refuses somebody else's bundle (2026-09-23)
@@ -6612,6 +6621,10 @@ Next is 16.2.3 (`export/node_modules/next/package.json`). The plan and every sli
target is untouched. Proved on the integrated tree: `e2e:2origin` with `TWO_ORIGIN_REBUILD=1` and
the links left in place (`release-10.md`, "Integration (S4 + L1 + L2), as merged"; L1's own runs
before it). A worktree run leaves plain files where it wrote; the next per-path seed relinks them.
+ *(2026-09-28, release 11 O6-B: the variable is `E2E_TWO_ORIGIN_REBUILD=1` now. Re-proved at the
+ release 11 integration: `e2e:2origin` 3 passed in a worktree with the links in place, the primary's
+ `export/public` byte-identical — `release-11.md`, "Integration, as merged". Never run it from the
+ primary checkout, where the entries are real files.)*
- **`pnpm wt` port blocks are `git worktree list` order**, which is the admin dirs under
`.git/worktrees/` sorted by name (`scripts/worktree.mjs:101-109`). Adding `brand-found-line` put it
at #1 ahead of `diet-series`, which moved to #2 (reconfirms the release 9 fact above); removing
@@ -6756,7 +6769,9 @@ S4, as shipped"; `release-10.md` "Slice L2 / L1, as shipped". Every anchor below
never parse or compare it as a hex; `perBaseColor` throws on anything but a `#rrggbb` per base, and a
fourth base must add its own flag to tokens.css (`themeTokens.test` holds one 1 per base over
`BASE_GROUND_IDS`). A visitor-ADDED hub archive's published hex goes through the same fit
- (`fittedHex`, in `useHubSites`; release 11 O2b), and a non-hex accent is dropped. With no accent the
+ (`fittedHex`, in `useHubSites`; release 11 O2b), and a non-hex accent is dropped. *(2026-09-28
+ integration: `fittedHex` is `perBaseColor`'s ONLY production caller, `siteColor.ts:124`; a new
+ caller goes through `fittedHex`, never `perBaseColor` directly.)* With no accent the
fallback is the site's CHART colour, `siteChartColors(sites)`: the `--chart-N` of its accent's hue
family (blue 1, green 2, violet 3, brass 4, sakura 5, vermilion 6 — `--chart-6` is a rust since
release 11), else `seriesColor(i)` when free (the first SIX are `var(--chart-1..6)`), else the
@@ -6876,3 +6891,252 @@ S4, as shipped"; `release-10.md` "Slice L2 / L1, as shipped". Every anchor below
`describeCutFailure(outcome, target)` (`:415`) is the ops route's 400 `error`: the failure,
`<workspace>: `-prefixed for `all`, then `Before it, editor was already cut (## [x] - date,
committed <sha8>).` The route revalidates whenever `untouched !== true`.
+
+## Release 11 — slices O1–O6 (verified 2026-09-28, `main` @ `eb28a341`)
+
+Merged overnight, gated together on `eb28a341` (`release-11.md`, "Integration, as merged"), NOT rolled
+out. O3's facts are the section just above ("O3 — runner lows"). Anchors are at `eb28a341`.
+
+### The CLI's last subcommands, and one short form (O6)
+
+- **`pnpm archilyzer <command>`** is a root script (`package.json`: `pnpm --filter
+ yt-dlp-transcript-common exec tsx bin/archilyzer.ts`). pnpm prints its `$ …` line on stderr, so
+ `-- pnpm -C "$PWD" archilyzer mcp` is a clean MCP registration (stdout carries only the protocol).
+- **`archilyzer doctor [--json]`** (`common/bin/doctor.ts`, row `archilyzer.ts:280`) is STRICTLY
+ read-only: it stats the LMDB index and never opens it, never mkdirs, never writes settings, and
+ calls a port "in use" when a TCP connect succeeds (it never binds). The one process-state change
+ is a `chdir` around umtool's tool table, put back at once. A **FAIL (exit 1)** is only something
+ the machine is configured to do and cannot (`:14-19`): settings that do not parse or are not a JSON
+ object, an enabled worker's engine or model missing BESIDE a corpus (`:216`), a required binary
+ missing (`:239`), node below next's `>=20.9.0` (`:87`), no `node_modules/.pnpm` (`:96`). A clone
+ with no corpus gets warnings, never a fail. It reads the port block through `scripts/worktree.mjs
+ ports` over `lib/ports.mjs` (`:30`, `:262`), and umtool's table by a runtime import of
+ `umtool/lib/tools.mjs` (common does not depend on umtool).
+- **`archilyzer run <operation> <channel> [ids…] [--lane local|remote]`** (`common/bin/run-operation.ts`,
+ row `archilyzer.ts:197`) calls `runOperationChannelJob`, the body the editor's buttons and lane
+ runners call — a real `backfill-channel` / `digest-channel-*` job with a record and log under
+ `transcripts/.jobs/`. It runs the four registry operations (diarization, attribution-diarized,
+ attribution-text, digest) and REFUSES the `external` ones with a sentence read off the descriptor
+ (sync, metadata-scan, download: the paced per-platform queue lives in the editor; transcription:
+ the worker pool). Also refused: an operation switched off, a paused lane (the batch would wait for
+ a resume only the editor can give), `--lane` off digest, ids none of which is on disk. Exit
+ (`:31-34`): 0 done, 1 failed or refused, 2 usage (unknown operation or channel, a stray flag), 130
+ cancelled. **It does not see the editor's lanes** (`:26-29`): beside the editor's lane on the same
+ channel it does the same videos twice (atomic writes: wasted work, not damage), and the backfill
+ lane's yield-to-transcription reads THIS process's (zero) transcription activity.
+- **The digest channel job carries `ids`** (as the backfill job did): `runOperationChannelJob`
+ passes them to both runners and the editor's digest replay forwards them, so a replayed scoped run
+ cannot widen to the channel. **An editor built before `5da3e9e1` (the live :3001 until its restart)
+ replays an ids-scoped `archilyzer run digest` job over the WHOLE channel** — restart before
+ retrying one. No spec replays a digest job; the forwarding is type-checked only.
+- **`archilyzer mcp [args…]`** (`common/bin/mcp.ts:22`) is a CHILD process, not an import: mcp's own
+ tsx, cwd `mcp/`, env passed through, nothing printed on stdout.
+- **Every `common/bin/*.ts` is a subcommand**, and `_cli.test.ts` fails when a bin has no row. Rows
+ that parse their own argv are `passthrough: true` (matched on their leading words; everything after
+ is handed on verbatim, `_cli.ts:159-170`); those that must run as a child go through `script(…)`
+ (`archilyzer.ts:305-313`) → `_spawnBin.ts` `runBinScript` (with a heap cap where the old script had
+ one, e.g. `duplicates`' 8 GB). `archilyzer transcribe` / `transform.ts` were deleted (O6 review Q2).
+- **The package scripts the publish pipeline runs by NAME are kept and call the CLI**: export's
+ `build:index|stats|templates|archives`, `compose:site|hub`, homepage's `build:index` / `compose`.
+ Export's `detect:duplicates` is gone (`archilyzer duplicates`). Root `build:index` and `sync:tick`
+ are kept: `sync:tick` is the documented cron line. **`homepage`'s `build` still has a `prebuild` that
+ runs `build:index` over the corpus** — the integration gate builds it with `pnpm --filter homepage
+ exec next build`, never `run build`.
+- **`common/lib/toolProbe.mjs`** is umtool's `probeOne` lifted out (plain ESM, `probeTool` `:48`);
+ `umtool/lib/tools.mjs` keeps its table and calls it, and doctor shares it.
+
+### Ports and environment variables are declared once (O6)
+
+- **`common/lib/ports.mjs`** is the port table: `PORTS` (`:34`, name → `{base, what}`), `PORT_BASES`
+ (`:54`), `OFFSET_STEP = 100` (`:59`), `portsForOffset(offset)` (`:67`), `portFor(name, env)` (`:86`;
+ the env wins, else the base; an unknown name throws). `scripts/worktree.mjs:13`, `sync-tick.ts:19`,
+ doctor, every playwright config and the e2e helpers read it (since checkpoint B, `baseUrl.ts`, the
+ two export specs, the ollama stub, the umtool editor stub and the 2origin spec too). It gained the
+ three e2e ports worktree.mjs never offset: `HUB_PORT` 3041, `ORIGIN_B_PORT` 4610, `HUB_A_PORT` 4611.
+ **Two places cannot import it** — a package.json script's `${NAME:-N}` and queue-lock's `--ports
+ NAME:N` lists — and `ports.test.ts:93` reads them as text and fails on a drift.
+- **`common/lib/envVars.ts`** lists every variable the apps' code reads, by audience (`:33`: `paths`
+ = exactly what `getPaths()` reads, `runtime`, `port` = generated from ports.mjs, `internal` = set
+ by the pipeline, `docker` = the `ARCHILYZER_*` set, `test`). **`ENVIRONMENT.md` is generated** from it
+ (`archilyzer docs env [--check]`, `common/bin/env-docs.ts`). `envVars.test.ts` holds it both ways:
+ every `process.env.X` / `env.X` read under common/, editor/, export/, homepage/, mcp/src and scripts/
+ is declared (`:84`; the scan knows `env(Int|Float|Bool)Override` and `parseIntArg`); every entry is
+ still named outside the list (`:111`); every `ARCHILYZER_*` in docker/, the Dockerfiles and the
+ compose files is declared (`:105`). umtool's own knobs stay in `umtool/docs` until Phase 5.
+- **Every test-only variable is `E2E_`-prefixed and declared in its playwright config**
+ (`envVars.test.ts:156`, `:168`; excepted: `PLAYWRIGHT_BASE_URL`, `QUEUE_LOCK_HELD`, `:154`). The
+ renames (old → new): `EDITOR_TEST_ROUTES` → `E2E_TEST_ROUTES`; `AUDIO_CHECK_*_OVERRIDE` →
+ `E2E_AUDIO_CHECK_*`; `AUDIO_CHECK_DEBUG_PAUSE_MS` → `E2E_AUDIO_CHECK_DEBUG_PAUSE_MS`;
+ `FAKE_YTDLP_*` / `FAKE_GALLERY_DL_AUTH_FAIL` → `E2E_FAKE_*`; `FIXTURE_MAX_LIFETIME_MS`,
+ `OLLAMA_STUB_MODEL`, `RACK_SHOTS`, `TWO_ORIGIN_REBUILD`, `HOMEPAGE_SUMMARY_FILE` → `E2E_…`; the
+ sharded runner's `SHARDS` / `IMAGE` / `SKIP_BUILD` → `E2E_SHARDS` / `E2E_IMAGE` / `E2E_SKIP_BUILD`;
+ umtool's `EDITOR_STUB_LOG`, `UMTOOL_CUT_DELAY_MS`, `UMTOOL_EXTRA_KINDS` → `E2E_…` (declared in
+ `umtool/playwright.config.ts`, not envVars.ts). **Unchanged:** the queue lock and port check
+ (`E2E_QUEUE`, `E2E_PORT_CHECK`, `E2E_QUEUE_TIMEOUT`, `E2E_PORT_GRACE_MS`, `E2E_QUEUE_LOCK_FILE`),
+ `PLAYWRIGHT_BASE_URL`, `CI`, `E2E_MODE`, the port names, the docker `ARCHILYZER_*` set, and the two
+ node-unit knobs `KEEP_TAG_PREVIEW_FIXTURE` / `CUES_TEST_DIR`. Old names survive only in `plans/`
+ and released changelog text.
+- **The editor's test-server env is `E2E_SERVER_ENV` in `editor/playwright.config.ts:37-44`**
+ (`E2E_TEST_ROUTES=1` and the five audio-check knobs), passed as the webServer's `env`. `dev:test` /
+ `start:test` no longer carry them, so **a hand-started `dev:test` has no `/api/test/*`** — a server
+ reused under `E2E_PORT_CHECK=0` must be started with those values (WORKTREES.md).
+- **`e2e:2origin` is run as `E2E_TWO_ORIGIN_REBUILD=1 node scripts/worktree.mjs run -- pnpm --filter
+ export run e2e:2origin`**, from a WORKTREE only (never the primary: there `build:hub` would write
+ the live hub files). With the variable set playwright loads the config twice, so the hub is built
+ twice per run (N1, pre-existing).
+
+### Docs (O6)
+
+- **`PUBLISH.md` absorbed `DEPLOY_CLOUDFLARE.md` and `DEPLOY_DOCKER.md`** (both removed): what gets
+ published, the editor / `pnpm ops` / CLI table (`:40-60`), Pages, previews (`## Preview deployments`
+ `:89` — the "who can open a preview" note FACTS cites below now lives there), R2, the cost-abuse
+ defenses, containers (`:352`), the MCP registration. README's and SETUP's env tables are pointers to
+ `ENVIRONMENT.md` and `doctor`. Two released editor changelog links were retargeted to PUBLISH.md
+ anchors, words unchanged (`ef84f535`).
+- **The build mode is a label.** No build reads `settings.buildPipeline.mode`: Build all / Build &
+ deploy all use containers whenever `docker version` answers, else the serial host build. Settings
+ ("Basic (a label for now)" / "Docker (a label for now)"), the schema text, SETTINGS.md, `/sites` and
+ the homepage's Docker page say so. Whether they should honour it is an open operator question.
+
+### The homepage builds and deploys from `/sites` (O4)
+
+- Three job kinds, all through `runManagedFunction` in `editor/app/sites/lib/homepageDeployActions.ts`:
+ `build-homepage` on the `build` queue (`:47-48`), `deploy-homepage` (`:69-70`) and
+ `build-deploy-homepage` (`:90-91`) on `deploy`; the bodies are `buildHomepage` / `deployHomepage`,
+ unchanged, and a build-deploy deploys only on exit 0. No `jobKinds.ts` entries (the hub's have none).
+- **Refusals before any job:** a bad preview name (`previewBranchProblem`), and for a deploy-only
+ `builtHomepageProblem(homepageOutDir(paths))` (`common/lib/builtExport.ts:93`; `builtHomepageAt`
+ `:106` is index.html's mtime; `homepageOutDir` `common/publish/build.ts:948`, exported so the editor
+ never re-derives it, and `deployHomepage` ships exactly that directory).
+- **Ops:** `/api/ops/build-homepage` `{deploy?, preview?}` (a preview without deploy is 400),
+ `/api/ops/deploy-homepage` `{preview?}` answering `previewUrl`; `pnpm ops build-homepage` /
+ `deploy-homepage` (`scripts/archilyzer-ops.mjs:40-41`, `:113-114`). The preview is `--json
+ '{"preview":"<b>"}'`, not a flag. Both routes exist only on an editor built from this code.
+- **`JobLane` under Strict Mode** (`editor/app/sites/components/JobLane.tsx:46-64`): liveness is its
+ own `aliveRef`, kept by its own effect. The old launch effect gated updates on a flag its cleanup
+ set, so `next dev`'s mount → cleanup → mount left a lane on "Starting…" forever while the job ran.
+ Production mounts once and was unaffected.
+- **No e2e builds or deploys the homepage.** The Build homepage click runs with the `build` AND
+ `deploy` queues held by `/api/test/stuck-job` (never released) and is cancelled from its lane; the
+ refusal tests hold both queues (`holdBuildAndDeployQueues`, `ops-api.spec`).
+
+### The hub (O1, O2b)
+
+- `builtinsLoaded` / `listed` / the three DRAFT lines: see "L1 — hub lows" above, AMENDED 2026-09-28.
+ The DRAFT constants are `export/app/ask/hubScopeCopy.ts` `NO_ARCHIVES_IN_SCOPE` `:7` (release 10's,
+ still unruled), `NO_ARCHIVES_ON_HUB` `:16`, `LIVE_CHAT_MISSING` `:24`, `ASK_SCOPE_LINE` `:33`.
+- **`normalizeSocialSvg` themes a SINGLE-colour icon only** (`common/lib/settingsSchema.ts:1373`).
+ The solid colours of the root and every child outside a `<mask>` or `<clipPath>` are counted
+ together (spellings folded by `paintKey` `:1304`: `#FFF`, `#ffffff`, `white`, `rgb(255,255,255)`
+ are one); `none` / `transparent` / `inherit` / `url()` never count. At most one colour → every fill
+ becomes `currentColor`; two or more → kept as pasted. Masks, clip paths (block and self-closing,
+ self-closing matched first, `:1351`) and animation tags (`fill="freeze"` is timing, `:1352`) are
+ passed over. Idempotent. **It runs on EVERY `writeSettings`** (`validatedSocialLinks`), not only
+ the Settings form, so the first settings write after the :3001 restart rewrites the stored x.com
+ icon; every site, the hub AND the homepage then need a rebuild. Left: a `<pattern>`'s children are
+ themed; class fills in a `<style>` block and an unquoted `fill=white` are neither counted nor
+ themed; strokes are never themed.
+- **Hub added archives are fitted per base** (O2b): `useHubSites` maps every external archive's
+ accent through `fittedHex` (`export/app/components/hub/useHubSites.ts:51`); a value that is not a
+ `#rrggbb` is dropped and the card falls back to `var(--brand)`.
+
+### Colour (O2, O2b)
+
+- **`fittedHex` is `perBaseColor`'s ONLY production caller** (`common/lib/siteColor.ts:124`), and
+ `fittedHex` is called by `siteColor` (`:130`) and `useHubSites.ts:51`. A new caller goes through
+ `fittedHex`, which drops a non-hex, rather than calling `perBaseColor` — which THROWS a
+ `RangeError` on anything but a `#rrggbb` per base (`:107`). The value is CSS only
+ (`rgb(calc(… * var(--base-light, 1) + …))`): never parse or compare it as a hex.
+- **The one-hot base flags** `--base-light|sepia|dark` are declared once per base block in
+ `common/styles/tokens.css` (light `:217-219`, sepia `:319-321`, dark `:402-404`); a fourth base must add
+ its own (`themeTokens.test` holds exactly one 1 per base).
+- **`--chart-6` is a rust, Vermilion's family**: `#823c10` light and sepia (`tokens.css:255`, `:340`),
+ `#a54a08` dark (`:424`); `--color-chart-6` in `@theme` (`:101`). `CHART_SLOTS = 6`
+ (`common/lib/homepageChart.ts:22`), `seriesColor(5)` is `var(--chart-6)`, `ACCENT_CHART_SLOT.vermilion
+ = 5` (`siteColor.ts:48-54`), `REQUIRED_TOKENS` is 50 (`common/components/themeConfig.ts:104`). Past
+ six, `seriesColor`'s golden angle still gives hue 105 (7th) and 243 (8th, near chart-1's blue).
+ **`ChartView.tsx` (export and editor charts) still cycles `(i % 5) + 1`** and never wears
+ `--chart-6`. The rust sits near `--state-gone` (normal ΔE 7.2–13.1) — an open operator question;
+ gone is only ever labelled text on the homepage.
+- **The mark's forced-colours edge** is two classes on `BRAND_MARK_RING_CLASS`
+ (`common/components/BrandMark.tsx:36-38`): `forced-colors:outline
+ forced-colors:outline-[color:CanvasText]`, a 1px outline at offset 0 on every base under
+ `@media (forced-colors:active)`, matching nothing outside it. Chromium keeps an svg's box-shadow in
+ forced colours (its UA sheet gives `svg` `forced-color-adjust: preserve-parent-color`), so the dark
+ ring stays there and the outline is painted over it. Playwright applies a forced palette with
+ `page.emulateMedia({ forcedColors: "active" })`; this version's `test.use` has no `forcedColors`.
+
+### The homepage e2e reads a synthetic summary (O2, O2b)
+
+- `homepage/e2e/fixture-summary.ts` runs the REAL `buildHomepageSummary` over made-up recordings
+ (`:67-130`; `FIXTURE_NOW` 2026-09-15 `:35`; six public sites `:43`: Brass, the pale custom hex
+ `#f4c2d7` `:38`, three with no accent, Vermilion). `homepage/playwright.config.ts` writes it to
+ `e2e/.e2e-summary.json` (gitignored) before the server starts and passes
+ `E2E_HOMEPAGE_SUMMARY_FILE` (`:47`). **`homepage/app/lib/summary.ts:47` honours that variable only
+ when `NODE_ENV !== "production"`**, so a production build never reads a test file.
+- Nothing is read from `homepage/public` or the primary: a fresh clone runs every homepage spec with
+ 0 skipped (31 at the integration gate). `e2e/fixture-accents.ts` is gone.
+- **`no-data.spec.ts` renames the fixture aside** (`.e2e-summary.json.aside`, same gitignore pattern)
+ and restores it in `finally`; it runs serially (`:15`). A killed run can leave the `.aside`; the
+ next config start rewrites the fixture anyway.
+
+### report-to-video measures the brand's face per character (O5)
+
+- **Under `render.brand: "archilyzer-media"` the rail, ledger, scroll and chart SVG text are IBM Plex
+ Sans, fitted by the face's own advances**, not an average. `svg-faces.mjs`: `FIRA_SANS`
+ (`{family, em: 0.5}`, `:44`), `IBM_PLEX_SANS` (a measured face, `:60`), `textWidth(text, size, face,
+ {weight, ls})` (`:73`), `FALLBACK_EM = 1.3` for a character Plex does not map (`:41`). **1.3 em
+ covers emoji, CJK and the common scripts, but not every fallback glyph** (O5 review L2, measured
+ through the branded conf: `⟹` 1.42 em, `﷽` 1.93, `Ⅷ` 1.31) — a label made mostly of long arrows
+ would overrun by ~9 %; no real label has one. The table is `face-metrics.mjs`, GENERATED by `fonts/gen-face-metrics.py` (fontTools
+ 4.65.0) from the vendored `IBMPlexSans[wdth,wght].ttf` at wght 400/700, wdth 100 (891 characters,
+ the font's sha256 in its header) — regenerate it, never edit it.
+- `render-cards.mjs` `fit(text, size, maxPx, face = FIRA_SANS, run)` (`:428`) and `wrapPx(…, face)`
+ (`:1010`): an average face (Fira) runs the old code path verbatim; a measured face keeps the
+ longest prefix whose width — ellipsis, weight and letter-spacing included — fits. **Kerning is
+ ignored, and the plain sum is NOT conservative in general** (O5 review L1; `svg-faces.mjs:32-35` and
+ the README still say it is): IBM Plex Sans has POSITIVE kerning pairs (412 at wght 400, 348 at 700,
+ up to +55 units: `r”`, `y’`, `f”`, `TT`, `AA`; bold `(j` +70), so `T`×60 at 13 px renders 464 px
+ of ink against a table sum of 446.2. Over the 144 real strings and their capitals the worst net
+ kerning is +0.50 px, so it is sub-pixel today. The fix, if it ever matters, is positive-only kern
+ pairs emitted by `gen-face-metrics.py` and added in `textWidth` (branded path only). `svgFaceOf(render)`
+ (`:376`) = `brandSvgFace(render)` (`brand.mjs:174`, null without a brand) `?? FIRA_SANS`.
+- **Why a table:** an average cannot promise no overrun. Fira's own 0.50 overruns the scroll's bold,
+ letter-spaced column head in 40 of 87 real strings; uppercase overruns in both faces at every
+ average 0.50–0.55. **The unbranded Fira path can still overrun** given long enough text; the
+ byte-identity rule forbids changing it (a fix would be a Fira table pinned to one Fira build).
+- **`rasterize()` hands rsvg-convert `childOpts(render, …)`** (`render-cards.mjs:408`): without it a
+ branded SVG found no Plex (not a system font) and silently fell back to another sans.
+- **IBM Plex Mono Bold is vendored** (`fonts/IBMPlexMono-Bold.ttf`, unmodified, google/fonts
+ `0b58fb37`, sha256 `ac27abd6…`); `resolveBrandRender` sets `render.fontBold` to it
+ (`brand.mjs:50`, `:117`), so compose-chrome's HyperFrames band sets its bold in the brand's mono.
+- **An unbranded render is byte-identical** (334 files: 201 raw-identical, 133 identical but for
+ ImageMagick's `tEXt date:*` chunks).
+
+### Runner lows (O3) — see "O3 — runner lows" above
+
+- The maybe-missing change is a SEMANTICS change for a published badge: an `error` probe after the
+ scan keeps a video `maybe_missing` (`availability-server.ts:156`, one caller `buildIndex.ts:1180`).
+ Five real videos move to "Missing?" at the next index build (jeralyzer 4, rekietalyzer 1).
+- `sourceFetchFailure` (`common/ytdlp/downloadOneManaged.ts:534`) is how a caller that asked for the
+ source tells whether it got it; `archiveSourceVideo` throws on it and `persistKept` counts it failed.
+
+### What the integration gate found (2026-09-28, `eb28a341`)
+
+- **`transcript-source.spec.ts:76` is intermittent in the full editor suite** (first seen here: 652
+ passed / 1 failed of 665, 39.5 min; alone `--repeat-each=3` 9/9). The Diagnostics stage read a
+ snapshot with no `nonStandardVtt` id, so "Channel health" said "All clear." and the
+ `Non-standard transcript VTT name (1)` heading never came. The likeliest mechanism is
+ `generateReport` returning early whenever `snapshot.json` already exists (`editor/e2e/helpers.ts:193`):
+ a snapshot written between the test's `resetData` and its VTT swap (a debounced flush left by
+ the previous test, `:42`, which switches the primary transcript) is stale and never regenerated.
+ The fixture ships no `snapshot.json`. The fix would be to `rm` the snapshot after the swap, or to
+ always click Refresh report. Nothing in release 11 touches that path.
+- **The umtool pair `mix.spec.ts:166` / `:201` still fails in every full umtool run** (175 / 2 / 45
+ here, as at O5 and O6-B; alone ×3: 36/36) — the order-dependent clip window above.
+- **A worktree's homepage e2e is self-sufficient**: 31 passed, 0 skipped, with `homepage/public`
+ holding only its tracked `_headers`.
+- **`export/public` in a worktree, per run** (the links seeded from the primary; the primary
+ byte-identical after every run): the export suite replaces the `sw.js` link with its own file;
+ `e2e:2origin` (`compose hub`, twice) replaces `sw.js`, `hub-sites.json`, `corpus.json`, `llms.txt`,
+ `robots.txt` and `_headers`. `e2e:hub` writes none. Re-seed before the next export run.