From c2bc79f71c553f3c8e76c592715266c8469e583e Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 10 Sep 2026 10:29:12 +0200 Subject: feat(hub): pin the app controls while a library scrolls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , 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 Claude-Session: https://claude.ai/code/session_01Tx16FhyD2BUdpooGb5jcyN --- .../meshbay-hub/src/meshbay_hub/static/style.css | 301 ++++++++++++++++++++- 1 file changed, 292 insertions(+), 9 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/style.css') 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 ``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; -- cgit v1.2.3