Bottom room for toasts and Compose

Repository
rubas/inbox
Base
3968af3 main (after #377)
Variant A
bottom-room-a at 622ad9a
Variant B
bottom-room-b at 8769bf7
Captured
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.

VariantRuleResultCode (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.

CaseWidthmainAB
Worst cursor row, j top to bottom390−721616
768−752424
1440---
Room under the last row at the list end390144164164
768 / 1440144180180
Last row checked, bar vs lane, 0 toasts390294646
Last row checked, 1 toast39012−1116
Last row checked, 2 toasts39012−7149
Last row checked, 0 to 2 toasts768 / 1440- (bar left of lane)--
Pinned bar, distance to window bottom14401616104
Last row moves when a toast leaves at the list endall0060, 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.

A: one static token

client/src/routes/layout.css

After client/src/routes/layout.css at 622ad9a
 	/* 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);
After client/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">

B: the lane sets its own height

client/src/routes/[...path]/+page.svelte

After client/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 …">
After client/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();
Before client/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

CheckResultOutput
vitest run SelectionBar.svelte.test.ts, A and BPassed10 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 BPassed0 errors, 0 warnings.
DOM geometry script, 390 / 768 / 1440, main, A, BRanPlaywright 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, 390pxPassedLast row top 556 to 616px in 134ms, no frame skips more than 23px.
pixel-measure gap on B, 390px, 1 toastPassed16px clear between the bar's bottom edge (679) and the toast (696), same as the DOM.
Full client suite, CINot runNo PR, by design of the experiment.
SafariNot runNot verified. scroll-padding-bottom with scrollIntoView needs a check on the Mac mini before the pick ships.

Click the image for actual size. Escape closes.