aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/locales
Commit message (Collapse)AuthorAgeFilesLines
* fix: the chat tab no longer scrolls, and a group is listed or invite-onlyChristophe Besson2026-08-1810-80/+20
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | **The chat tab was 8px too tall, at every window size.** The panel is sized from JS to `viewport - top - 16`, which puts its bottom 16px above the fold — but it sits inside `.main`, which adds 24px of padding below it. Eight pixels of document past the window, whatever the window. Measured at 700, 900 and 1200: `scrollHeight` 708, 908, 1208. This is the second one of these — the sign-in card was `.page-center` and `.layout` each reserving `100vh - 52px` — so it is now measured in the suite rather than reasoned about. `tests/harness/scroll_probe.py` renders the real markup against the real stylesheet and **runs the real `fit()` lifted out of `app.js`**: a copy of the formula in a test would go on passing after the original changed, which is exactly the bug being guarded. The fix does not encode 24 anywhere. The first pass runs as before, then the leftover is measured and taken off, so anything added below the panel later is absorbed the same way. Now `scrollHeight == innerHeight` at all three heights, nothing below the fold, and the panel still fills the room it has — that last one has its own test, because shrinking the chat to 240px would satisfy every other assertion here and be useless. The Settings tab was measured too and is **not** a bug: it fits at 1200px and overflows only when its content is genuinely taller than the window. **Group creation asked one question twice.** Visibility and admission were separate selectors that could only ever be set together — picking Public reached over and set the policy — and two of the four combinations are meaningless. The API already refused public+invite with a 422, so the form could build a request that could not succeed. Private+open was accepted and should not have been: a group anyone may join that nobody can find is a listing with the listing removed, since joining goes through the node and there is no link to pass around. So: one selector, "who can join", and the request derives the rest. The API now refuses the other impossible pair as well, with a message that says which way to resolve it. Six locale strings the visibility box owned are deleted rather than left unread in ten files, and the two surviving descriptions now say what each choice means for who can *find* the group — with the word "public" gone from the page, nothing else would have said it, and someone would publish a group without meaning to. 865 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(ui): name the section after its button, drop the folder slash, colour ↵Christophe Besson2026-08-1810-10/+0
| | | | | | | | | | | | | | | | | | | | | | | | | | | | the widget **"Zone sensible" said nothing.** The heading is now the name of the action in it — "Quitter le groupe", or "Supprimer le groupe" for the owner, who sees a different button. Worth stating because it is not quite what was asked for: a fixed "Quitter le groupe" would have sat above a delete button for whoever owns the group. The red goes with it; only the button is red, which is where the warning belongs. `members.danger_title` is gone from all ten locales rather than left behind unread. **A folder name no longer ends in a slash.** The folder icon in the cell beside it already says what it is. **The transfers widget turns green while transfers run.** The badge counts them, but a count has to be read; colour is what carries from across the room, which is the point of a widget in the nav bar rather than on the page. Derived from the live list on every render, so there is no state that can forget to clear when the last transfer ends. The class is set in `app.js` and coloured in `style.css` — either alone does nothing and neither fails loudly, so there is a test for each half. 848 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: two waits with no deadline, resume positions per account, group ↵Christophe Besson2026-08-1810-0/+40
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | settings tab **Joining a group could hang.** Reported after a first attempt that never finished and a later one that worked — the shape of a network wait with no deadline, and there were two. Signaling here is non-trickle: the offer is not sent until ICE gathering says it is done. A STUN server that is slow, filtered, or resolved through a DNS that is not answering means `icegatheringstatechange` never reaches `complete`, and `connect()` never returns. Same shape as the fullscreen denial fixed yesterday: a promise that never settles leaves no error to find. Gathering now has four seconds, after which the offer goes out with what it has — host candidates are already there, which is enough on a LAN, and giving up instead would turn a slow STUN server into a refusal to connect. The second: `hub:fetch` in the desktop client had no timeout, so a host that accepts a connection and then says nothing holds the request for as long as the OS allows. `hub:probe` had one; the handler that carries signaling did not. Now thirty seconds — longer than the hub's own fifteen-second signaling wait, so it cannot abort a call that was about to succeed — and it says the hub did not answer rather than "fetch failed". **Resume positions belonged to the machine, not the account.** Stored as `mb:pos:<file>`, so a second account signing in on the same computer was offered "resume where you left off" in a film it had never opened. Wrong on its own terms, and a small disclosure of what the other person watches, since the offer only appears for files someone has actually been through. The account is in the key now. Positions written before this are deleted rather than re-keyed: there is no record of whose they were, and guessing hands them to whoever signs in next, which is the bug. **The staggered rules in the members table.** `display: flex` on the actions `<td>` — a flex table cell stops being a table cell, so it no longer stretches to its row and its bottom border is drawn wherever its own content ends. Measured: in a row whose other cells were `top 76, height 40`, that cell was `top 77, height 30`, its rule nine pixels above the rest. It is a table cell again, held open by a zero-width strut so the owner's row — which has no remove button — stays as tall as the others. Every cell now shares its row's top and bottom exactly, at 420px and 900px. **Members became Settings.** It was a list with three unrelated forms stacked above it, laid out with inline styles on whichever element needed them, and the group's own controls somewhere else entirely — leaving or deleting a group sat in the page header beside the title. Now one tab in sections: invitations, operator pairing, your devices on this node, leaving or deleting, and the roster last, since it is the only part with no upper bound. One consequence worth stating: the tab bar no longer waits for the node. Membership is hub-side, and gating it on a live connection would have made "leave this group" unreachable exactly when a node is down — which is when someone most wants it. Files and chat still need the node and say so. **A download button in the viewer**, beside the close button and in the same style, for both the video player and the file preview. 844 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(client): downloads stream to disk, and two rough edges on first runChristophe Besson2026-08-1810-0/+10
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | **Downloads were going through RAM.** `_openDownloadTarget` tries a granted folder, then a service worker, then its floor: collect the whole file in the page and hand the browser a blob. Both of the first two are absent in the desktop application — `showDirectoryPicker` does not exist, and Chromium refuses a service worker on a custom scheme — so every download under 512 MB took the floor. A gigabyte of film meant a gigabyte of RAM, and the only visible symptom was a Save As dialog at the *end* rather than the start, which is what the operator noticed and asked about. The main process now streams to disk: it honours "save automatically" with a folder chosen once and no dialog, never overwrites (a colliding name gets a suffix), awaits each write so the renderer cannot outrun the disk and queue the file in memory anyway, and unlinks a cancelled download rather than leaving a truncated file that looks complete to whoever opens it next. Settings now offers the native folder picker instead of saying downloads are unsupported. Measured in the running application: the file on disk grows 256 KB → 512 KB → 768 KB → 1 MB as the chunks arrive, and an aborted download leaves nothing behind. **A permanent scrollbar on sign-in.** `.layout` and `.page-center` each reserved `100vh - 52px`, and `.page-center` sits inside `main`'s 24px vertical padding — so the page overflowed by exactly 48px at every window size. Found by measuring in the app rather than reading the stylesheet: `scrollHeight` 819 against a 771 viewport, then the bottom edge of every element. The centring page brings its own padding, so main's is dropped for it and the duplicated arithmetic goes rather than growing a third term. Now `scrollHeight == innerHeight`, no overflowing elements. **The first-run screen was unstyled.** It used a class name I invented (`auth-page`) that appears nowhere in the stylesheet, so it had no card and the button sat against the input. It now uses the same `page-center` + `login-card` markup as sign-in, which is where the 12px gap comes from. The sign-in link in the nav is hidden until a hub is chosen — it led to a page that could not work. 809 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(client): three defects a real desktop found in ten minutesChristophe Besson2026-08-1810-0/+40
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | All three came from the operator running the application on Ubuntu GNOME. None would have been found by anything already in the suite. **A second copy of the hub address.** `keyderive.js` carried `const HUB = '' // same origin` — true of a page the hub served, false of one loaded from a package, where the origin is `app://meshbay` and `/v1/users/register` resolves against the application's own protocol handler. **Sign-up and sign-in, the first two things anybody does, failed with "Not found."** The seam was changed in `app.js` and in the signalling call and this was missed: the same shape as the duplicate `MNP_VERSION` in `protocol.py`, a second copy of a constant that is harmless until the context changes. `test_hub_address_seam.py` refuses any file that decides where the hub is, and any `fetch('/v1/…')` relative to the page origin. **A window handler reading a variable another path reassigns.** Changing the hub closes one window and opens another; `closed` arrives *after* the replacement is assigned, so the outgoing window nulled the reference to the incoming one and its `ready-to-show` crashed on it — a modal "A JavaScript error occurred in the main process". Every handler now belongs to the window it was created with. The CDP test wrote `config.json` in advance, so it never took the one path that creates a second window; it does now, starting from an empty user-data dir. **A first run that could not be undone.** The hub address was accepted on anything URL-shaped and there was no way to change it afterwards — the prompt only appears when none is set, so a typo meant editing JSON by hand. `https` typed at a hub speaking `http` produced `TypeError: fetch failed`, which names nothing. Now: the address is probed before being written, failures say which URL and why ("does not speak https. If this hub is on your own machine, it is probably http"), Settings can change it, and Electron's "Error invoking remote method" wrapper is stripped from what a person reads. Verified on the operator's desktop: **safeStorage really uses the GNOME keyring** — Settings reports `gnome-libsecret`, and `secrets.bin` is written 0600 with Chromium's `v11` prefix, the marker for keyring-backed encryption (the fixed-key fallback writes `v10`). Headless, the same code reports `unavailable` and refuses to store rather than downgrading in silence, which is now explained in Settings instead of shown as a bare word. Unrelated but found while testing: `test_locales.py` assigned to `globalThis.navigator`, which is read-only from Node 22. The client's build already requires Node 22+, so the first CI machine configured for it would have failed these tests for no visible reason. 809 tests pass on Node 18 and Node 24. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(client): hybrid sign-in — passphrase once, then this device's keyChristophe Besson2026-08-1810-0/+60
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | D4. The passphrase stays the account's credential and its only recovery path; what changes is that it is not asked for on every launch. **The renderer never holds the device key.** It is generated, stored and used entirely in the main process, which signs `meshbay:user_auth:<username>:<ts>` on request. Same rule as the save dialog, for the same reason: the renderer is the part of this application that parses decrypted content from nodes, which is attacker-controlled input. And this key is *not* a per-node identity key — those are generated per node and never leave that relationship, so nothing here correlates a person across operators. First run asks which hub, with no default. A client that picks its own hub is a client that can be pointed at one, and the address is the whole of what this application trusts a hub for — the interface comes from the package. Verified against a hub running this code, not against the deployed one: register 201 → passphrase login 200 → device register 201 → **device sign-in 200 with a real session** → `/v1/users/me` 200 → a stranger's key 401. The signature was also checked directly against the hub's own Python verifier before any of that. Inside the running application, over the debugging protocol: the bridge reaches the main process, the renderer calls the hub **through it** (200 — the CORS fix working end to end), and a call to a host that is not the configured hub is refused. **Not verified:** safeStorage persisting the key. This session has no secret service, and standing one up in xvfb did not succeed. The application behaves correctly there — it *refuses* rather than storing unprotected, and now says so in Settings, which is a real case rather than a hypothetical one since it is exactly what a headless or minimal desktop looks like. Worth remembering for next time: meshbay.org runs whatever was last deployed. It answered 405 on the Stage-C endpoints and reported MNP 0.2 while the tree had 0.3, so a local `uvicorn meshbay_hub.app:create_app --factory` on SQLite is what tests hub changes. Nothing was deployed to production for this. 799 tests pass; e2e.py passes end to end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(client): the platform seam, and an Electron shell that has never been runChristophe Besson2026-08-1810-0/+30
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Stage D, and the honest half of it. D1 — the seam (done, and verified) ---------------------------------- `static/platform.js`. `HUB` becomes `platform.hubBase()` and the transport is built with the same base, so one address has one source. In a browser it returns '' and every path stays relative to the origin that served the page — the acceptance criterion for this split was "the browser SPA behaves identically", and it does. `platform.js` joins `_ASSETS`, or a change to it would not move the content hash and a cached browser would never ask for it. D2 — the shell (written, never launched) ----------------------------------------- **There is no npm on this machine. Electron was never installed and `packages/meshbay-client/` has not been run once.** That is stated here rather than discovered later. What is there: a main process serving the packaged interface over a privileged `app://` scheme (`secure` and `standard` are not cosmetic — without them the service worker refuses to register and streamed downloads break silently), a preload exposing an enumerated bridge that never passes a filesystem path, a window with `sandbox`, `contextIsolation` and no node integration, navigation away from the package refused, and a CSP where the hub is reachable over connect-src and is not a script source. The hub address arrives as a process argument because `platform.hubBase()` runs before anything can await. `test_desktop_shell.py` pins each of those by reading the source — the treatment `test_downloads.py` already gives the three browser save paths. It catches a property being removed and proves nothing about the application running. Two were checked by breaking them. The interface is *copied* into the package by `build/sync-ui.js` from the hub's static directory, and `ui/` is gitignored: a silent fork is the only real way to end up maintaining the interface twice. D3 — partial ------------ The bridge, and the part worth having now: safeStorage's backend is reported rather than assumed. On Linux it falls back to a fixed key when no keyring is running, silently — someone who believes the OS is holding their keys is told when it is not. The native key lifecycle belongs with D4 and needs a running application to mean anything. D8 — partial, and a real defect found -------------------------------------- `meshbay-node.spec` installed the SYSTEM template — the one carrying `User=%i` — into `%{_userunitdir}`. A user unit already runs as its owner and cannot carry `User=`; systemd refuses the file, so the packaged unit could never have started. Nothing noticed because nobody had built and installed the RPM. Two units now: the template to `%{_unitdir}`, and a new `meshbay-node-user.service` that a person enables themselves without a password — which is what lets the desktop client install a node without asking for one. It carries ExecReload, so `meshbay-node reload` does not have to stop a service somebody is streaming from, and documents the drop-in for a drive outside the home, RequiresMountsFor included. 798 tests pass; e2e.py still passes end to end. Nothing here was built or launched: no npm, no rpmbuild. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat: device linking, and signing in to the hub with a device keyChristophe Besson2026-08-1810-0/+130
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Stage C. Identity keys are per node, so a browser and a desktop client are two keys on one account there — and the node refused the second where it accepted the first. Without this, an account created natively could never be opened in a browser without an operator code per node, and "a native client must not prevent web use" would have been dead on arrival. Device linking (node) --------------------- `identities` is keyed by `(user_id, pk_ed25519)` instead of `user_id` alone. The old shape did `INSERT OR REPLACE`, so a second device overwrote the first silently; SQLite cannot change a primary key in place, so the table is rebuilt. Existing pins are carried over — verified against a live roster with 10 of them, nobody re-pairs. A new device files a request bound by `sha256(code ‖ its own keys)`, and a key the node **already pinned** countersigns it. The hub cannot: it has stored no user keys since 2026-08-14, which is what makes this safe to do without an operator in the loop. **The code never reaches the node.** It lists this account's pending requests with their stored hashes; the approver recomputes and keeps the match. A node offering fabricated keys would have to produce a hash over a code it has never seen. Nothing rests on a human comparing digits — that ritual was dropped in 12.1 as "correct, unusable as the default" and must not return by the back door. The design document had the approver look a request up *by* its hash, which is circular: computing it needs the keys being asked about. Corrected in both. Revocation marks rather than deletes, because a deleted row is a key the node would happily pin again — which is the laptop somebody just reported lost. Your last device cannot be revoked: coming back would need an operator's code. Hub — the only change in the whole plan --------------------------------------- `POST /v1/users/auth` signs in with a device Ed25519 key, on the same pattern as `/v1/nodes/auth`, plus `/v1/users/devices` to register, list and retire. New `user_devices` table with an Alembic migration, because `create_all()` is not one. This is **not** the key directory that was H3, and the tests say so: nothing reads it but the hub, no group key is ever wrapped for one, and it is a different key from the per-node identities. What it does cost is metadata — the hub now knows how many devices an account has and when each last signed in. Also `client.minimum` / `client.recommended` in `GET /v1/hub/version`: an installed client meets a newer hub the day the interface ships in a package, and that is cheap now and awkward to retrofit. Browser ------- The `key_changed` refusal becomes `unknown_device` and offers a linking code instead of telling someone to find their operator. The Members panel lists this account's devices here, approves one by code, and retires one. 773 tests pass. `e2e.py` gained a step that links a device end to end against the live deployment — file, list, recompute, countersign, then open the group with the new keys and no code — and it also gained `recv_type`, because a step that assumes the next message is its own answer reads an ack left by the step before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(node): several named roots per group, and one implementation per operationChristophe Besson2026-08-1810-0/+10
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Stage A — a group's content is a set of named roots --------------------------------------------------- `shared_dir` becomes a list of {name, path, kind}. The name is the directory's basename, derived once at add time and *stored*: recomputing it would re-identify a whole library the day someone renames a folder on disk. Duplicate names are refused case-insensitively and no root may contain another — both compared with NFC folding, because most of these directories live on exFAT or NTFS where `Films` and `films` are one directory. Every index path carries its root name, in a one-root group as much as in a five-root one. One path shape has to be got right once; two have to be kept right for ever. **A root that goes away freezes; it never empties.** Unmounting a volume makes watchdog report every file under it as deleted, or presents an empty directory to the next scan. Acting on either propagates deletions for a whole library to every member, as though the owner had erased it. So a deletion is acted on only once its root is confirmed readable, and availability is tracked per root — one unplugged drive leaves the others serving. 12 tests, verified to fail against an indexer without the check. Events are not trusted to be complete either: ReadDirectoryChangesW drops them under load and inotify on a FUSE mount misses changes made outside it. A periodic reconciliation sweep is the only thing that recovers a missed event. MNP 0.2 → 0.3 (additive). The hub needs no change: SwarmSource carries a content hash, a node id and an endpoint — no paths, no filenames — and private groups register nothing (H7). Stage B — one implementation behind every front door ---------------------------------------------------- C1 and C6 were both "a second path into the node with its own weaker handshake". Two implementations of `revoke` with two authorization checks is that shape one size down. `meshbay_node/ops.py` holds each operation once, takes the daemon state, and knows nothing about HTTP, argv or MNP. The loopback API is one `_op(...)` line per endpoint; the MNP handlers call the same functions. test_ops.py asserts the shape rather than trusting it. Phase 14 is finished on top of it — `group list`, `gek init|rotate`, `reload` (SIGHUP), `denylist show|clear`, `file list|rm`. **No operator action requires a browser any more.** Plus `gek_rotate` and `member_unpin` as operator-signed MNP operations: rotation is the half of revocation that revocation cannot do, since the ex-member holds the current key, and the node generates the replacement with its own CSPRNG — no key material crosses the wire, which is what the C5b rule is actually about. Two bugs found by running it rather than by testing it ------------------------------------------------------ GroupIndex is keyed by **content hash**, so the same bytes at two paths are one entry — which is also why a scan reports ten files and indexes nine. Reconciliation compared paths, so it decided the second path was a missed event every 60 s, rewrote the entry and pushed an index update to every connected peer. Seen in a live node's log. `meshbay-node reload` crashed on first use with `subprocess` unimported: the module compiles fine, which is the "syntax, not names" trap already recorded for the SPA. test_cli_dispatch.py now walks every verb and refuses to let one be added to the parser without an entry there. Also corrected: protocol.py declared a second MNP_VERSION of "0.1" while the wire carried "0.2" — harmless only because nothing imported it. And _do_dir_create/_do_dir_delete referenced an undefined `filename` on their error path. 740 tests pass; QE/deploy/e2e.py passes end to end against the live deployment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(hub): move about in a film, and pick it up where it was leftChristophe Besson2026-08-1710-0/+20
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Clicking the scrubber now restarts the stream there. A seek inside what is already buffered never reaches the node. Dragging is debounced at 350 ms, because `seeking` fires continuously and each one acted on kills an ffmpeg and spawns another. `SourceBuffer.mode` changes from 'sequence' to 'segments', and each stream sets `timestampOffset` to where the node says it started — otherwise a stream beginning at forty minutes is buffered at zero and the scrubber lies about everything. Proved in Chrome before any of it was written: abort, remove, offset 600, append, and playback resumes at 600 with `readyState` 4. A seek makes the buffer discontinuous, and everything reading `buffered` then has to mean a particular range. "The last one" stops being "the one playing": the read-ahead would report a full buffer across a gap while the player starves, and the eviction would take out everything between the first range and the playhead, including what is showing. Both work from the range around the playhead now. `reinitAt` also clears the buffer outright rather than keeping two ranges, and calls `abort()` first — ffmpeg was killed mid-fragment, so the parser holds half of one and the next header would land on top of it. The position is kept in localStorage, per file: no protocol, no storage anyone else has to keep, and nothing new learns what you watch. Not below thirty seconds, not past 97% of the film, and a pill offers the beginning back. Two things this had to get right and did not at first, both found by running it rather than reading it. Between asking for a seek and its `stream_init`, everything on the channel belongs to the film being left — same file, so `file_id` cannot separate them, but ordering can. And a segment that arrived is no longer in flight whatever is then done with it: discarding one before decrementing the window leaked a slot every time, and `reinitAt` is asynchronous, so a whole window could arrive while it waited. The player then believed eight segments were in flight, granted nothing further, and the node waited for credit that could not come — a race, which is why the same seek worked twice and hung on the third.
* feat(hub): chat, presence, a Profile page, and downloads that do not freezeChristophe Besson2026-08-1610-0/+170
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Chat opens on the newest hundred messages, loads fifty older on demand with the reading position anchored — the distance from the *bottom*, since everything above the viewport just grew — and follows new messages only when the reader was already at the end. Day separators, sender grouping, an unread marker, and a jump-to-latest pill. Messages are keyed by id: index keys plus prepending makes Preact reuse the wrong bubbles. A presence dot per group in the sidebar, three states, each backed by something: the hub's registry, or a connection this browser made or failed to make. Never colour alone — red and green are the pair colour-blind readers cannot separate — so each dot carries a title and an aria-label. Profile is split out of Settings: identity, node link, pinned node identities and account deletion. Mixing them put an irreversible button two scrolls under a theme picker. The create-group page loses its centred 520 px card, which left 190 px of margin either side, and its two button panels become a radio group — a button conveys no chosen state to a screen reader, and side by side they read as two independent actions rather than one either/or. The Files toolbar shows its actions as icon buttons the moment Select is on, disabled when they do not apply rather than appearing and vanishing. On a phone the right-hand group could not wrap and ran 130 px off the screen. Streamed downloads no longer freeze after one chunk. `registration.active` says a worker exists, not that this page is controlled by it — and an uncontrolled page's requests never reach its fetch handler, so the worker took the stream and was never asked for it, leaving `writer.write()` waiting on backpressure that would never lift. The page now requires control and the worker confirms it actually served the request before the sink is trusted. Fixed on the way: `setActionsOpen` outlived the state it belonged to and threw on every Files action; the chat scrollbar stopped short of the bottom; the owner's row sat lower than the rest; About showed a version hardcoded two releases ago. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(hub): translate the web client into nine more languagesChristophe Besson2026-08-1610-0/+4009
French, Spanish, Brazilian Portuguese, Simplified Chinese, Japanese, German, Italian, Dutch and Polish, all in the formal register. `hub`, `node` and `GEK` stay in English: they name the CLI, node.toml and the docs, and translating them would cut the interface off from everything an operator reads and types. Catalogues move out of i18n.js into locales/, one file per language, fetched with a dynamic import. A visitor downloads their language plus English as a fallback — about 36 KB rather than the ~180 KB that ten inlined catalogues would have cost everyone. i18n.js keeps only the loader, so the first render now waits for initLocale(). Three things the old code got wrong, none of them visible until there was a second language: - Resolution trimmed a tag to its base before matching, so a browser reporting pt-BR looked for a `pt` catalogue that does not exist and fell back to English. Matching is now exact first, then by base language. - Counted strings were single strings, so Polish could not express 1 plik / 2 pliki / 5 plików at all. t() selects through Intl.PluralRules; en.js gains the same treatment, which incidentally fixes "1 files". - Interpolation used String.replace, which reads `$&` in the replacement. A file named rap$&sody.mp3 rendered corrupted in its own delete dialog. Five strings were still hardcoded in app.js — the group name placeholder and the four visibility/join-policy descriptions — and are now keyed. test_locales.py holds the nine translations to the shape of en.js: same keys, a counted string stays counted everywhere, every plural entry covers each category Intl actually produces for that language, and the {placeholders} survive translation. Verified failing first, against a catalogue with a key removed, a placeholder dropped and the Polish `few` form deleted. The language menu also grew from one entry to ten, which overran a short viewport inside a dropdown that clipped instead of scrolling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>