Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mplans/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.