aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/sticky.js
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 /packages/meshbay-hub/src/meshbay_hub/static/sticky.js
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
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/sticky.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/sticky.js84
1 files changed, 84 insertions, 0 deletions
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]);
+}