Skip to content

Wild international[ization] - **feat: on-device message translation (WebGPU LLM + WASM seq2seq) and a fully translated interface in 23 languages** - #139

Open
PseudoSean wants to merge 545 commits into
evilnet:developfrom
PseudoSean:wild-international
Open

PseudoSean wants to merge 545 commits into
evilnet:developfrom
PseudoSean:wild-international

Conversation

@PseudoSean

@PseudoSean PseudoSean commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Branch wild-international against origin/develop (2401a0cba, #148). The branch already contains that commit, so it fast-forwards.

  • Size (three-dot, as GitHub's "Files changed" counts it): 538 commits, 17 of them merges; 555 files; +626,811 / −3,613.
  • Where the bulk is: the generated catalogs.
    • The 23 .po files plus messages.pot: +360,413.
    • The compiled client/locales/*.json: +64,937.
    • The 22 archived catalogs in client/locales/attic/: +118,407.
  • Code, tests, tools and docs (excluding client/locales/): 483 files, +83,054 / −3,613.

Two features that grew together, because the reading language and the interface language are one setting:

Change What the user gets Where
On-device translation
  • A channel can be read in the user's language. Each line is translated under the original, the topic too.
  • A message can be written in another language: the first Enter shows the translation and its round trip, the second sends it.
  • No server and no translation API: the models run in the browser.
client/js/translate/, TranslationLine.vue, TranslationPanel.vue, ChatInput.vue
A translated interface
  • Every label, tooltip, system line, notification and server-notice template goes through the catalog.
  • 23 languages, chosen automatically from the browser, or picked in Settings → Translation.
  • Arabic lays out right to left.
client/js/i18n/, client/locales/, every component
The upstream-label sweep
  • The ~1,220 labels upstream added while this branch was open are translated too.
  • That covers the oper tools, X3 services, do not disturb, the install guide, the inbox and the rest.
ae08bc46, 98a1f0f0
Asking before a download
  • The first download of a model that no one asked for names it and its size, and waits for Download or Not now.
  • NLLB-200, the one non-commercial model, is off until the user switches it on.
translate/consent.ts, service.ts, Settings/Translation.vue
Every model on the GPU or the CPU
  • Settings → Translation has a Run on choice per model: Auto, GPU or CPU, default Auto, each choice the device cannot run struck through with the reason.
  • Qwen3 1.7B runs on the CPU (llama.cpp through wllama), so a phone whose GPU fails can still use the language model, slowly.
  • The CPU models run multithreaded where the page can be cross-origin isolated, GitHub Pages included: the service worker supplies the headers.
models.ts deviceOrder, engines/onDevice.ts, engines/cpuLlm.ts, threads.ts, service-worker.js
GPU limits and what can run
  • Phones that offer 256 MiB–1 GiB of GPU buffer can load the GPU model, and phones at WebGPU's 128 MiB floor run Qwen3 1.7B as a small-buffer build.
  • Settings says, per model, whether this device runs it and why not.
  • Every translation action that cannot work says why instead of failing or vanishing.
engines/gpuLimits.ts, models.ts, router.ts, helpers/translateWhy.ts

Nothing new goes out on the wire.

  • No capability, tag or command changed: translation never leaves the device, and the i18n changes are display-only.
  • The one IRC-layer API change is caps.ts: CapResult.error (an English string) became errorCode, which the lobby renders through the catalog.

On-device translation

docs/resources/translation.md describes how it works today. docs/projects/client-translation.md is the spec, with the date of every decision.

Engines and routing

  • Two engines run in a dedicated worker (js/translate-worker.js, a third webpack configuration):
    • WebLLM running Qwen3 1.7B or 4B on WebGPU (q4f16);
    • transformers.js running NLLB-200 600M and twelve OPUS-MT pairs on WASM (q8).
    • The libraries are imported only from engines/*.real.ts, so mocha never loads them.
  • A per-pair route table of quality classes (router.ts, routes.default.ts):
    • Every placement was measured: a round trip on chat lines per language and engine, re-scored on the web build's own 4-bit weights. The results are in tools/translate-eval/results/.
    • There is one set of placements per GPU model.
    • 46 languages are offered (SUPPORTED_LANGUAGES). Four that came back under 55% from every engine were dropped.
    • A deploy can override routes in config.json (translation.routes).

Reading a channel (reader.ts)

  • The pipeline: detect, then eligibility, then context, then queue.
  • Detection is chat-tuned.
    • A function-word classifier (chatdetect.ts, generated stopwords.json) runs first.
    • franc runs second, as a lazy chunk.
    • Lines of three words or fewer go to a frequency lookup (wordlookup.ts, the wordlist.json chunk).
  • A weak verdict is only a routing hint, never a claimed source.
  • The context sent with a line holds recent lines, the reply target, the topic, nicks and the channel's terms.
  • The queue batches LLM work, drops lines that have fallen behind, and pauses a failing engine for 60 s instead of hammering it.
  • Output:
    • Results render under the original message.
    • A line in the reading language is skipped, with a "Translate anyway" chip.
    • The topic is translated in place, with a flipper back to the original.

Writing (outgoing.ts, writer.ts)

  • The composer's first Enter translates the draft into the channel's write language and shows it in a strip, with a read-back into the reading language. The second Enter sends it.
  • /me is the one command that translates.
  • The same language on both sides gives a cleanup pass on the GPU model.
  • The posted line shows its own read-back rather than being translated again.

Per-channel controls

  • TranslationPanel.vue, opened from the header's 文A button, holds reading on or off, the write language, formality, variant and terms.
  • On a phone or a narrow window it is a full-screen sheet.

Lifecycle

  • Models load on demand, and the first download of each asks first (§ Downloads ask first, below).
    • A GPU model is unloaded after 3 idle minutes and a CPU model after 5.
    • Everything is unloaded at once when no channel uses translation.
  • Downloaded weights live in Cache Storage (see Storage). Settings → Translation lists every model with Download and Delete.

Deploy knobs (docs/resources/branding.md § Translation)

translation.enabled, modelBase (a mirror for the weights and WebLLM's compiled library), llm (model, lib), cpu (nllb, opus), routes and glossary.

Internationalization

docs/resources/i18n.md is the workflow and docs/projects/i18n.md the design record.

Strings and the catalog

  • Every UI string goes through t()/tCount():
    • useI18n() in components;
    • the core t in Vue-free modules;
    • the same resolver inside the push service worker, so notifications are translated too.
  • client/locales/messages.pot is the only English copy.
    • 2,297 keys, each with a #. context written for translators.
    • tools/i18n/check.ts (run in yarn test) fails when a call site and the pot disagree.
    • Keys are whole phrases with {var} placeholders, never concatenated fragments.
    • Numbers are localized and plurals follow CLDR (tools/i18n/plural-rules.json).
  • User and server text is never translated: nicks, message bodies, numerics, FAIL payloads, X3 replies. It renders verbatim, wrapped in <bdi> inside a translated frame.

Languages

  • 23 catalogs: de fr es it pt nl sv da pl cs sk hu ro ru uk tr ar th vi zh ja ko fil.
  • translation-languages.txt is the one list. tools/i18n/sync.ts (inside yarn i18n:compile, which yarn build now runs first) scaffolds a new target and archives a dropped one.
  • The 22 languages dropped for the beta were unusable machine output. Their catalogs are kept in client/locales/attic/ for a later refill rather than deleted.
  • Traditional Chinese reads English, not the Simplified catalog (e5fcf874).
    • zh-TW, zh-HK, zh-MO and zh-Hant-* no longer fall to zh, and neither does the bare zh Chrome appends after them (zh-TW, zh, en-US, en). The reader gets their next preference, or English.
    • A script subtag decides over the region: zh-Hans-TW is Simplified, zh-Hant-CN is not. zh, zh-CN, zh-SG and zh-Hans-* keep zh; pt-PT keeps the Brazilian pt.
    • The rule lives in client/js/i18n/traditionalChinese.ts, and the pre-paint script restates it, so <html lang> is right before boot. Picking Chinese by hand still gives zh.

Right to left and the qqx pseudo-locale

  • Arabic is right to left. New CSS uses logical properties, and direction comes from one source (core.isRTL()) shared with the pre-paint script.
  • qqx doubles every string and flips the direction. It is a development build's rig only, and a production build has no trace of it. Verified on this build:
    • no qqx.json in public/locales/;
    • not in the pre-paint list;
    • not selectable.
    • The one occurrence left is qqx in the inline RTL regex, which is dead code there.

Language selection

  • One setting, locale, drives the interface and the reading language.
    • Default auto, from navigator.languages.
    • Picked in Settings → Translation (LanguageSelect.vue). A development build adds the 🌐 globe by the logo.

Filling and gating the catalogs

  • Two ways of filling:
    • Machine fill, with the app's own engines: tools/i18n/fill.ts.
    • Translation agents: how the October entries were written.
  • Either way, tools/i18n/sweep.ts gates them. It empties any slot quality.ts slotVerdict judges broken:
    • placeholders that do not match;
    • an answer that is the English source again, except the IDENTICAL_EVERYWHERE jargon, and except a plural form beside a translated sibling;
    • repetition or runaway length;
    • the wrong script;
    • lost padding.
  • An emptied slot falls back to English, never to noise.
  • Coverage is 94–99.5% per language. The rest reads the same as English: jargon, loanwords, examples and placeholder-only frames.

The review pass (0c336d89)

Every slot of every catalog was then read by a reviewing agent, one language at a time, against its English and its translator context.

  • Method:
    • Each language got a glossary for the recurring terms (channel, network, query, nick, alias, away, oper, push), kept in the review notes.
    • One register was fixed per language: de Sie, nl u, fr vous, cs and sk vy; da, tr, pl and hu informal.
    • Corrections went in through the catalog tools, so the sweep still checks them, and it empties none.
  • 11,555 slots changed or filled (changed + filled):
de 469 + 50 fr 390 + 1 es 382 + 3 it 458 pt 293 + 43 nl 466 + 51
sv 531 + 46 da 497 + 29 pl 452 cs 563 sk 612 hu 619 + 2
ro 465 + 6 ru 613 + 1 uk 576 + 1 tr 566 + 20 ar 615 + 1 th 487
vi 432 + 1 zh 297 + 1 ja 372 + 1 ko 362 fil 741 + 40
  • What it fixed, for example:
    • Reversed meanings: de and ja Yes as "No"; da "Disconnected" as "Connected" and "Show less" as "Show more"; tr Import as "export"; pt "cannot contain" as "can contain"; th "enable TLS" as "disable".
    • Wrong words: "Leave" as "vacation" or "stay" (de, ru, ja), "Account" as a bank account (ru, it), "query" as a question or a search (da, hu, tr) or as "conversion" (pl), the alias as "the Creator" (ar), hu and sv colour names.
    • fil was largely Cebuano and Indonesian, and vulgar in places. It is rewritten in Tagalog.
    • The sample of mistranslated labels that this description used to list (de "Urlaub", "Zusammenbruch", "Warenbezeichnung"; ru "Счет", "Каналыхожиеения"; ja "紙色"; da "Omsætning"; pl "Przetłumaczenie"; fil "Ibabaw") is gone from every catalog.
  • "Show MOTD" is one frame. It was the bare verb "Show" with the abbreviation glued after it, so it read "表示 MOTD" and "Anzeigen MOTD". It is now Show {motd}, with the <abbr> in an I18nFrame slot.
  • Native-speaker review is still welcome for every language. The reviewers are agents, not native speakers; the cases they could not settle are in the open items below.

The upstream-label sweep

The 454 upstream commits merged in d4ed1277 brought their UI in plain English.

ae08bc46 puts every string they added through t(), about 1,220 keys:

  • Oper tools:
    • notice kind labels and plain-words templates (profiles/noticeText.ts);
    • snomask bits;
    • STATS/CHECK labels;
    • the Opers, Alerts and Me tabs;
    • the investigation cards (verdicts and fates are structured data, rendered by fateText);
    • the alert builder;
    • the services liveness line.
  • Other surfaces: the install guide, the presence pill and do not disturb, the network form, the WHO table, whois.
  • Weekday names come from Intl.

Still never translated: wire text, X3 keywords, server and O3 replies, and jargon (G-line, shun, snomask, WALLOPS, …).

English output is unchanged, except for four small corrections named in the commit.

98a1f0f0 translates those keys into all 23 catalogs, in each catalog's own register: de Sie, fr vous, pt-BR, zh Simplified, and so on.

GPU limits and what can run

The GPU model on phones

The problem. WebLLM 0.2.85 asks the GPU for a 1 GiB storage binding, or WebGPU's 128 MiB floor when the adapter cannot give 1 GiB, with nothing between. Qwen3 1.7B's embedding is one 148 MiB tensor. So a phone offering 512 MiB got a 128 MiB device and the model never loaded.

The fix.

  • engines/gpuLimits.ts (2c7553a0) wraps GPUAdapter.prototype.requestDevice in the worker.
    • It asks for what the adapter offers, capped at WebLLM's own 1 GiB, so a desktop's device is unchanged.
    • If the raised request is refused, it falls back to the original one.
    • Nothing under node_modules is patched.
  • The tier floor is the smallest model's largest tensor (5dbd2bbc): models.ts LLM_MIN_BUFFER_BYTES, 148 MiB.
    • llmFits decides each model.
    • A chosen model the GPU cannot bind runs the default instead.
  • The 4B is never a phone's default.
    • It is the default only on a computer that reports 6 GiB of deviceMemory.
    • Chrome reports 8 GiB on many phones, which is why the computer check matters.
  • Download sizes now match Hugging Face: 1.7B 984 MB, 4B 2.28 GB, NLLB 912 MB, an OPUS pair about 113 MB.

Qwen3 1.7B on a 128 MiB GPU (3e306fb4, 2c1262c7)

The problem. Many phones bind no more than WebGPU's 128 MiB floor in one storage buffer, and a model binds each weight tensor whole. In Qwen3 1.7B only one tensor is larger: the vocabulary table (model.embed_tokens.q_weight, 148.4 MiB), which the output head reuses. Every other tensor is 24 MiB or less.

The build.

  • A small-buffer build of the model library splits that table by rows into two halves of 75,968 rows (model.embed_tokens.part0/part1, 74.2 MiB each). The lookup takes a token from its half, and the output head joins the two halves' logits in vocabulary order.
  • It is compiled with the exact toolchain behind WebLLM 0.2.85's own libraries (TVM bc1a904, MLC-LLM 2008fe8, emsdk 4.0.23). The 73-line model patch and the recipe are in tools/translate-split/.
  • The library ships with the app: client/models/qwen3-1.7b-q4f16_1-split-c4939e7d.wasm, 5.7 MB, copied to public/models/ and never precached by the service worker.

The weights are the published ones.

  • The build is pinned to Hugging Face revision 80b3abce of mlc-ai/Qwen3-1.7B-q4f16_1-MLC, so its cache keys are its own and its byte offsets cannot drift.
  • Before loading, the worker fetches that revision's tensor-cache.json, rewrites the table's records into two byte ranges of the same shard (engines/splitManifest.ts, which refuses a manifest whose shapes or dtypes differ from what the library expects), and seeds it into WebLLM's webllm/model cache under the URL WebLLM asks for. Nothing else is hosted.

What the user sees. Settings still lists one "Qwen3 1.7B". On a GPU that cannot bind its 148.4 MiB table, the row says it runs here as a small-buffer build ("its largest buffer is 74.2 MiB"), and Download fetches that build. models.ts smallBufferBuildFor decides, for the engine, the cache questions, Settings and the download consent alike. A deploy that mirrors the weights (modelBase) or names its own 1.7B library gets no small-buffer build.

Testing on a desktop. localStorage["thelounge.state.gpuTestLimitMiB"] = "128" makes the probe report at most 128 MiB and caps WebLLM's device request in the worker (gpuLimits.ts), so a desktop GPU loads the small-buffer build. Settings shows a line while it is on; the settings backup never carries it.

How it was proven. On WebGPU over SwiftShader (headless Chromium, the f32 variant, since SwiftShader has no shader-f16), with the device capped at 128 MiB:

  • WebLLM's own 1.7B library: WebGPU validation errors and garbage output.
  • The split library with the rewritten manifest: no errors, and ten greedy tokens identical to the stock library on an uncapped device (" Guten Morgen, Fre").

What can run, and why not (c99f8541, 3f61b620)

One Vue-free rule answers it for every surface:

  • models.ts modelBlock per model;
  • router.ts explainRoute per language pair. resolveRoute is now its route, so the UI disables exactly what the router would refuse.
  • translationBlock for translation as a whole.

Where users see it:

  • Settings → Translation, each model row:
    • "Can run on this device", or "Can't run on this device" with the reason, e.g. "Needs a GPU buffer of 185.5 MiB; this device binds at most 160 MiB".
    • Download is disabled where the model can't run.
    • The device note shows the largest GPU buffer.
  • The composer dialog and the chips' "Retranslate from…": the confirm button is disabled with a lead sentence and the reason, e.g. English → English correcting without the GPU model.
  • The channel panel: a notice when nothing can translate, reading's On disabled, and languages that cannot be written in greyed out.
  • The header button: dimmed; a click opens the panel.
  • The toolbar's Translate: disabled, with the reason as its tooltip.
    • Under a finger it is aria-disabled instead, and a tap opens the panel with the reason (696d7882).
  • A failed line: names the actual cause.

A GPU below WebLLM's shader limits has its own reason, GPU_LIMITS: fewer than 10 storage buffers per shader stage, or less than 32 KiB of workgroup storage (8e25b00d).

Behaviour change: the CPU models are skipped on a device without WebAssembly SIMD, even when its GPU works.

The GPU model select greys what cannot bind (1c3d8cb6). On a device that can run a GPU model at all, a model the GPU cannot hold is a disabled option, and a line under the select names it with the buffer numbers ("Qwen3 4B can't run on this device. Needs a GPU buffer of 185.5 MiB; this device binds at most 160 MiB").

Downloads ask first, and NLLB-200 is opt-in

1c3d8cb6, b32c2697, 698dca20; docs/resources/translation.md § A download asks first and § Licences.

The download question

Before: the router sends a request to the best class whether its model is on the device or not, so one click on the header's translate button could start a 1–2.3 GB download.

Now the first download of a model that is not cached, not downloading and never agreed to asks once:

  • Where: translate/consent.ts (Vue-free) holds the rule. The service asks in resolve(), which both route() and translate() go through, so the question comes before the reading queue or the composer starts its request timeout: a dialog left open cannot time a line out.
  • Once: every request waiting on the same model shares one question, and only one dialog is open at a time.
  • The dialog is the existing ConfirmDialog: "Download {model}?", the size, and an extra line when navigator.connection reports Save-Data or a cellular link. It sits above the phone's translation sheet.
  • Download is remembered per model in thelounge.state.translateConsent. The Download button in Settings counts as consent, and deleting a model withdraws it.
  • Not now falls back to a model already on the device. With none, the line fails with "Needs {model}, a {size} download: get it in Settings → Translation." That failure never counts toward the queue's pause, and Retry on the line asks again.

NLLB-200 is opt-in

  • New setting translateNllb, off for new and existing users. The switch in Settings → Translation carries a line naming the CC BY-NC 4.0 licence.
  • With it off, the router skips NLLB. OPUS-MT already comes before NLLB in every shipped route (a test checks it), and the GPU model takes the rest where it runs.
  • A pair only NLLB could serve says "This needs NLLB-200, which is switched off in Settings → Translation."
  • Switching NLLB off never makes translation unavailable as a whole: the OPUS pairs still run.

Every model on the GPU or the CPU

The spec is docs/projects/translation-devices.md (with the date of every call); docs/resources/translation.md § Devices describes it as built. Built 2026-10-29 (671090ac..fb843552), after the user's phone (Chrome, Vulkan) lost its GPU mid-translation (VK_ERROR_DEVICE_LOST).

Run on: Auto, GPU or CPU, per model

  • The device is a choice per model, not the engine. Each catalog model lists a copy per device (ModelRef.copies.gpu/cpu), with its own download size, consent, cache state and Download/Remove line in Settings.
  • Three settings, translateLlmDevice, translateOpusDevice, translateNllbDevice, each auto/gpu/cpu, default auto; an unknown value (an old or foreign backup) reads as auto. The engine switches keep their keys and become one per model: translateLlm the language model, translateCpu OPUS-MT, translateNllb NLLB-200 (no longer gated by translateCpu).
  • One rule says what can run where: modelBlock(ref, device, …). A pinned device that cannot run the model says why and never falls back to the other device.
  • Auto's order (models.ts deviceOrder) is the language model on the GPU only (the CPU runs it only when the user pins it there), NLLB-200 GPU-then-CPU where its GPU copy fits, OPUS-MT CPU-first on a phone and GPU-first on a computer. It is a placeholder until someone measures on real GPUs.
  • The router walks candidate × device (DownKey <candidate>@<device>): a GPU copy marked down after failures moves Auto to the other copy; a declined download of the preferred copy falls back to a downloaded one.
  • Consent per copy (consent.ts consentId): the copy a model ran on before keeps its old consent id, so an existing "yes" covers exactly what it was given for; the other copy is <id>@<device>.
  • Settings (Settings/Translation.vue): per model, the switch, a Run on radiogroup (a choice the device cannot run is disabled with its reason as visible text and aria-describedby), a line saying what Auto picks here and why, and a line per copy. Browser checks: translate-capability.mjs (seven stubbed devices, also --mobile) and translate-settings.mjs.

Qwen3 1.7B on the CPU

  • The library is wllama 3.8.1 (llama.cpp in WebAssembly) with unsloth/Qwen3-1.7B-GGUF Q4_K_M (1.1 GB, revision pinned). A spike chose it over transformers.js's ONNX build, which would not load at the shipped optimisation level, decoded 3× slower and peaked at 5.9 GB (tools/translate-eval/results/2026-10-29-cpu-llm.md).
  • It reuses the GPU engine: engines/cpuLlm.ts puts wllama behind WebLLM's MlcLike interface, so the prompts, think-block handling, fitToWindow and overflow logic are shared. engines/onDevice.ts holds one device's engine per slot at a time and serialises a load against the other device's.
  • Needs WebAssembly Memory64 (wllama's standard build; desktop Safari lacks it), and is blocked on iPhone/iPad: its peak is 2.9 GB, past WebKit's page limit. The 4B has no CPU copy.
  • Slow: 1.2 tokens/s on one thread of this host's 2016 Xeon; a composer translation with context took 107 s. Reading on the CPU uses a 1024-token window, batches of 2 lines and a 6-minute deadline.

Multithreading through the service worker

  • The root service worker adds Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: credentialless to the app's documents and its own worker scripts (service-worker.js isolate), so the page is cross-origin isolated on GitHub Pages, which cannot send headers. credentialless keeps link previews, media and the uploader loading without headers of their own; the app embeds no iframes and relies on no window.opener.
  • Not on WebKit (no credentialless support): Safari and iOS stay single-threaded. No forced reload: isolation starts on the next load once the worker controls the page, and Settings says "Reload the app to use N CPU threads" when that applies (threads.ts isolationState).
  • Threads (cpuThreads): a phone at most 4 and never more than cores − 1, a computer at most 8. Measured: 186 s on one thread → 31 s on eight (5.9×). A live run on 7 threads translated a line to Japanese in 20 s including the model load.
  • CSP: wllama starts its workers from blob: URLs, so the documented CSP and the Electron shell now allow worker-src 'self' blob:.

Not in this build

  • OPUS-MT and NLLB-200 on the GPU. The worker's ONNX Runtime is the wasm-only bundle (webpack.config.ts aliases onnxruntime-web/webgpu to /wasm), so their GPU copies are left out of the shipped catalog (CatalogOptions.seq2seqGpu, on in the tests) and their GPU choice is shown disabled with a reason. Shipping them needs ORT's WebGPU bundle and its jsep wasm (about 26 MB, and a change to the CPU path everyone uses), a real-GPU check, and GPU-loss handling for the seq2seq engine.

Since the red team

  • A phone's page memory (15b6837e): on a mobile device the GPU model loads with a 2048-token window (the KV cache is the memory WebKit counts), the 4B is blocked as memory, every LLM request is fitted to the loaded window (budget.ts fitToWindow, a context overflow retried bare and alone), and only one engine stays in memory (service.ts makeRoom).
  • The cleanup pass on the web weights (e41e3994): the 4B's "you"/"your" coin flip at temperature 0.1 is fixed with example turns for the 4B, examples in the 1.7B's system prompt, greedy decoding, and a kept question mark (tools/translate-eval/results/2026-10-28-polish.md).
  • A lost GPU says so (7ec9f3fa): the device's lost reason is recorded and a failure on a lost device reads "the GPU stopped (<the browser's reason>): …" instead of the mapAsync call that happened to be running.
  • Both switches named (57fbd7a9): a pair only the language model or NLLB-200 could take, with both off, names both switches.
  • Emoji are kept (face1cdee, from rdrake's review): the Marian models dropped them ("thanks for the help 👍" → "danke für die Hilfe"), so spans.ts protects each emoji sequence whole (skin tones, ZWJ sequences, flags) as a verbatim span, for every engine.
  • The sheets clear the native shell's bars (7a2498527, 0e2e379f9, rdrake's patch): the translation sheet is in #viewport with .screen-overlay, the source-language sheet has its own inset rule, both pad the bottom with --inset-inner-bottom; test/tests/screenOverlays.ts (Overlays keep clear of the native shell's bars by a class, not a list #148) passes.
  • Upstream rolled in (cb86bb013, c894b6d7b): Lua scripting, the help modal, the backdrop and image-viewer fixes and Overlays keep clear of the native shell's bars by a class, not a list #148. Their strings go through t() in all 23 languages (26c10d027, bc06c69a5), and the Lua editor stays left to right in a right-to-left interface (16d6a33fd).

Dependencies, licences, bundle

New runtime dependencies

Package Version Licence Notes
@huggingface/transformers 4.2.0 Apache-2.0 Pulls in onnxruntime-web 1.26.0-dev (MIT, a nightly pin), onnxruntime-node 1.24.3 (MIT) and sharp (Apache-2.0). Only onnxruntime-web reaches the browser.
@mlc-ai/web-llm 0.2.85 Apache-2.0
franc 6.2.0 MIT With n-gram, trigram-utils and collapse-white-space, all MIT.
@wllama/wllama 3.8.1 MIT The CPU language model. It compiles llama.cpp/ggml (MIT) into js/wllama.wasm; both notices are in js/translate-worker.js.LICENSE.txt. Imported only by the worker.
  • Licence compatibility. All are compatible with Seance's MIT.
    • Neither Apache-2.0 package ships a NOTICE file, and their builds carried no notice either.
    • 44d92d63 adds both to js/translate-worker.js.LICENSE.txt, and franc to js/franc.js.LICENSE.txt, through the existing chunkNotices banner.
  • Model weights are fetched at run time and never bundled:
    • Qwen3: Apache-2.0.
    • OPUS-MT: Apache-2.0.
    • NLLB-200: CC-BY-NC-4.0 (non-commercial). It is now opt-in (below). docs/resources/translation.md § Licences says so for deploys, which can point translation.cpu.nllb at another model.
  • yarn.lock is consistent: yarn install --frozen-lockfile --ignore-scripts changes nothing.
    • No existing dependency was bumped.
    • The only package.json changes are the four dependencies above and the i18n:* scripts (CodeMirror and fengari come from upstream's scripting tab).

Bundle (production build, .map files excluded, against origin/develop built the same way)

"After" re-measured at face1cdee (2026-11-01). "Before" is origin/develop at fc2bb3fb and was not re-measured, so part of the growth is upstream's own since then (the scripting tab).
Loaded on every page:

File Before After Why it grew
bundle.js 1,006 KB 1,457 KB The English catalog (141 KB), the detector's stopword table (19 KB), the i18n runtime and the translation glue
push.js 114 KB 257 KB The English catalog, for translated notifications
style.css 146 KB 174 KB

Lazy, fetched only when used:

File Size When it is fetched
js/translate-worker.js 6.9 MB The first time translation is used
js/ort/ort-wasm-simd-threaded.wasm + .mjs 12.9 MB With OPUS-MT or NLLB-200
js/wllama.wasm 8.8 MB With the language model on the CPU
wordlist.js 390 KB Short-line detection
franc.js 245 KB Detection fallback
23 locale chunks 3.5 MB total One per non-English visitor, 129–218 KB each
public/locales/*.json 3.8 MB One fetched by the push worker per push
  • The deploy tree is 51.3 MB at face1cdee without source maps (7.4 MB before the branch); js/wllama.wasm and the 5.7 MB small-buffer library are the largest additions since the last measure.
  • The Pages site carries no source maps (21ca9bcc). tools/pages-site.mjs assemble() drops the *.map files (about 18 MB a build) from the release, develop and every PR preview. Local builds and the workflow's cache keep them.
  • The service worker's precache list is unchanged. It is still the shell, which now includes the larger bundle.js and push.js.
    • Model caches survive a shell update (isModelCache).
    • Weights under models/ never touch the fetch handler.

CI

  • Pages builds now run yarn i18n:compile before webpack (yarn build).
    • The compile reads only tracked files, and the tree stays clean after it.
  • The CUDA download is skipped. onnxruntime-node's postinstall fetched its ~316 MB CUDA and TensorRT providers from NuGet on every uncached Linux build. 44d92d63 sets ONNXRUNTIME_NODE_INSTALL=skip on the Pages install.
  • Build output is clean of development-only code. A production build has no trace of ?fakeTranslate/fakePort, the 🌐 globe, the 🐞 devtools button, the missing-key console warnings or qqx.
    • 44d92d63 also stops copying client/locales/tags.json into public/locales/. Nothing fetched it, and it named qqx.

Storage

New settings, in thelounge.settings and carried by the settings backup:

Setting Default What it holds
locale "auto" Interface and reading language
translateFormality "auto" Formality of translations
translateLlm true The GPU engine's switch
translateCpu true The CPU engine's switch
translateLlmModel "" The GPU model choice; resolved at read time, never written back
translateNllb false Whether NLLB-200 (CC BY-NC 4.0) may be routed to; off for new and existing users

New localStorage key: thelounge.translate.

  • What it holds: per-channel reading, write language, formality, variant, languages and terms.
  • It is kept when a channel is left.
  • It is carried by the settings backup, whose envelope is now version: 2.
    • KEY_SINCE lets an older file leave the key alone.
    • A newer file is refused.

New localStorage key: thelounge.state.translateConsent. The models the user agreed to download. Like every thelounge.state.* key, the settings backup never carries it. A yes is also kept in memory for the session, so a browser that cannot store it does not ask again on every request.

IndexedDB: the push preferences (push-prefs.ts) gain locale, so the service worker translates a notification with no page open.

Cache Storage, owned by the libraries:

  • Names: transformers-cache, webllm/model, webllm/wasm and webllm/config.
  • Size: about 1–2.3 GB per GPU model and 113–912 MB per CPU model, downloaded only on demand or from Settings.
  • Lifetime: the service worker's shell-cache cleanup never evicts them.

What the client expects from the ircd

Nothing new.

Translation needs from the deploy:

  • HTTPS, or localhost, for WebGPU.
  • .mjs served as text/javascript and .wasm as application/wasm. GitHub Pages and nginx do this.
  • Network access to huggingface.co. WebLLM's compiled library also comes from raw.githubusercontent.com, unless translation.modelBase mirrors both.

Testing

Measured on 2026-10-28 on a shared host (load average about 40 on 56 cores). The gate ran at 0c336d89 in this order; f0e6f1f7, CSS only, came after it and was re-checked as noted below.

Step Result
npx tsx tools/i18n/sweep.ts --dry 0 slots emptied
yarn i18n:compile 23 locale catalogs, plus en/qqx, 24 targets, 2,290 static call-site keys
yarn i18n:check the pot and the call sites agree
npx tsc --build clean
yarn lint (eslint, prettier, stylelint) clean, again at f0e6f1f7
NODE_ENV=production yarn build app, push and translate configurations compiled, no errors, again at f0e6f1f7
full mocha suite ('test/**/*.ts', after that build) 4319 passing, 15 pending, 2 failing
full mocha suite again with the small-buffer build (2c1262c7) 4371 passing, 15 pending, 0 failing
at face1cdee (2026-11-01): yarn lint, npx tsc --build, yarn i18n:check, NODE_ENV=production yarn build clean; the build's one warning is fengari's (upstream's Lua interpreter) dynamic require
full mocha suite at face1cdee 4680 passing, 15 pending, 0 failing (a first run had one timing failure that passed on the rerun)
  • The two failures are timing tests under load, in files the branch does not touch:
    • test/helpers/parseMarkdown.ts "cost of a hostile 16 KB message": 142 ms against a 120 ms budget in the full run. Outside the full suite it failed once and passed once at the same load (the pass at f0e6f1f7, run with test/tests/build.ts and test/themes/ps.ts: 321 passing).
    • test/scenes/ps/legibility.ts, one 60 s timeout. Alone it timed out again (33 passing, 1 failing). It is the ps theme's CPU-heavy contrast sweep and needs a quiet machine.
  • Everything else passes, test/tests/build.ts included.

Unit tests

  • 4197 tests passed before the red team. The fixes and this round's features added about 120 more.
  • Translation and i18n tests live in test/translate/, test/tests/i18n-po.ts, test/tests/i18n-toolchain.ts, test/helpers/i18n.ts, test/helpers/i18nFrames.ts, test/helpers/translateWhy.ts, test/push/i18n.ts, test/push/prefs.ts and test/client/js/i18n/datesTest.ts.
  • Notable tests:
    • test/translate/availability.ts crosses 7 devices × 4 switch states × 6 language pairs and checks the explanation always agrees with the router.
    • test/translate/consent.ts and test/translate/service.ts cover the download question: one question per model, Not now, a storage that cannot keep a yes.
    • test/helpers/i18n.ts runs the Traditional Chinese rule through both bestLocale and the pre-paint script over the same tag lists.
    • test/translate/gpuLimits.ts runs the shim against a stand-in prototype.
    • test/tests/service-worker.ts translates notifications in the real worker.

Browser checks

Headless Chromium has no GPU, so navigator.gpu is stubbed by an init script. Every run passed with no console errors. Screenshots read: all of i18n-rtl-layout.mjs (38), settings-modal.mjs (both sizes), translate-settings.mjs and translate-consent.mjs, the install guide's desktop steps, and the Settings, GPU-select and download-question shots of translate-capability.mjs for every stand-in device; not the composer and panel shots of the capability run, nor translate-composer.mjs's.

Scenario Checks passed Build Commit
tools/scenarios/translate-capability.mjs 136 production f0e6f1f7
translate-capability.mjs --mobile 149 production f0e6f1f7
translate-small-gpu.mjs (the small-buffer build, served under /seance/develop/) 35 production 2c1262c7
translate-small-gpu.mjs --mobile 35 production 2c1262c7
install-guide.mjs 26 production 0c336d89
install-guide.mjs --mobile 26 production 0c336d89
settings-modal.mjs 9 production 0c336d89
settings-modal.mjs --width=390 --height=844 9 production f0e6f1f7
i18n-rtl-layout.mjs (every theme, desktop and phone) 399 development f0e6f1f7
translate-consent.mjs (fake engine) 9 development f0e6f1f7
translate-settings.mjs (?fakeTranslate) 24 development f0e6f1f7
translate-composer.mjs (?fakeTranslate) 81 development f0e6f1f7
  • translate-capability.mjs stands in six devices: no WebGPU, no 16-bit float, low shader limits (lowLimits), 128 MiB, 160 MiB, and a 2 GiB desktop.
  • It also checks the switch states, the download question and Not now, the greyed 4B option, the composer dialog's default pair, the phone sheet's Tab trap, and the accessible names of the model buttons.
  • translate-consent.mjs --mobile (9) ran at 698dca20.

Rendered in five languages. A production build was rendered in de, ru, ja, ar and pl against the dev ircd, all five on the final CSS: the connect form, the sidebar with a channel, a channel with join and part lines, the composer with a draft, and Settings → General, Appearance and Translation, scrolled to the end; ar also with Latin text typed into both searches. Every screenshot was read. It found five things, all fixed (db9030ac, 0c336d89, f0e6f1f7):

  • In Settings → Translation, the German "Nicht heruntergeladen" could not wrap, so every model row pushed its Download button 28 px past the column.
  • Under Arabic, a dir="auto" field turned left-to-right when empty or holding Latin text, so the user list's "1 مستخدم", the jump-to placeholder and typed text sat under the search glyph. Such a field now keeps the page's sides.
  • Under Arabic, the sidebar's "#seance" read "seance#". Channel and network names are <bdi> now, as the header's was.
  • The "Show MOTD" word order (above).
  • On a phone, the connect form's "Channels" label broke into "Channel / s" (in English too). A label stacked over its field now has the whole row.

The earlier red-team rounds were also checked in a browser when they landed (at 7ada7d06): who-list.mjs (11, and 8 --mobile), install-guide.mjs --mobile for Android and iOS (30 and 30), translate-composer.mjs --mobile (92), i18n-frames.mjs in en, de, ja, ar and qqx, i18n-system-messages.mjs, translation-off-locale.mjs, translation-chips.mjs, translate-reading.mjs, and i18n-rtl-layout.mjs (with ps under RTL deferred to ps plan 5).

Other scenarios the branch adds:

  • translate-reading.mjs, translation-chips.mjs, and translate-cpu-real.mjs (real CPU models, SEANCE_REAL_MODELS=1);
  • the i18n set: i18n-rtl-layout.mjs, i18n-language-switch.mjs, i18n-system-messages.mjs, i18n-context-menu.mjs, i18n-dynamic-keys.mjs, i18n-warnings.mjs, sidebar-language.mjs.

Not exercised here:

  • A real GPU. Translation quality on real hardware was measured offline, in tools/translate-eval/.

Deliberate limits and follow-ups

Devices and builds

  • The device choice is untested on real GPUs and phones. No GPU is available in the sandbox. Auto's order is a placeholder until measured; the CPU language model was measured only on this host, never on a phone (expected tens of seconds a message there, and a 2.9 GB peak that a 6 GB Android phone may not survive).
  • Two rare load-ordering gaps in OnDevice are left: a load that never settles wedges a later load of the other device, and two overlapping loads on one device can leave both devices resident. Both need sequences that have not been seen.
  • The small-buffer build's f16 library has not run on a real GPU. Headless Chromium's SwiftShader has no shader-f16, so the method was proven with the f32 variant only (§ Qwen3 1.7B on a 128 MiB GPU). The first real run is a desktop with the 128 MiB test switch, then a phone. The 4B has no small-buffer build, so 128 MiB phones run the 1.7B or the CPU models.
  • A device that cannot translate at all (no WebAssembly SIMD, or the probe failed) still hides the header, composer and toolbar buttons rather than disabling them. Only Settings explains why.
  • The native shells have not been tried with translation on a device.
    • The Electron shell's CSP now allows 'wasm-unsafe-eval' and serves .wasm (25512c5a).
    • ONNX Runtime's WebAssembly was shown to compile under that policy in headless Chromium.
    • WebLLM's model library, which needs a GPU, was not.

Interface

  • The app's general disabled-button style looks filled and active. The Download button got its own muted style; the rest did not.

The catalogs are machine-filled and agent-reviewed, not native-reviewed

  • Every slot has had one reviewing pass (§ The review pass). No native speaker has read them.
  • Left for a code change or a native speaker:
    • pl {Kind} titles: the kind words are genitive now ("obrazu", "dźwięku") to fit their frames, but LinkPreview.vue capitalises the word and shows it alone as a title when there is no host, so pl shows "Obrazu" there.
    • cs/sk composer.fallbackNetwork is in the dative ("síti", "sieti") to fit "k {network}", and reads wrongly in the other frames that use it.
    • ru and uk oper.usage.* translate the command arguments, unlike the other usage lines; uk calls the oper Alerts tab and ordinary notifications both «сповіщення».
  • sweep --plurals lists 188 plural forms that read the same for different counts (66 of them ar, then pl 27, cs 23, uk 21, ru 20, sk 19, ro 12). Most are frames where the noun does not change, such as ar's "{count} مستخدم" for 0, 1 and 100; they are a list to read, not errors.

Measurement gaps

  • Layout under long translations was checked through qqx's doubled strings (i18n-rtl-layout.mjs) and by rendering de, ru, ja, ar and pl in a production build (§ Testing). The other 18 languages were not rendered surface by surface, so a long label may still wrap or clip somewhere; the oper tools, Help and the dialogs were not rendered in any of them.

Red-team review

Before this PR, the branch had an adversarial review on six dimensions:

  1. merge: what upstream's 454 commits and the ~1220 converted labels changed;
  2. i18n: catalog quality, plurals, sentence fragments, the check and sweep tools;
  3. translate: queue, worker and probe correctness;
  4. security: sent translations, private data, CSP;
  5. perf: big channels, the boot path, the message rows, the push worker;
  6. ux: what the user is told about which models can run and why.

Each finding was checked against the code before anything was fixed. Every fix is its own commit on this branch, with tests where mocha can reach the code.

Fixed

merge

  • A timestamp that names no clock follows the 12/24-hour setting (7563e313). Before, English full timestamps came out on en-US's 12-hour clock whatever the setting said.
  • The WHOWAS card has its missing space back (5b815fcb, repaired in 2b466a1a). 5b815fcb on its own fails check.ts.
  • In Mentions, a mention under 45 s old reads "now", not a stale seconds count (7aeb5c65).
  • The tests the t() conversion had weakened pin upstream's English again (b149d705). That covers the upload tests, the typing sentence (moved into a Vue-free typingSummary helper), psWords and own messages.

i18n

  • system.*, condensed.*, the unread and mention badges, the connection strip and Help were rewritten by hand in all 23 catalogs, every plural slot included (7b683902, be25e531, a8c3905b, 7b19b9da, aaa941f0).
    • This fixes wrong meanings such as ru part/kick, ja nick/away, de away and back reading the same, and pl "untranslated".
    • Only join, part and nick were rendered in a browser. Kick, invite, mode, ctcp and chghost were rewritten but never rendered.
  • Sentences that were cut into t() fragments around elements are now one key each, rendered by I18nFrame.vue in the translation's own placeholder order (898f0b15, 0837ed34, de5b4013, 486f1347).
    • This covers Help, the topic-set line, Aliases, search, the release lines, and the system lines with more than one nick.
    • Two side bugs went with it: invite.vue printed its trailing text twice, and RTL Help reversed /connect.
  • Plurals: a slot that CLDR also reads for other counts gets the plural text (1eb0d0db). This fixes fil, ru/uk 21/31 and fr/pt 0. upload.confirmCount is now a real plural.
  • New tool rules:
    • the sweep and fill refuse runs of English (9ddedc57) and stray Latin tokens in CJK and Thai (2d21ec0c);
    • sweep --plurals lists plural forms that read the same for different counts (aaa941f0);
    • check.ts fails two t() calls placed side by side in a template (898f0b15) and a msgid that opens a parenthesis it never closes (de5b4013).
  • ar: menu paths and route chips use ←, and the "disconnected" typo is fixed (a8c3905b).
  • The translate controls and settings tabs were rewritten in every catalog (7f7c1079, 2d21ec0c). The language-name frames in ru, uk, pl, cs, sk and hu no longer need a grammatical case (be25e531).
  • Oper numbers and times follow the app's language and clock, and friendlysize writes "B" rather than "Bytes" (2ed3df16).

translate

  • A batch that the router sends to seq2seq goes again line by line instead of loading a CPU model for it (9ffbb192).
  • A worker that never starts fails its requests instead of hanging them, and the next call gets a fresh worker. The cache question now has a 60 s deadline, and Settings clears the error once a worker answers (94fcb62c, e4df9c5a).
  • The probe checks WebLLM's floors on shader storage buffers and workgroup storage. A device below them gets the new reason GPU_LIMITS (8e25b00d).
  • A parted or quit channel forgets its topic's translation (e6d58239).

security

  • No line of a sent translation runs as a command the user did not type (10cdb654, 1d4e30c4).
    • Without draft/multiline, every later line the model starts with / is escaped.
    • When the user typed a command line, their own line is sent, never the model's.
  • Terms learnt in a query follow the private-conversation rules (f36825ea): none are recorded while keepPrivateConversations is off, they are forgotten when it is switched off or the query closes, and the settings backup leaves them out.
  • The Electron CSP allows 'wasm-unsafe-eval', and .wasm gets its MIME type (25512c5a).
    • The pre-paint language script moved out of index.html into js/prepaint.js, so no inline script is left.
    • test/tests/build.ts fails on any inline script that comes back.

perf

  • protectNicks is no longer quadratic: a 400-character line against 2000 nicks went from 21.8 ms to 2.5 ms. History loads also yield to the page between lines (1a7563fa).
  • A deploy with translation off never probes the GPU (eee89359).
  • A message row mounts no TranslationLine until it has an entry (25bd225c).
  • The push worker waits at most 2 s for a locale overlay before showing a notification in English (1c3551e9).

ux (what the user is told about which models can run)

  • The composer's dialog never opens on a pair the device cannot run (6a7b7728). It opens on the first target that routes, and when nothing routes it says why.
  • The interface language can be chosen with translation off, labelled "Interface language" (e0bc0ab3, 0efa65ff).
  • A blocked Translate under a finger is aria-disabled, and tapping it opens the panel that gives the reason (696d7882).
  • The panel's write note names the option it greys out: "{language} is greyed out: correcting a message needs the GPU model." (17f66efb).
  • Settings' model rows (5a497482):
    • each Download or Delete button names its model;
    • the download bar is a progressbar;
    • a polite live region says when a download ends.
  • The phone sheet keeps Tab inside it, and the panel opens with focus on the reading switch (0b204056, 7ada7d06).
  • New tests for the "why" sentences (087c9668). The docs drop the reading-language override that no longer exists (e33a3c3f). Scenario fixes in 223bf1bc and cc91aa58.

Refuted, downgraded or left on purpose

  • The English output differs from upstream, on purpose.
    • The unread badge is Intl compact: 1.2K, 12K, 1K.
    • 12-hour times read "12:58 PM" with a narrow no-break space.
    • Full timestamps use en-US date order: "May 22, 2014 at 12:00:00".
    • Relative times are Intl's: "now", "1 minute ago".
    • ActionDialog's preview past 20 matches drops a period. Restoring it would mark the key fuzzy in all 23 catalogs.
    • The jupe and channel-ban fates now read "jupe set by X" and "channel ban set by X", which fixes upstream's "juped by" and "channel band by".
  • Main-bundle growth is about 50 KB gzip at most to win back by lazy-loading the translation modules. That is a large refactor, left for a follow-up PR.
  • Locale overlays not precached for offline: every lazy chunk already has this gap. The suggested fix would not work, because the overlay ships as a numbered webpack chunk.
  • Model integrity is lowered to low. Loading from Hugging Face and GitHub main is how stock WebLLM and transformers.js load models, and a deploy can mirror everything through translation.modelBase.

Open items for the maintainer's or the user's call

  1. One "Not now" holds every implicit download, for any model, until a Retry or a reload. That avoids a chain of questions (Qwen, then an OPUS pair, then NLLB), but the composer, the toolbar's Translate and switching reading off and on then fail without asking. A per-model rule is the alternative.
  2. A confirm dialog that opens over the download question answers it "no", which counts as a decline the user never made.
  3. Model integrity: whether to pin model and library revisions and record hashes at release time.
  4. The English shape changes listed above: keep the Intl forms or restore upstream's.
  5. Two English source wordings the render check noticed, left alone because a change marks the key fuzzy in all 23 catalogs:
    • the language select's hint says "Automatic follows your browser's language", while its option reads "System default ({language})";
    • the "can't run" reasons mix full stops: "This browser has WebGPU, but no usable GPU found." ends with one, "Needs a GPU buffer of 185.5 MiB; this device binds at most 160 MiB" does not.
  6. Still open from the perf review:
    • an enabled deploy probes the GPU once, the first time a channel shows; moving the probe would change when the translate button appears;
    • push.js still bundles the whole of en.json.
  7. A Delete confirmation for downloaded models was left out as a product call.
  8. On a device that can run the GPU model, the composer's dialog opens on the cleanup pass (the same language twice), as documented. Is that the right default?

Settled since the last draft: the download now asks first and names its size, with Save-Data and cellular links called out; Traditional Chinese reads English; the Pages site carries no maps; NLLB-200 is opt-in; the GPU select disables a model that cannot bind; and fil and the rest of translate.* had their review pass.

Review hints

A branch this size reads best by directory. Three-dot line counts per area:

Area Files Lines What it is
client/js/translate/ 42 +13,018 The feature, almost all Vue-free; stopwords.json and wordlist.json are generated tables
client/js/i18n/ 9 +3,438 The runtime
client/js/ (rest) 104 +3,827 / −1,263 Mostly t() conversions
client/components/ 89 +6,816 / −1,803 Mostly t() conversions, plus TranslationLine.vue, TranslationPanel.vue, Settings/Translation.vue, LanguageSelect.vue and the composer strip
test/translate/ and test/ (rest) 63 +15,944 / −88 Tests
tools/i18n/ 39 +4,712 Catalog tooling with its fixtures
tools/translate-eval/ and tools/translate-llm.ts 20 +4,190 Offline evaluation, never shipped
tools/scenarios/ 19 +7,308 / −6 Browser checks
docs/ 11 +5,357 / −49 Documentation
client/locales/ 72 +520,933 Generated and translated catalogs

Suggested order:

  1. Start with the docs.
    • docs/resources/translation.md § "Two engines, one router" and § "What runs here, and why not";
    • docs/resources/i18n.md;
    • CLAUDE.md's Translation bullet and its "Every UI string goes through t()" convention.
  2. Then the Vue-free core:
    • models.ts, router.ts, capability.ts, engines/gpuLimits.ts;
    • reader.ts (store glue), outgoing.ts/writer.ts;
    • client/js/i18n/core.ts.
  3. Then the components.
    • Most of the 89 component diffs are mechanical string-to-t() conversions.
    • git diff -w origin/develop...HEAD -- client/components reads quickly.
    • The real UI is in the four translation components and ChatInput.vue.

Catalogs.

  • Review them by sampling, not line by line. client/locales/*.json is compiled from the .po by yarn i18n:compile.
  • client/locales/attic/ holds the 22 dropped languages' catalogs, kept for a future refill.
    • It is never compiled.
    • Drop it if the attic is not wanted upstream: −118k lines.

Dev-rig files that came with the branch and are not part of the app:

  • tools/serve.py, a one-port HTTPS host with a WebSocket proxy;
  • tools/iauth-agent.py, which does SASL without services for the dev ircd;
  • the watchdogs.
  • Their defaults name the maintainer's paths (/seance/tmp/…), as the existing dev-rig docs already do.

🤖 Generated with Claude Code

seance and others added 30 commits September 16, 2026 23:39
`models.ts`, `service.ts` and `capability.ts` are Vue-free -- mocha loads
them -- so they had no way to reach the catalog and carried English
instead: a model's label was a built string, `loadNote` a sentence, and
the device probe's reasons prose.

They now return data. `ModelRef.label` names its kind ("opus" with its
two languages, "nllb", "llm" with its product name), `loadNote` reports
{phase, ref, percent}, and a probe reason is one of six codes. The
phrases live in the pot and are rendered in one place each --
`helpers/modelLabel.ts` for a model and its load note, `Settings/
Translation.vue` for a reason -- so the three surfaces that show a model
cannot drift apart. The failure reasons the strip and a reading line
show go the same way through `helpers/translateErrors.ts`, leaving the
constants' values alone: they are compared across module boundaries.

With them, Settings → Translation stops speaking for itself: the tab's
own strip label, the intro, the device verdict (whose reasons are now a
list of whole phrases, not a comma-glued parenthetical), the model rows
and their buttons. Sizes come from `helpers/friendlysize.ts` and the
language list sorts through `i18n/collation.ts`, so both follow the
active locale.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`TranslationLine.vue` builds `retryLabel` from `translate.retry` but never
returned it from setup, so the retry button on a failed translation drew
an empty title and an empty aria-label and Vue warned on every render.
Found by `tools/scenarios/translate-reading.mjs`, which renders the
failure rows nothing in `yarn test` reaches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`restartAllReading` called `setReading(…, true)` on a channel already
reading, which returns at its no-op guard, so moving the reading language
retranslated nothing: the lines on screen kept their old target until
something else touched them. And `lastReadingLanguage` started null, so the
`first` guard swallowed the first change after a page load anyway.

The requeue body is now `requeueReading(network, channel)` -- the same one
a switch-on runs -- and the language change calls it directly; the reader
records the language it starts in when it is initialised, so the first
change counts like any other. `setReading`'s off path keeps its own
cancellation.

Two leaks beside it (F31): `arrivals`, the count of what a channel has said
since a line was queued, counted every `msg` event, so a netsplit's quits
looked like the conversation moving on -- it now counts chat lines alone
(`eligibility.ts` `isChatLine`). And the paths that drop a message's
translation for good -- the message-limit trims, `clearHistory`, a REDACT,
an edit's original -- now also drop the queue item the pipeline remembered
for it (`forgetTranslations`), which nothing could have reached again.

The browser check's language-change step asserted the old behaviour ("a
finished translation keeps its English"); it now waits for the on-screen
translation to come back in the new language and checks that nothing
finished is still in the old one. Its next step lets the requeue drain
before the next line is said, so that line is a request of its own rather
than one batched behind the fake's scripted failures.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The stopword generator drops a word shared by four tables or more, which
leaves `je`, `suis` and `la` out of French while `la` survives in
Vietnamese -- so "je suis là" places as Vietnamese on one hit, and the
reader then translated it *from* Vietnamese. A narrow trigram lead does the
same ("hasta luego" → Polish at 0.213).

`Detection.weak` marks a verdict too thin to translate from: a classifier
verdict under `CHAT_STRENGTH_MIN` (2) hits, or a franc verdict whose
*measured* lead is under `WEAK_CONFIDENCE` (0.2). A declared language or
the channel's prior is never weak -- `DETECT_MIN_GAP` is how "the reader's
claim decided it" is written down, not a measurement, and marking those
weak would invert the declared-languages feature.

`detectionSkip` still reads `lang` whatever its weight, so a weak English
verdict still leaves an English line alone for an English reader. What weak
changes is the new `sourceFor(detection, from, to)`, now the one place the
reader decides what a line is translated from: a chosen source wins, a
verdict that is neither missing nor weak is the source, anything else
queues the line with no source named and `unsure` set, and the engine
places it. F34 with it: the ternary at the end of the short-line branch
tested `chat.lang` where it is known null.

On screen: a one-hit line now shows "German? → English", the chip's
existing wording for a source it only guesses at, and its request carries
`from: null` -- the browser check asserts both. The fake engine's
batch guard was keyed on the line while its failure is keyed on the
request's text, so a batch holding an already-failed `[fail]` line threw
for all its neighbours instead of falling back to singles; a requeue sends
exactly such a batch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
F4 left a weak verdict with no source anywhere: `from: null` and
`context.sourceHint: null`. The prompt half of that is right -- the model
must not be told "probably Vietnamese" on one function-word hit -- but the
router reads the same field, and `resolveRoute` skips every seq2seq
candidate when no source is known. On a CPU-only device such a line
resolved to NO_ROUTE: no translation at all, where before there was a wrong
one.

The read side now has the shape the write side already had
(`outgoing.ts` `sourceHintFor`): `QueueItem.routeHint` is routing only and
distinct from `context.sourceHint`. The queue routes on it and puts it in
`TranslateRequest.hint` (the batch's is its head's -- only LLM items batch,
and the LLM takes its source from the prompt); the prompt keeps
`context.sourceHint` alone. `sourceFor` decides both, given the channel's
prior: for a weak verdict the hint is the prior first and the verdict as
the last resort, for everything else it is what it was. A line nothing
placed at all still offers nothing -- the prior announced a Spanish line in
a German channel as German.

An item without `routeHint` routes on its `context.sourceHint` as before,
so nothing that builds a `QueueItem` elsewhere changes meaning.

Also: the scenario header names the oper credentials a rig outside the
Docker default needs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…es after a minute

A worker torn down under a running request (the pagehide teardown, an idle
unload) and an abort the queue did not ask for said nothing about the
engine, yet both were counted as its failures: three background/return
cycles on a phone paused the engine, and every line after that was
committed pending and never ran again, since only a user's retry on an
already-failed line could resume it.

Both now put the batch back at the front of its engine's queue, entries
still pending, nothing marked failed and nothing counted — after
REQUEUE_WAIT_MS, so a worker that keeps dying is retried once a second
rather than spun. And a pause is a minute off rather than the end of
reading: PAUSE_RESUME_MS later the engine resumes itself, failures back at
zero, through the same resume() a user's retry goes through, which fires
the new QueueDeps.onResume so the header's paused banner clears. A retry
disarms the waiting timer; a second pause waits again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The abort listener was registered `{once: true}` while the request still
had no id, so an abort during the wait phase -- resolving the route (whose
first call asks the worker what is downloaded), a GPU model switch
settling, another model's work finishing -- cancelled nothing, and the
request was then posted to the worker, model download included, long after
the queue had given the line up.

The listener now outlives every candidate the call tries, and `aborted` is
re-read after each of those awaits, throwing ABORTED before anything is
posted. A teardown still reports itself first: a worker disposed while the
route resolved is the truer report of the two.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Deleting a model from Settings unloaded the seq2seq engine at once, out
from under whatever was generating on it: the GPU path has waited for its
model's work since the model switch landed, the CPU path never did.

It now waits the same way — `engineRunning` counts downloads too, so a
delete also waits behind another seq2seq download — and re-checks
afterwards, so work that started again while it waited keeps its engine and
its idle clock. The files go either way; only the unload waits. With
nothing running the unload is still posted in the caller's own tick, so a
teardown in that tick rejects it as a disposal rather than leaving it
posted to a worker that will never answer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An LLM batch is one request for several people's lines, and every line of
it was given the head's context: the head's reply target stood above the
whole block as "This line replies to <ada>: …", so line 3's answer was
written as if it replied to whoever line 1 did.

A batched request now carries `lineContexts`, one entry per line, and the
prompt writes each note against its own number — "Line 3 replies to <bob>:
…" — above the numbered block, never inside it, since the answer is parsed
by strict numbering and an exact count. The field is optional and a batch
without it reads exactly as before; the channel's own context (the earlier
lines, the topic, the names, the terms) is the head's and stays where it
was.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`⟦` and `⟧` are ordinary characters someone can type, and the protection
numbered its own spans into the same shape: a line carrying `⟦2⟧` came back
with span 2 -- a URL, a nickname, half a code span -- spliced into the
middle of it.

A placeholder the user typed is now the first stage of `protect()`, a span
of its own before anything else is numbered, so the engine never sees the
number and `restoreAll` puts the brackets back as they were written. Its
new `typed` meta kind is verbatim in every restore policy but is never
expanded as a nested span: its own text is placeholder-shaped, and reading
it as one is the bug it is protected against.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An ACTION is prose, and the reading side translates everyone else's -- but
the composer's gate stopped at the leading slash, so the one line a user
writes about themselves went out in whatever language they typed it in.

`draftGate` now answers with the part of the draft a translation is of:
the draft itself, or what follows `/me `. The strip is keyed on that text
(the watcher that invalidates it compares the same way), and what goes out
is `/me <translation>` -- the command back on the front, never escaped, so
`deliver`'s slash intercept sees an action rather than text. An action with
nothing after it is still the command's own business, and `/mention bob` is
not an action at all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The queue retries all five judged failures bare (`failJudged`); the
composer named four of them in two places, so a draft the model looped on
in four or five repeats (DEGENERATE) got no second try while a six-repeat
one (REPETITION) did, and the read-back check behaved the same way.

The set is now BARE_RETRY_ERRORS in outgoing.ts, where mocha can pin it and
both call sites test membership against it. Also: the router's `hint` doc
says where the hint comes from (`QueueItem.routeHint` and the composer's
`sourceHintFor`, as `TranslateRequest.hint`), not `PromptContext.sourceHint`,
which is the one thing it is not.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
What the six fixes changed, in docs/resources/translation.md: a pause is a
minute off rather than the end of reading, a torn-down worker or a foreign
abort puts the batch back instead of counting against the engine, the
service honours an abort before the request is posted, a batch's reply
targets travel per line, a placeholder somebody typed is text, the composer
translates a `/me`, and both sides retry the same five judged failures.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Step 8 pinned "a command never translates" on `/me`, which is now the one
command that does: the action's text goes through the strip and its
read-back, the second Enter sends it, and the listener hears an ACTION
carrying the translation. A plain command (`/topic`) keeps the old
assertion that no strip appears.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
On a keyboard whose Return puts its newline in the draft before the
`keypress` fires -- Android's and iOS's, the case `onSubmit` already
handles by stripping exactly that newline back off -- the `input` event
reached the `pendingMessage` watcher first, the draft no longer matched
the outgoing entry's, and the strip was cancelled in the microtask
between the two. The Enter that followed then found nothing to send and
translated afresh: the second Enter could never send.

The watcher now tolerates one trailing newline on the gated draft, so
"hello\n" and "/me hello\n" compare equal to what the strip translated.

The browser check is `tools/scenarios/translate-composer.mjs`, under
`--mobile`: `onEnterKey` only takes the newline-tolerating path on a
touch-primary device, since with a hardware keyboard the Return is
preventDefaulted and no newline is ever typed. The new case types a
draft, translates it, inserts the newline through a separate `input`
event, asserts the strip is still up and presses Enter -- prose and a
`/me` alike. That run also needed the sheet's formality assertion
scoped to the formality group: the reading switch above it is a
segmented control of its own now, and the old query counted all five
buttons.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ion quiet while streaming

Seven findings from the wild-international review, all in the components
around translation:

- F16: Settings -> Translation rendered a `<select name="locale">` of its
  own over the translation codes, while `activate()` accepts a different
  set; a code in one list and not the other resolved silently to
  "Automatic". The tab now renders `LanguageSelect`, the one control the
  sidebar globe uses: one list, one "Automatic" label. `name` is not a
  prop, so it falls through onto the `<select>` and the settings
  container stores it exactly as before. `translate.settings.automatic`
  loses its only call site and goes from the pot (merge + i18n:compile
  carry the removal into the catalogs and call-sites.ts).
- F26: the GPU model picker was bound to the resolved choice, so
  "Automatic" could never show selected -- picking it snapped straight
  back to whatever it resolves to. It is bound to the raw setting now
  ("" is Automatic) and the "in use" tag goes on naming what runs.
- F20: the channel menu offered "Translation..." on a deploy with the
  feature off and on a device that can run neither engine. It is behind
  `translationAvailable()`.
- F21: the composer's strip was `aria-live="polite"` while the
  translation streamed in token by token, so a screen reader read it out
  again on every token. It is `off` while pending and `polite` once the
  strip is done or has failed: one announcement, whole.
- F25: a chip menu opened with the keyboard positioned at 0,0 -- a
  keyboard activation carries no pointer. `openMenu` anchors every way in
  under the button that was pressed, off `event.currentTarget` (only one
  of the three chips carries the `ref`).
- The OPUS model label named its pair in English; it names it in the
  reading language, like every other translation surface.
- `reasonText`'s `default:` silently answered "no WebGPU" for any code;
  every `CapabilityReason` has a case and the default is an
  exhaustiveness check, so a new code is a compile error.

Browser: `tools/scenarios/translate-settings.mjs` (green, plus a new case
that picks Automatic and finds the picker still on it while the tag names
the model it resolves to), `translation-chips.mjs`, `settings-modal.mjs`
at both widths, `translate-composer.mjs` both ways.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
F19: the translation surfaces were laid out with physical properties, so
under an RTL interface the panel hung off the wrong edge, the strip's
accent rule and its rounded corners were on the wrong side and its
buttons sat at the inline start.

`right` -> `inset-inline-end`, `border-right`/`border-left` ->
`border-inline-end`/`border-inline-start`, `margin-left`/`margin-right`
-> `margin-inline-start`/`margin-inline-end`, the chip's asymmetric
padding -> `padding-block` + `padding-inline` (start first, so the order
flips), the strip's four-value `border-radius` -> the two
`border-*-end-radius` longhands. `transform-origin` takes no logical
keywords, so the source-language picker keeps `top left` and gets a
`[dir="rtl"]` mirror.

Verified in a dev build under the `qqx` pseudo locale (RTL by
definition): the panel hangs below its button flush to the mirrored
edge, the strip's accent rule is on its right and its buttons at the
inline end, and a chip's menu opens under the chip.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review of the previous round: the exhaustiveness check I put in
`reasonText` is decorative in this pipeline. Nothing type-checks an SFC's
script block here -- webpack compiles .vue through babel-loader with the
types stripped, ForkTsCheckerWebpackPlugin carries no `extensions.vue`,
and vue-tsc is not installed -- so "a new CapabilityReason is a compile
error" was false, and the `default` I wrote returned the raw enum token
into the UI: an untranslated string past t().

The `never` binding stays, so a type-checker that one day reads this
block still trips over an unhandled code, but the branch returns prose
again: an unknown reason reads as "no WebGPU", which is what it was
before every code got a case of its own. The comment says what is
actually true.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
F19 missed one rule: `.translation-panel--sheet .translation-panel-chip`
kept a four-value `padding` shorthand, and a shorthand resets the base
rule's `padding-inline`, so on a phone under RTL the extra room before
the label landed after it instead -- against the remove button, which
brings its own.

`padding-block` + `padding-inline: 0.625rem 0.375rem` (start first).
Measured in the sheet under the qqx pseudo locale at 390x844:
padding-inline-start 12.5px, -end 7.5px, resolving to padding-right
12.5px and padding-left 7.5px, which is the mirror.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The tolerance the previous round added was unconditional, so it also
swallowed a deliberate Shift+Enter on a hardware keyboard. There
`onEnterKey` preventDefaults the Return and nothing ever strips a
newline, so the draft keeps it, the strip lingers over a draft it is no
longer a translation of, and the next Enter reaches `onSubmit(false)`
with a gated text that still ends "\n" -- cancel and retranslate, after
showing a stale strip.

The tolerance is behind `hasVirtualKeyboard()`: only there does the
Return put its newline in first, and only there does `onSubmit(true)`
take it back off. Everywhere else a trailing newline is an edit of the
draft like any other and the strip goes.

`tools/scenarios/translate-composer.mjs` asserts both halves now: the
`--mobile` run keeps the strip through the newline and sends on the
Enter, the desktop run watches the same newline drop the strip and
checks the draft kept it (`hardwareNewlineIsAnEdit`). The append is one
helper shared by both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ersisted

F22. `translate/index.ts` wrote the adapter's pick into
`translateLlmModel`, which turned a device guess into the user's choice and
defeated "an unset value follows the deploy default". The pick now stands in
for the unset setting where it is read: `llmChoice(catalog, setting,
capability)` resolves "" (and a stored id that is no longer a choice) to the
deploy's model, else `defaultLlmForAdapter(capability)`, else the catalog
default while the probe is still running. The service redoes the resolution
when its probe lands, Settings and the panel read the store's capability
inside their computeds, and nothing is written back.

`defaultLlmForAdapter` takes the capability: with `deviceMemoryGiB` the 4B
needs 6 GiB, otherwise the old buffer rule stands — Chrome clamps the
addressable buffer to ~2 GiB on most desktop GPUs, so the buffer rule alone
never reached the 4B there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
F24. `thelounge.translate` (what each channel reads and writes, its
formality, variant, languages and term memory) is a preference like the
mutes and the aliases beside it, and a restore that dropped it left every
channel's translation switched off. It joins BACKUP_KEYS, the module's own
list of what the file holds, and the table in docs/resources/settings-backup.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
F17. Two locale changes in quick succession raced on their overlay imports
and whichever resolved last won, so the older pick could overwrite the
language the user had just asked for. activate() takes a monotonic token,
and a run whose token has moved on returns before it touches the catalog,
the refs, <html lang>/<html dir>, the splash copy or the push mirror — the
load is hoisted out of the try so nothing is installed before the check.

The overlay fetch goes through a swappable loader (useOverlayLoader, after
settingsBackup's useStorageBackend): a real dynamic import cannot be made to
resolve out of order, and the test drives the shipping path rather than a
copy of the guard.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…stics fold out

F13. compile.ts baked `export const DEV = …` and the qqx entry from its own
NODE_ENV into two tracked generated files. `yarn watch`, `yarn test:e2e` and
a bare `webpack` skip the compile, so they shipped whichever flavor was
committed last, and a production build dirtied the tree. available.ts now
lists every compiled catalog with qqx marked devOnly, tags.json (the
pre-paint list, substituted at build time and unable to check anything at
runtime) leaves the dev-only tags out, and the filtering is the bundler's
own fold: DEV_I18N moves to core.ts and the selector, activate()'s
resolvable set and boot's diagnostics banner read it from there.

F27. The lazy locale import takes `webpackExclude: /(qqx|tags)\.json$/` on a
production-only branch, so a production build emits a chunk for the 23
shipped catalogs and none for the rig or the tag list.

F33. The generated call-site table (980 keys, diagnostics only) is imported
in a branch webpack's NODE_ENV fold removes whole — written inline so the
dependency is never recorded — and the dynamic-label check does nothing
until it is in hand. Verified on a production build: no qqx/tags chunk, no
call-site table in bundle.js; on a development build both are there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Follow-up to F22. `llmChoice` applied the adapter pick for any probed
device, but the selected GPU model also picks the route table
(`service.ts` buildTable), and the two tables differ in entries that carry
no `llm` candidate at all — so a cpu-tier machine with 8 GiB of RAM would
have had its seq2seq routing quietly changed by a model it cannot run. The
capability's `tier` now gates the pick (`AdapterMemory.tier`, absent = gpu
for the pure helper's own callers); a cpu or none device keeps the
catalog's default.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s it

Follow-up to F13. Leaving the dev-only tags out of the generated tags.json
made it mode-independent, but it also took the qqx pick's lang/dir away
from the first paint — which tools/scenarios/i18n-language-switch.mjs
checks by attribution ("the first dir write happened at readyState
loading"). The generated file lists every tag again, and the one consumer
that cannot check at runtime — the inline script webpack bakes into
index.html — is where the filter belongs: readAvailableLocales() drops
DEV_ONLY_TAGS for a production build. tools/ is a separate TS project, so
webpack.config.ts spells the list out and the toolchain test pins the two
copies to each other, as the RTL set is pinned to index.html.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review round 1, findings 2 and 3. Two of this task's production-only
behaviours were unguarded by the suite:

- `webpackExclude: /(qqx|tags)\.json$/` (F27) had no test at all —
  test/tests/build.ts knows nothing about build modes, so the brief's other
  branch applies: the toolchain test asserts the magic comment and its
  production-only branch are in client/js/i18n/index.ts.
- the pre-paint tag filter could be deleted and the suite stayed green: the
  old test only compared two copies of the tag list. `readAvailableLocales`
  moves into tools/i18n/available-locales.ts (webpack.config.ts imports it,
  hence the root tsconfig's `files` entry — tools/ is otherwise outside that
  project) and the test drives that very function over the real tags.json in
  both modes. `DEV_ONLY_TAGS` lives there too, and compile.ts builds the
  dev-only available.ts entries from it, so there is one list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review round 1, finding 4. `translateLlmModel`'s comment still described the
pre-F22 resolution ("both resolve to the catalog's default"); it now names
the order that ships: the deploy's model, else the adapter's pick on a
gpu-tier device, else the catalog default — and that nothing writes a
resolved id back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review round 1, finding 5. A restore empties the covered keys the file does
not carry, so adding `thelounge.translate` to the list meant that restoring
a file written before it existed wiped every channel's translation state —
absence read as "this device had none", which that file could never have
said. The format version goes to 2 and `KEY_SINCE` records when a key joined
it: a restore only removes keys the file's own version covered. Everything
version 1 did carry is still replaced exactly as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The `BrandingTranslation` block ended on a doc comment for a field that
does not exist, and `strings` still claimed a "small set" of overridable
keys while `KNOWN_KEYS` has been every key of the English catalog.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
seance and others added 10 commits October 29, 2026 15:07
A second push registration of this app whose subscription the server
still pushes every message to — push/<uuid>/ for a uuid no saved network
has — has a worker that finds each message already shown by the live
network's registration and shows nothing: on Chrome a silent push per
message, and after the budget "This site has been updated in the
background" with every one. Settings' network removal unsubscribes first,
so it should not arise, but nothing swept one up if it did.

dropStaleRegistrations runs at every boot after dropRootSubscription:
every push/<uuid>/ registration under this app's base whose uuid is no
saved network loses its subscription (endpoint queued for UNREGISTER on
the network whose key it carries) and is unregistered; the journal says
so. Another app on the origin (a Pages preview) has its own base and is
never touched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
# Conflicts:
#	CLAUDE.md
#	client/components/Settings/General.vue
#	client/components/Settings/Navigation.vue
#	client/components/Windows/Changelog.vue
#	client/components/Windows/Help.vue
#	client/components/Windows/OperPanel.vue
#	client/components/Windows/Settings.vue
#	client/js/autocompletion.ts
#	client/js/helpers/settingsBackup.ts
#	package.json
#	test/helpers/settingsBackup.ts
#	webpack.config.ts
#	yarn.lock
…gh t(), in 23 languages

The Scripts tab and its editor, the Lua API reference, the script
library's errors (names, storage limits), /lua's lines and the scripting
manager's lobby lines, Help's build-track sentences and the release
notes' close button now go through t()/tCount(); the default script
folder stays stored as "Unsorted" and is shown in the reader's words.
85 keys new or changed (backupIntro and the release notes' label
reworded), translated into all 23 catalogs; slots identical to English
(Scripts, Name, Folder, Error…) are left to English.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rface

Under ar (and qqx) the CodeMirror field took the page's direction: code
right-aligned, the gutter on the right, "-- comment" drawn as
"comment --". The code area is dir="ltr", the API reference's code
spans are isolated LTR runs (".from" no longer becomes "from."), the
editor's physical margins are logical ones (the title's gap, Delete
pushed to the far end) and the free-text fields (folder, description,
search) take dir="auto".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Error in <event> handler: …", "Error in /<command> command: …" and
"Skipped the rest of this <event> event: …" were composed inside the Lua
bootstrap; it now reports host.error(id, kind, name, detail) and the
host words the sentence with t() (scripting.handlerFailed,
scripting.commandFailed, scripting.eventSkipped — English unchanged), in
23 languages. The interpreter-style raises (read-only seance, too many
handlers, string.rep's limit, the instruction limit) stay as Lua errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rdrake

rdrake commented Oct 31, 2026

Copy link
Copy Markdown
Collaborator

Needed after merging develop: the translation sheets and #148's .screen-overlay rule.

#148 (merged into develop as 2401a0cb) moved the native shell's safe-area handling for overlays from a list of ids to one class, .screen-overlay, and added test/tests/screenOverlays.ts, which fails any component with role="dialog", aria-modal, an id="…-overlay" or a <Teleport to="body"> that lacks the class (unless an exemption names why). Merged with develop, this PR fails it twice:

TranslationPanel.vue covers the screen without .screen-overlay
SourceLanguagePicker.vue covers the screen without .screen-overlay

The failures are real bugs, seen on an iPhone in the iOS shell: the translation sheet's title and ✕ sit under the status bar (it is position: fixed; inset: 0 on <body>, and only reserves the PWA's 8px tint), and the source-language sheet's buttons sit under the home indicator / Android's button bar.

What to change (tested on an iPhone 16 in the shell, and in Chrome with the shell faked on iOS and Android):

  1. Translation sheet: teleport it to #viewport instead of <body> and make it position: absolute; inset: 0; height: auto instead of fixed + height: var(--viewport-height). #viewport already follows the visual viewport (the iOS keyboard), so the sheet needs no height of its own. Give it screen-overlay while it is the sheet: :class="{'translation-panel--sheet screen-overlay': phone}". The shared rule then starts it below the status bar and stops it above Android's bar. Gate the PWA tint reservation on the title off in the shell (html:not([data-shell="native"])), or the title gets both.
  2. Source-language sheet: it is an anchored popover whose phone form is a bottom sheet, so it keeps its own rule rather than the class: html[data-shell="native"] .source-language-picker--sheet { bottom: var(--inset-outer-bottom); padding: 0 var(--inset-right) 0 var(--inset-left); }, and an exemption in test/tests/screenOverlays.ts (after the Mentions.vue entry).
  3. Both sheets' bottom padding: replace calc(0.75rem + env(safe-area-inset-bottom, 20px)) with max(0.75rem, var(--inset-inner-bottom, 0px)), as the composer does: iOS's home-indicator strip in the shell, nothing on Android (whose bar is already outside the sheet), and the raw env() doubled up with the shell's own reservation.

The patch below applies cleanly to this PR's head (d4e41c13). Then merge develop and add the exemption; screenOverlays.ts passes 14/14 and prettier/stylelint are clean.

Patch against this PR's head
diff --git a/client/components/TranslationPanel.vue b/client/components/TranslationPanel.vue
index 75ade5428..ade1a5173 100644
--- a/client/components/TranslationPanel.vue
+++ b/client/components/TranslationPanel.vue
@@ -1,12 +1,12 @@
 <template>
-	<!-- One markup, two layouts. The sheet is teleported to <body> so it can
-	     cover the whole screen: the desktop panel is absolutely positioned
+	<!-- One markup, two layouts. The sheet is teleported to #viewport so it can
+	     cover the whole app: the desktop panel is absolutely positioned
 	     inside the channel view, which clips its overflow. -->
-	<Teleport to="body" :disabled="!phone">
+	<Teleport to="#viewport" :disabled="!phone">
 		<div
 			ref="panel"
 			class="translation-panel"
-			:class="{'translation-panel--sheet': phone}"
+			:class="{'translation-panel--sheet screen-overlay': phone}"
 			role="dialog"
 			:aria-modal="phone ? 'true' : undefined"
 			:aria-label="panelLabel"
diff --git a/client/css/style.css b/client/css/style.css
index 04b74ec47..6845cb0a3 100644
--- a/client/css/style.css
+++ b/client/css/style.css
@@ -2460,17 +2482,18 @@ textarea.input {
    sidebar and its overlay (10 and 9 inside #viewport); #context-menu-container
    is 1000 but is only in the document while a menu is open, and no menu can be
    opened from the sheet. */
 .translation-panel--sheet {
-	position: fixed;
+	/* Absolute in #viewport, not fixed: #viewport follows the visual viewport
+	   (helpers/viewport.ts), which iOS shrinks for the keyboard where it never
+	   shrinks the layout one, and in the shell `.screen-overlay` starts it at
+	   the insets. */
+	position: absolute;
 	inset: 0;
 	z-index: 1001;
 	width: auto;
 	max-width: none;
-
-	/* iOS never shrinks the layout viewport for the keyboard, so the sheet
-	   follows the visual viewport helpers/viewport.ts publishes. */
-	height: var(--viewport-height, 100%);
+	height: auto;
 	max-height: none;
 	overflow: hidden;
 	gap: 0;
 	padding: 0;
@@ -2486,12 +2509,13 @@ textarea.input {
 }
 
 /* The sheet sits flush against the top of the page, above the sidebar's
  * z-index: it needs #status-bar-tint's same reservation so the title clears
- * it on an iOS home-screen app (see #status-bar-tint above). */
+ * it on an iOS home-screen app (see #status-bar-tint above). The shell has no
+ * tint, and `.screen-overlay` already starts the sheet below the status bar. */
 @media (hover: none) and (pointer: coarse) {
 	@supports (-webkit-touch-callout: none) {
-		.translation-panel--sheet .translation-panel-title {
+		html:not([data-shell="native"]) .translation-panel--sheet .translation-panel-title {
 			padding-top: calc(1rem + var(--status-bar-tint-height, 0px));
 		}
 	}
 }
@@ -2603,10 +2627,12 @@ textarea.input {
 .translation-panel--sheet .translation-panel-foot {
 	flex-direction: column;
 	gap: 0.5rem;
 
-	/* Above the home indicator, whatever the device says that is. */
-	padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom, 20px));
+	/* Out of iOS's home indicator strip in the shell, as the composer is
+	   (Android's bar is under the sheet's edge, which `.screen-overlay` or
+	   its own rule moves). */
+	padding: 0.75rem 1rem max(0.75rem, var(--inset-inner-bottom, 0px));
 	border-top: 1px solid var(--window-border, rgb(128 128 128 / 30%));
 }
 
 .translation-panel--sheet .translation-panel-more {
@@ -2746,8 +2772,16 @@ textarea.input {
 	border-width: 1px 0 0;
 	border-radius: 0.75rem 0.75rem 0 0;
 }
 
+/* Fixed to the screen's bottom edge, which in the shell is under Android's
+ * button bar: it stops above it (iOS's home indicator is reserved by the
+ * actions row); in landscape the notch is a side. */
+html[data-shell="native"] .source-language-picker--sheet {
+	bottom: var(--inset-outer-bottom);
+	padding: 0 var(--inset-right) 0 var(--inset-left);
+}
+
 .source-language-picker--sheet .source-language-picker-field {
 	gap: 0.375rem;
 	padding: 1rem;
 }
@@ -2764,10 +2798,12 @@ textarea.input {
 	font-size: 1rem;
 }
 
 .source-language-picker--sheet .source-language-picker-actions {
-	/* Above the home indicator, whatever the device says that is. */
-	padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom, 20px));
+	/* Out of iOS's home indicator strip in the shell, as the composer is
+	   (Android's bar is under the sheet's edge, which `.screen-overlay` or
+	   its own rule moves). */
+	padding: 0.75rem 1rem max(0.75rem, var(--inset-inner-bottom, 0px));
 	border-top: 1px solid var(--window-border, rgb(128 128 128 / 30%));
 }
 
 .source-language-picker--sheet .source-language-picker-actions .btn {
After merging develop: the exemption
diff --git a/test/tests/screenOverlays.ts b/test/tests/screenOverlays.ts
index 6a7d04d40..ec90a7417 100644
--- a/test/tests/screenOverlays.ts
+++ b/test/tests/screenOverlays.ts
@@ -21,6 +21,10 @@ describe("overlays carry .screen-overlay", function () {
 		// A popover under the @ button; its phone layout takes the insets in
 		// its own native rule (Mentions.vue).
 		"Mentions.vue": "anchored popover, own phone rule",
+		// A popover at its chip; its phone sheet sits on the bottom edge and
+		// has its own inset rule (`html[data-shell="native"]
+		// .source-language-picker--sheet`).
+		"SourceLanguagePicker.vue": "anchored popover, own sheet rule",
 		// The connect form is a page, not an overlay: its link-approval
 		// dialog is the window itself, laid out inside #viewport's padding.
 		"Windows/Connect.vue": "a page inside #viewport",

seance and others added 4 commits October 31, 2026 16:37
…overlay)

The translation sheet is teleported to #viewport and absolutely positioned,
with .screen-overlay while it is the sheet, so the shell starts it below the
status bar and stops it above Android's bar; the PWA tint reservation on its
title is off in the shell. The source-language sheet keeps its own inset rule.
Both sheets pad the bottom with --inset-inner-bottom, as the composer does.

Patch from rdrake's review on PR evilnet#139, tested by him on an iPhone 16 in the
shell and in Chrome with the shell faked on iOS and Android.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wn sheet rule)

From rdrake's review on PR evilnet#139.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Marian (OPUS-MT) models drop or mangle emoji: "thanks for the help 👍"
came back "danke für die Hilfe", 6 of 6 emoji lines lost. spans.ts now
protects each emoji sequence whole -- a pictograph or a regional-indicator
pair, with its skin-tone modifiers, VS16 and ZWJ-joined pictographs -- as a
verbatim span after the shortcodes, so it is put back where it stood, or
appended when an engine loses its placeholder. An emoji inside a URL or a
code span stays in that span; a line of emoji alone has nothing left to
translate.

Reported by rdrake on PR evilnet#139 with the regex this uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PseudoSean pushed a commit to PseudoSean/seance that referenced this pull request Nov 1, 2026
…s (sheets clear of the shell's bars, emoji kept) and evilnet#148

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@PseudoSean

Copy link
Copy Markdown
Collaborator Author

Thanks — both applied.

  • The sheets: your patch as-is (7a2498527), develop with Overlays keep clear of the native shell's bars by a class, not a list #148 merged (c894b6d7b) and your exemption (0e2e379f9). test/tests/screenOverlays.ts passes 14/14; lint is clean.
  • Emoji: protected as verbatim spans in spans.ts with your regex (face1cdee; VS16 moved out of the character class to satisfy no-misleading-character-class). It applies to every engine, so the LLM and NLLB routes are covered too. A dropped emoji's placeholder is appended back, an emoji inside a URL or code span stays in that span, and a line of emoji alone has nothing left to translate.

The description is updated with everything since 29 October, including the per-model GPU/CPU choice and the multithreaded CPU path.

@PseudoSean
PseudoSean marked this pull request as ready for review November 1, 2026 00:52
seance and others added 7 commits October 31, 2026 19:48
The language model's GPU copy marked down under Auto (a load failure, or
the second failed generation in a row) now asks, once a session and only
where its CPU copy can run (never an iPhone/iPad, the 4B, or a browser
without Memory64/SIMD): "The GPU stopped" -- Use the CPU this time /
Always use the CPU here / Not now, with the browser's reason quoted when
the loss carried one (the user's option C).

- service.ts: `ServiceDeps.askCpuFallback` and `saveLlmDevice`; the
  question is asked after the failed attempt's bookkeeping, every route
  and every concurrent failure waits on the one open question, and
  `loadTicks()` moves on each read while it is open so no armed deadline
  runs out. A yes grants the CPU copy's consent (`<id>@cpu`), puts the CPU
  after the GPU in the language model's Auto order for the session
  (`RouteInput.llmCpuAfterGpu`, `deviceOrder`) and routes the same request
  again; "always" also pins `translateLlmDevice` to cpu. A request carried
  on to the CPU under that yes moves the counter per chunk (its deadline
  may be the GPU's), and a batch over `CPU_BATCH_MAX_LINES` on the CPU is
  refused with BATCH_REFUSED so the queue sends it line by line. "Not now"
  is today's failure and is never asked again this session.
- ConfirmDialog.vue: an optional second yes (`alt`), the three answers
  stacked main-first, and a `quote` line rendered through I18nFrame with
  the browser's text verbatim in a <bdi>.
- index.ts wires the dialog and the store write; fakePort.ts gains a
  `[gpu-lost]` token; tools/scenarios/translate-gpu-fallback.mjs.
- Six strings (translate.gpuFallback.*) in the pot and all 23 catalogs;
  translate.consent.notNow's context now covers both dialogs.
- Docs: translation.md § Devices, translation-devices.md § Auto
  (amended 2026-11-01), CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The one confirm dialog answers the question it replaces "no", and a
download question answered that way is a session's "Not now" to
downloads. The service already routes nothing while the fallback question
is open; index.ts now holds the fallback question back until a download
question on screen is answered. Also pins "Always" with no model chosen:
the device's default stays the model and its CPU load is not retired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A request carried over to the CPU by the answer keeps the GPU's
  two-minute deadline while a cold CPU start takes about three: until its
  first chunk the service moves the deadline counter every minute
  (`keepAlive`), never past CPU_REQUEST_TIMEOUT_MS from the answer (or from
  the end of a download after it). The per-chunk bump is limited to that
  attempt, not every later CPU request.
- Ask only when the GPU ran the model: its copy on the device or its
  download finished, or the failure carried a GPU-loss reason. A download
  that broke off (network, quota, cache) is today's failure.
- resolve() re-checks the open fallback question on every pass.
- ConfirmDialog: a `wait` question (both translation questions) queues
  behind any open dialog, and one the user opens over it sends it back to
  wait instead of answering it no (replaces index.ts's download-question
  wait). role="dialog", aria-modal, aria-labelledby the title, initial focus
  on the main answer (a destructive dialog's cancel), also after the
  overlay's fade, and back where it was on close. The stacked dialog's main
  answer is filled.
- quality.ts: a sentence after a break may open with ¿ or ¡ (es); the
  Spanish body is a question again, German "always" keeps "hier".
- Tests: cold CPU start past 2 min completes, past CPU_REQUEST_TIMEOUT_MS
  times out, later CPU requests move no counter, a failed download and a
  first (request-class) generation failure ask nothing, a GPU-loss reason
  asks before the copy is on the device, the composer's deadline outlasts
  the dialog; the ¿/¡ verdicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jhDUWX8TKBoBUAchx4J3N
- keepAlive aims its last tick 5 s (KEEP_ALIVE_SLACK_MS) before
  since + CPU_REQUEST_TIMEOUT_MS - 2 x REQUEST_TIMEOUT_MS, ticking every
  minute until then: every deadline check for about four minutes sees a
  fresh tick, timers late or not, and none lets the request outlive
  CPU_REQUEST_TIMEOUT_MS. The second half-period tick used to miss its
  bound by the milliseconds the attempt started after the answer.
- Any language-model attempt on the CPU under the fallback's yes moves
  the counter per chunk, and one that starts the model cold (not loaded
  and answering on the CPU) is kept alive from its own start: a reading
  line routed to the GPU before the answer and run after it carries the
  GPU's deadline too.
- ConfirmDialog: a question that opened by itself (`wait`) takes no answer
  for 450 ms (ARM_MS, the push prompt's rule; `data-armed` after), the
  focus on the dialog until then and on the main answer once armed; a held
  Enter/Space never answers it; a held Escape's repeats answer nothing; a
  run of questions gives the focus back to what had it before the first.
- Scenarios wait for `data-armed` before answering a translation question.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jhDUWX8TKBoBUAchx4J3N
On a phone (Adreno 750, Chrome 156, Android 17, Vulkan, a 128 MiB binding)
the GPU model lost the device, and stock WebLLM answered a one-word message
but failed on a ~300-word one: one big prefill job is the likely trigger.
WebLLM 0.2.85 prefills in chunks of the library's prefill_chunk_size (1024
for these builds), and the kernels and the KV cache take any smaller chunk.

- models.ts: MOBILE_PREFILL_CHUNK = 64 and prefillChunkFor(gpu): the test
  switch's value when set (0 = the library's own), else 64 on a phone or
  tablet, else the library's. GpuBudget.prefillChunk carries the switch.
- engines/webllm.ts: after every GPU reload (the small-buffer build's too,
  never the CPU instance) WebLlmDeps.setPrefillChunk(mlc, reloadedId,
  tokens); webllm.real.ts lowers LLMChatPipeline.prefillChunkSize in
  MLCEngine.loadedModelIdToPipeline to min(tokens, current), or warns once
  ("WebLLM internals changed: prefill chunk not set") and keeps the
  library's. Never throws; a refusal does not fail the load.
- gpuPrefillChunk.ts: the switch thelounge.state.gpuPrefillChunk (a whole
  number of tokens, 0 = the library's own), read with the device probe and
  passed to the worker in the GPU budget; never in the backup.
- testSwitches.ts + boot.ts: ?gpuPrefillChunk=N and ?gpuTestLimitMiB=N are
  stored at boot before anything probes the device (0 or empty removes,
  junk changes nothing) and taken out of the address bar, every other
  parameter and the hash kept.
- Settings → Translation says so while the switch is set (two strings,
  the counted one plural, all 23 catalogs).
- Docs: translation.md § Phones and small GPUs, CLAUDE.md.
- Tests: test/translate/prefillChunk.ts; browser check
  tools/scenarios/translate-test-switches.mjs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jhDUWX8TKBoBUAchx4J3N
The user, twice: "Marked as read" has to go by itself. On Android with no
window of the app open nothing scheduled for after a push event runs —
the process ends with the event and Chrome refuses a Background Sync to
a worker with no window — so there (closesRunLater false) the worker
gives up Chrome's count instead of leaving a card: a relay closes the
last card inside its event and converts nothing, Mark read closes the
last card at once, and keepChromeCount shows no stub. Each is a silent
push Chrome counts against the origin's budget (about six a day for a
fully engaged site), past which it shows its generic, which it deletes
once the site shows two notifications. A backgrounded phone still has a
window, so its sync registers and fires and the in-place "Marked as
read" stays the rule there, as on every desktop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

This branch was previously deployed

1 inactive deployment
github-pages — 8cd7f82b Deployed Nov 1, 2026 by PseudoSean via deploy #398
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants