<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-hub/src/meshbay_hub/static/transport.js, branch 0.6</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=0.6</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=0.6'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-08-23T13:15:35Z</updated>
<entry>
<title>feat(hub): split the group UI into a pluggable "applications" architecture</title>
<updated>2026-08-23T13:15:35Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-23T13:15:35Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83'/>
<id>urn:sha1:9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83</id>
<content type='text'>
GroupPage's 6620-line app.js carried Chat and Files wedged in directly, with
no way to add another group-level app without touching the shell itself. It
is now app.js (routing, non-group pages) plus nine focused files — apps.js
(the registry), chat-app.js, files-app.js, video-player.js, group-page.js
(the shell), group-settings.js, hub-client.js, icon.js and file-utils.js —
with docs/apps.md as the checklist for adding one (Videos/Music/Photos are
sketched there, not built).

Node side gained the matching enablement mechanism, mirroring
member_upload exactly: a roster setting, a signed apps_enabled op enforced
by _has_admin_authority, exposed in the handshake ack. Operators toggle
applications per group from Settings, which also gained a small reorder:
Invite, Pairing, Applications, Shared directories, Uploads, danger zone,
Your devices, Members.

Two bugs surfaced during the split, both missing an import across the new
file boundary and invisible to node --check or a module-load probe since
they only throw when the code path actually runs:

- group-page.js called onRefreshAuth on a stale-token handshake rejection,
  but app.js never imported refreshAccessToken from hub-client.js — so a
  brand new member (including a group's own creator) hit "Not a member of
  this group" and the retry silently failed, throwing before it could
  refresh the token.
- chat-app.js called getLocale() for message timestamps without importing
  it from i18n.js. Opening Chat on a group with real messages threw mid-
  render; uncaught, that appears to wedge Preact's render scheduler, so
  every button on the page stopped responding until reload.

Caught the second class of bug with a proper no-undef audit across all
split files (a temporarily installed ESLint 9, since the system one is too
old to parse this codebase's syntax) rather than trusting grep. 827 tests
pass; 6 new ones cover the apps_enabled policy.

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
</content>
</entry>
<entry>
<title>feat: LAN Wi-Fi casting to Chromecast via local HTTP relay</title>
<updated>2026-08-21T11:52:11Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-21T11:52:11Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=8730281d739d9ed1d6f2d366772582eea8ba0294'/>
<id>urn:sha1:8730281d739d9ed1d6f2d366772582eea8ba0294</id>
<content type='text'>
Re-serve decrypted fMP4 video over HTTP on the LAN so a Chromecast can
play the stream. The relay runs in the Electron main process — same trust
boundary as downloads and MSE playback.

- cast-relay.js: HTTP server with BoxAccumulator (reassembles WebRTC
  chunks into moof+mdat pairs), ring buffer, backpressure, finish() for
  clean end-of-stream, fixed port range 19550-19553
- cast-chromecast.js: mDNS discovery (bonjour-service) + CASTV2 protocol
  (castv2-client), connect/reload/disconnect lifecycle
- Seek-aware: relay restarts on every seek, Chromecast reloads new URL;
  generation counter prevents stale async errors from killing active
  restarts; landingPlayheadRef suppresses programmatic seeking events
- Device picker in video top bar with scan, device selection, copy-URL
  fallback, and cast status indicator
- IPC bridge (main/preload/platform) for start/push/stop/finish/status/
  discover/chromecastConnect/chromecastReload/chromecastDisconnect
- Phase 3 design doc for DLNA/Smart TV in docs/cast-smart-tv.md

Co-Authored-By: Claude Opus 4.6 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(node): full Node admin panel — CLI parity, hot-reload, group lifecycle</title>
<updated>2026-08-20T06:56:23Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-20T06:56:23Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=c8af746c846b5dbc792f7e4f0d806647d513cc5c'/>
<id>urn:sha1:c8af746c846b5dbc792f7e4f0d806647d513cc5c</id>
<content type='text'>
Node admin panel (NodePage) now covers every CLI operation over MNP:
group attach/detach, roster, member unpin, GEK rotate, denylist, reload.

Daemon hot-loads new groups and tears down removed ones on config reload
instead of requiring a full restart. Group attach/detach via MNP or
local API triggers an automatic reload so the group is live immediately.

Fixed GroupPage hang on first visit to a newly created group: the JWT
issued at login didn't include the new group, the node rejected with
not_a_member, and the token-refresh path returned without re-triggering
the connect effect (Boolean(token) didn't change). Now bumps retryKey
after a successful refresh so the effect re-runs with the fresh token.

NodePage marks groups hosted by the node but absent from the hub with a
"not on hub" badge so stale groups are visible and easy to remove.

Co-Authored-By: Claude Opus 4.6 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(node): the operator can close uploading to everyone but themselves</title>
<updated>2026-08-18T16:21:57Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-18T16:21:57Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=b86981f7b4ffe758136a527542ce256315823a46'/>
<id>urn:sha1:b86981f7b4ffe758136a527542ce256315823a46</id>
<content type='text'>
A group where every member may add files stays the default. Some groups want a
library the operator curates, and until now the only way to get one was to
designate no upload root at all — which refuses the operator too.

**The node enforces it; the interface merely stops offering it.** The Upload
button in the Files toolbar and the paperclip in the chat composer both
disappear, which is a courtesy to the people who are not trying. The control is
`_do_file_upload` refusing with `member_upload_off`, so a member on an old tab,
or one speaking MNP directly, gets the same answer. There is a test for each,
and the enforcement test is in the node package rather than beside the UI one so
nobody reads the hidden button as the mechanism.

**Changing it is a signed operator instruction** — `OP_MEMBER_UPLOAD`, on the
same path as removing a member. An unsigned one would let any member turn it
back on and make the setting a suggestion. The transcript's subject is `on` or
`off`: what the operator is shown before signing has to name the outcome, not
the operation.

**It lives on the node**, in a new `group_settings` table in `roster.db`. Not
the hub, which has no business deciding who may write to someone else's disk.
Not `node.toml` either: that file is hand-written and full of comments recording
decisions, `ops.py` appends to it rather than round-tripping it through a
writer, and a setting toggled from a panel must not rewrite the operator's file
or need a restart. The value is cached in the group context because the upload
path is synchronous, and the signed operation updates both — storing it without
applying it would make the panel say one thing while the node did another.

**Absent means allowed**, at every layer: no row in the table, no key in the
context, no field in `handshake_ack`. An older node and an older client both
behave exactly as before, and upgrading never silently closes a group. Each of
those three has its own test, because they fail independently.

The operator is always exempt — otherwise turning it off locks them out of their
own node with a config file and a restart as the only way back. `is_node_admin`
was being computed in two places by then and is now one function, since two
copies of "is this the operator" is how the ack and the gate come to disagree.

A change reaches everyone already connected via `member_upload_ack`, so the
button goes without a reconnection. That message is both a broadcast and the
reply to the request that caused it, which is why the client does not return
early on it.

Docs updated for a cold start: draft-v6 §2.1b and change 9, a new "Where Phase
13 stands" section in CLAUDE.md recording what is built, deployed and still
missing, the module map row, and desktop-client-v1 §10b on the Settings tab and
where group settings live.

883 tests pass.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix: two waits with no deadline, resume positions per account, group settings tab</title>
<updated>2026-08-18T14:34:12Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-18T14:34:12Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=eedbca3f0d47af39b4dd8812683e5a14ae4e48e6'/>
<id>urn:sha1:eedbca3f0d47af39b4dd8812683e5a14ae4e48e6</id>
<content type='text'>
**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:&lt;file&gt;`, 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
`&lt;td&gt;` — 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 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix(client): the desktop client runs, and running it corrected three things</title>
<updated>2026-08-18T08:30:59Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-18T08:30:59Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=68bfe56a19aeb4c16f8e185fc85d8eee61aef78f'/>
<id>urn:sha1:68bfe56a19aeb4c16f8e185fc85d8eee61aef78f</id>
<content type='text'>
Electron 42 / Chromium 148, launched under xvfb. The packaged interface mounts
over `app://` with a secure context, `crypto.subtle` present, Argon2 WASM
loaded, and no console errors. Three statements in the design were wrong, and
only launching it found them.

**A CSP in a `&lt;meta&gt;` tag silently drops `frame-ancestors`.** Chromium says so
in the console. A policy carrying a directive that does nothing is worse than
one without it, so the policy is sent as a header by the protocol handler —
which is also the only thing serving the interface, so one source instead of
two.

**`secure: true` is not what makes the service worker register.** Chromium
refuses a worker on a custom scheme whatever its privileges: "The URL protocol
of the current origin ('app://meshbay') is not supported". The application has
no service worker and needs none — it saves through a native dialog, which is
the better of the two paths. `sw.js` stays in the package because the same files
serve the browser, where it is one of only three ways to write a large file.

What `secure: true` is actually for was measured at the same time: without it
**the whole of `crypto.subtle` is undefined**. The first probe loaded a `data:`
URL and every algorithm failed with TypeError, AES-GCM included — which is why
the probe was rewritten before believing its answer. X25519 and Ed25519 are both
present on Chromium 148, settling the version floor left open as O6.

**The renderer cannot call the hub.** Its origin is `app://meshbay` and CORS
refuses it. The hub has *no CORS middleware at all* — its API is reachable from
no web origin whatever — and that is worth keeping. Widening it for
`app://meshbay` would be worse than it looks: that origin is not a credential,
since any Electron application can claim the same scheme and host name.

So every hub call leaves from the main process, exactly as saving a file does,
and it refuses any origin that is not the hub the user signed in to.
`platform.apiFetch()` is `fetch` in a browser and the bridge in the application,
so no caller has to know which it got. `transport.js` reaches it through a
global because it is a classic script, not a module — the alternative was a
second fetch path, which is how two callers of one hub start disagreeing about
how to reach it.

Verified from inside Electron: the main process gets 200 from
/v1/hub/version, the renderer is refused by CORS, and **a script served by the
hub is refused by the policy** — T3's mitigation demonstrated rather than
asserted.

Build note, written into the README because it will bite the next person:
**Ubuntu 24.04's nodejs 18 cannot install Electron at all** — the download
script `require()`s an ESM module, which Node gained in 22. Node 24 LTS,
checksum-verified against nodejs.org, is what this was built with.
package-lock.json is committed; builds use `npm ci`, not `npm install`.

799 tests pass, e2e.py still passes end to end. The session harness needed a
platform stub: it lifts `hubFetch` out of app.js as text and runs it, so the
adapter is now part of the environment it models.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat: device linking, and signing in to the hub with a device key</title>
<updated>2026-08-18T01:24:55Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-18T01:24:55Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=768e07046368819b8a8f15c8b21e5a8bbfcdf282'/>
<id>urn:sha1:768e07046368819b8a8f15c8b21e5a8bbfcdf282</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(node): seeking, as a stream restarted somewhere else</title>
<updated>2026-08-17T00:16:02Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-17T00:16:02Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=3f74fcc3515cd461f9fdd8d95a9d80aee67e1e58'/>
<id>urn:sha1:3f74fcc3515cd461f9fdd8d95a9d80aee67e1e58</id>
<content type='text'>
The scrubber was drawn the length of the film — `ms.duration` has always been
the real duration — and then `onSeeking` quietly clamped every target back into
whatever happened to be buffered. The bar invited a click and refused it.

`stream_req` gains a `start`. The session's previous stream is retired by the
path that already exists for switching films, and ffmpeg is spawned again with
`-ss` **before** `-i`: an index lookup rather than decoding and discarding up to
the point, which is milliseconds on a 500 MB film instead of tens of seconds.
Measured over real MNP: 0s -&gt; 492 MB, 600s -&gt; 418 MB, 3000s -&gt; 179 MB.

A seek at or past the end is pulled back, because ffmpeg would produce nothing
and the player would wait for segments that are never coming.

`stream_init` reports the position actually used. It has to: ffmpeg restarts its
output timestamps at zero however far in it seeks — `-copyts` does not change
that for this input, measured — so the client is the one that puts the fragments
back on the film's timeline, and it cannot guess by how much. The value is also
not what was asked for, since `-c copy` lands on the keyframe at or before it.

The diagnostics that found the rest of this are here too: a seek, a first init
and a re-init are each one line at INFO, which is rare enough to keep on. The
five-second client report stays at DEBUG.
</content>
</entry>
<entry>
<title>fix(hub): bound the video read-ahead by the playhead, not by the network</title>
<updated>2026-08-16T18:57:41Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-16T18:57:41Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=373f83236816aaff536dfa2f9589801acac44012'/>
<id>urn:sha1:373f83236816aaff536dfa2f9589801acac44012</id>
<content type='text'>
A 500 MB film loaded about 100 MB and hung on "buffering" for good. 100 MB is
not a number in our code: it is where the browser stops. ffmpeg remuxes with
`-c copy`, so the bytes on the wire are the file's own, and credit granted per
append meant taking them as fast as the network allowed — which for a film is
very much faster than watching it. The SourceBuffer ceiling arrived in the
first minute.

Past it every append was refused, and the refusal was unrecoverable: a refused
append fires no `updateend`, `updateend` was where credit was granted, so the
node sent nothing and no segment arrived to retry the append. Every wakeup the
pipeline had was downstream of the append that had just failed. Playback
continuing — the one thing that frees room — woke nothing at all.

Credit now follows the buffer instead of the writes. `pump()` is the only
place it is granted, it keeps `STREAM_WINDOW` segments in flight while less
than `BUFFER_AHEAD_S` of film is held past the playhead, and it is driven by a
one-second clock and by playback progress, never by arriving data. Buffering
by time makes a two-hour film cost what a two-minute clip costs.

A window rather than a debt, and this took a second measurement to get right:
accumulating a credit per append and releasing the balance when the buffer
finally drained sent six megabytes in one burst, overshot by a minute of film,
then said nothing for forty-six seconds. Measured in Chrome against real
fragmented MP4.

Two smaller things found on the way. `updateend` fires for `remove()` as well
as `appendBuffer()`, so crediting from it paid the node for the player's own
evictions. And a viewer that is deliberately far enough ahead grants nothing
for minutes, which the node read as a closed tab — it now sends `stream_more`
with n=0, which grants no room but proves someone is there.

The first version of the test modelled the credit loop and passed while the
player still hung: a model written by whoever wrote the fix agrees with it by
construction. `tests/harness/mse_harness.mjs` lifts the real functions out of
app.js as text and runs them against a SourceBuffer that has a ceiling. What is
modelled is the browser.
</content>
</entry>
<entry>
<title>feat(hub): chat, presence, a Profile page, and downloads that do not freeze</title>
<updated>2026-08-16T13:29:11Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-16T13:29:11Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=84e778d5cdd1bb5cded8a7c0238797c17d48666c'/>
<id>urn:sha1:84e778d5cdd1bb5cded8a7c0238797c17d48666c</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
</content>
</entry>
</feed>
