summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-10 10:29:12 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-10 10:29:12 +0200
commitc2bc79f71c553f3c8e76c592715266c8469e583e (patch)
tree27e0a57231ed8b12518894526d0baee7621d261b
parent0b9b85bf2d434177968dfc6e27555d54d703ff7e (diff)
downloadmeshbay-c2bc79f71c553f3c8e76c592715266c8469e583e.tar.gz
feat(hub): pin the app controls while a library scrolls
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 <td>, 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 `-->`, 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tx16FhyD2BUdpooGb5jcyN
-rw-r--r--CLAUDE.md24
-rw-r--r--docs/apps.md17
-rw-r--r--packages/meshbay-client/scripts/index.html10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js26
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app.js20
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/search-page.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/sticky.js84
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css301
-rwxr-xr-xpackages/meshbay-hub/tests/harness/sticky_header_probe.py563
-rw-r--r--packages/meshbay-hub/tests/test_desktop_shell.py13
-rw-r--r--packages/meshbay-hub/tests/test_hook_ordering.py4
-rw-r--r--packages/meshbay-hub/tests/test_page_does_not_scroll.py7
-rw-r--r--packages/meshbay-hub/tests/test_sticky_header.py297
16 files changed, 1370 insertions, 33 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index b02eda7..269598e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -533,6 +533,30 @@ anything that assumes one key per person.
bound. Every await on a download path is now bounded and says which chunk it
gave up on — an unbounded one is a freeze nobody can report
+- **A name with no spaces in it sets a table column's minimum width, and on
+ Android that unpins the whole page.** Sticky headers were added to Files,
+ Videos, Music and Photos on 2026-09-09 and reported broken on a phone: not
+ the new bands but *everything*, the navigation bar included, which had been
+ `position: sticky` for months. That is the tell. A document wider than the
+ screen leaves everything pinned attached to a viewport the reader can no
+ longer see, so a header doing exactly what it was told looks like one that
+ was never pinned — **look for horizontal overflow before doubting the
+ sticky rules**. The overflow came from two `<td>`s that had no wrapping rule
+ because `.file-name` was only ever on a *file's* name: a folder called
+ `Rage_Against_The_Machine_Discography_1992-2000_FLAC` made a 527px table in a
+ 390px window, and Search's group column did the same at 442px with an
+ underscored group name. `word-break: break-word` is what lets such a cell
+ stop driving the column, and it has to be on every cell that carries a name
+ somebody else chose.
+ Two things cost more than the fix. The harness had measured this page in two
+ engines at three widths and found nothing, because its fixture said
+ `note-007.txt` and `un groupe` — **a fixture narrower than real data tests
+ the fixture**, and names are the one thing a file browser cannot be given
+ short. And a first diagnosis blamed the soft keyboard (the Search field is
+ `autofocus`, and Android's `interactive-widget` default splits the visual
+ viewport from the layout one), which was plausible, cost a round trip, and
+ was wrong; the screenshot showing no keyboard was already in hand.
+
- **Three headers decide whether a page may frame itself, and they must agree.**
The same streamed download navigates a hidden iframe to `/_mbdl/<id>`.
`frame-src` was reCAPTCHA's two origins with no `'self'`, `frame-ancestors`
diff --git a/docs/apps.md b/docs/apps.md
index 85b39da..8054802 100644
--- a/docs/apps.md
+++ b/docs/apps.md
@@ -236,6 +236,19 @@ registry, so a newly-registered app gets a checkbox for free.
through `saveDirectories`; anything only this app has, it does itself with
the transport. **Do not import `group-settings.js`** — that is a cycle, and
it fails as a component that silently does not render.
+2c. **The toolbar pins.** If the app has a toolbar — a row of controls above
+ whatever it is the app shows — give it `position: sticky` on the pattern
+ `style.css`'s "Sticky chrome" section holds, so that scrolling a library
+ does not take its own controls off the screen. Two conditions come with it,
+ and both are structural rather than cosmetic: the toolbar must be a
+ **direct child of the page root** (an app renders a fragment, so it already
+ is — do not wrap it in a container of your own), and it must be **opaque**,
+ or the content scrolls visibly through it. If anything of the app's pins
+ *below* that toolbar, as Files' column heads do, the toolbar has to publish
+ its own height with `useStickyBand` from `sticky.js` — its height is never
+ a constant, since it wraps on a phone. An app with no toolbar renders none:
+ an empty band still holds a strip of the page open, which is why Photos
+ draws no toolbar on the Search page.
3. **Node-side allow-list**: add the key to `ALLOWED_APPS` in
`webrtc_server.py`. Without this the node refuses `apps_enabled` for any
set naming it (`"Unknown app(s): ..."`), so an operator can never turn it
@@ -252,7 +265,9 @@ registry, so a newly-registered app gets a checkbox for free.
to every `.js` in `static/` (`sw.js` excepted, unversioned on purpose).
Written after `source-merge.js` shipped missing from the list.
6. **Test coverage that scans the file set**: `test_hook_ordering.py`
- (`STATIC_FILES`) and `test_transport_contracts.py`
+ (`STATIC_FILES`), `test_sticky_header.py` (add a case to its probe if the
+ app has a toolbar — a band that stopped pinning looks exactly like one that
+ never did) and `test_transport_contracts.py`
(`test_no_setter_survives_the_state_it_belonged_to`, `SPLIT_FILES`) walk a
fixed list of files looking for a whole class of bug each — add the new
file to both lists, or it is simply never checked, which fails silently
diff --git a/packages/meshbay-client/scripts/index.html b/packages/meshbay-client/scripts/index.html
index 337e180..ad7af0c 100644
--- a/packages/meshbay-client/scripts/index.html
+++ b/packages/meshbay-client/scripts/index.html
@@ -2,7 +2,15 @@
<html lang="en">
<head>
<meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
+ <!-- `interactive-widget=resizes-content`: on Android the soft keyboard
+ shrinks the visual viewport and leaves the layout viewport alone, which
+ is the platform default. `position: sticky` anchors to the layout
+ viewport, so with a keyboard up the pinned header of a group or of
+ Search sits at a coordinate the reader can no longer see — it reads as
+ though it had scrolled away. This asks for the keyboard to resize the
+ layout viewport instead, so what is pinned stays where it is looked at.
+ Ignored by browsers that do not know it. -->
+ <meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
<title>MeshBay</title>
<!--
The interface is loaded from this package, never from the hub. That is the
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
index 0d9d1f8..05485e0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
@@ -34,7 +34,7 @@ _ASSETS = ("style.css", "keyderive.js", "crypto.js", "transport.js", "app.js",
# app.js or group-page.js, so a change to any of them is a change
# to what the browser must fetch.
"icon.js", "file-utils.js", "hub-client.js", "apps.js",
- "source-merge.js",
+ "source-merge.js", "sticky.js",
"chat-app.js", "files-app.js", "video-player.js", "video-app.js",
"music-app.js", "music-player.js", "photos-app.js",
"group-settings.js", "group-page.js",
@@ -184,7 +184,15 @@ _HTML = """\
<html lang="en">
<head>
<meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
+ <!-- `interactive-widget=resizes-content`: on Android the soft keyboard
+ shrinks the visual viewport and leaves the layout viewport alone, which
+ is the platform default. `position: sticky` anchors to the layout
+ viewport, so with a keyboard up the pinned header of a group or of
+ Search sits at a coordinate the reader can no longer see — it reads as
+ though it had scrolled away. This asks for the keyboard to resize the
+ layout viewport instead, so what is pinned stays where it is looked at.
+ Ignored by browsers that do not know it. -->
+ <meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
<title>MeshBay</title>
<link rel="stylesheet" href="/a/{v}/style.css">
</head>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
index 65860ec..882a8fa 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
@@ -9,6 +9,7 @@ import {
FILE_ICONS, formatSize, formatDate, canPreview, CHUNK_SIZE, MEMORY_CEILING,
pipelinedDownload, downloadEntry, downloadDirectory as sharedDownloadDirectory,
} from './file-utils.js';
+import { useStickyBand } from './sticky.js';
// ── Files ────────────────────────────────────────────────────────────────────
//
@@ -35,6 +36,11 @@ function FilesPanel({
const [filter, setFilter] = useState('');
const [currentPath, setCurrentPath] = useState('');
const [refreshing, setRefreshing] = useState(false);
+ // The toolbar pins below the page's own band and tells the column heads how
+ // far down to pin. Its height is not a constant — it wraps to three rows on
+ // a phone and grows a field while a folder is being named — so it is
+ // measured rather than written down (sticky.js).
+ const toolbarBand = useStickyBand('--toolbar-h');
// Only the cross-group Search page shows this (`showRefresh`): it has no
// live node connection pushing index deltas, so its file list really is
@@ -406,7 +412,7 @@ function FilesPanel({
`}
${status === 'connected' && html`
- <div class="file-toolbar">
+ <div class="file-toolbar" ref=${toolbarBand}>
<div class="toolbar-group">
${currentPath && currentRootWritable && html`
<label class="tb-btn primary">
@@ -495,7 +501,7 @@ function FilesPanel({
<th class="sortable" onClick=${() => toggleSort('size')}>
${t('group.col_size')} ${sortKey === 'size' ? (sortAsc ? '▲' : '▼') : ''}
</th>
- ${showGroup && html`<th>${t('search.col_group')}</th>`}
+ ${showGroup && html`<th class="td-group">${t('search.col_group')}</th>`}
<th class="sortable th-type" onClick=${() => toggleSort('type')}>
${t('group.col_type')} ${sortKey === 'type' ? (sortAsc ? '▲' : '▼') : ''}
</th>
@@ -514,7 +520,7 @@ function FilesPanel({
<td>${'\u{1F4C1}'}</td>
<td>..</td>
<td class="file-size"></td>
- ${showGroup && html`<td></td>`}
+ ${showGroup && html`<td class="td-group"></td>`}
<td class="td-type"></td>
<td class="td-date"></td>
</tr>
@@ -536,7 +542,15 @@ function FilesPanel({
onChange=${() => toggle(dirKey(d))} />
</td>
<td>${isEjected ? '\u{23CF}' : isUnavail ? '\u{26A0}' : '\u{1F4C1}'}</td>
- <td>${d}${isEjected ? html`
+ ${/* `file-name`, like a file's own name cell. Without it this
+ was a bare <td>, so a folder called
+ `Rage_Against_The_Machine_Discography_1992-2000_FLAC` —
+ one unbreakable word, which is how music libraries are
+ named — set the column's minimum width to the whole
+ string. Measured on a phone: a 527px table in a 390px
+ window, and on Android a page wider than the screen takes
+ every pinned header out of the visible area with it. */''}
+ <td class="file-name">${d}${isEjected ? html`
<span class="root-offline"> ${t('group.root_ejected')}</span>
` : isUnavail ? html`
<span class="root-offline"> ${t('group.root_unavailable')}</span>
@@ -559,7 +573,7 @@ function FilesPanel({
}}>${isEjected ? '\u{1F50C}' : '\u{23CF}'}</button>
` : ''}</td>
<td class="file-size">${inside.length ? formatSize(bytes) : ''}</td>
- ${showGroup && html`<td></td>`}
+ ${showGroup && html`<td class="td-group"></td>`}
<td class="td-type"></td>
<td class="td-date"></td>
</tr>
@@ -582,7 +596,7 @@ function FilesPanel({
onClick=${() => { setFilter(''); setCurrentPath(e.path); }}>${e.path}</a>`}
</td>
<td class="file-size">${formatSize(e.size)}</td>
- ${showGroup && html`<td>
+ ${showGroup && html`<td class="td-group">
<a href="#/group/${e.groupId}" class="badge">${e.groupName || ''}</a>
</td>`}
<td class="file-type td-type">${e.type}</td>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index e897646..1b7ed53 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -11,6 +11,7 @@ import {
} from './hub-client.js';
import { APPS, visibleApps } from './apps.js';
import { GroupName } from './group-name.js';
+import { useStickyBand } from './sticky.js';
import { FilePreview } from './files-app.js';
import { VideoPlayer } from './video-player.js';
import { GroupSettingsPanel } from './group-settings.js';
@@ -26,6 +27,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
onRefreshAuth, onJoined, onGroupUpdated, onPresence, onLeft,
onPlayQueue: parentOnPlayQueue, onStopMusic }) {
const [status, setStatus] = useState('idle');
+ // The tab bar pins under the navigation bar and tells the application's own
+ // toolbar how far down to pin (style.css, "Sticky chrome").
+ const tabBand = useStickyBand('--chrome-h');
// Whether this connection has identified a device to the node (`device_hello`).
// Held as state, not read off the transport at render time: it is settled
// inside connect() and re-settled by every reconnect, and the Chat composer
@@ -711,7 +715,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
};
return html`
- <div>
+ <div class="sticky-chrome">
<div class="group-header">
<div>
<h2 style="margin-bottom:${group && group.description ? '4px' : '0'}">
@@ -811,7 +815,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
would otherwise have made them unreachable exactly when a node is
down — which is when someone is most likely to want them. The apps
below still need the node and say so. */ group && html`
- <div class="group-tabs">
+ <div class="group-tabs" ref=${tabBand}>
${apps.map(a => html`
<button key=${a.key} class="group-tab ${tab === a.key ? 'active' : ''}"
onClick=${() => setTab(a.key)} title=${t(a.labelKey)}>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
index 437af8b..dce5833 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -318,15 +318,23 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
</td>
${hasPaths && html`
<td class="sdt-col-path" title=${r.path || ''}>${r.path || ''}</td>`}
+ ${/* The same string as the column head above, and deliberately
+ the same key: below 768px the head is gone — the row is two
+ stacked lines there, not a table row — and a bare switch with
+ nothing beside it says nothing at all. The label is hidden by
+ the stylesheet at every width where the column head is
+ doing the job. */''}
${canEdit && html`
<td class="sdt-col-toggle">
<${ToggleSwitch} checked=${!!r.writable} disabled=${busy || !!r.ejected}
+ label=${t('node.root_rw')}
onChange=${(v) => doUpdateRoot(r.name, { writable: v })} />
</td>
`}
${canEdit && !isLocal && html`
<td class="sdt-col-toggle">
<${ToggleSwitch} checked=${!!r.removable} disabled=${busy}
+ label=${t('node.removable')}
onChange=${(v) => doUpdateRoot(r.name, { removable: v })} />
</td>
`}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
index a58af8c..2c4356c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
@@ -353,13 +353,19 @@ function PhotosApp({
<p class="page-message">${t('photo.no_roots_configured')}</p>
`}
${status === 'connected' && (photoDirectories || []).length > 0 && !openAlbum && html`
- <div class="photo-toolbar">
- ${!hideFilter && html`<div class="tb-search">
- <${Icon} name="search" />
- <input type="text" placeholder="${t('group.filter')}"
- value=${filter} onInput=${(e) => setFilter(e.target.value)} />
- </div>`}
- </div>
+ ${/* The filter is the only thing in it, so under `hideFilter` there is
+ no toolbar rather than an empty one — an empty band still pins,
+ and would hold a strip of the page open under the search field
+ for nothing. */
+ !hideFilter && html`
+ <div class="photo-toolbar">
+ <div class="tb-search">
+ <${Icon} name="search" />
+ <input type="text" placeholder="${t('group.filter')}"
+ value=${filter} onInput=${(e) => setFilter(e.target.value)} />
+ </div>
+ </div>
+ `}
${filteredAlbums.length === 0 && html`
<p class="page-message">${needle ? t('group.empty_filter') : t('photo.empty')}</p>
`}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
index 4ed3df1..81d4ac2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
@@ -14,6 +14,7 @@ import { PhotosApp, groupPhotoAlbums } from './photos-app.js';
import { VideoPlayer } from './video-player.js';
import { transfers } from './transfers.js';
import { mergeUnitEntries } from './source-merge.js';
+import { useStickyBand } from './sticky.js';
const BATCH_SIZE = 3;
// One WebRTC peer connection per group the search view touches. The cap bounds
@@ -653,6 +654,10 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
// No-op setters for FilesPanel
const noop = useCallback(() => {}, []);
+ // The search field and its view toggle are this page's equivalent of a
+ // group's tab bar: the same band, pinned the same way, publishing the same
+ // property for the toolbar underneath (style.css, "Sticky chrome").
+ const searchBand = useStickyBand('--chrome-h');
// -- Render --
@@ -662,8 +667,8 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
const defaultGRef = useRef(null);
return html`
- <div>
- <div class="search-bar">
+ <div class="sticky-chrome">
+ <div class="search-bar" ref=${searchBand}>
<${Icon} name="search" />
<input type="text"
placeholder=${t('search.placeholder')}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/sticky.js b/packages/meshbay-hub/src/meshbay_hub/static/sticky.js
new file mode 100644
index 0000000..e48d88d
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/sticky.js
@@ -0,0 +1,84 @@
+import { useRef, useCallback } from './vendor/htm-preact.js';
+
+// ── Sticky chrome ────────────────────────────────────────────────────────────
+//
+// Files, Videos, Music and Photos are read by scrolling, and everything that
+// steers that reading — which application, where in the tree, which filter —
+// used to scroll away with the first screenful. The controls stay pinned under
+// the navigation bar instead: the group's tab bar (or, on the Search page, the
+// search field and its view toggle), then the application's own toolbar, then
+// the file table's column heads. `style.css`'s "Sticky chrome" section holds
+// the rules; this holds the one thing CSS cannot supply.
+//
+// A band's `top` is the sum of the heights of the bands above it, and those
+// heights are not constants: the toolbar wraps to two or three rows on a phone
+// (`.video-toolbar { flex-wrap: wrap }` is deliberate), grows a field while a
+// folder is being named, and loses its filter entirely under `hideFilter`.
+// Writing a number down would be the second subtraction in a second file that
+// CLAUDE.md already records twice — a page permanently a few pixels wrong, and
+// nothing in either file to show it. So each band measures itself and publishes
+// its height as a custom property; the stylesheet does the arithmetic in
+// `calc()`, from the one measurement.
+//
+// This is not the mutate-then-measure loop that made the chat panel re-enter
+// itself 120 times a second. The property a band writes moves the `top` of a
+// *different*, lower band and nothing else: `--chrome-h` is read only by the
+// toolbars, `--toolbar-h` only by `.file-table th`. Neither can change the
+// height of the element being observed, so the observer cannot wake itself.
+//
+// The property lands on the band's **parent**, which is the page's own root
+// element and therefore an ancestor of every band under it. An application
+// returns a fragment rather than a single element (all four do), so there is no
+// per-application node to hang it on — but every one of those fragments is
+// rendered into the same page root, which is also where the tab bar and the
+// search bar sit.
+
+/**
+ * A ref for an element that pins under the navigation bar and publishes its
+ * own height as `name` for whatever pins under *it*.
+ *
+ * Returns a ref callback rather than taking a `useRef` object because these
+ * bands are rendered conditionally — the toolbar exists only once the node has
+ * answered — and an effect keyed on a ref would not run when the element
+ * finally appears. A ref callback is invoked when it does, and again with
+ * `null` when it goes, which is also where the property is withdrawn: a stale
+ * `--toolbar-h` left behind by Files would offset a table that is no longer
+ * on the page.
+ */
+export function useStickyBand(name) {
+ const attached = useRef(null);
+
+ return useCallback((el) => {
+ const prev = attached.current;
+ if (prev) {
+ if (prev.observer) prev.observer.disconnect();
+ prev.host.style.removeProperty(name);
+ attached.current = null;
+ }
+ // `parentElement` is null for the brief moment a ref is applied to an
+ // element not yet inserted; there is nothing to publish onto then, and the
+ // next mount calls this again.
+ if (!el || !el.parentElement) return;
+
+ const host = el.parentElement;
+ // Height **plus the band's own bottom margin**. What the band below needs
+ // is not where this one ends but where it ends *including the gap it keeps
+ // in the flow* — pinning absorbs that margin, and a band pinned flat
+ // against the one above it is what the first version of this shipped.
+ // The same number paints the gap (style.css's `--band-margin`), so the two
+ // cannot drift.
+ const publish = () => {
+ const gap = parseFloat(getComputedStyle(el).marginBottom) || 0;
+ host.style.setProperty(name, `${el.offsetHeight + gap}px`);
+ };
+ publish();
+
+ // Older engines without ResizeObserver keep the height measured at mount,
+ // which is right until the toolbar wraps. The band is still pinned; only
+ // the one below it can end up a row too high.
+ const observer = typeof ResizeObserver === 'undefined'
+ ? null : new ResizeObserver(publish);
+ if (observer) observer.observe(el);
+ attached.current = { host, observer };
+ }, [name]);
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 5e08a6a..774c9bc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -51,6 +51,28 @@
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.3);
}
+/* ── Metrics ──────────────────────────────────────────────────────────────── */
+
+/* Not a palette: these are the layout's own numbers, so they sit outside the
+ theme blocks and neither theme overrides them.
+
+ `--nav-h` is the height of `.nav`, which is `position: sticky` at the top of
+ every page — the offset everything else pins beneath. The other two are
+ *measured* at runtime by sticky.js and published onto the page's root
+ element, which shadows the value declared here. They are declared anyway, and
+ as `0px` rather than as nothing: an undefined custom property invalidates the
+ whole `calc()` that names it (test_css_variables.py has the story), so
+ without these a toolbar would silently lose its `top` — and lose it exactly
+ in the window between the first paint and the first measurement, which is
+ also the window a screenshot is most likely to catch. At `0px` the band pins
+ directly under the navigation bar instead, which is where it belongs when
+ there is nothing above it. */
+:root {
+ --nav-h: 52px;
+ --chrome-h: 0px;
+ --toolbar-h: 0px;
+}
+
/* ── Reset ────────────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -96,7 +118,12 @@ a:hover { text-decoration: underline; }
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-raised);
- margin-bottom: 12px;
+ /* A pinned band keeps this gap: `--band-margin` is both the margin in
+ the flow and the width of the opaque ring the band paints around
+ itself once pinned, so the two can never disagree (style.css,
+ "Sticky chrome"). */
+ --band-margin: 12px;
+ margin-bottom: var(--band-margin);
}
.search-bar .icon { width: 16px; height: 16px; flex-shrink: 0; color: var(--text-dim); }
.search-bar input {
@@ -454,6 +481,103 @@ a:hover { text-decoration: underline; }
visible and removes the duplicated arithmetic rather than adding more. */
.main:has(> .page-center) { padding: 0; }
+/* ── Sticky chrome ────────────────────────────────────────────────────────── */
+
+/* Files, Videos, Music and Photos are read by scrolling, and until now the
+ controls that steer that reading went with the first screenful: which
+ application, where in the tree, which filter, which column sorts. They pin
+ under the navigation bar instead, in three bands, on the two pages that show
+ those applications — a group (`group-page.js`) and Search (`search-page.js`),
+ both of which mark their root `.sticky-chrome`:
+
+ .nav 52px, already sticky, --nav-h
+ .group-tabs / .search-bar which application / what is being searched
+ the application's toolbar where in the tree, the filter, the actions
+ .file-table th the column heads
+
+ The group's name and description are deliberately *not* in the stack. They
+ say nothing a reader needs while walking a directory, and the height they
+ would cost is height the list does not get.
+
+ Each band pins below the ones above it, so its `top` is their heights added
+ up — heights that change with the window (`.video-toolbar` wraps on a phone,
+ by design), with the state (naming a folder grows the toolbar; `hideFilter`
+ shrinks it) and with the theme's font. sticky.js measures them and publishes
+ `--chrome-h` and `--toolbar-h`; everything here reads those rather than
+ repeating a number that would be right in one file and wrong in another.
+
+ `>` throughout, and not by accident: a band publishes its height onto its
+ own parent, so these rules and sticky.js are asserting the same structural
+ fact — these elements are children of the page root. Nest one deeper and
+ both stop applying, rather than the stylesheet pinning something the script
+ is no longer measuring.
+
+ `.file-toolbar` is also the toolbar of the public directory
+ (`explore-page.js`), which is not one of these pages and is left alone. */
+
+.sticky-chrome > .group-tabs,
+.sticky-chrome > .search-bar {
+ position: sticky;
+ top: var(--nav-h);
+ z-index: 30;
+}
+
+.sticky-chrome > .file-toolbar,
+.sticky-chrome > .video-toolbar,
+.sticky-chrome > .photo-toolbar,
+.sticky-chrome > .photo-album-bar {
+ position: sticky;
+ top: calc(var(--nav-h) + var(--chrome-h));
+ z-index: 20;
+}
+
+.sticky-chrome > .file-table th {
+ position: sticky;
+ top: calc(var(--nav-h) + var(--chrome-h) + var(--toolbar-h));
+ z-index: 10;
+}
+
+/* A pinned band has the list running underneath it and must be opaque, which
+ most of these were not: only `.search-bar` and `.file-toolbar` came with a
+ surface of their own. So the bare bands take the page colour outright. */
+.sticky-chrome > .group-tabs,
+.sticky-chrome > .video-toolbar,
+.sticky-chrome > .photo-toolbar,
+.sticky-chrome > .photo-album-bar,
+.sticky-chrome > .file-table th {
+ background: var(--bg-base);
+}
+
+/* **The gap a band keeps when it pins.** In the flow every band is followed by
+ a margin — 16px under the tab bar, 12px under the search field and the file
+ toolbar, 14px under the media ones. Pinning absorbs it: the band below stops
+ at the bottom edge of the band above and the two come into contact. Reported
+ on both counts, and they are the same fault — two rounded panels flush
+ against each other in Files read as *encastrés*, and the row of controls in
+ Videos/Music/Photos reads as glued to the tab bar.
+
+ So each band paints its own margin as a ring of page colour around itself,
+ and the band below pins past it: `--chrome-h`/`--toolbar-h` are published by
+ sticky.js as **height plus that margin**, which is why the ring and the
+ offset can never disagree — they are the same number, declared once beside
+ the component's own `margin-bottom` and nowhere else. The pinned layout is
+ then pixel-identical to the flow layout, so nothing shifts at the moment a
+ band pins.
+
+ The ring must be exactly the margin, not more: it is painted at the band's
+ own z-index, above every band below it, so a ring wider than the gap would
+ paint page colour over the top of the next band down. It is also what covers
+ the four transparent corners a border radius leaves, where a row's rule
+ sliding past would read as a flicker nobody can reproduce. */
+.sticky-chrome > .group-tabs,
+.sticky-chrome > .search-bar,
+.sticky-chrome > .file-toolbar,
+.sticky-chrome > .video-toolbar,
+.sticky-chrome > .photo-toolbar,
+.sticky-chrome > .photo-album-bar {
+ box-shadow: 0 0 0 var(--band-margin) var(--bg-base);
+}
+
/* ── Cards ────────────────────────────────────────────────────────────────── */
.card {
@@ -583,7 +707,12 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.group-tabs {
display: flex;
gap: 0;
- margin-bottom: 16px;
+ /* A pinned band keeps this gap: `--band-margin` is both the margin in
+ the flow and the width of the opaque ring the band paints around
+ itself once pinned, so the two can never disagree (style.css,
+ "Sticky chrome"). */
+ --band-margin: 16px;
+ margin-bottom: var(--band-margin);
border-bottom: 2px solid var(--border);
}
@@ -798,7 +927,12 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
display: flex;
align-items: center;
gap: 10px;
- margin-bottom: 12px;
+ /* A pinned band keeps this gap: `--band-margin` is both the margin in
+ the flow and the width of the opaque ring the band paints around
+ itself once pinned, so the two can never disagree (style.css,
+ "Sticky chrome"). */
+ --band-margin: 12px;
+ margin-bottom: var(--band-margin);
padding: 8px;
background: var(--bg-raised);
border: 1px solid var(--border);
@@ -924,7 +1058,15 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.file-table {
width: 100%;
- border-collapse: collapse;
+ /* Separated, with no spacing, rather than collapsed. A collapsed border is
+ shared between two cells and so belongs to the table rather than to either
+ of them — it stays where the table's flow put it while a `position:
+ sticky` column head moves away, so the head loses its rule the moment it
+ pins. Only the bottom edge of any cell here carries a border, so nothing
+ was being shared and the drawn result is the same single line; it now
+ travels with the head. */
+ border-collapse: separate;
+ border-spacing: 0;
font-size: 0.9em;
}
@@ -954,10 +1096,30 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.dir-row { cursor: pointer; }
.dir-row td { font-weight: 500; }
-.file-name {
+/* Every cell whose text is a name somebody else chose.
+
+ A table column is never narrower than its widest cell's minimum, and a name
+ with no spaces in it — `Rage_Against_The_Machine_Discography_1992-2000_FLAC`,
+ `musique_de_la_maison_2024` — is one unbreakable word. Left to itself such a
+ cell sets the column's minimum to the whole string and the table grows past
+ the window: measured on a phone at 390px, a 527px table from a folder name
+ and a 442px one from a group name. That is not merely ugly. **On Android a
+ document wider than the screen takes everything `position: sticky` out of
+ the visible area with it** — the navigation bar included — so the reported
+ symptom was "the header does not stay", and the cause was a table column.
+
+ Both cells used to be bare `<td>`s: `.file-name` was on a file's name and on
+ nothing else, so a *folder* row and the Search page's group column had no
+ wrapping rule at all. `test_sticky_header.py::test_no_view_scrolls_sideways`
+ measures the page against its window in both engines and at three widths. */
+.file-name,
+.td-group {
word-break: break-word;
min-width: 0;
}
+/* The column heads are `white-space: nowrap` like every other one; the group
+ head is two syllables and never needed the exception. */
+.file-table th.td-group { white-space: normal; }
/* Where a search result lives, under its name; click to open that folder
and clear the filter. */
.file-loc {
@@ -2127,6 +2289,13 @@ a.transfer-name {
.page-center { padding: 16px; }
.th-type, .td-type { display: none; }
.th-date, .td-date { display: none; }
+ /* And the group column, on the same grounds as those two: there is no room
+ for it, and the name column is what a phone is short of. Nothing is lost
+ with it. Search's Files view re-roots every result under a folder named
+ after its group, so you are always inside exactly one group and the
+ breadcrumb above the table already says which — the column repeated it on
+ every row. */
+ .td-group { display: none; }
/* The toolbar's three groups each take a line rather than competing for one.
`margin-left: auto` on the right-hand group is what pushed it off the
@@ -2145,9 +2314,104 @@ a.transfer-name {
.video-toolbar { flex-wrap: wrap; }
.video-toolbar .tb-search { flex-basis: 100%; margin-left: 0; }
+ /* ── Shared directories: not a table on a phone ────────────────────────
+ Four columns — name, Writable, Removable, and the eject/remove pair —
+ have a combined minimum of about 440px, and none of it is padding that
+ can be squeezed: the two switches are 90px apiece by design and the
+ buttons are touch targets. On a 390px screen that is ~100px hanging off
+ the right, with the two buttons the first thing to go over the edge.
+ Reported from a phone, with short directory names, so no amount of
+ wrapping the *name* would have helped.
+
+ So the row stops being a row. The head goes (each switch carries its own
+ label instead, see `.sdt-col-toggle .toggle-switch-label`), the name
+ takes a line of its own, and the two switches sit under it with the
+ actions pushed to the right margin:
+
+ 📁 Musique
+ [•] Writable [•] Removable ⏏ ✕
+
+ Everything that was on screen is still on screen, and nothing is
+ truncated. The rules are scoped to `.shared-directories-table` on
+ purpose: `folder-tree.js` borrows `.shared-dirs-tbl` for a two-column
+ list — a name and one button — which fits a phone as it is, and stacking
+ it would only make it taller. */
+ .shared-directories-table .shared-dirs-tbl,
+ .shared-directories-table .shared-dirs-tbl tbody,
+ .shared-directories-table .shared-dirs-tbl tr,
+ .shared-directories-table .shared-dirs-tbl td { display: block; }
+ .shared-directories-table .shared-dirs-tbl thead { display: none; }
+
+ /* Two rows, not three. Left to wrap, the eject/remove pair ends up alone on
+ a line of its own under the switches — legible, but a third of the height
+ for two buttons, and they read as belonging to nothing. A grid puts them
+ back beside the name they act on, which is where they were:
+
+ ┌──────────────────────────┬────────┐
+ │ 📁 Musique │ ⏏ ✕ │
+ ├──────────────────────────┴────────┤
+ │ [•] Writable [•] Removable │
+ └───────────────────────────────────┘
+
+ The switches are not placed: they fall into the second row on their own,
+ so a group with one switch (the create-group page has no Removable) or
+ none needs no rule of its own. */
+ .shared-directories-table .shared-dirs-tbl tr {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 8px 16px;
+ padding: 10px 0;
+ }
+ .shared-directories-table .shared-dirs-tbl td { padding: 0; }
+ /* The separator moves to the row: with the cells turned into blocks, a
+ border on each of them draws four lines instead of one. */
+ .shared-directories-table .shared-dirs-tbl tbody tr + tr td { border-top: none; }
+ .shared-directories-table .shared-dirs-tbl tbody tr + tr {
+ border-top: 1px solid var(--border);
+ }
+
+ .shared-directories-table .sdt-col-dir {
+ grid-column: 1;
+ grid-row: 1;
+ min-width: 0;
+ }
+ /* No longer a single truncated line: it has the width of the row to itself
+ and there is no column left to protect from it. */
+ .shared-directories-table .sdt-col-path {
+ grid-column: 1 / -1;
+ max-width: none;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ }
+ .shared-directories-table .sdt-col-toggle {
+ width: auto;
+ min-width: 0;
+ text-align: left;
+ }
+ .shared-directories-table .sdt-col-toggle .toggle-switch {
+ justify-content: flex-start;
+ gap: 8px;
+ }
+ .shared-directories-table .sdt-col-toggle .toggle-switch-label { display: inline; }
+ /* Beside the name, against the right margin, exactly where the wide layout
+ puts them. */
+ .shared-directories-table .sdt-col-actions {
+ grid-column: 2;
+ grid-row: 1;
+ justify-self: end;
+ }
+
/* Every pixel here is one the conversation does not get. */
.group-header { margin-bottom: 10px; gap: 8px; }
- .group-tabs { margin-bottom: 10px; }
+ .group-tabs { --band-margin: 10px; }
+ /* Six tabs at 18px of padding a side come to 380px, plus the main column's
+ own 32px — wider than a 360px phone, and a page wider than the screen is
+ what takes every pinned band out of the visible area on Android. They
+ share the row instead of each claiming a fixed width, which also means a
+ seventh application costs nothing: the icon is what identifies a tab, the
+ padding around it never did. */
+ .group-tab { flex: 1 1 0; min-width: 0; padding: 10px 0; }
.chat-messages { padding: 12px; }
/* The transfers panel stops hanging off its button.
@@ -2671,6 +2935,10 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
.sdt-col-toggle { width: 90px; text-align: center; }
.sdt-col-toggle th { text-align: center; }
.sdt-col-toggle .toggle-switch { justify-content: center; }
+/* The switch carries its own label for the phone layout below, where there is
+ no column head to read it from. Wherever the head *is* there, it would be
+ the same word twice on every row. */
+.sdt-col-toggle .toggle-switch-label { display: none; }
.sdt-col-actions { white-space: nowrap; text-align: right; }
.sdt-action-btn {
background: none; border: 1px solid var(--border); border-radius: 4px;
@@ -3093,7 +3361,12 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
display: flex;
align-items: center;
gap: 6px;
- margin-bottom: 14px;
+ /* A pinned band keeps this gap: `--band-margin` is both the margin in
+ the flow and the width of the opaque ring the band paints around
+ itself once pinned, so the two can never disagree (style.css,
+ "Sticky chrome"). */
+ --band-margin: 14px;
+ margin-bottom: var(--band-margin);
}
.video-toolbar .tb-search { margin-left: auto; }
@@ -3897,7 +4170,12 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
display: flex;
align-items: center;
gap: 6px;
- margin-bottom: 14px;
+ /* A pinned band keeps this gap: `--band-margin` is both the margin in
+ the flow and the width of the opaque ring the band paints around
+ itself once pinned, so the two can never disagree (style.css,
+ "Sticky chrome"). */
+ --band-margin: 14px;
+ margin-bottom: var(--band-margin);
}
.photo-toolbar .tb-search { margin-left: auto; }
@@ -3910,7 +4188,12 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
align-items: center;
justify-content: space-between;
gap: 12px;
- margin-bottom: 16px;
+ /* A pinned band keeps this gap: `--band-margin` is both the margin in
+ the flow and the width of the opaque ring the band paints around
+ itself once pinned, so the two can never disagree (style.css,
+ "Sticky chrome"). */
+ --band-margin: 16px;
+ margin-bottom: var(--band-margin);
}
.photo-album-heading {
display: flex;
diff --git a/packages/meshbay-hub/tests/harness/sticky_header_probe.py b/packages/meshbay-hub/tests/harness/sticky_header_probe.py
new file mode 100755
index 0000000..9e9dc3c
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/sticky_header_probe.py
@@ -0,0 +1,563 @@
+#!/usr/bin/env python3
+"""
+Does the header stay put?
+
+Files, Videos, Music and Photos pin three bands under the navigation bar — the
+tab bar (the search field, on the Search page), the application's toolbar, and
+the file table's column heads — so that scrolling a library never costs the
+controls that steer it. Two of those offsets are `calc()` over a height
+measured at runtime (sticky.js), which means the arrangement can be wrong in
+ways no stylesheet reading finds: a band an inch too low leaves a stripe of
+list showing through above it, a band too high hides the one over it, and a
+toolbar that wraps to three rows on a phone moves the column heads by 96px
+that nothing in the CSS knows about.
+
+So this scrolls. It renders the shipped `GroupPage` and `SearchPage` against a
+stub node, walks to each application, scrolls the window down, and reports the
+rectangle of every band before and after — at a desktop width and at a phone
+width, where the toolbars wrap and the measurement earns its keep.
+
+ sticky_header_probe.py
+
+Prints JSON: one object per case, each with `before` and `after` maps of
+selector -> {top, bottom, height} in viewport coordinates, plus what the
+window did.
+
+Chrome by default; `--engine firefox` runs the same cases in Firefox, which is
+half of MeshBay's readers and has its own history with `position: sticky`.
+"""
+import argparse
+import http.server
+import json
+import os
+import socketserver
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+from pathlib import Path
+
+STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static"
+PORT = 8751
+RECORDS = []
+socketserver.TCPServer.allow_reuse_address = True
+# Set when every case has reported. Firefox needs it: it has no
+# "navigate and stay open" headless mode that runs a page to completion, only
+# `--screenshot`, which fires at the load event and exits. So each frame holds
+# one image open, the load event waits for that image, and the image is
+# answered here once the run is done. Chrome does not need it and does not get
+# it.
+FINISHED = threading.Event()
+
+# 1100 desktop; 420 is where the phone media query has been tuned; 390 is an
+# actual handset, and the width the first report of a band not pinning at all
+# came from; 360 is the small end of what is still sold, and it is where the
+# settings table's two switches and its two buttons have the least room.
+WIDTHS = [1100, 420, 390, 360]
+
+# The tab bar's buttons are the enabled apps in registry order, so Files is 1,
+# Videos 2, Music 3, Photos 4 (Chat is 0, Settings last). The Search page's
+# view toggle is files/videos/music/photos in that order.
+GROUP_CASES = [
+ ("group files", {"page": "group", "click": [".group-tabs .group-tab:nth-of-type(2)"],
+ "ready": ".file-toolbar",
+ "bands": [".group-tabs", ".file-toolbar", ".file-table th"],
+ "content": ".file-row"}),
+ # Inside a root, where the rows are the library's own folders. The report
+ # that started this came from there, not from the top level.
+ ("group files in a folder", {"page": "group",
+ # The second row, which is the music root:
+ # the first is `films`, whose folders are
+ # short and prove nothing.
+ "click": [".group-tabs .group-tab:nth-of-type(2)",
+ ".file-table tbody tr:nth-of-type(2)"],
+ "ready": ".file-toolbar",
+ "bands": [".group-tabs", ".file-toolbar",
+ ".file-table th"],
+ "content": ".file-row"}),
+ ("group videos", {"page": "group", "click": [".group-tabs .group-tab:nth-of-type(3)"],
+ "ready": ".video-toolbar",
+ "bands": [".group-tabs", ".video-toolbar"],
+ "content": ".video-tile-slot"}),
+ ("group music", {"page": "group", "click": [".group-tabs .group-tab:nth-of-type(4)"],
+ "ready": ".video-toolbar",
+ "bands": [".group-tabs", ".video-toolbar"],
+ "content": ".music-tile-slot"}),
+ ("group photos", {"page": "group", "click": [".group-tabs .group-tab:nth-of-type(5)"],
+ "ready": ".photo-toolbar",
+ "bands": [".group-tabs", ".photo-toolbar"],
+ "content": ".photo-album-tile-slot"}),
+ # An open album swaps the toolbar for its own title bar, which pins in the
+ # same place — the one band that is not a toolbar.
+ # The group's own Settings, as its operator sees it: the shared-directories
+ # table is four columns of controls, none of which can be squeezed, and it
+ # was the next thing to hang off the right of a phone.
+ ("group settings", {"page": "group",
+ "click": [".group-tabs .group-tab:nth-of-type(6)"],
+ "ready": ".shared-dirs-tbl",
+ "bands": [".group-tabs"],
+ "content": ".shared-dirs-tbl tbody tr"}),
+ ("group photo album", {"page": "group",
+ "click": [".group-tabs .group-tab:nth-of-type(5)",
+ ".photo-album-card"],
+ "ready": ".photo-album-bar",
+ "bands": [".group-tabs", ".photo-album-bar"],
+ "content": ".photo-tile-slot"}),
+ # Search's Files view opens on the list of groups, one folder per group —
+ # the rows are inside it.
+ ("search files", {"page": "search", "click": [".view-toggle button:nth-of-type(1)",
+ ".file-row.dir-row"],
+ "ready": ".file-toolbar",
+ "bands": [".search-bar", ".file-toolbar", ".file-table th"],
+ "content": ".file-row"}),
+ ("search videos", {"page": "search", "click": [".view-toggle button:nth-of-type(2)"],
+ "ready": ".video-toolbar",
+ "bands": [".search-bar", ".video-toolbar"],
+ "content": ".video-tile-slot"}),
+ ("search music", {"page": "search", "click": [".view-toggle button:nth-of-type(3)"],
+ "ready": ".video-toolbar",
+ "bands": [".search-bar", ".video-toolbar"],
+ "content": ".music-tile-slot"}),
+ # Photos on the Search page has no toolbar of its own: its only control is
+ # the filter, and the search field above it already is one.
+ ("search photos", {"page": "search", "click": [".view-toggle button:nth-of-type(4)"],
+ "ready": ".photo-album-grid",
+ "bands": [".search-bar"],
+ "content": ".photo-album-tile-slot"}),
+]
+
+CASES = [(f"{name} @{w}", dict(spec, width=w, label=name))
+ for w in WIDTHS for name, spec in GROUP_CASES]
+
+# The shell around the page under test: the real navigation bar (which is what
+# every band pins beneath), the real sidebar, the real main column. Measuring a
+# page mounted on a bare body would put every band at the top of the window and
+# prove nothing about the offset.
+SHELL = """
+<nav class="nav">
+ <div class="nav-left"><button class="nav-hamburger">&#9776;</button>
+ <a class="nav-brand" href="#/">MeshBay</a></div>
+ <div class="nav-right"><a class="nav-notif" href="#/">&#128276;</a>
+ <div class="user-menu"><button class="nav-btn">someone</button></div></div>
+</nav>
+<div class="layout">
+ <aside class="sidebar"><div class="sidebar-section">Groups</div></aside>
+ <main class="main"><div id="root"></div></main>
+</div>
+"""
+
+# Substituted by name, not by `%`-formatting: this template is JavaScript,
+# and JavaScript has a modulo operator. `i % ARTISTS.length` in the fixture
+# below made the whole page fail to render with "not enough arguments for
+# format string", from inside a request handler, which reads as the probe
+# measuring nothing rather than as a typo.
+FRAME = r"""<!doctype html><html><head><meta charset=utf-8>
+<link rel="stylesheet" href="/style.css"></head><body>
+<!--SHELL-->
+<script>
+const CFG = /*CFG*/;
+
+// An index big enough that every view scrolls: loose files for Files, a films
+// root, an album tree for Music, and forty photo albums.
+//
+// The names are the length real ones are, and that is not decoration. The
+// first version of this fixture used `note-007.txt` and one group called
+// `un groupe`, which fits any screen — so it measured a table that never
+// overflowed, and missed the fault that was actually reported from a phone.
+// A fixture narrower than the real thing tests the fixture.
+// Underscores on purpose. A name with no space in it is one unbreakable word,
+// and a table column is sized from what its content cannot be narrower than —
+// which is the whole point of the case reported from a phone.
+const ARTISTS = ['Augustus Pablo', 'Babylon Circus', 'Ben Harper',
+ 'Beruriers Noirs', 'Billy Ze Kick', 'Black Sabbath',
+ 'Chemical Brothers', 'Dire Straits', 'Dj_Shadow',
+ 'Massive Attack', 'Noir Desir', 'Rage Against The Machine',
+ 'Rage_Against_The_Machine_Discography_1992-2000_FLAC'];
+const ENTRIES = [];
+let n = 0;
+const add = (o) => ENTRIES.push(Object.assign(
+ { id: 'e' + (++n), size: 1024 * 1024 * n, added_at: 1750000000 + n }, o));
+for (let i = 0; i < 60; i++)
+ add({ name: `${ARTISTS[i % ARTISTS.length]} - ${String(i + 1).padStart(2, '0')} `
+ + `Un titre de morceau plutot long.flac`, path: '', type: 'document' });
+for (let i = 0; i < 40; i++)
+ add({ name: `${ARTISTS[i % ARTISTS.length]} au Zenith (2019) 1080p.mkv`,
+ path: 'films', type: 'video',
+ display_title: `${ARTISTS[i % ARTISTS.length]} au Zenith` });
+for (let a = 0; a < 20; a++)
+ for (let t = 0; t < 2; t++)
+ add({ name: `${t + 1} track.flac`,
+ path: `musique/${ARTISTS[a % ARTISTS.length]} ${a}/disque ${t}`,
+ type: 'audio', artist: `Artiste ${a}`, album: `Disque ${a}-${t}`,
+ track_no: t + 1, duration: 200 });
+for (let d = 0; d < 40; d++)
+ // The first album is deep enough to scroll on its own: an open album is a
+ // case of its own, and one that fits the window proves nothing.
+ for (let i = 0; i < (d === 0 ? 80 : 6); i++)
+ add({ name: `img-${String(i).padStart(3, '0')}.jpg`,
+ path: `photos/sortie ${String(d).padStart(2, '0')}`, type: 'image' });
+
+const ACK = {
+ is_node_admin: true, member_upload: true,
+ enabled_apps: ['chat', 'files', 'video', 'music', 'photo'],
+ tmdb_enabled: false, musicbrainz_enabled: false,
+ video_directories: ['films'], music_directories: ['musique'],
+ photo_directories: ['photos'],
+};
+
+// The node, as far as the page is concerned. Anything reached for beyond the
+// handful of calls below answers with a promise that never settles: a thumb
+// or a metadata lookup left pending shows a spinner, and a spinner occupies
+// exactly the tile the grid already reserved — which is all the bands need.
+window.MeshBayTransport = function () {
+ const self = {
+ // The operator of this node: it is their own library, and it is what puts
+ // the eject/remove pair in the settings table and the fifth button in the
+ // Files toolbar.
+ connected: false, memberRole: 'operator', supportsAppOps: true,
+ // Every property the page *reads* rather than calls has to be declared:
+ // the fallback below answers with a function, and a function is truthy, so
+ // an undeclared `newNodeBundle` sends the page off to store a key bundle
+ // that never comes back — it waits there for ever, on "Connecting".
+ sessionKeys: null, gekRaw: null,
+ newNodeBundle: null, newNodeBundleRecovery: null,
+ async connect() { self.connected = true; return ACK; },
+ async fetchIndex() {
+ return { entries: ENTRIES, dirs: ['films', 'musique', 'photos'],
+ roots: [{ name: 'films', available: true, writable: false,
+ removable: true },
+ { name: 'musique', available: true, writable: true,
+ removable: true },
+ { name: 'photos', available: true, writable: false,
+ removable: false }] };
+ },
+ async fetchChatHistory() { return { messages: [], hasMore: false }; },
+ async fetchLinkPreview() { return { ok: false }; },
+ close() {},
+ };
+ return new Proxy(self, {
+ get(target, prop) {
+ if (prop in target) return target[prop];
+ // A handler slot the page reads before assigning must stay empty, or the
+ // page calls this stub as though the node had spoken.
+ if (typeof prop === 'string' && prop.startsWith('on')) return undefined;
+ if (typeof prop === 'symbol') return undefined;
+ return () => new Promise(() => {});
+ },
+ set(target, prop, value) { target[prop] = value; return true; },
+ });
+};
+</script>
+<script type="module">
+import { html, render } from '/vendor/htm-preact.js';
+import { initLocale } from '/i18n.js';
+import { GroupPage } from '/group-page.js';
+import { SearchPage } from '/search-page.js';
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+// A page that never reaches "connected" says why in the console and nowhere
+// else; without this a failure reads only as "the toolbar never appeared".
+const LOGS = [];
+addEventListener('error', (e) => LOGS.push('error: ' + (e.message || e)));
+addEventListener('unhandledrejection',
+ (e) => LOGS.push('rejection: ' + (e.reason && (e.reason.stack || e.reason.message)
+ || e.reason)));
+for (const level of ['error', 'warn']) {
+ const orig = console[level].bind(console);
+ console[level] = (...a) => { LOGS.push(level + ': ' + a.map(String).join(' ')); orig(...a); };
+}
+
+async function waitFor(sel, ms = 15000) {
+ const until = Date.now() + ms;
+ for (;;) {
+ const el = document.querySelector(sel);
+ if (el) return el;
+ if (Date.now() > until) return null;
+ await sleep(50);
+ }
+}
+
+const box = (sel) => {
+ const el = document.querySelector(sel);
+ if (!el) return null;
+ const r = el.getBoundingClientRect();
+ return { top: Math.round(r.top), bottom: Math.round(r.bottom),
+ height: Math.round(r.height),
+ // The gap the band keeps below it, in the flow and once pinned.
+ gap: Math.round(parseFloat(getComputedStyle(el).marginBottom) || 0) };
+};
+
+const snapshot = () => {
+ const out = {};
+ for (const sel of ['.nav', ...CFG.bands, CFG.content]) out[sel] = box(sel);
+ return out;
+};
+
+// How far past the window the document reaches sideways. On Android a page
+// wider than the screen takes everything pinned out of the visible area with
+// it — the header is still stuck to the top of a viewport the reader can no
+// longer see — so this is measured on every case, not only the ones with a
+// table.
+const overflowX = () => Math.round(
+ document.documentElement.scrollWidth - document.documentElement.clientWidth);
+
+// Two frames: one for the scroll to be applied, one for the sticky bands to be
+// laid out against it.
+const settle = () => new Promise((r) =>
+ requestAnimationFrame(() => requestAnimationFrame(r)));
+
+(async () => {
+ const fail = (why) => parent.postMessage(
+ { case: CFG.index, error: why, logs: LOGS.slice(0, 12),
+ text: (document.getElementById('root').textContent || '').slice(0, 300) },
+ '*');
+ try {
+ await initLocale();
+ if (CFG.page === 'group') {
+ render(html`<${GroupPage} groupId="g1" token="t" username="me" userId="u1"
+ group=${{ id: 'g1', name: 'un groupe', owner_username: 'me',
+ is_admin: false }}
+ userPrefs=${{ default_tab: 'files' }} />`,
+ document.getElementById('root'));
+ } else {
+ render(html`<${SearchPage} token="t" username="me" userId="u1"
+ ${/* Underscores, like the folder names: the group's name is a whole
+ column of its own in Search, in a cell with no wrapping rule,
+ and a hyphen is a break opportunity while an underscore is
+ not. */''}
+ groups=${[{ id: 'g1', name: 'musique_de_la_maison_2024',
+ owner_username: 'cbesson' }]}
+ userPrefs=${{}} />`, document.getElementById('root'));
+ // Results only exist once an index has arrived; the view toggle is drawn
+ // with them.
+ const field = await waitFor('.search-bar input');
+ if (!field) return fail('no search field');
+ if (!await waitFor('.view-toggle')) return fail('no results to view');
+ }
+
+ // Whether the search field *asks* for focus, not whether it holds it:
+ // every case is an iframe of one page and only one document in a page can
+ // have an active element, so real focus is a property of the harness here
+ // rather than of the page under test.
+ const searchInput = document.querySelector('.search-bar input');
+ const autofocusAsked = !!(searchInput && searchInput.autofocus);
+
+ for (const sel of CFG.click) {
+ const el = await waitFor(sel);
+ if (!el) return fail('nothing at ' + sel);
+ el.click();
+ await sleep(120);
+ }
+ if (!await waitFor(CFG.ready)) return fail('never drew ' + CFG.ready);
+ if (!await waitFor(CFG.content)) return fail('never drew ' + CFG.content);
+ await settle();
+
+ const before = snapshot();
+ const room = document.documentElement.scrollHeight - innerHeight;
+ scrollTo(0, Math.min(500, Math.max(0, room)));
+ await settle();
+ await sleep(150);
+ await settle();
+ const after = snapshot();
+ const scrolledBy = Math.round(scrollY);
+
+ // All the way down. A sticky element only sticks inside its own parent, so
+ // a band whose containing block ends before the page does comes unstuck
+ // partway — which a single 500px scroll never reaches.
+ scrollTo(0, Math.max(0, room));
+ await settle();
+ await sleep(150);
+ await settle();
+ const bottom = snapshot();
+
+ parent.postMessage({
+ case: CFG.index, before, after, bottom,
+ bottomScrollY: Math.round(scrollY),
+ scrollY: scrolledBy, room: Math.round(room),
+ overflowX: overflowX(),
+ // Whether the Search page's group column is on screen. It is dropped at
+ // phone widths, where the file name needs every pixel it can get.
+ groupColumn: (() => {
+ const el = document.querySelector('.file-table th.td-group');
+ if (!el) return 'absent';
+ return getComputedStyle(el).display === 'none' ? 'hidden' : 'shown';
+ })(),
+ // What is actually too wide, when something is.
+ widest: (() => {
+ const limit = document.documentElement.clientWidth;
+ return [...document.querySelectorAll('.main *')]
+ .filter((el) => el.getBoundingClientRect().right > limit + 1)
+ .slice(0, 6)
+ .map((el) => (el.tagName.toLowerCase() + '.' + (el.className || ''))
+ .slice(0, 60) + ' →'
+ + Math.round(el.getBoundingClientRect().right));
+ })(),
+ innerHeight, innerWidth,
+ // What the stylesheet was actually handed, so a wrong offset can be told
+ // from an unmeasured one.
+ // The Search field focuses itself on a pointer device and must go on
+ // doing so — it stopped only for touch, where the keyboard it raises
+ // takes the pinned header out of the visible viewport.
+ autofocusAsked,
+ chromeH: getComputedStyle(document.querySelector('.sticky-chrome'))
+ .getPropertyValue('--chrome-h').trim(),
+ toolbarH: getComputedStyle(document.querySelector('.sticky-chrome'))
+ .getPropertyValue('--toolbar-h').trim(),
+ }, '*');
+ } catch (err) {
+ fail(String(err && err.stack || err));
+ }
+})();
+</script></body></html>"""
+
+# Appended to every frame when the engine needs the load event held back.
+HOLD = ""
+HOLD_TAG = '<img src="/hold" style="position:fixed;left:-4px;top:-4px;width:1px">'
+
+PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head>
+<body style="margin:0"><div id="frames"></div><script>
+const CASES = /*CASES*/;
+const seen = [];
+addEventListener('message', (e) => {
+ seen.push(e.data);
+ if (seen.length === CASES.length) {
+ fetch('/log', { method: 'POST', body: JSON.stringify(seen) });
+ }
+});
+for (let i = 0; i < CASES.length; i++) {
+ const f = document.createElement('iframe');
+ f.src = '/case?n=' + i;
+ f.style.cssText =
+ `width:${CASES[i]}px;height:760px;border:0;display:block;margin-bottom:4px`;
+ document.getElementById('frames').appendChild(f);
+}
+</script></body></html>"""
+
+
+def render_frame(index: int) -> str:
+ """One case's page: the shell around it and its own configuration."""
+ cfg = dict(CASES[index][1], index=index)
+ return (FRAME.replace("<!--SHELL-->", SHELL)
+ .replace("/*CFG*/", json.dumps(cfg)) + HOLD)
+
+
+class H(http.server.BaseHTTPRequestHandler):
+ def log_message(self, *a):
+ pass
+
+ def do_POST(self):
+ length = int(self.headers.get("Content-Length") or 0)
+ if self.path == "/log":
+ RECORDS.append(json.loads(self.rfile.read(length).decode()))
+ FINISHED.set()
+ else:
+ self.rfile.read(length)
+ self.send_response(204)
+ self.end_headers()
+
+ def _send(self, body: bytes, ctype: str) -> None:
+ self.send_response(200)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self):
+ path = self.path.split("?")[0]
+ if path == "/hold":
+ FINISHED.wait(120)
+ self._send(b"", "image/gif")
+ elif path == "/":
+ widths = [spec["width"] for _, spec in CASES]
+ self._send(PAGE.replace("/*CASES*/", json.dumps(widths)).encode(),
+ "text/html; charset=utf-8")
+ elif path == "/case":
+ index = int(self.path.split("n=")[1])
+ self._send(render_frame(index).encode(), "text/html; charset=utf-8")
+ elif path == "/v1/groups/g1/nodes":
+ self._send(b'{"nodes": [{"node_id": "n1"}]}', "application/json")
+ else:
+ asset = (STATIC / path.lstrip("/")).resolve()
+ if not str(asset).startswith(str(STATIC)) or not asset.is_file():
+ self.send_response(404)
+ self.end_headers()
+ return
+ self._send(asset.read_bytes(),
+ "text/css" if asset.suffix == ".css"
+ else "text/javascript" if asset.suffix == ".js"
+ else "application/octet-stream")
+
+
+# Each engine gets the same page and reports through the same `/log` POST, so
+# nothing here depends on a debugging protocol only one of them speaks.
+ENGINES = {
+ "chrome": lambda profile, size: [
+ "google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox",
+ f"--user-data-dir={profile}", f"--window-size={size}"],
+ # Firefox has no headless mode that simply opens a page and waits, so it
+ # is driven through `--screenshot`: the picture is thrown away, the point
+ # is that the page runs and the load event is what ends the process.
+ #
+ # And it is isolated with `HOME`, not `--profile`: given `--profile` on a
+ # directory it has not initialised itself, this Firefox starts, prints its
+ # headless banner, and then never requests the URL at all — no error, no
+ # page, nothing to debug. Pointing HOME at a throwaway directory lets it
+ # create its own default profile there, which works and leaves the
+ # developer's real one alone.
+ "firefox": lambda profile, size: [
+ "firefox", "--headless",
+ "--screenshot", str(Path(profile) / "shot.png"),
+ "--window-size", size],
+}
+# The environment each engine is launched with, on top of the current one.
+ENGINE_ENV = {"firefox": lambda profile: {"HOME": profile}}
+# Which engines need the load event held until the measurement is in.
+HOLDS_LOAD = {"firefox"}
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--engine", choices=sorted(ENGINES), default="chrome")
+ args = ap.parse_args()
+ launcher = ENGINES[args.engine]
+ global HOLD
+ HOLD = HOLD_TAG if args.engine in HOLDS_LOAD else ""
+
+ with socketserver.ThreadingTCPServer(("127.0.0.1", PORT), H) as srv:
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ # ignore_cleanup_errors for the same reason group_tab_probe.py gives:
+ # Chrome's children outlive terminate() by a moment and go on writing
+ # into the profile, and a throwaway profile is not worth a failed run.
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as profile:
+ env = dict(os.environ,
+ **ENGINE_ENV.get(args.engine, lambda _p: {})(profile))
+ proc = subprocess.Popen(
+ launcher(profile, "1200,900") + [f"http://127.0.0.1:{PORT}/"],
+ env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ for _ in range(900):
+ if RECORDS:
+ break
+ time.sleep(0.1)
+ proc.terminate()
+ try:
+ proc.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait()
+ if not RECORDS:
+ print(json.dumps({"error": "no measurement"}), file=sys.stderr)
+ return 1
+ by_case = {r["case"]: r for r in RECORDS[0]}
+ print(json.dumps(
+ [dict(name=CASES[i][0], engine=args.engine, width=CASES[i][1]["width"],
+ view=CASES[i][1]["label"], page=CASES[i][1]["page"],
+ bands=CASES[i][1]["bands"], content=CASES[i][1]["content"],
+ **by_case[i])
+ for i in sorted(by_case)], indent=1))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/packages/meshbay-hub/tests/test_desktop_shell.py b/packages/meshbay-hub/tests/test_desktop_shell.py
index 039ea2f..2e7ea82 100644
--- a/packages/meshbay-hub/tests/test_desktop_shell.py
+++ b/packages/meshbay-hub/tests/test_desktop_shell.py
@@ -154,8 +154,17 @@ def test_the_policy_is_sent_as_a_header():
"""
source = _main()
assert "'Content-Security-Policy': CSP" in source
- assert "Content-Security-Policy" not in INDEX.read_text(encoding="utf-8") \
- .split("-->")[1], "the packaged page still carries a policy of its own"
+ # Comments stripped, all of them, rather than skipping past the first
+ # `-->`. The page's only mention of a policy is the comment explaining why
+ # it is not here, so the check has to see the markup with every comment
+ # gone — the earlier version took `split("-->")[1]`, which meant adding a
+ # second comment anywhere above made it read that comment's own text and
+ # fail on correct markup. A guard that depends on how many comments precede
+ # it is not guarding the thing it names.
+ markup = re.sub(r"<!--.*?-->", "", INDEX.read_text(encoding="utf-8"),
+ flags=re.S)
+ assert "Content-Security-Policy" not in markup, \
+ "the packaged page still carries a policy of its own"
def test_the_policy_keeps_wasm_unsafe_eval():
diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py
index 01516bd..3e9c968 100644
--- a/packages/meshbay-hub/tests/test_hook_ordering.py
+++ b/packages/meshbay-hub/tests/test_hook_ordering.py
@@ -45,6 +45,10 @@ STATIC_FILES = [
"music-app-settings.js", "photos-app-settings.js",
"helloworld-app.js", "helloworld-app-settings.js",
"auth-page.js", "explore-page.js", "create-group-page.js",
+ # The Search page was missing from this list while being the densest
+ # `useMemo` chain in the tree — a dozen derived lists, each depending on
+ # the one above it, which is precisely the shape this checks.
+ "search-page.js",
]
pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailable")
diff --git a/packages/meshbay-hub/tests/test_page_does_not_scroll.py b/packages/meshbay-hub/tests/test_page_does_not_scroll.py
index 1187a7a..9f983af 100644
--- a/packages/meshbay-hub/tests/test_page_does_not_scroll.py
+++ b/packages/meshbay-hub/tests/test_page_does_not_scroll.py
@@ -13,12 +13,15 @@ Neither is visible in the stylesheet. Both are one subtraction against another,
in different files, and the only way to see them is to measure the document
against the window — which is what this does, running the real `fit()` lifted
out of `app.js` rather than a copy of it.
+
+The group fragment carries `.sticky-chrome`, as `group-page.js` does: the tab
+bar is `position: sticky` inside it, and a sticky element that is measured
+without the class it pins under is measured in its old, ordinary flow.
"""
import json
import shutil
import subprocess
-import textwrap
from pathlib import Path
import pytest
@@ -45,6 +48,7 @@ CHAT_TAB = NAV_AND_SIDEBAR + """
<div class="layout">
<aside class="sidebar"><div class="sidebar-section">Groups</div></aside>
<main class="main">
+ <div class="sticky-chrome">
<div class="group-header"><h2>a group</h2></div>
<div class="group-tabs">
<button class="group-tab active">Chat</button>
@@ -55,6 +59,7 @@ CHAT_TAB = NAV_AND_SIDEBAR + """
<div class="chat-messages"><p>hello</p></div>
<div class="chat-composer"><input type="text" /><button class="admin-btn">Send</button></div>
</div>
+ </div>
</main>
</div>
"""
diff --git a/packages/meshbay-hub/tests/test_sticky_header.py b/packages/meshbay-hub/tests/test_sticky_header.py
new file mode 100644
index 0000000..32b8124
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_sticky_header.py
@@ -0,0 +1,297 @@
+"""
+Scrolling a library does not cost you the controls that steer it.
+
+Files, Videos, Music and Photos are read by scrolling, and everything that says
+*what* is being read — which application, where in the tree, which filter,
+which column sorts — used to leave with the first screenful. Those controls pin
+under the navigation bar now, in three bands: the group's tab bar (the search
+field, on the Search page), the application's own toolbar, and the file table's
+column heads.
+
+Two of the three offsets are `calc()` over a height nothing declares: the
+toolbar wraps to three rows at a phone width, grows a field while a folder is
+being named, and loses its filter on the Search page, so sticky.js measures it
+and publishes `--chrome-h` / `--toolbar-h`. That is exactly the arrangement
+CLAUDE.md has been bitten by twice — two subtractions in different files, each
+correct on its own, the page a few pixels wrong at every window size and
+nothing in either file to show it. A number in a stylesheet cannot be read to
+find that out. So this scrolls the shipped pages in a real browser and measures
+the rectangles: every band pinned, edge to edge, nothing overlapping, nothing
+showing between.
+
+Three widths, for three different reasons. At 1100 the toolbar is one row; at
+420 it is three, and a stack that only ever adds up on a desktop is the whole
+class of fault this is written against; 390 is an actual handset. And both
+engines: Firefox is half of MeshBay's readers, `position: sticky` is exactly
+the kind of thing engines disagree about, and the first report that a band did
+not pin at all came from a phone.
+"""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "sticky_header_probe.py"
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("google-chrome") is None or not (STATIC / "style.css").exists(),
+ reason="Chrome or the SPA stylesheet is not available")
+
+ENGINES = ["chrome"] + (["firefox"] if shutil.which("firefox") else [])
+
+# A rectangle read back from the browser is rounded, and a border can land
+# either side of a half pixel. One pixel of slack, never more — the faults this
+# exists for are tens of pixels.
+SLACK = 1
+
+# Eleven views (seven in a group, four on the Search page) at four widths.
+EXPECTED_CASES = 44
+
+
+@pytest.fixture(scope="module", params=ENGINES)
+def measured(request):
+ proc = subprocess.run(["python3", str(HARNESS), "--engine", request.param],
+ capture_output=True, text=True, timeout=900)
+ assert proc.returncode == 0, (
+ f"probe failed in {request.param}: {proc.stdout}{proc.stderr}")
+ cases = json.loads(proc.stdout)
+ assert cases, f"the probe measured nothing in {request.param}"
+ return cases
+
+
+def test_every_view_was_reached(measured):
+ """
+ A case that never got to its application reports an error rather than an
+ empty measurement — and an empty measurement would satisfy every assertion
+ below by having nothing to assert on.
+ """
+ broken = [f"{c.get('engine')} {c['name']}: {c['error']}"
+ f"\n console: {c.get('logs')}"
+ f"\n on screen: {c.get('text')}"
+ for c in measured if "error" in c]
+ assert not broken, "\n".join(broken)
+ assert len(measured) == EXPECTED_CASES, (
+ f"expected {EXPECTED_CASES} measurements, got {len(measured)}")
+
+
+def test_the_page_really_scrolled(measured):
+ """
+ Everything below compares a scrolled page with an unscrolled one. A view
+ that fits its window compares it with itself and proves nothing — so this
+ fails the fixture, not the layout, before a silent pass can happen.
+ """
+ flat = [c["name"] for c in measured if c["scrollY"] <= 0]
+ assert not flat, "nothing scrolled, so nothing was tested: " + ", ".join(flat)
+
+
+def test_the_bands_stack_under_the_navigation_bar(measured):
+ """
+ From the top: the navigation bar, then each band, each starting exactly one
+ gap below the one above it — the gap being the upper band's own
+ `margin-bottom`, which it keeps when it pins and paints as a ring. Land
+ short of it and two bands come into contact, which is what the first
+ version shipped and what was reported: two rounded panels flush against
+ each other in Files, and the row of controls glued to the tab bar in
+ Videos, Music and Photos. Land past it and a stripe of the list shows
+ through between them.
+ """
+ faults = []
+ for case in measured:
+ after = case["after"]
+ edge = after[".nav"]["bottom"]
+ for selector in case["bands"]:
+ band = after[selector]
+ if abs(band["top"] - edge) > SLACK:
+ faults.append(
+ f"{case['engine']} {case['name']}: {selector} pinned at "
+ f"y={band['top']}, expected y={edge}")
+ edge = band["bottom"] + band["gap"]
+ assert not faults, "\n".join(faults)
+
+
+def test_a_band_keeps_the_gap_it_has_in_the_flow(measured):
+ """
+ Pinning must not change the spacing. Measured against the same page before
+ it was scrolled: the distance from a band to the next one down is the same
+ whether it is pinned or sitting in the flow, so nothing shifts at the
+ moment it pins.
+ """
+ faults = []
+ for case in measured:
+ for upper, lower in zip(case["bands"], case["bands"][1:]):
+ flow = case["before"][lower]["top"] - case["before"][upper]["bottom"]
+ pinned = case["after"][lower]["top"] - case["after"][upper]["bottom"]
+ if abs(flow - pinned) > SLACK:
+ faults.append(
+ f"{case['engine']} {case['name']}: {upper} → {lower} is "
+ f"{flow}px apart in the flow and {pinned}px pinned")
+ assert not faults, "\n".join(faults)
+
+
+def test_bands_stay_pinned_at_the_bottom_of_the_page(measured):
+ """
+ A sticky element only sticks inside its own parent's box, so a band whose
+ containing block ends before the page does comes unstuck partway down —
+ invisible to any test that scrolls a fixed amount. Scrolled to the very
+ end, every band is still where it was.
+ """
+ faults = []
+ for case in measured:
+ for selector in case["bands"]:
+ if abs(case["bottom"][selector]["top"]
+ - case["after"][selector]["top"]) > SLACK:
+ faults.append(
+ f"{case['engine']} {case['name']}: {selector} is at "
+ f"y={case['after'][selector]['top']} partway down and "
+ f"y={case['bottom'][selector]['top']} at the end")
+ assert not faults, "\n".join(faults)
+
+
+def test_each_band_stays_whole(measured):
+ """
+ Pinned and *entire*: a band clipped by the navigation bar above it, or with
+ its own content spilling out of the rectangle it reserved, is not visible
+ just because its top edge is in the right place.
+ """
+ faults = []
+ for case in measured:
+ for selector in case["bands"]:
+ before, after = case["before"][selector], case["after"][selector]
+ if after["height"] <= 0:
+ faults.append(f"{case['name']}: {selector} has no height at all")
+ elif abs(after["height"] - before["height"]) > SLACK:
+ faults.append(
+ f"{case['name']}: {selector} is {after['height']}px pinned "
+ f"but {before['height']}px in the flow — pinning resized it")
+ if after["top"] < case["after"][".nav"]["bottom"] - SLACK:
+ faults.append(
+ f"{case['name']}: {selector} runs up behind the navigation bar")
+ assert not faults, "\n".join(faults)
+
+
+def test_the_list_is_what_moves(measured):
+ """
+ The bands hold still and the content goes past them — not the other way
+ round, and not everything holding still because the page never moved. The
+ content is checked to have travelled by the full scroll: a band that
+ dragged its list along with it would show up here as a short journey.
+ """
+ faults = []
+ for case in measured:
+ selector = case["content"]
+ before, after = case["before"][selector], case["after"][selector]
+ travelled = before["top"] - after["top"]
+ if abs(travelled - case["scrollY"]) > SLACK:
+ faults.append(
+ f"{case['name']}: {selector} moved {travelled}px while the window "
+ f"scrolled {case['scrollY']}px")
+ assert not faults, "\n".join(faults)
+
+
+def test_the_offsets_come_from_the_measurement(measured):
+ """
+ The published heights are the bands' own, not a number that happens to
+ agree at one width. This is the assertion that fails if sticky.js stops
+ observing — a stale `--toolbar-h` still stacks perfectly at the width it
+ was measured at, and only at that one.
+ """
+ faults = []
+ for case in measured:
+ bands = case["bands"]
+ upper = case["after"][bands[0]]
+ chrome = upper["height"] + upper["gap"]
+ if case["chromeH"] != f"{chrome}px":
+ faults.append(f"{case['engine']} {case['name']}: --chrome-h is "
+ f"{case['chromeH']!r}, {bands[0]} is {upper['height']}px "
+ f"tall over a {upper['gap']}px gap")
+ # Only Files pins anything below its toolbar, so only Files publishes
+ # a toolbar height; the others leave the property withdrawn.
+ if ".file-table th" in bands:
+ mid = case["after"][bands[1]]
+ toolbar = mid["height"] + mid["gap"]
+ if case["toolbarH"] != f"{toolbar}px":
+ faults.append(f"{case['engine']} {case['name']}: --toolbar-h is "
+ f"{case['toolbarH']!r}, {bands[1]} is "
+ f"{mid['height']}px tall over a {mid['gap']}px gap")
+ elif case["toolbarH"] not in ("", "0px"):
+ faults.append(f"{case['engine']} {case['name']}: --toolbar-h left "
+ f"behind as {case['toolbarH']!r} by a view with no table")
+ assert not faults, "\n".join(faults)
+
+
+def test_no_view_scrolls_sideways(measured):
+ """
+ Nothing here may be wider than the window.
+
+ This is the fault that was reported from a phone, and it presented as the
+ sticky header not working at all — including the navigation bar, which had
+ been `position: sticky` since long before any of this. That is the tell: on
+ Android a document wider than the screen leaves everything pinned attached
+ to a viewport the reader can no longer see, so a header that is doing
+ exactly what it was told looks like a header that was never pinned.
+
+ The cause was a table column, and the reason no measurement here found it
+ first is worth keeping: the fixture said `note-007.txt` and `un groupe`,
+ which fit any screen. A fixture narrower than real data tests the fixture.
+ It now carries the names a music library actually has, and walks into a
+ folder, which is where the rows are directories — and a directory's name
+ cell was the one that had no wrapping rule on it.
+ """
+ faults = []
+ for case in measured:
+ if case["overflowX"] > 0:
+ faults.append(
+ f"{case['engine']} {case['name']}: the page is "
+ f"{case['overflowX']}px wider than its window"
+ + (f" — widest: {'; '.join(case['widest'])}" if case["widest"] else ""))
+ assert not faults, "\n".join(faults)
+
+
+def test_the_group_column_is_dropped_at_phone_widths(measured):
+ """
+ Search's group column goes at phone widths, where there is no room for it
+ and the file name is what the screen is short of — the same treatment the
+ type and date columns already get.
+
+ Nothing is lost with it, which is why it was the column to drop: the Search
+ page re-roots every result under a folder named after its group, so a
+ reader is always inside exactly one group and the breadcrumb above the
+ table names it. The column repeated that on every row.
+
+ Both halves are asserted, and the wide half is the one that matters: a
+ column that stopped rendering altogether would satisfy "hidden on a phone"
+ perfectly.
+ """
+ present = {c["name"]: (c["width"], c["groupColumn"]) for c in measured
+ if c["groupColumn"] != "absent"}
+ assert present, "no case rendered a group column at all"
+ wide = {n: g for n, (w, g) in present.items() if w > 768}
+ narrow = {n: g for n, (w, g) in present.items() if w <= 768}
+ assert wide and narrow, f"need both sides of the breakpoint, got {present}"
+ assert all(g == "shown" for g in wide.values()), (
+ f"the group column is missing on a wide screen: {wide}")
+ assert all(g == "hidden" for g in narrow.values()), (
+ f"the group column still takes room on a phone: {narrow}")
+
+
+def test_the_toolbar_wraps_at_a_phone_width(measured):
+ """
+ The reason the heights are measured rather than written down. If the
+ toolbar were the same height at 420 as at 1100, the whole mechanism would
+ be arithmetic nobody needs — and this test would be the one to say so
+ before the next reader replaces it with a constant.
+ """
+ tall = {c["view"]: c["after"][".file-toolbar"]["height"]
+ for c in measured if c["width"] == 420 and ".file-toolbar" in c["bands"]}
+ wide = {c["view"]: c["after"][".file-toolbar"]["height"]
+ for c in measured if c["width"] == 1100 and ".file-toolbar" in c["bands"]}
+ assert tall and tall.keys() == wide.keys()
+ for view in tall:
+ assert tall[view] > wide[view], (
+ f"{view}: the file toolbar is {tall[view]}px at 420 and "
+ f"{wide[view]}px at 1100 — it no longer wraps, and the column heads "
+ f"could pin against a constant")