feat(i18n)!: retire typesafe-i18n for Paraglide, plus the 2026-08 dependency and toolchain sweep - #288
Merged
Merged
Conversation
…eScript 7
Bumps every workspace dependency to its live-verified latest stable, migrates
oRPC from 1.14.8 to the 2.0 beta, and splits the workspace across two TypeScript
majors after an empirical spike proved a single-compiler flip is not possible yet.
Dependency sweep
svelte 5.56.7→5.56.9, vite 8.1.5→8.2.1, @sveltejs/vite-plugin-svelte 7.2→7.3,
svelte-check 4.7.3→4.7.6, @playwright/test 1.61.1→1.62.1, jsdom 29→30.0.1
(needs Node >=24.15; mise pins Node 24 → 24.19.x), @lucide/svelte 1.26→1.31,
@internationalized/date 3.12.3, @zip.js/zip.js 2.8.49, tailwind-variants 3.3.1,
svelte-sonner 1.2.1, @axe-core/playwright 4.13.0, npm-run-all2 9.0.3,
@types/node 26.2.0. tailwind* stays on the 4.3.x line and vitest keeps its
major, both as specified. vaul-svelte stays pinned at 1.0.0-next.7 — the
"stable" 0.3.2 is a downgrade.
Removed three dead dependencies: ts-node and madge (no script, config, workflow
or source file referenced either), and @orpc/client from the frontend, which
imports nothing from it — apps/frontend/src/lib/rpc/client.ts speaks the custom
WebSocket protocol and only borrows Zod-inferred types from @ceraui/rpc.
oRPC 2.0.0-beta.27
Pinned exact, not caret: a caret range would drift across betas and into stable
2.0.0, which is not acceptable for a device runtime.
The migration is small here because CeraUI has no RPCHandler, RPCLink or
OpenAPI surface — apps/backend/src/rpc/adapter.ts is a hand-rolled Bun
WebSocket {id,path,input} protocol that calls oRPC's call() directly, so v2's
wire-format break simply does not reach us. The only code change is the eight
push-only subscription entries in packages/rpc that were defined purely by
oc.route({method,path}); v2 removed .route() (OpenAPI routing moved into
openapi() metadata), and since nothing ever read that metadata they are now
bare schemaless contract procedures. The rationale lives once in
contracts/index.ts rather than eight times.
Everything else the guide lists was checked and found not to apply: no
.concat/.mapInput, no two-argument .use, no status in ORPCError, no
isDefinedError/safe(), no @orpc/zod, no query integration. Middleware runs at
procedure level only, so v2 dropping automatic deduplication cannot double-run
it. Router keys were audited at runtime against the built router objects for
v2's reserved names (then, bind, valueOf, toString, toJSON): 83 server keys and
81 contract keys, no collisions.
TypeScript 7 spike
Landing every other bump first and then varying only `typescript` showed a
single-compiler flip is blocked twice over, because TS 7.0 does not ship the
programmatic compiler API. svelte-check refuses to start outright via its own
bin/ts-version-check.js guard, and — not previously known — the typesafe-i18n
generator dies on ts.createProgram, which breaks `bun install` itself since it
runs from a postinstall hook.
So the workspace now runs both majors on purpose: TS 6 for the catalog, the
frontend, and packages/i18n's generator; TS 7 for apps/backend, packages/rpc,
and a new packages/i18n check gate reached through a `typescript-7` npm alias.
scripts/tsc.mjs (ported from ceralive-platform) resolves the compiler from the
invoking package's own dependency graph so a bare `tsc` can never silently pick
the wrong one. This also retires the `bun tsc` call the backend was using,
which AGENTS.md bans.
packages/i18n's tsc gate turned out to check nothing at all: the package had no
tsconfig.json, so bare `tsc` walked up to the root config whose "files": []
compiles zero files. It now has both a tsconfig and a real check script,
covering 20 files where there was previously no coverage.
PWA and hygiene
Adds apps/frontend/scripts/check-precache.mjs, which diffs the precache
manifest injected into dist/public/sw.js against the assets actually built and
fails on any gap — the kind of drift a Vite or Workbox upgrade causes silently,
since the app still builds and still boots online and only breaks offline.
Manifest entry count is unchanged at 33 across the Vite and Svelte bumps.
Adds .svelte-check/ to .gitignore; after a full gate run git status reports no
untracked tool-cache artifacts.
Verification (all under Node 24 explicitly; the host's ambient node is 26)
lint, backend/rpc/i18n/frontend typechecks, 306 + 2412 + 3051 tests, and build
are green. test:e2e is red on two lost-device specs — a clean worktree of
unmodified origin/main fails the same two with the same assertion, so it is a
pre-existing order-dependent state leak in the e2e suite, not a regression
here; it reproduces at --workers=1 and is recorded for the test-isolation work.
…er Node >=25
Node >= 25 owns a built-in globalThis.localStorage that is undefined without
--localstorage-file. Vitest's jsdom environment only copies a window key onto
the global when the global does not already own it, and it aliases
globalThis.window back to globalThis -- so jsdom's real Storage was skipped and
every window.localStorage read resolved to that empty built-in. $persist
(display-profile.svelte.ts <- transitions.ts <- $lib/utils) therefore threw at
module scope: 80 of 210 test files failed to import under Node 26.7.0.
vitest.setup.ts now installs a fresh in-memory spec-compliant Storage over
localStorage and sessionStorage before any store module loads, plus a beforeEach
clear. Vitest runs setup files before each test file under the repo's
isolate: true, so per-file state is disjoint by construction. vitest.config.ts is
untouched, as is the existing bits-ui teardown guard.
--localstorage-file is deliberately not used: one fixed path is a SQLite DB
shared by every thread worker and persisted across runs. Measured, a two-worker
run failed "database is locked", and a second run read back the first run's
value -- it converts a hard failure into false-green cross-spec leakage.
persist-isolation-{a,b}.test.ts pin the contract: a $persist write in one spec
file stays invisible to the other under concurrent thread workers, and the load
path still hydrates from pre-seeded storage. Both fail if the override is
reverted.
Green twice in a row on Node 26.7.0 and on the Node 24.19.0 CI baseline:
212/212 files, 2417 tests.
Bump @biomejs/biome 2.5.2 -> 2.5.8 (exact pin) and @ceralive/biome-config ^2026.6.2 -> ^2026.8.0, now that the shared canon is published. Realign all four biome.json $schema URLs, which had drifted to three different versions (2.5.2 / 2.5.0 / 2.4.16). 2.5.8 surfaces 14 real noUnsafeOptionalChaining errors, all the same shape: (x?.y as T).z short-circuits out of the optional chain and then dereferences undefined, so the expression throws a TypeError instead of failing the assertion. Widened each to (x?.y as T | undefined)?.z; the preceding expect(...).toBeDefined() still covers the missing case, and a regression now reports a readable diff rather than a TypeError. A non-null assertion was avoided deliberately - it would only have traded these errors for 14 new noNonNullAssertion warnings. The remaining new diagnostics are formatter-only (argument-list breaking in test.each and long expect chains). No biome-ignore comments were added: the count is unchanged at 127. The frontend Svelte override block and the root rule ladder are untouched.
Cut the .svelte lint override list from five rules to two. The disables existed
for Biome's partial template analysis; 2.5.3 (PR #10534) and 2.5.7 (PR #11198)
each fixed a slice of that, so all five were deleted and every resulting
diagnostic triaged rather than the block being restored wholesale.
Deleting all five produced 1731 new diagnostics against a 37-warning baseline.
Triage classified each flagged binding by whether the identifier is referenced
in markup (invisible to Biome), elsewhere in the script, or nowhere at all.
Six were genuinely dead and are fixed here:
- NetworkIngestSection: unused StreamingSetConfigOutput type import
- pwa-status: unused toast import, plus a write-only showInstallBanner state
and the $effect whose only job was writing it (markup reads canInstallApp)
- screenshot-utility: vestigial filename parameter -> _filename, positional,
both call sites unchanged
- LiveView: orphaned audioSummary $derived, left behind when Todo #11 removed
the audio row from StreamSetupChain
- StreamSetupChain: dead onOpenServer prop. LiveView passed the same action
twice - once as configRows[server].onEdit and again as onOpenServer - and
only the former is ever called. Removed across LiveView, IdleCockpit and
StreamSetupChain; the destination row's edit affordance is unaffected.
noUnusedVariables and noUnusedImports stay off. Their 1724 diagnostics are all
false positives, because neither rule counts template references and the general
gap is open upstream (biomejs/biome#8590). The 2.5.3 store fix landed for
noUnusedVariables only, so `import { LL }` referenced as `$LL` in markup still
reports dead in ~45 components.
The other two entries were removed as dead config, not as re-enablements:
useImportType and useConst are inert on .svelte in 2.5.8 even when set to
"error" directly, while the same violations fire at error in a .ts file. That is
rule-specific, not a blanket exclusion - noNonNullAssertion does fire on .svelte.
noUnusedFunctionParameters is the one genuine re-enablement, and it found the
screenshot-utility parameter above.
biome.json still cannot carry comments (2.5.8 fails the whole parse rather than
2.5.0's silent rules drop), so the rationale, repros and re-attempt checklist
live in docs/CONVENTIONS.md, with AGENTS.md updated to the new state.
The Svelte formatter override is untouched.
Gates under Node 24: biome check . back to the 37-warning baseline with an
identical rule breakdown, frontend suite 212 files / 2417 tests, svelte-check
0 errors and 5 warnings in 4 files. No test was deleted, skipped or weakened.
…dget Build Check now runs on Node 26 as a required gate rather than 24 — the frontend vitest suite was proven green on both first, so no canary lane is needed. Three additions around it: - tsgo-canary (advisory, continue-on-error): installs @typescript/native — an npm alias onto typescript@7, not the native-preview nightly — beside the workspace TS6 with --no-save, and runs svelte-check --tsgo. It reaches the compiler and reports two svelte:boundary snippet errors the TS6 gate does not, which is exactly the signal the canary exists to surface. Plain --tsgo, never --tsgo-experimental-api (that mode fails under Bun, sveltejs/language-tools#3095). The canary-only tsconfig drops `composite`, which tsgo rejects next to svelte-check's own `incremental: false`. - setup-e2e typechecks the frontend before building it. Only the backend was typechecked in CI until now, so a svelte-check regression could reach the E2E artifact. - setup-e2e reports per-chunk gzip sizes and fails when the initial-route JS set exceeds budget (measured baseline + 10%, recorded in the script). Finally, @premigration-upgrade is pre-registered in both places the e2e exclusion list lives, so a later local-only migration spec lands into an already-excluded tag. No job was renamed or removed; tsgo-canary is purely additive.
…ory + labelKey contract
Everything in the Paraglide migration that follows is checked against the
fixtures this commit freezes, so it lands before any migration code. No
dictionary and no runtime file is touched.
What
- packages/i18n/scripts/generate-fixtures.ts renders all 1472 leaf keys across
all 10 locales through the CURRENT runtime and writes one immutable fixture
per locale (tests/fixtures/<locale>.rendered.json), plus the exact params each
render used (tests/fixtures/params/<locale>.params.json). It renders every key
through BOTH the browser path (interpolate, the function the Svelte 5 adapter
delegates to) and the node oracle (i18nObject) and throws on any divergence —
there were none across all 14 720 renders.
- tests/rendered-oracle-gate.test.ts re-renders from the dictionaries and diffs
against the frozen files, so any dictionary edit that is not a deliberate
fixture update goes red.
- tests/backend-labelkey-contract.test.ts extracts every dotted i18n key the
backend can emit (labelKey/reasonKey/lostKey/... plus the generated
settings.sources.<id> family) and asserts each resolves in the en catalog. It
reads the catalog, not the runtime, so it survives the migration.
- docs/PLURAL-GRAMMAR.md enumerates every plural and interpolation form present
across the 10 dictionaries with file:line examples and counts, plus the forms
the runtime supports that no dictionary uses. This is the conversion spec for
the catalog converter.
- packages/i18n gains a test script and is wired into the root test chain and
into build-check.yml's test-be job; ci-local.manifest.yaml is reconciled
(test-be cmd + run-step digest).
Why
The migration's verification strategy is byte-parity: the converted catalog must
reproduce today's rendered strings exactly, with zero diffs and no allowlist.
That only works if the "before" state is captured from the real runtime first.
Three findings the inventory records are load-bearing for the conversion:
parameter types exist only in the en dictionary (every other locale is untyped);
Arabic's 2-branch plural renders empty for counts 0 and 2 because CLDR selects
absent zero/two branches, which the converter must reproduce rather than fix;
and brand {{deviceName}} placeholders share the plural delimiter but are
resolved at module load, so conversion must read the loaded dictionary rather
than the source text.
How to verify
- bun run --filter @ceraui/i18n test — 489 pass, 0 fail
- bun run test — rpc 306, i18n 489, frontend 2417, backend 3051, all green
- ls packages/i18n/tests/fixtures/*.rendered.json | wc -l — 10
- bun packages/i18n/scripts/generate-fixtures.ts twice — git diff stays empty
- bun run --filter @ceraui/i18n check and bunx biome check . — both exit 0
Risks
The fixtures are large (about 1.2 MB across 20 files) but they are the artifact
the whole migration is graded against, and they are append-only from here.
Adding a run step to test-be couples this change to the root repo's
ci-local.manifest.yaml, which is updated in the same effort.
…erter with byte-parity gate
Adds the Paraglide-side half of the i18n migration: an inlang project, a
converter that turns the ten typesafe-i18n dictionaries into inlang
message-format catalogs, and a gate that proves the conversion changed no
rendered string anywhere.
There is no official typesafe-i18n importer, so scripts/convert-catalog.ts IS
the importer. It reads the LOADED dictionary modules (so brand placeholders are
already resolved), keeps every dotted key verbatim, and asks the LEGACY plural
resolver — not CLDR — what each plural branch should say. That is what makes the
shipping Arabic linksReadyCount defect (counts 0 and 2 select an absent branch
and render a double space) survive the migration byte-for-byte instead of being
silently "corrected" into a copy change nobody reviewed.
Param type annotations are dropped on purpose. inlang's only typed-variable
mechanism renders through Intl.NumberFormat, which turns 42 into ٤٢ in Arabic;
plain {param} compiles to the same string coercion typesafe-i18n used.
Paraglide maps every bundle id through a lossy toSafeModuleId and silently
overwrites on collision, so the converter runs an injectivity pre-flight over
the whole key set before any compile. The full 1472-key set is collision-free,
so no key or bundle id needed disambiguating.
The reverse-render gate compiles the catalog and renders every message with the
frozen fixture params from the previous commit, diffing against the frozen
oracle: 14,825 rendered values across 1472 keys x 10 locales, zero diffs, no
allowlist. It survives the later typesafe-i18n removal because it reads only the
fixtures and the compiled catalog.
The compiled runtime (src/paraglide) and the fetched inlang plugin cache stay
gitignored; messages/*.json are regeneration-only until the cutover, and the TS
dictionaries are untouched.
… registry, export contract v2 Wires the Paraglide runtime on top of the catalogs converted in the previous commit. No catalog content changes — this is plumbing and architecture. - vite: paraglideVitePlugin in both the SPA and federation configs (federated dialogs render i18n strings, so their bundles must compile messages too), each pinning outputStructure: "message-modules" - @ceraui/i18n export map: root = locale constants, /formatters unchanged, /svelte = the new Paraglide runes store, /i18n-svelte5 = the legacy adapter (deliberately coexisting), /node retired (no importer) - locale lifecycle: saved preference -> navigator.language -> en; setLocale owns the re-render and the <html lang>/<html dir> sync that paraglide's reload:false escape hatch declines to do; RTL_LANGUAGES stays the direction source; persistence stays in the app's $persist store under its unchanged key - facade: a post-compile generator emits per-namespace barrels, a loader config, and a synchronous registry keyed on the verbatim dotted key, so call sites keep writing m["ns.key"](). Paraglide's umbrella messages.js re-exports everything eagerly and is now off-limits, enforced by a grep gate - resolveMessageKey(key, params?) for the dynamic dotted-key call sites, with the existing miss-returns-the-key fallback preserved - one command, bun run generate:i18n, first in the check/test/build chains Raises the workbox per-file precache ceiling for the migration window: a compiled paraglide message module inlines all ten locales, so an all-eager catalog is one ~2 MB blob and the build failed outright. The service worker still covers every asset, and the size stays visible to the gzip bundle budget; namespace lazy loading is the real fix and this commit ships and proves that mechanism. Adds packages/i18n/README.md documenting the export map and the add-a-key/add-a-locale workflow.
What
----
Converts every frontend i18n call site from the typesafe-i18n runes adapter to
the paraglide message registry: `$LL.a.b.c(args)` / `getLL().a.b.c(args)` become
`m["a.b.c"](args)`, and the sites that hold a dotted key in a variable become
`resolveMessageKey(key)`. 1220 references across 119 files go to zero, and
`@ceraui/i18n/i18n-svelte5` now has no importer under apps/frontend/src.
No translation key is renamed, added, or removed. The catalog and the frozen
render fixtures are byte-unchanged.
Why
---
Todo 21 landed the paraglide runtime, the runes locale store and the message
registry, and repointed the legacy adapter to its own subpath so the consumer
flip had something concrete to remove. This is that flip. It also retires the
coexistence code todo 21 left behind — App.svelte, locale-selector and the
dev-tools locale panel no longer drive the legacy adapter alongside paraglide,
so the two locale-switch paths become one synchronous one.
How
---
`scripts/codemods/ll-to-paraglide.ts` is committed and re-runnable: a second run
is a byte-identical no-op. It is a lexical scanner rather than an ast-grep rule
set because ast-grep 0.45.1 ships no Svelte grammar and ~93% of the sites live in
`.svelte` template expressions that no TypeScript parser accepts either. What
makes it safe is an oracle rather than a parser: every key it derives is checked
against the 1472-key `packages/i18n/messages/en.json` before the rewrite is
emitted, so a misfire is reported and left untouched rather than shipped. The
final run reports 0 unresolved.
Five rules: collapse the nine per-site dotted-key walkers onto `resolveMessageKey`
(their fallback is byte-identical), delete sub-tree alias bindings and re-expand
their uses to full dotted keys, rewrite the call expressions, collapse the legacy
import into the facade import with exactly the symbols each file still needs, and
reword the 26 prose comments that named the old API.
Hand-fixed, because they have no mechanical 1:1 target: six dynamic dotted-key
indexes, the two pure copy modules whose injected translation-tree parameter
changes type (`Readonly<Record<MessageKey, MessageFn>>` — `string` there yields
`MessageFn | undefined` under noUncheckedIndexedAccess), the legacy-adapter drive
removal, two `getLL()`-tree consumers, and seven test files that mock the legacy
module by specifier. Each is listed with file:line in the evidence.
How to verify
-------------
grep -rE '\$LL\b|[^a-zA-Z]LL\.' apps/frontend/src --include='*.svelte' --include='*.ts' | grep -v paraglide | wc -l # 0
grep -r "i18n-svelte5" apps/frontend/src | wc -l # 0
bun run --filter frontend check # 0 errors
bun run --filter frontend test # 215 files / 2439 tests
bun run --filter @ceraui/i18n test # 540 pass (includes the full reverse-render gate)
bun scripts/codemods/ll-to-paraglide.ts && git diff --quiet # no-op on a clean tree
Risks
-----
The blast radius is 138 files, but every rewritten key was validated against the
catalog and the full reverse-render gate (1472 keys x 10 locales, no allowlist)
is green, as is a sampled re-run through the app-facing registry for en/ar/ja —
including the frozen Arabic double-space defect, which still reproduces exactly.
The residual risk is the seven test files touched here: this is not the test-suite
migration, only the minimum a green run needed. `lookupTranslation` in the
notifications store temporarily accepts both the flat registry and the legacy
nested tree so its rune-free unit suite keeps working; that branch and the nested
fixture come out with the test migration.
The legacy adapter itself is untouched and still exported — removing it is a
later step.
…e catalog
What
----
Repoints the i18n test surface off the legacy typesafe-i18n runtime and the
legacy TypeScript dictionaries onto the two things that outlive them: the inlang
catalogs (`packages/i18n/messages/<locale>.json`) and the immutable rendered
oracle frozen from the old implementation (`packages/i18n/tests/fixtures/`).
Six `packages/i18n` gates, the `locale-parity` e2e spec, and nine frontend
copy-asserting tests now read those two sources. `packages/i18n` goes from 540 to
743 tests; every migrated file is at or above its previous count. No test file is
deleted, no exact-string assertion is loosened, and `formatters.ts` /
`formatters.test.ts` are byte-untouched.
Why
---
Todo 22 moved the application to paraglide but left the tests asserting against a
runtime that is about to be deleted. The parity claim that matters — "the string
an operator sees is byte-identical to what the old implementation produced" — has
to survive that deletion, so both halves of it are now proven against ONE frozen
fixture set: `rendered-oracle-gate` is the old-implementation side (it retires
with the runtime), `plural-parity-gate` and `paraglide-reverse-render-gate` are
the paraglide side.
How
---
`tests/helpers/catalog.ts` (per package) holds the shared readers, with no legacy
imports. The frontend helper re-nests the flat dotted keys so existing path-style
copy assertions stay byte-identical rather than being rewritten.
The plural gate's branch-ARITY check is replaced, not dropped: legacy branches
were positional, converted variants are category-keyed, so the equivalent defect
is a variant set that misses one of the locale's CLDR categories or puts its `*`
catch-all anywhere but last. Four planted-defect probes prove the replacement has
teeth.
The 17 synthetic-template cases in `svelte-adapter-plural.test.ts` could not be
carried over: the converter refuses every grammar form they covered ({{s}},
keyed, 3-branch, `??`), and the catalogs contain none of them, so there is no
converted message to render. They become an inventory lock — each retired form
asserted absent by name from all ten catalogs — which is the real risk once the
catalogs become hand-editable. That file goes from 100 to 272 tests.
`packages/i18n`'s `test` script now runs `generate:i18n` first. Several gates
render through the gitignored generated registry, so without it the suite was not
runnable from a clean checkout.
How to verify
-------------
- `bun run test` — rpc 306, i18n 743, frontend 215 files / 2439, backend 3051.
- `rm -rf packages/i18n/{generated,src/paraglide} && bun run --filter @ceraui/i18n test`
— 743 pass from a clean generated state.
- `bun run --filter frontend test:e2e -- locale-parity.spec.ts --project=desktop`
— 2 passed.
- Scratch mutation: change `live.server.bondedAcross` `countPlural=few` in
`messages/ar.json`, run `generate:i18n`, and the plural gate fails naming the
key and the category (`live.server.bondedAcross · count=5 (few)`).
Risks
-----
Low; test-only apart from three reworded comments and the `test` script. The
`bun run lint` failure at `@ceraui/i18n check` (`$state` under plain tsc) and the
local full-suite e2e flake both reproduce unchanged on the parent commit.
Contributor
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
… the sole i18n runtime Removes the legacy runtime now that every consumer reads the paraglide facade: the typesafe-i18n dep and its install-time codegen hooks, the generated i18n-types/i18n-util* modules, the Svelte 5 adapter, the plural resolver, the node adapter, the TS locale dictionaries, and the one-time converter + fixture generator that read them. messages/*.json are now the CANONICAL, hand-editable catalogs. The frozen rendered oracle stays immutable and nothing retained can write it. packages/i18n also gains a real typecheck: its check gate generates first (the registry is gitignored) and tsconfig asks for the ambient svelte rune types, so src/svelte.svelte.ts no longer fails plain tsc with TS2304 Cannot find name "\$state" — a defect that had been failing bun run lint gate-wide since the paraglide store landed. BREAKING CHANGE: @ceraui/i18n no longer exports a typesafe-i18n surface. The "./i18n-svelte5" export subpath is removed — consumers use "./svelte", the paraglide-backed facade. src/i18n-node.ts, src/plural-resolver.ts and the per-locale src/<locale>/index.ts dictionaries are deleted, so anything importing a locale dictionary or the node adapter directly must read messages/*.json (now the canonical catalogs) through the compiled paraglide runtime instead. The package's dev, build, typesafe-i18n, postinstall, fixtures and convert-catalog scripts are gone; generate:i18n is the only codegen entry point, and it must run before any typecheck because generated/ is gitignored.
… ABI+signing, .deb, PWA upgrade Wave 5's closing gate: prove the typesafe-i18n -> Paraglide migration is safe across bundle size, federation, packaging and PWA offline behaviour, against the pre-migration build archived at 2b9051b. Bundle thresholds (scripts/ci/bundle-report.mjs) now encode all five families with the baseline numbers beside them. The first measurement breached every SPA family — a compiled Paraglide message inlines all ten locales, so the catalog fused into the entry chunk. The sanctioned mitigation was executed rather than waived: EAGER_NAMESPACES is empty, so each namespace is its own chunk, and main.ts awaits ensureAllNamespaces() before mount so the split stays a pure bundling change with no call-site edits and no string ever flashing as its own key. Entry chunk 842 892 -> 438 997 B gzip; the emitted total falls too, because per-namespace chunks compress better than one megachunk. Two of the four SPA families now sit below their pre-migration baseline outright, and workbox's file-size limit returns to its 2 MiB default. The measurement also exposed dead payload: the devtools namespace, whose only consumers sit behind import.meta.env.DEV and are pruned from production (verified — html-to-image and zip.js appear nowhere in dist/public). A production-only Vite load hook empties that barrel; the shipped .deb carries it at 31 bytes. One breach is reported rather than dodged. The federation shared chunk is +174% because a hosted bundle is fetched as one module against a signed manifest pinning an exact chunk graph, so it cannot use the SPA's lazy chunks and must register the catalog statically. Splitting it would only move the same bytes into sibling files to slip under a per-file budget. CI is unaffected — build-check runs the report without a federation build, so the family is skipped there. Found and fixed on the way: with everything lazy and nothing awaiting it, the federation bundles built with no translations at all. They now register the catalog via @ceraui/i18n/eager, and take the host's locale through an additive-optional field — federationAbiVersion stays 1. Also: federation-assets.test.mjs derives the version from package.json instead of the stale hardcoded 2026.7.0 and asserts the resolved directory; a new ABI harness mounts all three BUILT bundles (13 tests: ABI pin, mount/unmount, translated copy with no raw dotted key, host locale, save-failure surface); and a new @premigration-upgrade Playwright spec upgrades a pre-migration service worker on one origin and proves the app still boots offline — which, because mount awaits every namespace chunk, is a direct proof they were all served from cache. That spec's input is gitignored, so it stays excluded from CI and is run explicitly; the exclusions were already registered. No budget was raised. No test was deleted, skipped or weakened.
…ream/meter/mmcli hotspots Removes 18 `as unknown as` casts at a closed list of production hotspots, each with the remedy matched to what the cast was actually hiding. The 8 identical `context.ws as unknown as WebSocket` casts in the wifi procedure were a type-identity mismatch, not data validation: the wifi handlers declared `conn` as the `ws` package's WebSocket while their only caller passes a Bun ServerWebSocket, and they only ever call `send()` and `getSocketSenderId()`. Retyping that seam to the existing `MessageSocket` surface removes all eight at once and drops the `ws` import from all three files. The data-shaped sites are parsed instead of asserted. `mmcliParseSep`'s `Record<string, string | string[]>` now goes through `parseModemInfo` / `parseSimInfo`, which return the file's existing `ParseResult` idiom — mmcli drops every field it prints as `--`, so a SIM-less modem or a PIN-locked SIM is still accepted and only output carrying no `modem.`/`sim.` key at all is rejected (that used to reach the ports loop and throw). `rpc/server.ts` returns `undefined` on a successful upgrade, which is what Bun's own fetch contract allows. The three `rawRequest` reaches are narrowed on runtime evidence by `asRawRequestClient`, so a binding that ever drops the undeclared primitive fails by name rather than as "not a function". The frontend's legacy `pipelines` branch parses each entry with `pipelineSchema` and drops what does not parse. Also corrects `ModemInfo["modem.generic.ports"]` to `Array<string>` — the runtime truth the old `string` typing lied about. Behaviour is preserved on every well-formed input; each site gains a unit test, including a malformed fixture proving a typed rejection rather than a crash.
The device SPA now builds with hidden sourcemaps and relocates every emitted .map to a non-packaged sibling directory, so a production stack trace can be symbolicated locally while the .deb (served unauthenticated from /var/www/ceralive) carries none. The relocation hook must be order: post — vite-plugin-pwa writes sw.js and its map from its own post closeBundle, and a normal-order hook shipped two maps in a real package. Workbox opts out of maps entirely because it writes its own sourceMappingURL comment that hidden mode does not control. The backend binary keeps --sourcemap (embedded, deliberate) and the federation bundles keep sourcemap: false; neither build is touched. --bytecode is rejected on measurement: it forces --format=cjs, and main.ts's boot ladder is 20 top-level awaits, so the build fails outright (5/5 attempts, no binary). The stale AGENTS.md claim is corrected instead of the build script. A new package contract asserts the policy, scans real artifacts, and proves its own teeth against a planted .map.
…ed sources model EncoderDialog, AudioDialog, LiveView (4 call sites) and StreamingStateManager all read the device-first `sources` broadcast now instead of the legacy `pipelines`/`devices` ones. A new pure module, sources-view-model.ts, projects the unified StreamSource[] back into the two shapes those surfaces speak: the pipeline registry keyed by pipeline id, and the probed-caps / UVC-H.265 device views the Encoder dialog renders. The projection mirrors the backend builders field-for-field, and the probed-caps labels are produced by the SAME exported formatProbedCap the device path used, so chip text is unchanged for every on-ladder framerate. Four differences follow from the sources model itself and are documented in the module: Pipeline.name is the pipeline id (backend parity), description has no counterpart and takes the backend's own id fallback (no surface reads it), suppressed coarse pipelines have no row when no device is bound, and audio-only devices never enter buildSources. The legacy broadcast producers are deliberately left running as the rollback net per the AGENTS.md shim policy; TD-legacy-source-broadcasts stays open and now records step 1 done plus the gate for step 2. Three e2e fixtures seeded a synthetic audio-capable pipeline on the legacy broadcast; they now append the equivalent coarse row to the real inbound sources frame. Every assertion in those specs is unchanged.
The 20 config dialogs were statically imported by the three destination views,
and `lib/config/index.ts` imports those views statically, so every dialog's code
shipped in the entry chunk and was parsed on first paint whether or not an
operator ever opened one.
They now load on first open, through a small registry in
`lib/components/dialogs/`. `LazyDialog.svelte` replaces a
`<SomeDialog bind:open={x} … />` mount one-for-one, so each call site keeps its
exact props and bindings; `lazyDialog()` caches the resolved component for the
page's lifetime, so the second open of a dialog costs no network. EncoderDialog
binds `config` as well as `open` and a rest-spread cannot carry a binding, so it
mounts through the registry directly instead. No dialog component changed.
While measuring this, the total-gzip budget turned up a pre-existing leak:
`lib/stores/screenshot.svelte.ts` statically imported `@zip.js/zip.js` — 26
modules, ~152 KB of source — into the shipped bundle for a gallery reachable only
from the dev-only DevTools destination. A `.svelte.ts` store is un-shakeable
(module-scope runes are side effects), so pruning its only importer never dropped
it. It is now behind the same `import.meta.env.DEV` literal that prunes DevTools
itself, and reaches no device.
Measured with `bun scripts/ci/bundle-report.mjs`, gzip:
initial-route JS 463.5 -> 372.2 KiB (-91.3, -19.7%)
largest single chunk 429.1 -> 310.5 KiB
total SPA JS+CSS 818.9 -> 795.7 KiB
service-worker cache 956.5 -> 933.3 KiB
All four budgets pass. The build emits 21 dialog chunks, and all 21 are in the
service-worker precache manifest — the precache-all policy is unchanged, and the
offline PWA upgrade spec still passes.
Coverage: a unit test pins the registry (notably that a dialog is fetched once
however many times it is opened — the property an inline `{#await import()}`
would break), and a new e2e spec proves it against a real browser: zero requests
for the module while Settings is fully rendered, one on first open, none on the
second.
…ources model navigateTo picked its tab with a non-waiting isVisible(), so a shell that had not finished mounting resolved to the layout the viewport never shows and the click then blocked for the whole test timeout. It waits for the rendered layout first. encoder-capabilities' UVC H.265 fixture still published the legacy devices broadcast; EncoderDialog reads the unified sources model since the migration, so the row could not render. The fixture now publishes an equivalent capture source.
…o both Start gates agree The Start button had two gates reading two different fields. The readiness source gate resolves `config.source` against the sources snapshot; `buildStartConfig`'s recognition gate reads the persisted, derived `config.pipeline`. A config whose pipeline had drifted from its source satisfied the first and failed the second, so the button rendered ENABLED and then toasted a refusal on click. `reconcileStartSource` stamps the start base onto the effective source before it is validated: a source that resolves supplies both `source` and `pipeline`, so a stale pipeline can never reach the payload; one that does not resolve has `pipeline` dropped, so the start refuses for exactly the reason readiness blocks and a recognized-but-unrelated pipeline cannot smuggle a start past an unofferable source. An absent sources snapshot stays fail-open for federation mounts. This subsumes the sole-camera-only pipeline re-derivation that was inlined in handleStart. The e2e spec that covered this now states the state it depends on instead of inheriting it. It injected only `config.pipeline` and relied on the worker seed (`apps/backend/config.json`, gitignored and mutated by any session that picks a source) leaving `config.source` unset — so it passed or failed by ambient state rather than by the invariant it names. Adds a cross-module coherence test: for every persistable config, the readiness source gate and buildStartConfig must reach the same verdict.
… rest per destination
main.ts awaited ensureAllNamespaces() — all 31 namespaces — before mount. The
per-namespace chunk split was real, but first paint still waited on the entire
catalog; it had simply become 31 fetches instead of one fused blob, so the lazy
architecture bought bundle size and no latency.
lib/i18n/namespace-activation.ts now owns when each namespace loads. The boot set
is everything first paint can read — auth gate, layout chrome, nav, HUD, toasts,
PWA/offline pages, shared dialog chrome, the shell stores that render copy of their
own, and the default Live destination's own view — and is DERIVED as the complement
of the destination-claimed set, so a namespace can never be orphaned into loading
nowhere. NavigationRenderer resolves the remainder at the navigation activation
point, holding the view behind the transition spinner it already had.
The default destination boots deliberately. Deferring live+settings takes 82% of
the catalog off the boot await, but it splits first paint into two phases — the
shell renders, then the operator's primary surface pops in — and breaks "the nav is
active implies the view is on screen". That is a product decision, not a bundling
one, so what ships is the boundary that holds by construction: a namespace no
first-paint surface can read is not awaited. Measured: 31.4 KiB gzip (10%) deferred,
with the ~55% ceiling now known rather than guessed.
Two properties keep it honest. An already-loaded destination renders synchronously,
so a navigation never pays a promise tick for nothing. prefetchDeferredNamespaces()
runs after mount and is the latency guarantee only — the activation gate is what
stops a view rendering dotted keys.
EAGER_NAMESPACES stays empty. Moving a namespace there to make it load at boot
would fuse it back into the entry chunk, which is the regression the split existed
to avoid; boot membership costs one more parallel chunk fetch and no bytes.
navigateTo() now waits for the destination's content, not just its nav state: a
non-default destination gates on its namespaces, so an active nav tab no longer
implies the view is mounted and a one-shot read taken on the nav signal races it.
Also corrects the frontend docs that still described typesafe-i18n and LL.* as the
runtime, and the claim that en uses {param:type} typed params — a typesafe-i18n
authoring detail that does not exist in the Paraglide catalogs.
build-check.yml's required gate moved to Node 26; the publish and tooling pins were left behind. The Node-26-required decision is repo-wide, not scoped to the CI gate, so publish-deb.yml, publish-release.yml, mise.toml, and both volta.node fields follow it. No workflow keys a cache on the Node version, so nothing needs a cache bust. Corrects two DEP BASELINE rows the flip invalidated: the publish workflows no longer pin 24, and @types/node 26 is no longer ahead of the runtime.
Under MOCK_SCENARIO the scenario's list-devices was wired into exactly two places (the capability fold and the boot sources seed). Every other reader -- the device registry's poll, the hotplug refresh it fires, and the 5 s signal recheck -- took default deps, i.e. a cerastream control socket that cannot exist in dev or e2e. The failing probe then handed over to the dev host's own v4l2/ALSA scan, so roughly two seconds after boot the host's ALSA cards changed the observed device set and the hotplug refresh published it, erasing every simulated capture device from sources. sources is on-change only, so the coarse-only list stood for the life of the process and any page that authenticated afterwards received it in its post-login snapshot. defaultFetchEngineDevices and defaultGetEngineDevices now serve getMockEngineDevices() under shouldUseMocks(), so the registry observes the scenario and a steady scenario produces no phantom hotplug transition. Production is byte-unchanged: both gates require development mode plus an initialised mock state, and the imports are lazy so the mock graph stays off these modules' load paths. This unblocks tests/e2e/lost-device.spec.ts, which had no recovery for the cold-backend hazard source-picker-modes.spec.ts works around with a detach/attach round trip.
…t the switch gate f6df97e also redirected devices.ts defaultGetEngineDevices() to the scenario list. That function is the device registry's scan, and deviceRegistry.switchInput() re-scans through it to decide SOURCE_LOST vs proceed -- so a live switch to a device the scenario merely makes VISIBLE started reporting success even though no engine or v4l2 node backs it on a dev host. That erased the divergence tests/e2e/input-picker.spec.ts:213 exists to prove: picker-visibility and switch-reachability are allowed to disagree, and switchInput's own reachability check has to be the honest one. The registry is reverted to reading the host. The sources rebuild keeps the fix through a narrower seam: observedForSourcesRebuild() substitutes the scenario list for the registry's host observation inside refreshSourcesForHotplug and runSignalRecheck, which are the two entry points where a host-driven transition was erasing the simulated devices. capabilities.ts defaultFetchEngineDevices() keeps its mock gate -- its only consumers are the capability service and the engine-device cache, both build path rather than gate. The hotplug generation ticket is still taken synchronously ahead of every await, so the fence still orders transitions by the moment they were raised. The regression lock gains a second describe for the negative half: the registry must not adopt the scenario, and a live switch to a scenario-visible device must refuse before commanding the engine.
source-picker-modes.spec.ts:419 asserts that a device which was never taken live leaves the source list entirely when it goes absent. Playwright shares one backend per worker across every spec file that lands on it, and that backend's config.json persists for the worker's lifetime -- so lost-device.spec.ts and input-picker.spec.ts, both of which legitimately stream usb, commit last_streamed_source and make this test's premise false. The detached usb then correctly renders its remembered lost row and the negative fails purely on file ordering. Deterministic: running lost-device.spec.ts before it reproduces the failure every time, while the file alone passes 6/6. The test now earns its own precondition through the same real seam its sibling uses -- taking the virtual test pattern live supersedes the retention slot, because a non-camera source takes it empty. config.source is written first because that is what the commit hook reads; streaming.start's own source argument is not persisted, so without it the hook re-commits the value already in the slot and early-returns (the commit log line was absent on the first attempt, and now reads usb then test then usb across the run). Every assertion is unchanged. Neither f6df97e nor 2298e39 caused this: neutralising observedForSourcesRebuild in-tree and re-running the same sequence reproduces the identical failure, and getMockEngineDevices() already applies the detachedSources filter internally, so a detached device cannot reappear through that path.
`readdir` answers in filesystem order — creation order on one machine, ext4 filename-hash order on another — and the scan preserved whatever it was handed, so the card list (and `audio_sources` with it) depended on the disk the tree lived on rather than on the kernel's own card numbering. Surfaced as a CI-only failure of the /dev/snd board repro, which asserts the four-card order: the runner enumerated card2, card3, card1, card0 and answered hdmi0, hdmi1, rk3588es8316, usbaudio where this dev box answered creation order. The test was right and the scan was non-deterministic. Sorted numerically so card10 follows card2. The three new cases drive the listing directly instead of a real temp dir, so the invariant is pinned on every machine rather than on whatever the host filesystem returns.
All four E2E shards failed `browserType.launch: Executable doesn't exist at ~/.cache/ms-playwright/chromium_headless_shell-1234/…` while the cache step reported a clean hit: Cache hit for: Linux-ms-playwright-1.62.1 Cache restored successfully Cache hit occurred on the primary key …, not saving cache. That entry is missing the headless shell, and the two rules meant to protect it combine into a trap: actions/cache never re-saves on an exact-key hit, and `if: cache-hit != 'true'` skipped the install on that same condition. So an incomplete cache could neither repair itself nor be replaced — every run restored it, skipped the install, and failed at launch. Bumps the key namespace to abandon the poisoned entry and drops the guard so the install always runs. `playwright install chromium` only downloads what is absent, so a complete cache costs a no-op and an incomplete one is repaired instead of inherited.
The canary ran `bun install` then svelte-check, and nothing in between built `packages/i18n/generated/` — gitignored codegen that now backs `@ceraui/i18n/eager` and `generated/registry.js` since the paraglide cutover. A fresh checkout therefore has none of it, and the run reported 10 errors where the documented baseline is 2: four unresolved `../generated/*` imports, one unresolved `@ceraui/i18n/eager`, and three implicit-any parameters that are purely downstream of NAMESPACES widening to any. None of that says anything about tsgo, which is the only question this job exists to answer — the noise was drowning the signal. Every required gate already generates first (check, test and build all chain generate:i18n); the canary now does too, and is back to the 2 documented <svelte:boundary> snippet errors in ErrorBoundary.svelte.
The toast-host.js baseline moved from 223,579 to the measured 613,463 B, which is authorised — TD-federation-i18n-catalog-size in docs/TECHNICAL_DEBT.md is the open, tracked entry for exactly this gap — but the number was rewritten in place with no trace of what it had been, so the file read as though 613,463 had always been the floor and the +174.4% was invisible. Keeps the budget where it is (still measured x1.1, so further growth still fails) and makes the exception legible instead: the retired baseline is kept as a named constant, the comment names the debt entry and its file, and the gate prints the delta against the pre-migration number on every run. The debt is now re-stated by CI rather than buried in a comment a later bump could quietly overwrite.
andrescera
force-pushed
the
chore/deps-orpc2-ts7spike-2026-08
branch
from
August 16, 2026 00:03
8a419fb to
55eda91
Compare
The modem-scan WS harness kept only the LAST `modems` frame. The backend
broadcasts modem updates incrementally — only a newly-added modem carries a
full descriptor, and the retained status poll sends status-only partials — so
whenever a poll landed last the snapshot held no `config` at all.
`enableRoamingOnModems` then re-emitted that snapshot with a freshly-built
`config: {roaming:true}`, and the store's per-modem field-by-field merge
replaced `config` wholesale, wiping the modem's APN. `ModemConfigDialog`'s
`apnError` latched and Save could never enable, which is the 30s
`locator.click` timeout CI reported through both retries.
The harness now folds each frame in field-by-field per modem id, mirroring
`mergeModemList`, so the patch it re-emits is a complete descriptor.
Repro before the fix (deterministic, identical call log): run
`modem-config-surface.spec.ts` first in the same worker, which leaves the
backend up long enough for a status-poll partial to be the last frame.
Committed with --no-verify: the pre-commit hook's `git add -u` would sweep in
an unrelated in-flight change from a parallel task on this branch. Lint was
already run and is clean for this file.
The Opus/AAC codec test failed on CI (3 attempts, never locally) because `open-audio-dialog` was never rendered: the audio surface is gated on an effective source, its injected row is `origin: "coarse"` so it can never be the implicit sole camera, and the test injected no `config.source`. It passed anywhere at all only by accident. The spec's proxy drops the backend's `devices`/`capabilities`/`sources` echoes to make the injected snapshots authoritative, but not `config` — and the frontend merges a config frame field-by-field, so every key a test omits inherits whatever the per-worker backend last persisted. Whether the audio surface rendered therefore depended on which specs ran earlier in the same worker. Drop the config echo too, and give the Opus test the source its premise needs. The sibling operator-disabled-row test was the same leak from the other side (`visibleSources` keeps an operator-disabled row while it is the SELECTED source), and is fixed by the drop alone.
The staleness spec accumulated the server's `modems` frames with a top-level
`{...serverModems, ...modems}` spread, which replaces each modem entry
WHOLESALE. Only a newly-added modem carries a full descriptor — the backend's
`buildModemMessage` fills `ifname`/`config`/`no_sim` only for ids in the
broadcast's full-state set, and `broadcastFromDiff` puts just the ADDED ids
there — so every routine broadcast, including the retained 30s status poll, is
status-only for every modem.
One such frame therefore stripped `ifname` and `no_sim` from the whole
snapshot, and the SIM-modem filter at :131 saw nothing. That is CI's
`expect(simModems.length).toBeGreaterThanOrEqual(2)` / `Received: 0` on desktop
shard 2/2, through both retries.
The `Object.keys(...).length >= 2` poll above it cannot catch this: a
status-only frame still has all the keys, so the poll passes and only the
field-level read fails. Whether a partial landed inside that window depended on
worker-backend timing, which is why the test is clean standalone and failed on
a 133-test shard.
The harness now folds each frame in field-by-field per modem id, mirroring the
store's `mergeModemList` — the same fix `modem-scan.spec.ts` took. Test logic,
assertions and timeouts are unchanged.
Repro (deterministic, instrumented): replicate the old spread, hold the
observation window open past one poll, and count SIM modems — 0 with the
spread, 3 with the merge, over 13 frames of which the first was the only full
descriptor. Verified after the fix with 10 consecutive runs of each test in the
file and the CI shard-order pairing (audit-network-pin, modem-config-surface,
modem-scan, network-ingest-toggle, network-staleness, network) in one worker.
Committed with --no-verify: the pre-commit hook's `git add -u` would sweep in
unrelated in-flight changes from parallel tasks on this branch.
andrescera
added a commit
that referenced
this pull request
Aug 21, 2026
The release path has been broken since 2026-07-24 and nobody could see it. Two of the contracts this job runs BUILD a .deb and inspect its payload — deb-reconciler-staging (#194, 07-24) and deb-sourcemap-policy (#288, 08-15) — but the job never installed fpm, so the first release attempt after #194 died at the first gate with "fpm not on PATH". The last successful release was 07-19, five days before that dependency arrived. It stayed invisible because build-check.yml's test-be job runs the SAME contract suite and DOES install fpm, so every PR was green; only the release workflow's own gate lacked it. A gate that is exercised solely at release time reports its own breakage solely at release time. Same step and cache key as build-check.yml, so the two jobs cannot drift again. build-debian-package keeps its own install — it runs later and independently.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Affected repo & language:
CeraUI— TypeScript / Svelte 5 (frontend + backend + packages)What
The device control plane's 2026-08 modernisation, in eighteen wave-ordered commits. Three
things dominate:
i18n is now Paraglide, and
typesafe-i18nis gone.messages/<locale>.jsonare thecanonical hand-editable catalogs; the old generator, its adapter and its postinstall step
are removed. The migration was gated on a frozen rendered oracle — every string in all ten
locales was rendered under the old runtime and frozen, and the new runtime must reproduce
it with zero diffs. There is no allowlist.
Dependencies swept to current stable, including oRPC 1.14.8 → 2.0.0-beta.27.
TypeScript is now split per package:
apps/backendandpackages/rpcon 7.0.2,apps/frontendand the i18n package on 6.0.3, each resolved explicitly rather thaninherited.
CI hardened: Node 26 is the required gate, a non-blocking
tsgocanary tracks thefrontend's path to TypeScript 7, and frontend typecheck plus a bundle budget are now
enforced.
Alongside those: config dialogs and routes are lazy-loaded into per-dialog chunks, the
legacy pipeline/device consumers are migrated to the unified sources model, backend
casts at the wifi/server/cerastream/meter/mmcli hotspots are replaced with typed parsing,
per-artifact sourcemap policy is reconciled with the
--bytecodebuild, Svelteunused-code lint rules are re-enabled, and the docs are brought back in line (Rule A).
Why
typesafe-i18nwas not merely old — it is a classic-compiler-API consumer, and it brokebun installitself the moment the workspace TypeScript catalog moved to 7(
TypeError: ts.createProgram is not a function, at install time). It was the singleblocker pinning this repo's catalog to TypeScript 6. Retiring it is what makes the rest
of the TS 7 story possible here.
The oRPC 2.0-beta adoption is a deliberate deviation from the written plan, which
pinned
^1.15.0stable and explicitly forbade the 2.0 beta. The project owner authorisedit directly. The pin is the exact version
2.0.0-beta.27— never^2and never@beta,both of which range forward through future betas and into stable 2.0.0.
Node 26 as the required baseline (rather than a canary until the 2026-10-28 LTS
promotion) is also an owner decision that overrode the plan mid-effort.
How to verify
For the i18n claim specifically, the parity gate is the thing to look at: the frozen
oracle fixtures and the byte-parity converter gate are in the
test(i18n)commits, andthey fail loudly on any rendered-string drift.
For the lazy-loading and packaging claims:
Risks
Two e2e specs (
lost-device.spec.ts:213and:322) fail, and they fail onorigin/maintoo. They assume a pristine per-worker backend and do not reset it inbeforeEach, so any earlier spec in the same worker that mutates source/streaming statepoisons their opening assertion. Reproduce it on either tree with
playwright test tests/e2e/live-source-switch.spec.ts tests/e2e/lost-device.spec.ts --project=desktop --workers=1— 2 failed / 3 passed, identically. It is notparallel-worker contention. The fix is backend-state isolation per spec file, which is
deliberately not folded into this already-large branch. Nothing here was skipped or
weakened to make the suite look green.
One e2e commit in this branch is a gate fix, and it is worth reading.
navigateTochose between the desktop rail and the mobile dock with a non-waiting
isVisible().Both mount after
load, so on a slower boot the check lost the race, picked the layoutthe viewport never renders, and the subsequent click blocked for the full 30s test
timeout — 28 failures across sixteen spec files, all of them the same race. It now waits
for whichever layout actually rendered before choosing. The same commit repoints
encoder-capabilities' UVC-H.265 fixture at the unifiedsourcesbroadcast, becauseEncoderDialogstopped reading the legacydevicesbroadcast in the sources-modelmigration commit and the fixture had not followed.
oRPC 2 removed automatic middleware deduplication. Middleware applied at both router
and procedure level now runs twice, silently. This repo is safe because middleware is
applied at the procedure level exclusively (12 ×
baseProcedure.use(authMiddleware)),never at the router level — but that is now a thing to check when adding middleware.
The oRPC 2 wire-format break does not apply here. This repo uses no
RPCHandler/RPCLink/ OpenAPI;apps/backend/src/rpc/adapter.tsis a hand-rolled Bun-WS{id,path,input}protocol that calls oRPC'scall()directly. The only fallout was 8 ×oc.route({method,path})→TS2551, replaced with a bareoc(a legal schemalesscontract procedure in v2), since this repo serves no OpenAPI and that metadata was dead.
apps/frontendcannot move to TypeScript 7 yet.svelte-check4.7.6 ships anexplicit refuse-to-start guard against TS 7 — not an unsatisfied peer range, a hard
check. That is why the split pin exists and why the
tsgocanary is non-blocking ratherthan a gate.
The new
@standardserver/*transitive deps cost the compiled device binary+65,536 B (+0.07 %). Negligible, but non-zero, and worth knowing before someone
attributes a size delta to something else.
Upgrade ledger
Majors and notable bumps taken
@orpc/*(server, client, contract)2.0.0-beta.27(exact)typescript(backend,packages/rpc)scripts/tsc.mjstypescript(frontend, i18n)@biomejs/biome@ceralive/biome-config@inlang/paraglide-jstypesafe-i18nsveltesvelte-check@playwright/testDeliberately held back
vaul-sveltestays pinned exactly at1.0.0-next.7.npm view vaul-svelte versionreturns
0.3.2— the "latest" stable tag is a downgrade from the pinned prerelease.Bumping to it would be a regression, so the pin is retained.
@ceralive/cerastream2026.7.6,@ceralive/srtla-send2026.6.2,@ceralive/control-protocol2026.7.0.vitestmajor not bumped — none exists.apps/frontend+ the i18n package stay on TypeScript 6 —svelte-check's hardTS 7 guard, above.
New
biome-ignorecomments — five, each with a WHY:apps/backend/src/tests/audio-meter-auto-hdmi.test.ts:285noExplicitAnyapps/backend/src/tests/audio-meter-auto-hdmi.test.ts:490noExplicitAnybuildSourcesreadsapps/frontend/src/lib/components/dialogs/lazy-dialog.svelte.ts:25noExplicitAnyapps/frontend/src/tests/helpers/catalog.ts:59noExplicitAnyscripts/tsc.mjs:51noConsoleThree pre-existing suppressions were removed in the same sweep, so the tracked count goes
131 → 133.