diff options
| -rw-r--r-- | CLAUDE.md | 24 | ||||
| -rw-r--r-- | docs/apps.md | 17 | ||||
| -rw-r--r-- | packages/meshbay-client/scripts/index.html | 10 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/webapp.py | 12 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/files-app.js | 26 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/group-page.js | 8 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/group-settings.js | 8 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/photos-app.js | 20 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/search-page.js | 9 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/sticky.js | 84 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/style.css | 301 | ||||
| -rwxr-xr-x | packages/meshbay-hub/tests/harness/sticky_header_probe.py | 563 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_desktop_shell.py | 13 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_hook_ordering.py | 4 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_page_does_not_scroll.py | 7 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_sticky_header.py | 297 |
16 files changed, 1370 insertions, 33 deletions
@@ -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">☰</button> + <a class="nav-brand" href="#/">MeshBay</a></div> + <div class="nav-right"><a class="nav-notif" href="#/">🔔</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") |