summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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")