<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-hub/src/meshbay_hub/static/search-page.js, branch 0.13</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=0.13</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=0.13'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-09-10T15:49:58Z</updated>
<entry>
<title>refactor(mnp)!: one operation for an app's folders, not one per app</title>
<updated>2026-09-10T15:49:58Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-10T15:49:58Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=07ff8b4f6143039fcc74b8cf7c423282bce093c1'/>
<id>urn:sha1:07ff8b4f6143039fcc74b8cf7c423282bce093c1</id>
<content type='text'>
`video_root`, `audio_root` and `photo_roots` are gone — the messages, the
signed operations, the handlers, the `ops` wrappers, the three scalars on the
handshake ack, and the client's handlers for their acks. `app_directories`
does the same thing for every application, keyed by the app's own registry
name, and it is what the SPA has been sending.

The three were the same instruction three times, differing only in the key they
wrote and whether they carried a string or a list. That shape is what made
adding an application mean adding a message type, an op, a handler and a widget;
it also meant three validation paths, and the older ones validated nothing —
a typo was stored and then quietly matched no entry, an app showing an empty tab
with no way to tell "misconfigured" from "no files yet".

**What stays, and why.** `Roster.LEGACY_DIR_KEYS` still reads `video_root` and
friends out of `group_settings`: that is a key on an operator's disk, not on the
wire, and a node upgraded into this must find its own configuration. The Search
page still reads its own older cache keys, for the same reason — the cache
outlives a deploy. `CTX_ALIASES` keeps only `chat`, which is the one app whose
second name something still reads.

The two per-app policy test files go with the messages. What only they held —
the real challenge/response path from message to database, which no other test
exercises — is retargeted at `app_directories` in
`test_app_directories_signed.py`, and the handler's own refusals (unknown app,
malformed `directories`, nobody to authorize it) join `test_app_directories.py`.

Node and common suites 1368 passed.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
</content>
</entry>
<entry>
<title>feat(hub): pin the app controls while a library scrolls</title>
<updated>2026-09-10T08:29:12Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-10T08:29:12Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=c2bc79f71c553f3c8e76c592715266c8469e583e'/>
<id>urn:sha1:c2bc79f71c553f3c8e76c592715266c8469e583e</id>
<content type='text'>
Files, Videos, Music and Photos are read by scrolling, and everything that
steers that reading left with the first screenful. Three bands now pin under
the navigation bar, in a group and on the Search page alike: the tab bar (the
search field, on Search), the application's own toolbar, and the file table's
column heads. The group's name and description still scroll — they say nothing
a reader needs while walking a directory, and the height they would cost is
height the list does not get.

A band's offset is the heights of the bands above it, and those are not
constants: the toolbar wraps to three rows on a phone, grows a field while a
folder is being named, and loses its filter on Search. So each band measures
itself and publishes `--chrome-h` / `--toolbar-h` (static/sticky.js) and the
stylesheet does the arithmetic in calc(), rather than a number written down
twice — the fault CLAUDE.md already records against this layout twice over.
A band publishes height *plus its own bottom margin*, and paints that margin
as a ring of page colour, so the pinned layout is pixel-identical to the flow
layout and nothing shifts at the moment a band pins.

Three overflow faults came out of it, all of the same class and all of them
what "the header does not stay" actually meant on Android — a document wider
than the screen leaves everything pinned attached to a viewport the reader can
no longer see, the navigation bar included:

  - a directory's name cell was a bare &lt;td&gt;, so an unbreakable folder name
    (`Rage_Against_The_Machine_Discography_1992-2000_FLAC`) set the column's
    minimum: a 527px table in a 390px window
  - Search's group column did the same at 442px with an underscored group
    name. It also goes entirely below 768px, where there is no room for it and
    the breadcrumb already names the group
  - the shared-directories table has four columns of controls with a combined
    minimum near 440px, none of it compressible. On a phone the row stops
    being a row: the name and its eject/remove pair on one line, the two
    switches — each carrying the column head's own string as a label — on the
    next
  - and, found by measuring at 360px, the tab bar itself was 19px too wide

`.file-table` moves to separated borders: a collapsed border belongs to the
table rather than to the cell, so the column heads lost their rule the moment
they pinned.

Measured, not read. tests/harness/sticky_header_probe.py drives the shipped
GroupPage and SearchPage against a stub node, walks to each application,
scrolls to the end and reports every rectangle — 11 views x 4 widths x 2
engines. Its fixture says what real data says: the first version used
`note-007.txt` and `un groupe`, which fit any screen, and found none of the
above. A fixture narrower than real data tests the fixture.

Also: `test_desktop_shell` no longer looks for the CSP after the first `--&gt;`,
which made it fail on correct markup as soon as a comment was added above it,
and `search-page.js` joins test_hook_ordering's file list.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01Tx16FhyD2BUdpooGb5jcyN
</content>
</entry>
<entry>
<title>feat(client): Phase 2 — per-app settings panes, folder tree, multi-directory</title>
<updated>2026-09-06T17:03:22Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-06T17:03:22Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=ab44526a291fa673aa2850d105f6412a70a5341f'/>
<id>urn:sha1:ab44526a291fa673aa2850d105f6412a70a5341f</id>
<content type='text'>
Each app's settings were inlined in `group-settings.js` — TMDB, MusicBrainz,
and one folder picker per app, each with its own draft state and save handler
saying the same thing about a different key. They are one file per app now,
reached through the `apps.js` registry, and the page that renders them names no
application at all: adding one is a registry entry and a settings file.

The line between the two is what makes that true. What every app has — folders
— the page does generically, through one `saveDirectories` bound to the app.
What one app alone has, its pane does itself with the transport it is handed.
An app that only needs directories touches neither `group-settings.js` nor
`group-page.js`, which is `test_app_settings_plugin.py`'s subject.

`settings-ui.js` exists because a pane importing the page that renders it is a
cycle, and ES modules answer that with a temporal-dead-zone ReferenceError at
first render — a component that silently does not appear, the fault already
recorded in CLAUDE.md about hook ordering.

The flat depth-indented `&lt;select&gt;` of every folder in the library becomes a
modal tree. It asks the node for nothing: the tree is derived from paths the
client already holds, so it shows exactly what the group's index contains and
adds no folder-browsing protocol. For Chat's attachment folder — the one
directory that is written to rather than read — read-only roots are greyed
out, so the node's refusal arrives before the operator picks rather than when
somebody sends a file.

Videos and Music take a list of folders. A library on two drives could not be
described before; the only recourse was pointing the app at a parent containing
both, which pulls in everything else under it. The scalar shapes survive on the
wire alone, for a node speaking MNP 1.0, and the client reads them as a
one-element list.

Two things the tests caught that I would not have:

`test_asset_versioning` — six new modules were missing from `_ASSETS`. Reached
through the registry rather than imported by name, they are exactly the files
nothing else would notice changing, and a stale one is served from cache with
no version bump.

And `node --check foo.js` does **not** reliably report a module syntax error:
it accepted `${/* ... */''}` — htm template syntax pasted into a plain object
literal — and reported success. A `.mjs` copy forces the module parser and
reports it. The suite had no syntax check at all, which is how that reached a
file; `test_spa_syntax.py` does it for every module now, and pins that the
loose path is not what it uses.

Suite: 12 failures, all pre-existing.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
</content>
</entry>
<entry>
<title>fix(groups): finish Phase 1 — MNP root management, upload targets, eject state</title>
<updated>2026-09-06T15:48:36Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-06T15:48:36Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=ea56b8c79538323875c00db2e7006b255f7cd494'/>
<id>urn:sha1:ea56b8c79538323875c00db2e7006b255f7cd494</id>
<content type='text'>
Review of the Phase 1 commit found the RO/RW model sound but three paths
unfinished, each of which broke the flow the phase exists to deliver. Plus
29 test failures it introduced and no coverage for anything it added.

Uploads went to the wrong directory. The node read a `root` field on
file_upload that no client ever sent, so every upload landed in the first
writable root while the Files toolbar offered its button based on the root
being browsed — with two writable roots, uploading from one wrote into the
other. Files now names the root it is showing; Chat names one chosen in the
shell (an operator-configured directory arrives in Phase 2); the node refuses
an unknown name rather than falling back, and refuses read-only and ejected
roots by code.

Shared directories were unreachable on the web. The table read its roots
only from the loopback API, which resolves to "not available" in a browser,
so the section rendered for nobody there — while the Uploads controls it
replaced had worked — and the transport.updateRoot/ejectRoot/plugRoot methods
beside it were dead. MNP is now the path, loopback the fallback for a local
node with no live connection, and adding a root over MNP takes a typed path
since no web page can browse a remote disk.

Ejecting updated nobody's screen. transport.js resolves an admin ack against
the pending request and returns, which is right for every op whose caller
knows the value it chose; the root acks carry state only the node can compute,
so the operator who clicked Eject was the one client that never saw it happen.
And the ejected flag reached roster.db but was never read back, so a restart
undid it and the next scan read an empty mount point as an erased library.

Also: the member-upload endpoint answered 200 and did nothing (removed); the
wizard ignored the first root's RW switch; reload compared roots on name and
path, so editing writable in node.toml did nothing; the table had no path
column, which is the only thing separating two libraries sharing a basename;
apps_enabled normalisation differed between the two sides of a signed subject.

Tests: eject/plug, per-root upload refusal and the node.toml rewrite had no
coverage at all. test_member_upload_policy.py is replaced by
test_root_writable_policy.py — it tested a removed feature — and every
property worth keeping from it moved rather than being dropped.

Docs: draft-v6 structural decision 9 is annotated as superseded (the operator
can no longer have a directory only they may write to — a real capability
removed, flagged rather than hidden), the man page documents the root verb and
the RO/RW fields, and refactor-groups.md §7b records what the plan got wrong.

Suite: 41 failures before, 13 after — all 13 pre-existing on main.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
</content>
</entry>
<entry>
<title>fix(hub): merge duplicate sources in Search's Music and Photos too</title>
<updated>2026-09-02T14:21:48Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-02T14:21:48Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=10f8266e7152d7dc38dbfe2449327829bf020ad1'/>
<id>urn:sha1:10f8266e7152d7dc38dbfe2449327829bf020ad1</id>
<content type='text'>
Phases 5-8 of docs/refactoring-search.md, extending the Videos merge
outward. A library shared by two groups now lists each track once inside
an album and each photo once inside a photo album, and a card served by
several groups says "N sources" instead of naming one of them.

Units come from each application's own grouping, never a copy of its
keys. For Music that meant exporting foldKey: groupMusicEntries folds
case to group but keeps the first-seen spelling to display, and which
group is seen first is whichever index arrived first — so keying a unit
on the display strings would let the chosen source change between page
loads.

A group whose connection fails is marked down and stops being chosen, so
a unit fails over to another group that has the file. Eviction is not a
failure. Every source being down still yields an entry: a tile that
fails to load beats a film that vanished from the grid.

sourceLabel now takes the whole unit rather than one entry. A show's
poster entry is picked for its thumbnail, so a show in two groups whose
cover episode sits in only one of them would have claimed a single
source. SourceTag lives in group-name.js — source-merge.js must keep
importing nothing (its test executes it standalone), and a copy in each
of the three apps is three chances to disagree.

test_search_files_unmerged.py holds the one thing that must not change:
the Files explorer is not merged, because there each group is a folder
and merging would remove a file from one of them. It also asserts the
other three lists are merged, or deleting the merge outright would leave
it passing and saying nothing.

One plan item was dropped as wrong rather than built: the Music queue in
onPreview needed no change. It filters by groupId and is reachable only
from FilesPanel, which is not merged.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01AbwJDbNTkiRUh7HTWEoyss
</content>
</entry>
<entry>
<title>fix(hub): one entry per file in the Search view's Videos grid</title>
<updated>2026-09-02T13:23:30Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-02T13:23:30Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=313b72f15e8788ba3abcd3e44b5f7785fbc779fe'/>
<id>urn:sha1:313b72f15e8788ba3abcd3e44b5f7785fbc779fe</id>
<content type='text'>
A library shared by two groups arrived in the cross-group Search view as
two entries per file: every film was two poster cards, every episode was
listed twice in the season list under the synopsis. Inside one group
this cannot happen — GroupIndex is keyed by blake3 — so the duplication
was the Search page's own, from concatenating N independently keyed
indexes.

source-merge.js folds entries on the content hash and resolves one
source per *unit* (a film, a whole show), so a season does not scatter
across two nodes. A group hosted by the reader's own node wins; failing
that the pick is a hash of the unit key and the reader's id, stable
across renders and reloads — a source that changed mid-stream would tear
down the connection under a film that is playing — and spread across
readers and units.

The units come from video-app.js's own groupVideoEntries rather than a
second copy of its keys here. Only the Videos view is wired up so far;
Music, Photos, failover and the "N sources" badge are phases 5-8 of
docs/refactoring-search.md.

Every test was checked against the fix removed. That is how the first
version of "a unit's files share its source" turned out to prove
nothing: with every episode in every group, per-file and per-unit
picking give the same answer, so it passed against a per-file
implementation. It now uses a unit whose files have unequal sources.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01AbwJDbNTkiRUh7HTWEoyss
</content>
</entry>
<entry>
<title>fix(hub): stop Search-view video posters flickering to spinners</title>
<updated>2026-09-01T12:48:13Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-01T12:48:13Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=b83803a91753b38435eb4d3be2e683daae9b4de9'/>
<id>urn:sha1:b83803a91753b38435eb4d3be2e683daae9b4de9</id>
<content type='text'>
The cross-group "Search files" view caps its WebRTC connection pool at
MAX_POOL_SIZE but pre-connected every indexed group, so any account with
more groups than the cap thrashed the pool. An evicted transport was
never removed from SearchPage's own groupConns map, so every tile of that
group kept a `_tRef` pointing at a closed transport; MediaThumb and
useMediaMeta bail on a disconnected transport with no retry, so posters
rendered for a moment then fell back to a loading spinner for good. Every
connect also fired the module-wide bumpMediaMetaGeneration(), clearing
every mounted tile's metadata across all groups and flickering the whole
grid through the warm-up walk. A one-group account (grenet) never hit it;
a many-group account (cbesson) always did.

- ConnectionPool takes an onEvict callback; SearchPage prunes groupConns
  and rebuilds the entry lists when a connection is evicted or closed.
- MAX_POOL_SIZE 3 -&gt; 12; the pre-connect walk is capped to it. Groups
  past the cap connect lazily when a tile scrolls into view (onNeedConn).
- connectGroup() no longer fires the module-wide meta/thumb generation
  bumps on a normal connect (kept for operator TMDB override/rematch).
  Per-group gen counter + concurrent-caller guard: a group's tiles
  refetch once per (re)connect, not once per mounting tile.
- MediaThumb/useMediaMeta take an optional reloadKey; each group's
  `_connGen` is threaded through so a tile refetches when its group
  reconnects instead of staying stuck on a spinner.

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01W4Yj8EuXkjjYxbeS5kPd3U
</content>
</entry>
<entry>
<title>fix(hub): show the Files refresh button only on the Search page</title>
<updated>2026-08-30T13:19:15Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-30T13:19:15Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=117f3d2611780aacda60ca529992ca3111de6634'/>
<id>urn:sha1:117f3d2611780aacda60ca529992ca3111de6634</id>
<content type='text'>
In a real group the node streams every index change to connected clients
as it happens (onIndexSync / onIndexDelta), so a manual re-fetch button
there just duplicates the live push. The cross-group Search page has no
such connection — its file list is a one-shot cached fetch — so the
button belongs there and only there. Gated on a new explicit `showRefresh`
prop that only search-page.js passes; `onRefreshIndex` stays in
commonProps for ChatPanel's own use after an attachment upload.

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
</content>
</entry>
<entry>
<title>fix(hub): wire the Files refresh button on the Search page</title>
<updated>2026-08-30T08:50:51Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-30T08:50:51Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=397727d2bf3f24c81ddda1e7a9eda197d7ed3b9e'/>
<id>urn:sha1:397727d2bf3f24c81ddda1e7a9eda197d7ed3b9e</id>
<content type='text'>
The refresh button (added in 1cb9054, left of the breadcrumb home icon)
was gated on `onRefreshIndex`, which the Search page's embedded FilesPanel
did not pass — so it never showed on the one page it was asked for. The
Search page now passes an `onRefreshIndex` that re-runs its all-groups
index fetch (the only cache it has); the existing progress bar is the
feedback. Unchanged in the normal group Files view.

Co-Authored-By: Claude Sonnet 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
</content>
</entry>
<entry>
<title>feat(hub): drop the Search page title, add a Files refresh button</title>
<updated>2026-08-29T15:35:20Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-29T15:35:20Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=a36661b4c2a24199922d60be1bcb6ed597b433a5'/>
<id>urn:sha1:a36661b4c2a24199922d60be1bcb6ed597b433a5</id>
<content type='text'>
1. The "Search Files" &lt;h2&gt; 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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
</content>
</entry>
</feed>
