diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-10 10:29:12 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-10 10:29:12 +0200 |
| commit | c2bc79f71c553f3c8e76c592715266c8469e583e (patch) | |
| tree | 27e0a57231ed8b12518894526d0baee7621d261b /packages/meshbay-hub/src/meshbay_hub | |
| parent | 0b9b85bf2d434177968dfc6e27555d54d703ff7e (diff) | |
| download | meshbay-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')
8 files changed, 440 insertions, 28 deletions
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; |