summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/style.css
Commit message (Collapse)AuthorAgeFilesLines
* refactor(node): JSON-only control API, Node page absorbs the admin dashboardChristophe Besson2026-09-011-0/+25
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Remove the node daemon's server-rendered admin UI (GET / and /audit, the _render_* helpers and inline templates) and the `meshbay-node ui` CLI verb. The loopback control API stays; it is now JSON only, ruff-clean, and 453 lines (was 1074). Also drop three never-wired endpoints (/api/config, /api/chat/history, /ws/chat, plus broadcast_chat) and the pointless 18000/tcp firewall profiles. The desktop client's Node page (static/node-page.js) takes over what the dashboard showed, reorganised into six tabs (Overview, Groups, Roster, Peers, Audit, Settings): - Overview: version, node id, QUIC port, hub, index-cache maintenance - Roster: node-wide view with unpin - Peers and Audit: auto-load on open, no Load button - Audit: real usernames and group names (resolved from the roster and node.toml), Previous/Next pagination newest-first, Export CSV of every matching row - Settings: node settings, STUN, ICE, denylist, then Unlink from hub Backend: audit.get_entries gains `offset`; /api/audit and /api/peers resolve ids to names via a new _display_names helper; CSP tightened to default-src 'none' now that no HTML is served. draft-v6 sections 2.11 and 2.12 corrected -- the Node page uses the loopback API, not MNP. One capability is intentionally dropped: browser-based admin on a headless server. The CLI covers every operation there. See docs/refactor-node-ui.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MQCaZnde4Bjjdu84dhSuF5
* feat: configurable STUN server fallbacks for WebRTC ICEChristophe Besson2026-08-301-0/+53
| | | | | | | | | | The WebRTC transport relied on a single Google STUN server — if it was unreachable, ICE gathering waited the full 4s timeout. Now four public servers are used by default (Google ×2, Cloudflare, Mozilla), configurable via node.toml, the Node page UI, and the CLI (meshbay-node stun list|add| remove|reset). Changes are hot-swapped on the live transport. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* revert(hub): drop the poster-grid movie merge (V12)Christophe Besson2026-08-301-1/+0
| | | | | | | | | | | | | | | | | | | | V12 collapsed movies that TMDB resolved to the same tmdb_id into one card. With the matcher still imperfect that fuses *different films*: every numbered entry of a saga whose bare title resolves to the same base id becomes one card, and two unrelated movies sharing a title do too (seen live on a 9-film saga and a 2-film pair). A tmdb_id-keyed merge only works once matching is reliable, which it is not yet. Movies render one card per file again; VideoDetailModal loses the `files` prop and the versions list, back to a single Play button; `.video-version-list` and the `video.versions` key (×10 locales) are removed. mergedShows (V6) is untouched — the operator's report was about movies. docs/mediacenter.md §10.1: V12 un-struck, marked reverted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* fix(hub): make "Re-match" and "Fix match" the same size in the detail modalChristophe Besson2026-08-301-2/+4
| | | | | | | | | | | | .video-fix-match carried `margin: 8px 0` from when it was a standalone button; now that it sits in the .video-admin-actions flex row next to the plain .admin-btn "Re-match", `align-items: stretch` (the flex default) made the margined button 16px shorter than its neighbour — "Re-match" looked twice as big. Drop the margin (the row already has its own), pin the font-size to .admin-btn's, and center the row. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* Merge remote-tracking branch 'origin/main'Christophe Besson2026-08-291-0/+50
|\
| * feat(node): editable node settings in the Node page (D5)Christophe Besson2026-08-291-0/+50
| | | | | | | | | | | | | | | | | | | | | | | | | | Expose invite_ttl_hours, pair_ttl_hours, device_request_ttl_minutes, max_concurrent_streams and transcode_incompatible_video in the Node management panel. Changes are applied immediately via roster.db and written back to node.toml so they survive a DB wipe. On startup, roster overrides take precedence over node.toml defaults. Draft v6 §2.11 documents the design; MNP gains node_settings_set / node_settings_set_ack for the browser path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* | feat(hub): drop the Search page title, add a Files refresh buttonChristophe Besson2026-08-291-0/+9
|/ | | | | | | | | | | | | | | | | 1. The "Search Files" <h2> is removed from the search page — it duplicated the nav and cost a line of vertical space on mobile. `search.title` is dropped from all ten locales (its only use). 2. A monochrome refresh button sits left of the Files breadcrumb home icon. It calls `onRefreshIndex` (re-fetch the group index from the node — the file list is a view over a cached copy with no other way to pull a fresh one), spins while in flight, and only renders where an `onRefreshIndex` is wired (the real group view, not the cross-group search results). New `refresh` icon; `group.refresh_index` in all ten locales. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* feat: V13 — per-card "re-match this one file" (MNP 0.13)Christophe Besson2026-08-291-0/+1
| | | | | | | | | | | | | | | | | | | | | | | | A one-click alternative to the full "Fix match" search-and-pick flow, and reachable without SSH (`meshbay-node video rematch` clears a whole group). - MNP 0.13: tmdb_rematch / tmdb_rematch_ack (additive — an older node logs "unknown type", the button just does nothing). OP_TMDB_REMATCH, signed like tmdb_override (media_cache is shared node-wide). - media_cache.drop_tmdb_match(file_id): forgets the match AND the override marker — deliberately stronger than clear_file_tmdb, since the operator is explicitly asking for a fresh resolution. - webrtc_server: _do_tmdb_rematch / _admin_exec_tmdb_rematch, dispatch + admin-response routing, broadcasts tmdb_rematch_ack. - transport.js: rematchTmdbMatch(fileId, signFn); 'tmdb_rematch' in the admin-op allowlist; tmdb_rematch_ack handled like tmdb_override_ack. - video-app.js: a "Re-match" button beside "Fix match" in the detail modal (isNodeAdmin), then bumpMediaMetaGeneration(). video.rematch_one key in all ten locales. docs/mediacenter.md §10.1: V8–V13 marked done. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* feat(hub): V12 — merge movies by TMDB id in the poster gridChristophe Besson2026-08-291-0/+1
| | | | | | | | | | | | | | | | | Two movie files that TMDB resolves to the same id (the same film at two resolutions, or the same rip in two folders) now collapse to one poster card, mirroring the existing show merge — same keying discipline so an unmerged movie keeps its card and a merge updates props rather than remounting. The detail modal lists the versions (resolution · duration · size), each a Play button, when there is more than one; a single-file movie is unchanged. New `video.versions` key in all ten locales. Known edge, noted in §10.1: "Fix match" on a merged movie corrects only the representative file; the other version un-merges and can be corrected on its own. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* fix(node): correct TMDB movie matching, per-file overrides, rematchChristophe Besson2026-08-291-0/+24
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A batch of wrong poster-grid matches found live on a real library (2026-08-29): a two-volume film's second part matched the first; a numbered sequel matched a same-year making-of documentary; several entries of one franchise matched a single early entry whose localized TMDB title is the franchise name; one matched nothing. One mechanism: _tmdb_search returned the first candidate query whose title-similarity ratio merely cleared 0.6, before alternative_title / the Roman-numeral variant was ever tried. Matching: - title_parse: fold guessit's volume/part number back into display_title so the parts of a multi-part film stay distinct in the query, the card and the override. - _tmdb_search: keep a strong PASS 1 fast path (ratio >= 0.85, one request), otherwise score every candidate query and pick the best. A year-exact rescue lifts a sub-0.6 top hit to the confidence floor only when TMDB's own year-filtered result lands exactly on the filename's year. No local re-ranking of any single result list; no tmdb.py change. Fix match / rematch: - _admin_exec_tmdb_override: a movie override touches its own file only (guessit gives a whole franchise one display_title); a show override still fans out. Corrected files are marked in media_cache.tmdb_override. - media_cache: tmdb_override table; clear_file_tmdb / clear_tmdb_matches drop auto-resolved matches while sparing manual corrections. - ops.rematch_video + `meshbay-node video rematch` (loopback endpoint + CLI verb): re-resolve a group's video matches after a matcher fix. file_tmdb is keyed by content hash and otherwise only pruned on deletion, so nothing dislodged a cached match before. - a rename now drops the stale auto match too (daemon _reenrich_renamed_video_entries). UI: - VideoDetailModal shows the source filename and resolved TMDB id; an unmatched poster gets a badge (3 new video.* i18n keys x 10 locales). So a wrong match can actually be identified before hitting Fix match. docs/mediacenter.md 10.1 records this and the V8-V13 follow-up backlog (show-branch ladder, year-aware _best_match, wider sequel_variants, the 0.6-0.85 extra calls, movie grid merge, per-card rematch). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* feat(hub): search downloads, photos view, pre-connect optimizationChristophe Besson2026-08-281-1/+2
| | | | | | | | | | | | | | | | | | Downloads from search: FilesPanel accepts a getTransport callback for cross-group file downloads. Select/Download are available in search; delete and zip are hidden (readOnly). Each download connects to the entry's group via the pool on demand. Photos view: PhotosApp reused in search with the same pattern as Videos/Music — synthetic root, per-entry transport refs, group badges on album cards. Lightbox uses per-entry transport for full-resolution image fetches. Zip download hidden in search context. Pre-connecting: connections to all groups start as soon as indexing finishes, regardless of the active view. Thumbnails cache across pool evictions, so they're ready when the user switches views. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(hub): cross-group search with reuse of existing viewsChristophe Besson2026-08-281-1/+105
| | | | | | | | | | | | | Search page fetches indexes from all groups, then renders consolidated entries through the existing FilesPanel, VideoApp, and MusicApp components — no reimplemented views. Groups appear as top-level directories in the file browser; video/music entries use a synthetic root with per-entry transport refs for thumbnails and metadata across groups. Music player lifted to app.js with getConnection(groupId) for cross-group playback. Connection pool (max 3, LRU eviction) manages lazy WebRTC connections. All 10 locales updated with search keys. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(hub): sidebar — status dot on the name line, not between name and @ownerChristophe Besson2026-08-281-1/+18
| | | | | | | | | | | The @owner line turned the sidebar name into a two-line block, and the status dot (a flex sibling, align-items:center) floated to its vertical middle. A group entry is now a flex column: dot+name on one row (.si-head), @owner on a subordinate line indented past the dot. Non-group sidebar links are untouched (.sidebar-item stays a row; only .sidebar-group flips). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* feat(hub): group names unique per owner, shown as name@ownerChristophe Besson2026-08-281-0/+20
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A group's identity stays its UUID. What changes is that "the name is unique" — until now an unenforced expectation — becomes real, scoped to the owner account, and the owner's username is surfaced so two groups called "photos" on different nodes can be told apart. Hub: - `groups` gains a functional unique index `uq_groups_owner_name` on `(admin_id, lower(name))` (model + migration c3d4e5f6a7b8). The migration pre-flights: if the data already clashes it aborts and lists the offending (admin_id, name) pairs rather than renaming anyone's group. meshbay.org checked clean. - `create_group` trims the name, rejects blank (422) and an owner-scoped case-insensitive clash (409), with an IntegrityError backstop for the race, and returns `owner_username`. - `owner_username` added to `/v1/groups/mine`, `GET /v1/groups` (local rows), `POST /v1/groups/{id}/join`, `GET /v1/admin/groups`. SPA: - new `static/group-name.js` — `<GroupName name owner [inline]>` renders the name with the `@owner` handle on a smaller grey line under it. - used in the sidebar, the group-page header, Explore cards, the Admin groups table, and cross-group Search (via a widened `cacheGroupIndex` carrying the owner). Delete/leave confirmations show `name@owner` inline. - federated Explore rows show `@<source_hub>` instead of an account. Design record and the locked decisions: ~/next/groupnames.md (out of repo). MNP unchanged. Tests: test_group_name_unique.py, test_group_name_migration.py. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* fix(files): make "Filter files" search the whole groupChristophe Besson2026-08-281-0/+10
| | | | | | | | | | | | | | | | | The filter only ever matched files whose folder was exactly the current path, so at the top of a group — where every row is a root, never a loose file — typing in the field did nothing at all. With text in the field the panel now searches every file in the group by name or containing folder, wherever it lives, and hides the folder rows (you are searching, not browsing). Each result shows its folder as a sub-line; clicking it clears the filter and opens that folder. Empty field restores the normal folder-by-folder view unchanged. Also hardened the sort comparators against an entry missing `name`/`type`. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* feat(chat): link previews for pasted URLsChristophe Besson2026-08-281-0/+52
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Paste an http(s) link in a group's chat and it unfurls into an OpenGraph card — title, description, site name, and image — the way WhatsApp/Signal/ Slack do it. The fetch is the node's, never the browser's or the hub's. The browser cannot: a strict img-src/connect-src and CORS block it, and a direct fetch would leak every reader's IP to the linked host on each render. The hub must not touch group content (draft-v6 §2.5). The node already fetches third-party metadata for the Videos and Music apps, over the same authorised path. Flow mirrors media_meta_req: the client sends `link_preview_req {url}`, the node replies `link_preview_resp` with the card fields (or `ok: false`), and any OG image is stored under its blake3 in the existing media_cache thumb store — the client then fetches it via the normal file_req path, exactly like a poster. Nothing durable is added: the card text lives in a bounded in-memory TTL cache on the node (draft-v6 §2.7 — enrichment on demand, the asking device caches), and MNP goes 0.11 → 0.12 (additive: an older node logs "unknown type" and the client shows the bare link). Because the URL is chosen by a *member* and triggers an outbound request from the operator's machine, `linkpreview.safe_url` is an SSRF gate: http(s) only, no credentials, and every resolved address must be globally routable — no loopback, private, link-local, multicast or reserved range, cloud-metadata included. Redirects are followed by hand so each hop is re-checked. Residual, documented in the module: DNS rebinding between the check and connect, closed properly by pinning the checked IP — a follow-up. Also fixes a long-standing chat annoyance the preview cards made worse: opening the Chat tab landed a screen or two above the newest message because the scroll-to-bottom ran before attachment thumbnails and (now) preview cards had loaded and grown the content. A ResizeObserver keeps the view pinned to the bottom through late content growth, and does nothing once the reader scrolls up. Tests: test_linkpreview.py (the SSRF gate and the OpenGraph parse, incl. redirect re-validation and image downscaling) and test_link_preview_request.py (reply shape, the media_cache image round-trip, the result cache). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* fix(video): let the toolbar wrap on mobile — no room left for the filter fieldChristophe Besson2026-08-261-0/+7
| | | | | | | | video-app.js's toolbar isn't built from .toolbar-group, so it never got the existing row-per-group mobile treatment. Mode buttons plus the new All/Movies/Series control left nothing for the search field on a phone width. Wraps now, with the filter field taking its own full-width line like .toolbar-group's search field already does elsewhere.
* feat(video): add an All/Movies/Series filter to the toolbarChristophe Besson2026-08-261-0/+32
| | | | | | | A segmented control to the left of the search box, defaulting to "All". Applied before the text filter — a title match within a type nobody asked to see still isn't shown. Resets to "All" on group change, matching the text filter's own reset.
* fix(hub): disable Android's default tap-highlight flashChristophe Besson2026-08-261-0/+10
| | | | | | | | | | | | | | | | | | | | | The :hover/:focus fix in 909505f didn't fully clear the "stuck pressed" look reported on mobile Chrome: screenshots showed the music player's "next" button (which carries no state class in the JS at all — not .active, not .music-player-play) filled with a solid accent-blue circle after a tap, and the same on "repeat". Neither :hover (already scoped to `hover: hover`) nor the default focus outline (already cleared for non-:focus-visible) produces a filled background, so neither explained it. Third, separate mechanism: -webkit-tap-highlight-color, Android's own touch-feedback flash, never disabled anywhere in this stylesheet. Left at its default, many Android builds tint it from the OS accent colour, which is why it read as "the app's own blue" rather than an obviously foreign highlight. Unrelated to real :hover/:focus, so disabling it carries no keyboard-accessibility tradeoff at all. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XSohfUQQiaE77qyFLgSv3
* fix(hub): stop hover/focus states from sticking on mobile touchChristophe Besson2026-08-261-2/+22
| | | | | | | | | | | | | | | | | | | | | | | | | | A tap on a touchscreen fires a synthetic hover with no "pointer left" to end it, and leaves the tapped element genuinely focused (unlike a desktop click) — so any :hover-styled button looked permanently "pressed" until something else was tapped, and any plain <button> with no custom hover at all (the nav hamburger) showed the browser's default focus ring for the same reason. Not reproducible on desktop, which actually has a mouse to move away with. Found live on mobile Chrome, 2026-08-26 — most visible on the music player's next/prev/shuffle buttons, but the same mechanism affects any button in the app. Two small, general fixes rather than a per-button patch: - `:focus:not(:focus-visible) { outline: none; }` in the global reset — clears a lingering focus ring from a pointer/touch interaction while leaving real keyboard-navigation focus untouched. - `.music-player-btn`/`.music-player-play`'s :hover rules scoped inside `@media (hover: hover)`, so a touch tap never triggers them at all. Only the two reported, confirmed cases are touched here — the same `@media (hover: hover)` wrap applies to any of the stylesheet's other :hover rules if the same stickiness shows up elsewhere. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XSohfUQQiaE77qyFLgSv3
* feat: add Photos group appChristophe Besson2026-08-251-0/+277
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A new group application (docs/apps.md's plug-in mechanism), following the plan in docs/photos.md. Unlike Videos/Music: several photo roots per group instead of one (photo_roots is a set, one signed op replaces it whole), a single album-grid view with no third-party matching step, and per-photo info read from the file's own EXIF at index time — no metadata service, no credential, no outbound network call at all. Protocol (meshbay-common, MNP 0.10 -> 0.11, additive): `taken_at`/`camera` on IndexEntry; `photo_roots`/`photo_roots_ack`; `OP_PHOTO_ROOTS`. Node: roster.py stores photo_roots as a group_settings entry (JSON list, same shape as enabled_apps); ops.py/webrtc_server.py validate and sign the whole set in one op, same pattern as apps_enabled; a new PhotoEnricher (indexer/enrich_photo.py) runs Pillow in its own small bounded pool, separate from the video/audio pools, producing a resized thumbnail plus the two EXIF fields — never GPS, checked by a grep-based regression test. Client: photos-app.js — one album card per directory containing images, a per-album photo grid, and a lightbox with next/previous (keyboard and buttons), zoom in/out/fit/100% starting from the actual on-screen fit percentage, and a "zip this album" button reusing files-app.js's own zip mechanism (lifted into file-utils.js's downloadDirectory so both call the same implementation). group-settings.js gets an add/remove multi-root picker, distinct from Videos/Music's single-value one. Bugs found and fixed before this ever shipped, worth keeping the story of: - enrich_photo.py read width/height from the raw image *before* applying EXIF orientation correction, and read DateTimeOriginal off the plain 0th-IFD Exif object — a real camera stores it in the Exif sub-IFD, which Pillow only exposes via get_ifd(Exif). A flat, hand-built EXIF dict round-trips through Pillow either way, which is exactly what would have hidden both bugs; the regression test builds EXIF with piexif instead, matching what real hardware produces. - photos-app.js's album grouping stripped a trailing path segment from entry.path under the assumption it still carried a filename — it doesn't (files-app.js's own convention: e.path is already the containing directory), so every album collapsed one level into its parent. Found live against a real multi-folder library. - transport.js's ADMIN_OP_TYPES allowlist (already the fix for an identical bug on video_root/apps_enabled, see 4783d81) was missing photo_roots: its admin_challenge matched no pending request and was silently dropped, so saving a photo root just timed out after 30s with no error. - daemon.py pruned a thumbnail when its file left the index (root removed or reconfigured) but never forgot the content hash was "already attempted" — the same bytes reappearing under a renamed/relocated root (an operator's real workflow) were then permanently skipped, forever, with nothing to indicate why. Discarding the attempt alongside the cache entry on prune is what makes pruning actually reversible. - packages/meshbay-client's app:// protocol handler served every file with no Cache-Control header, so Chromium was free to serve a stale cached copy indefinitely — none of several `npm run sync-ui` + reload cycles during development actually picked up the new code until the renderer's disk cache was cleared by hand. Now sends Cache-Control: no-store. - the lightbox's zoomed image used flex centering (align-items/ justify-content: center) combined with overflow: auto — a well-known trap where the browser centers overflowing content by shifting it, and the leading half of that overflow (here, the top of a zoomed photo) sits outside what the scrollport can actually reach. Reported live as "unusable". Fixed by switching to top/left alignment once zoomed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiZG4AuSnxHohQMpwTHTyL
* feat(hub): audio_root wiring, mutually-exclusive players, Settings reworkChristophe Besson2026-08-241-0/+105
| | | | | | | | | | | | | | | | | | | | | | | | Five related pieces of polish against the Music app and Settings, all from the same conversation: - Music app now requires audio_root, same as Videos requires video_root: an empty-state message until one is set, and grouping filtered to only what's under it (underAudioRoot, mirroring video-app.js's underVideoRoot). Wires the new audio_root/audio_root_ack pair through transport.js and group-page.js state the same way video_root already flows. - Starting one player now stops the other — opening a film closes the music queue, starting a track closes the video modal. Both used to run at once, found live. - Group Settings reworked: every section but a bare form (invite, pair-operator, approve-device) is now collapsible (CollapsibleSection); the uploads on/off button is a real toggle switch (ToggleSwitch, reused for TMDB/MusicBrainz's enabled switches too, each now with an icon + status badge in its header instead of a plain checkbox row); and shared directories, the Videos root picker, and the new Music root picker are merged into one "Directories" section (RootFolderRow) instead of three separate ones scattered down the page — the root pickers only show once their app is actually enabled.
* fix(hub): give the Music flat list its own look instead of Videos' reskinChristophe Besson2026-08-241-3/+28
| | | | | | | | | | | | | | | | | | Two complaints against real use: the artist -> album -> track hierarchy was invisible (every depth sat flush left, distinguishable only by which chevron happened to be open — Videos' own flat list never needed more than one level, so there was nothing to reuse for this), and a filled-in album unfolded into a wall of identical little icon-box squares, one per track, carrying no information a track row can actually use (unlike Videos' per-episode thumbnail). Track rows now reuse Mode A's own numbered tracklist style (.music-track-row: number, title, duration, no icon box) instead of Videos' boxy thumb-slot row. A folder's expanded contents get wrapped in a new .music-flat-children indent + rule line, so nesting reads as visible steps into the tree rather than same-level siblings. Folder rows (artist/album headers) still reuse Videos' flat-row style, which fits them fine — this is not a wholesale rewrite, only what didn't actually work.
* fix(hub): show what's actually playing in the queue panelChristophe Besson2026-08-241-0/+11
| | | | | | | | The panel's header said "Playing now" but only the panel itself was named that — the current track was just a highlighted row you had to spot in the list, easy to miss on a long queue and often scrolled out of view entirely on open. Now shows the track's own title/artist right under the header and scrolls the highlighted row into view when the panel opens.
* feat(hub): consolidate loose tracks, "&"/"and" fold, player close/queueChristophe Besson2026-08-241-0/+3
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Album-grid readability, part two: - groupMusicEntries (music-app.js): an album bucket left with exactly one track - a real album tag, but only one song from it, not the whole release - clutters the grid the same way an untagged loose track does. Both kinds now fold into one "<artist> - Various" tile per artist, unless there is only one leftover track overall, where relabeling buys nothing and the track keeps its own name (or the generic placeholder, if it never had one). - foldKey also normalizes "&" vs "and" ("Artist & The Band" / "Artist and The Band" is one act, tagged both ways across different rips of the same catalogue) alongside the existing case/whitespace fold. - music-player.js: a close button pauses and tears the player down; an unmount cleanup effect (pause, revoke every cached blob URL) fires either way, whether that's the close button or the shell tearing the bar down on its own. A "current queue" button opens an overlay listing the whole playing queue with the current track highlighted, click any to jump to it - works identically regardless of how the queue was built (an album, the consolidated misc bucket, a single standalone track), since it only ever reads the player's own live tracks/order/pos. - group-page.js: this component is not remounted when switching to a *different* group on the same /group/:id route (only the groupId prop changes) - so without an explicit reset, music from one group would carry into the next one opened. Resets musicQueue to null on groupId change; a tab switch inside one group still leaves it alone. - Scrubbed real artist/band names that had leaked into code comments and test fixtures (enrich_audio.py's docstrings, several test_enrich_audio.py assertions, a music-app.js comment) - replaced with generic placeholders, no behavioural change. - i18n: music.various, music.player_close, music.player_queue, music.queue_title added across all ten locales. Client-side only except none of this touches the node at all. npm run sync-ui re-run. Full suite: 1129 passed, no regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
* feat(hub): draw an actual CD for covers with no art, not a flat iconChristophe Besson2026-08-241-14/+18
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The monochrome disc icon from the previous commit read as placeholder UI chrome, not as artwork - too plain for something that is, for most tiles in a real library, the default look of the grid. - DiscPlaceholder (music-app.js): a small inline SVG illustration - dark disc base, an iridescent radial-gradient sheen mimicking the rainbow reflection a real CD's data side has, two faint groove rings, a light label ring, a dark spindle hole. Each instance gets its own gradient id (a module-level counter) rather than one literal id repeated - a grid renders many of these at once, and a shared id would leave every disc after the first pointing at whichever <radialGradient> the browser happened to resolve. - AlbumCard and MusicDetailModal now branch on coverHash directly: MediaThumb (the real chunk-path image) when there is one, DiscPlaceholder when there isn't - rather than routing "no cover" through MediaThumb's own generic small-icon fallback, which is still right for its other callers (list rows, Videos). - icon.js: the flat monochrome "disc" icon this replaces is removed - nothing else used it. - style.css: .music-disc-empty/.music-disc-svg replace the old .video-thumb-empty overrides; still sized relative to the tile (82%) so it scales with .music-grid's auto-fill columns. Client-side only. npm run sync-ui re-run. Full suite: 1129 passed, no regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
* feat(hub): big monochrome disc placeholder for covers with no artChristophe Besson2026-08-241-0/+15
| | | | | | | | | | | | | | | | | | | | | | | Album tiles/detail covers with no embedded art, no sibling image file, and no MusicBrainz match (or MusicBrainz off) used the same small music-note icon list rows use elsewhere - fine as a rare fallback, but most tiles in a real library land here, so it read as broken rather than as the default look of the grid. - icon.js: a "disc" icon (two concentric circles, same stroked style as the rest of the set) - a plain CD/vinyl glyph. - music-app.js: AlbumCard and MusicDetailModal's cover now pass emptyIcon="disc" instead of "music". - style.css: sized at 55% of the tile via CSS rather than a fixed px value, so it scales with .music-grid's auto-fill columns; color stays var(--text-dim), the same neutral tone every other empty state already uses - monochrome, not a new accent. Client-side only. npm run sync-ui re-run. Full suite: 1129 passed, no regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
* feat(hub): Music app client — album grid, flat list, persistent playerChristophe Besson2026-08-241-0/+250
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Implements the client half of docs/musicbay.md against MNP 0.8: - music-app.js: album grid (grouped by artist -> album, from index-time artist/album fields) or flat folder view, per-group localStorage toggle like Videos. MusicBrainz (music_meta_req) is only looked up when a track has no embedded cover at all — well-tagged files never trigger a network call, unlike Videos where TMDB is unconditional. Reuses video-app.js's MediaThumb/LazyTile (now exported) rather than duplicating the chunk-path thumbnail decode + virtualization. - music-player.js: the persistent player bar — queue, shuffle (Fisher- Yates, keeps the current track in place when toggled), repeat (off/ all/one), volume (localStorage), prev/next, a one-track prefetch cache. No MSE, no node-side streaming: a track is downloaded and decrypted once via file-utils.js's pipelinedDownload, same chunk pipeline Files already uses, then played from a blob URL. - group-page.js: owns musicQueue/musicbrainzConfig state and renders MusicPlayerBar outside the tab-switched area — deliberately, so playback survives navigating to Chat/Files, the same reasoning the video/preview modals are shell-owned rather than app-owned. - apps.js: registers "music". transport.js: fetchMusicMeta (keyed by path, same reordering-hazard fix as fetchMediaMeta), setMusicbrainzConfig/setMusicbrainzEnabled (signed ops, mirroring TMDB's), and the three new ack handlers. group-settings.js: a MusicBrainz settings section (contact string, per-group toggle) — the existing Applications checklist already picks up "music" for free, per apps.md's own claim. - icon.js: music/pause/skip-next/skip-prev/shuffle/repeat/volume, drawn in the same stroked style as the existing set. - i18n: group.tab_music, the music.* and settings_node.musicbrainz_* keys, translated (not just copied) across all ten locales, Polish carrying full one/few/many/other plural forms for music.n_tracks. - webapp.py's _ASSETS, test_hook_ordering.py's STATIC_FILES and test_transport_contracts.py's SPLIT_FILES gain the two new files. Full suite (common + hub + node): 1116 passed, no regressions. `npm run sync-ui` in meshbay-client confirmed both files copied. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
* feat(node,hub): season-specific overviews, manual TMDB match correction, and ↵Christophe Besson2026-08-241-0/+61
| | | | | | | | | | | | | | | | | | | | | | | | | wizard polish Two operator-facing fixes for a real 3-season show whose automatic TMDB match was wrong at the show level: per-season overview/air_date tabs in the detail modal (falling back to the show-level text when a season's own is empty), and a "Fix match…" search-and-correct affordance that re-resolves every file sharing the corrected show's display_title. New signed op OP_TMDB_OVERRIDE and two read-only pairs (season_meta_req/resp, tmdb_search_req/resp), MNP_VERSION 0.5 -> 0.6. Also: the create-group wizard gets a spinning indexing indicator and an app-selection step, group settings default the TMDB language to the operator's own locale (never as a global default), and a file renamed mid-session now re-triggers title parsing instead of being silently skipped by the enrichment dedup guard. Fixes two bugs found during this work: the search overlay's z-index lost to the base video-overlay class and rendered invisibly, and season_meta's own empty overview didn't fall back to the show-level one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAmyXtc6dAADsH23ydXQpY
* feat(node,hub): add Videos group app (poster grid, flat list, TMDB metadata)Christophe Besson2026-08-241-0/+225
| | | | | | | | | | | | | | | | | | | | | Implements docs/mediacenter.md: a "Videos" group application built on the existing files index rather than a separate catalogue. On the node side, new indexer enrichment (technical probe, filename/season parsing, thumbnail generation) runs per-file once an operator has chosen a video_root for the group, plus a TMDB client for on-demand poster/metadata lookups (never client-side, thumbnails delivered over the existing chunk path). On the hub side, a new video-app.js renders a lazily-mounted poster grid or a thumbnail-only flat list, with TMDB entirely optional per group. Along the way: the global apps registry now drives Settings' default-tab picker instead of a hardcoded list, and the video_root is configured from group Settings (like uploads) rather than from Files, with the node refusing to run any TMDB/thumbnail work until one is set. Fixes several bugs found via live testing against a real library, notably a race between two effects writing the same "image ready" state that could leave a poster grid spinning forever on a same-tab revisit — see mediacenter.md §5.4 for the full account of each one.
* feat(node): persistent index cache, visible scan progress, adaptive ↵Christophe Besson2026-08-231-2/+57
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | reconcile, and delta sync Indexer performance work, in four parts: - Persistent (path, size, mtime) -> hash cache (indexer/cache.py) so a node restart no longer re-hashes every file — measured at 23 minutes for a 114 GB library on a slow disk before this, near-instant after. Hashing is deliberately kept sequential (max_workers=1): it was never actually concurrent despite the pool size, and two interleaved reads seek-thrash a spinning disk instead of going faster. - Byte-based scan progress (IndexProgress), surfaced via the loopback index-status route, the handshake ack, and a periodic INDEX_PROGRESS push to connected peers — drives a progress bar in the Create Group wizard and "add a directory" in Settings, and an animated presence dot. Guaranteed to settle back to idle via try/finally and a final push on the scanning->false transition. - The reconcile backstop's directory walks now run in the executor instead of blocking the daemon's event loop; its interval defaults to 10 min (was 60s) with adaptive backoff to 2h when nothing changes, reset on a real change or a peer connecting, and is now a per-group operator setting (signed op + group Settings UI). - INDEX_DELTA wired up (protocol support existed, nothing called it): _on_index_change now sends additions/deletions instead of rebuilding the full entries list, coalesced over a short window so a burst of file events produces one push, and the hub swarm registration for public groups only (re-)registers newly added hashes. Also fixes several bugs found while testing the above against real libraries (a 114 GB and a 100+ GB group on a USB HDD): - /api/reload blocked until the reload — including a brand-new group's full initial scan — finished, which the Electron bridge's fixed 30s call timeout turned into a hard failure on any real library. The route now fires the reload without waiting (ops.start_reload), matching add_root/remove_root's existing pattern; the wizard's own step order was fixed to wait for the group to actually appear hosted before the steps that need it (extra roots, GEK), with retries for the residual race between that and the daemon's own bookkeeping. - transport.js's hand-rolled msgpack codec had no case for uint64/int64 (0xcf/0xd3) and crashed decoding any message containing one — hit by IndexProgress.scanned_bytes/total_bytes for any group over ~4.3 GB. Verified against real msgpack-encoded bytes from the Python side. - chat_hist_resp, and this change's own index_progress and set_scan_settings_ack pushes, were not routed by message type and could be handed to an unrelated pending request by the transport's "oldest pending" fallback, stalling it until its own 30s timeout and corrupting whatever received the wrong reply in its place. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
* feat(node): systemd service panel on the Node page, and a clean CLI restartChristophe Besson2026-08-221-0/+21
| | | | | | | | | | | | | | | | | | | | | Add a status panel at the top of the Node page — always visible, even before an MNP connection exists — showing the meshbay-node systemd unit's own state (via `systemctl --user show`, main process only) with Start/Stop/Restart controls. This is the piece the rest of the page cannot provide: it has to work while the daemon is stopped or crash- looping, which the MNP-based sections require the daemon to already answer. While touching node lifecycle: `reload` and `restart-daemon` in the CLI shelled out to pgrep + SIGTERM/SIGHUP and respawned the process by hand, logging to a hardcoded /tmp path. That pattern already SIGHUPed a developer's own running node by accident once (see the old test_cli_dispatch.py comment). Both now delegate to `systemctl --user reload|restart meshbay-node`, which the unit already supports correctly (ExecReload=, Restart=on-failure). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
* feat: LAN Wi-Fi casting to Chromecast via local HTTP relayChristophe Besson2026-08-211-0/+33
| | | | | | | | | | | | | | | | | | | | | | 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 <noreply@anthropic.com>
* feat: unified group management, public groups, and activity-based sidebarChristophe Besson2026-08-201-0/+68
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Create Group wizard (Electron-only) consolidates 6 steps across 4 interfaces into a single multi-step page: group creation on hub, node attachment, root selection via folder picker, GEK initialization, and auto-pairing — all in one flow. Browser SPA keeps its current behavior unchanged. Public group support (Option A — GEK for all groups): - All groups have GEK regardless of visibility; open-join groups auto-admit via TOFU when join_policy is "open" - Key rotation blocked for public groups (API guard + UI hidden) - Hub signaling allows WebRTC offers for nodes hosting open-join groups even when the caller isn't a member yet - attach_group writes join_policy to node.toml - Daemon loads GEK for all groups, not just private ones - Known-device path in join_request now auto-admits to open-join groups Node loopback API bridge (Electron IPC): - node:detect, node:call, node:pairing-code IPC handlers in main process - Renderer never sees tokens, paths, or keys (session token = physical access) - platform.js node namespace for UI consumption - Loopback endpoints: roots CRUD, member-upload toggle, reload Bug fixes: - Root change detection: removed premature ctx["roots"] updates from add_root and remove_root that prevented indexer retarget on reload - Duplicate offline message: global fallback now gated on !group - Signaling membership check: fallback to open-join groups for non-members Sidebar groups sorted by last_activity_at (most recent first): - New Group.last_activity_at column with Alembic migration - POST /v1/groups/{id}/activity endpoint, called on connect and chat send - Client-side sort + throttled hub updates (1/min) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(node): full Node admin panel — CLI parity, hot-reload, group lifecycleChristophe Besson2026-08-201-0/+174
| | | | | | | | | | | | | | | | | | | | 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 <noreply@anthropic.com>
* feat(ui): 11-point UI overhaul — tabs, transfers, settings, uploadsChristophe Besson2026-08-191-16/+31
| | | | | | | | | | | | | | | | | | | | | | | | | | | | Hub/UI: - Icon-only group tabs (chat, files, settings) with per-group default tab - Transfer widget: filename becomes a clickable link to open completed downloads - Pulse animation on transfer icon (pale→dark green) while active - Download button feedback in FilePreview (spinner, auto-reset) - Group mute toggle persists across navigation - Login page autofocus, chat refocus after send - Theme toggle closes menu, status badge and duplicate connecting removed - Create-folder restricted to operators, download-path note removed - User preferences API (CRUD) with Alembic migration - Profile: email display/edit via PATCH /v1/users/me - Settings: "Defaults" section for default tab selector - All 10 locale files updated Node: - upload_dir in node.toml: separate filesystem path for uploads - Root.direct flag: uploads land at root path, no subdirectory - CLI --upload-dir flag on `group add` - Admin UI accepts upload_dir Client (Electron): - shell.openPath bridge for opening completed downloads - platform.js passes open callback from native save Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(ui): the M of the wordmark is the logoChristophe Besson2026-08-191-2/+34
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The stylised M replaces the letter M in "MeshBay" at the top left; `eshBay` beside it stays text. **The source artwork is now in git.** It was in `QE/`, which is gitignored, so it would not have survived a clone — and the tree is about to be moved to another machine. `assets/brand/` holds the original and the `convert` command that derives the 10 KB nav asset from it (trimmed, 72px tall for a picture shown at 30, so it survives a 2× screen). Verified: the documented command reproduces the committed file byte for byte. The derived file is in `_ASSETS`, which is what the `/a/<hash>/` fingerprint is computed from. A file missing from that list is a file whose change never moves the URL, so a browser holding the old one never asks again — the comment on that list says so, and a logo is exactly the kind of asset one would forget. Its URL is resolved from `import.meta.url`, so the hub's fingerprinted path and the application's `app://` scheme both come out right without either being named in the source. **The two sizes are independent, and that took four rounds to get right.** The picture's height was written in `em` — a fraction of the lettering — so every adjustment to the text resized the picture by the same stroke and the ratio between them could never change. The operator spotted the coupling before I did ("si on change la taille du M, tu vas encore tout décaler"). The height is in pixels now: `font-size: 1.375em` (22px) and `height: 30px`, two knobs that move one thing each. The vertical nudge went with it — it was correcting a misalignment I was causing myself. Also: the wordmark blue goes one step down from sky-400 towards sky-500. It sat brighter than the picture next to it, which made the two read as different materials rather than one object. A note on how this went wrong first: I checked the result on a mock HTML page I wrote, not in the application. The picture was loading the whole time, but at 25px against a 29px line box it was *smaller* than the letters, so the wordmark read as unchanged and the report was "none of what I asked for was done". Every size since has been measured in the running window over CDP. 883 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-181-7/+5
| | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-181-8/+99
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-181-0/+8
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | **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>
* feat(node): several named roots per group, and one implementation per operationChristophe Besson2026-08-181-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>
* fix(hub): the transfers panel hung off the side of a phoneChristophe Besson2026-08-171-0/+23
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported: on mobile you see only the right-hand edge of the panel, without the content. Measured, before anything was changed: 320 px viewport -> panel at -138..192, 138 px off the left 360 px -> -98..232 412 px -> -46..284 The panel is 330 px wide and anchored to the right edge of its button — but that button is not at the right edge of the screen, since the bell and the user menu come after it. What falls off is the left-hand side, which is where the file names are, so what stayed on screen was a strip of progress bars belonging to nothing. Narrowing it would not have helped: the overflow comes from where the right edge is pinned, not from the width. Below the existing 768 px breakpoint the panel is anchored to the viewport instead, full width on a phone and capped at 420 px on a tablet, where stretching two filenames across 750 px would be silly. Desktop keeps its 330 px against the button. The interesting part is how it was found. The responsive tests read numbers out of the stylesheet and said, in their own docstring, that a layout could not be measured because the suite had no browser. It has one now — Chrome, from the video work — so layout_probe.py renders the real stylesheet at a given width and returns rectangles. `width: 330px` was never the thing worth asserting on. An iframe carries the viewport, because a headless window will not go below about 500 px, and one browser measures every width: launching one per test put three minutes on the suite against twenty-six seconds for all of them. Checked that the new tests fail with the rule removed — three of them do — and that they pass with it back.
* feat(hub): move about in a film, and pick it up where it was leftChristophe Besson2026-08-171-0/+35
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-161-54/+252
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-161-1/+6
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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>
* fix(downloads): automatic really is automatic, and a selection downloads all ↵Christophe Besson2026-08-151-7/+76
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | of it Two bugs in what shipped last, and both were mine. Automatic mode still opened Save As, because with no folder granted the code fell through to the file picker — while the documentation said it would use the browser's own download folder. It does that now. Over 512 MB it still asks, since getting there means holding the file in memory and a tab will not survive a 40 GB blob; Settings is where to stop it asking again. Selecting two files downloaded one. They were started without awaiting, so each asked the browser for a save dialog at once, and a browser allows exactly one — the rest were rejected and the errors went nowhere. They are awaited one at a time now, which serializes the dialogs and not the transfers: each call returns as soon as its transfer is registered. Then the adjustments. The transfers widget offers Open on a finished download that went into a granted folder — the bytes go to a new tab, and that is the whole of what a page can do: no browser lets one start a desktop application or show a file manager, so the folder half of that request cannot be built and the guide says so. The Files toolbar was four controls of three different heights in a row. It is three groups now — what you can add, where you are, what you can do with what is here — on one baseline, with icons from the set and a gap between the dots and the word Actions. Chat comes first among the tabs and is the one you land on. The three Discover entries in the sidebar have icons. And a link in a chat message becomes a link: built as an element and never as markup, http and https only, so `javascript:` is not one message away from running here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(settings): choose between Save As and saving into a folderChristophe Besson2026-08-151-0/+11
| | | | | | | | | | | | | | | | | | | | | | | | | | Downloading a selection of twenty files meant twenty Save As dialogs, which is the wrong answer for the feature that had just been built. Settings → Downloads now offers saving automatically, and that is the default; asking every time stays available for people who want it. The correction worth recording: a web page cannot be given a filesystem path and cannot read one either. There is no ~/Downloads to configure and nothing to type, on any operating system — which is also why none of this will need changing on Windows. What a browser grants is a handle to a folder the user picked in a dialog, so that is what the setting keeps: picked once, stored in IndexedDB, re-confirmed once a session because the grant comes back as a claim rather than a permission. Where no folder has been granted, and in Firefox and Safari where none can be, files go to the browser's own download folder — which on most machines is the folder that was meant all along. Automatic saving has one risk a dialog does not: it can silently replace a file. It does not — a taken name gets a suffix before the extension, `clip (2).mp4`, so a download folder does not fill up with files the system no longer recognises. That, and the default, are what test_downloads.py pins. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(files): transfers that outlive the page, and selection instead of ↵Christophe Besson2026-08-151-25/+76
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | per-row menus Downloads and uploads were state inside GroupPage. Leaving a group unmounted the component, its cleanup closed the DataChannel, and a half-written file was all you had — which is also why only one thing could be in flight at a time. They live in a module-level store now. A group page hands its transport over on the way out rather than closing it, and the last transfer using it closes it; signing out is the one thing that cancels everything, because those transfers are moving data on a token about to stop being ours. The store is plain JavaScript with no browser globals, so test_transfers.py runs it under Node and pins the parts that are timing and lifetime rather than markup: that a cancel stops the work instead of greying out a row, that a stalled transfer reads as stalled rather than reporting its own historical average, and that a released transport is closed by the last transfer and not before. The widget by the bell shows each transfer with its rate and a cancel button, so the Files panel no longer carries progress bars — you can watch a 40 GB archive from the chat, or from another group. Selection replaces the per-row menu: a Select toggle puts checkboxes on files and folders, and ⋮ Actions acts on what is ticked. Ticks survive walking into another folder, so a selection can span directories. Downloads start together and run together. Videos offer Play only — View did the same thing, which is the sort of duplication that makes people wonder what the difference is. Uploads had to become parallel-safe for any of this to mean anything: their acks were matched by arrival order, so two at once credited each other's progress. The node names the file in every ack, so they are keyed by name now — with the same file twice refused, since the node keys its own upload state that way too. Two mistakes worth recording. The selection column went into the body rows and not the header, because that edit matched nothing and I had not made it assert; the columns were misaligned until a screenshot showed it. And the Actions menu opened leftwards from a button at the right edge of the toolbar, half of it off-screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(groups): editable description, and one source of operator authorityChristophe Besson2026-08-151-0/+47
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A description could only be set the moment a group was created, so every group made before anyone thought of one stayed blank for good. The owner can now edit it from the group's page, and PATCH /v1/groups/{id} takes it. That endpoint takes the description and nothing else, deliberately. The name, the visibility and the join policy are the terms members joined on; a private group that can quietly become public is not the group they agreed to be in. Changing those needs a decision about who gets told, not a field on a form — there is a test saying so. Separately, the legacy operator key is gone. `admin_pk_ed25519` in node.toml named the operator before the roster existed and was kept so that an existing deployment would keep working; nothing uses it, and a second source of node authority is not something to carry around out of politeness. Authority is the roster, read fresh on every check. It is removed rather than ignored: a config that still names the key gets a warning at startup pointing at the file. Dropping it in silence would refuse invites and file deletion with a signature error that looks like a bug somewhere else — which is exactly how finding M3 presented. Two tests were verifying admin operations by naming a key in the context, which was the legacy path. They now pair an operator into a roster, the way an operator does. The authority test anchored on the deleted function and passed vacuously once it disappeared; it states the invariant against the verifier and the daemon instead. Also defined .btn-secondary, used in four places and styled in none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* The Files panel stops guessing, and search says how old its answer isChristophe Besson2026-08-151-0/+2
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | Four small things, two of which were the same bug wearing different hats. The index cache seeded the Files panel and then raced the live index: IndexedDB is async, so a fast node could hand you the real list and have it overwritten a moment later by the cached one. That is the "choses bizarres". The panel now shows what the node says, or says it cannot reach the node — no third state that looks like data but is memory. The cache stays, written on every sync, and the search page is the only thing that reads it. Search across groups has no other source: it cannot connect to every node to answer a keystroke. So it now says how stale each hit is — "synced 2 hours ago", per group — and a line under the results explains that opening a group refreshes what search knows about it. Deleting a file also rewrites the cache now; it used to refresh the table and leave the cache holding a file that no longer existed, which is why search kept offering it. The invite form is shown only once this browser holds an operator key. Invites are signed with it and the node checks the signature against its roster, so an unpaired browser could fill the form in and fail on submit. An owner who is not the node's operator is told to ask the one who is. Group descriptions now show on the home cards, as they already did in Explore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>