25 September 2026, Chromium, fixture data (VITE_FIXTURES=1)
Status
Experiment. No PR; you pick one.
Pick
Today the bottom of the page has no rule. The j/k cursor parks rows under Compose (75px under it at 768px, over the row's Trash action), and the Seam bar needs its own invisible strip (#377) to lift itself above toasts. Both variants give the bottom one rule, like scroll-padding-top gives the top, and both delete the strip.
Variant
Rule
Result
Code (no docs, no tests)
A Fixed room
One CSS token, --bottom-room: the lower lane at rest, Compose in its gutter (88px phone, 104px from 640px). Cursor scroll padding and list end read it.
No row under Compose at any width. Fails with toasts: at 390px the bar under the last row sits 11px under 1 toast and 71px under 2, and the cursor row can park under a toast.
+21 / −32
B Live lane Recommended
The lower lane measures its own height (bind:clientHeight) and sets --lane-h. Cursor scroll padding, list end, and the Seam bar all read it.
Nothing parks under Compose or a toast: 16px clear on a phone, 24px from 640px, with 0, 1, or 2 toasts. Also removes the bar's lane query and its 12px and 16px gaps. Two costs, below.
+31 / −38
B costs. (1) When a toast leaves while the list end is on screen, the page gets shorter and the last row moves down 60px. After the polish pass it settles over the settle token (160ms, 0 with reduced motion) instead of jumping: 556, 579, 595, 605, 612, 616px, one frame each. (2) At 768px and wider, the pinned bar sits on the lane box, 104px above the bottom (main: 16px), although Compose is right of the column. A toast that arrives there lifts the pinned bar by the toast's height.
Your call on cost 2. Keep one rule, "the bar stops on the lane box" (my pick, now in DESIGN.md), or pin 24px above the bottom from 640px, where the lane is narrow and right-aligned. That adds one width check and one number back.
Advisor (Fable, polish pass). Ship B; A breaks the case #377 fixed, and on a phone that is the common flow. Settle the 60px drop (done), give --lane-h a default (done), keep the 16px / 24px lane gap.
Numbers
Clearance is the gap from the bottom of the row or bar to the top of the toast or Compose it shares columns with. Negative means under it. Measured from DOM boxes in Chromium; - means nothing in the lane shares its columns.
Case
Width
main
A
B
Worst cursor row, j top to bottom
390
−72
16
16
768
−75
24
24
1440
-
-
-
Room under the last row at the list end
390
144
164
164
768 / 1440
144
180
180
Last row checked, bar vs lane, 0 toasts
390
29
46
46
Last row checked, 1 toast
390
12
−11
16
Last row checked, 2 toasts
390
12
−71
49
Last row checked, 0 to 2 toasts
768 / 1440
- (bar left of lane)
-
-
Pinned bar, distance to window bottom
1440
16
16
104
Last row moves when a toast leaves at the list end
all
0
0
60, over 160ms
The 12px on main is the bar's own gap above a toast; A and B use the lane's gutter instead (16px, 24px from 640px), the same gap Compose keeps to the window edge.
Evidence
Each capture shows main, A, and B side by side, at the same state.
768px, j to the last row. main: the row and its Trash action sit under Compose. A and B: the row stops above the lane.390px, last row checked with 2 toasts. A: bar and row under the toasts. main (with the strip) and B: bar above them.390px, last row checked with 1 toast. A: the toast covers the bottom 11px of the bar.390px, list end scrolled while an undo toast shows. In B the extra room is there; when the toast leaves, the room goes and the last row settles 60px down over 160ms.1440px, bar pinned while its row is below the window. B pins it on the lane box, 104px up. This is cost 2.1440px, list end. 144px of room on main, 180px in A and B.
Flow, 390px
j to the last row, k, x checks it, e archives it (undo toast), x checks the last row under the toast, Esc, scroll to the end, wait for the toast to leave.
main 3968af3. Mid-list, the cursor row passes under Compose.A 622ad9a. The cursor clears Compose; with the toast, the bar under the last row goes under it.B 8769bf7. Everything clears the lane; at the end, the toast leaves and the list end settles down.
Code
Both variants delete the strip under the Seam bar and the tick() that waited for it, and replace the fixed pb-28 end padding with the rule. Full diffs: A, B.
/* How far a spread pile row rises into place. */
--spread-rise: 6px;
+
+ /* The lower lane's gutter (1rem, 1.5rem from sm), and the room its box
+ takes at rest: Compose inside that gutter. A row scrolled into view and
+ the end of every list stay above it, so nothing parks under Compose. */
+ --lane-gap: 1rem;
+ --bottom-room: calc(2 * var(--lane-gap) + 3.5rem + env(safe-area-inset-bottom));
}
…
scroll-padding-top: calc(var(--topnav-h) + env(safe-area-inset-top));
+ scroll-padding-bottom: var(--bottom-room);
Afterclient/src/routes/[...path]/+page.svelte at 622ad9a
- <main class="shell-grid flex-1 pb-28 pt-4 sm:pt-5">
+ <!-- The page ends above Compose with room for a Seam bar under the last row. -->
+ <main class="shell-grid flex-1 pb-[calc(var(--bottom-room)+--spacing(11))] pt-4 sm:pt-5">
Afterclient/src/routes/[...path]/+page.svelte at 8769bf7
+ // The lower lane's box (toasts and Compose in their gutter) is the bottom
+ // room of the page: rows scroll above it, the page ends above it, and the
+ // Seam bar pins above it.
+ let laneHeight = $state(0);
+ $effect(() => document.documentElement.style.setProperty("--lane-h", `${laneHeight}px`));
…
- <main class="shell-grid flex-1 pb-28 pt-4 sm:pt-5">
+ <main class="shell-grid flex-1 pb-[calc(var(--lane-h)+--spacing(11))] pt-4 transition-[padding-bottom] duration-(--duration-settle) ease-(--ease-settle) sm:pt-5">
…
-<div data-lower-lane class="lane-gutter …">
+<div bind:clientHeight={laneHeight} data-lower-lane class="lane-gutter …">
Afterclient/src/lib/components/mail/SelectionBar.svelte at 8769bf7
minY = (document.querySelector("[data-topnav]")?.getBoundingClientRect().bottom ?? 0) + 8;
- // Low on screen, it stays above the toasts and Compose where they would meet.
- const lane = [...document.querySelectorAll("[data-lower-lane] > *")]
- .map((el) => el.getBoundingClientRect())
- .filter((r) => r.height > 0 && x + width > r.left - 12 && x < r.right + 12)
- .map((r) => r.top - 12);
- floor = Math.min(innerHeight - 16, ...lane);
+ floor = innerHeight - laneHeight;
maxY = floor - height;
- room = innerHeight - floor;
follow();
Beforeclient/src/lib/components/mail/SelectionBar.svelte:214-225 at 3968af3, deleted in A and B
- /* An empty strip under the hanging bar, as tall as the window below the
- pinned spot, keeps the page long enough to scroll the bar above the lower
- lane, however many toasts it holds, also while the fade-in still holds
- it up. It takes no clicks. */
- .bar::after {
- content: "";
- position: absolute;
- top: 100%;
- width: 1px;
- height: calc(var(--room) + var(--bar-drop));
- pointer-events: none;
- }
Checks
Check
Result
Output
vitest run SelectionBar.svelte.test.ts, A and B
Passed
10 of 10 each. B's tests now pass the lane height as a prop instead of faking toast boxes.
npm run check (svelte-check), A and B
Passed
0 errors, 0 warnings.
DOM geometry script, 390 / 768 / 1440, main, A, B
Ran
Playwright Chromium, the numbers above. pw-measure cannot press keys, so the same box math ran in one script.
List end settle in B, frame by frame, 390px
Passed
Last row top 556 to 616px in 134ms, no frame skips more than 23px.
pixel-measure gap on B, 390px, 1 toast
Passed
16px clear between the bar's bottom edge (679) and the toast (696), same as the DOM.
Full client suite, CI
Not run
No PR, by design of the experiment.
Safari
Not run
Not verified. scroll-padding-bottom with scrollIntoView needs a check on the Mac mini before the pick ships.