Rename the app shell to the scaffold and give it a FAB slot

Plan step 35: <x-app-shell> becomes <x-scaffold>, M3's and Compose's name
for the structure of bars, rails and panes, with no alias; every package
view, the showcase, the tests, the skills, README and UPGRADE follow. Its
markup and Tailwind classes stay for the navigation rewrite. The new fab
slot places the FAB as Compose's Scaffold does - bottom-end, 16px from the
edges below medium and 24px from it, clear of the navigation bar and of a
snackbar on screen (resources/css/layout/scaffold.css) - and the content
region tells a pane inside that the margin is already drawn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 14:51:48 +02:00
co-authored by Claude Opus 5
parent acd6ba21ae
commit 0013e0154d
24 changed files with 301 additions and 120 deletions
@@ -24,12 +24,12 @@
down and springs back on a scroll up, never before the first screenful has gone by, and never
while a snackbar, a bottom sheet or a drawer is on screen those are anchored to the bar's
edge and would slide with it. Focus reaching the bar brings it back, which is as close as the
web gets to M3's "never hide it while a screen reader is active". Inside `<x-app-shell>`
web gets to M3's "never hide it while a screen reader is active". Inside `<x-scaffold>`
`--material-bottom-bar` follows the bar down and up, so a `fab` button, the snackbar and the
page's bottom padding keep their distance from it rather than from where it was.
It does not position itself: wrap it in the element that pins it (`fixed inset-x-0 bottom-0`)
and hides it where a rail takes over. `<x-app-shell>` does both, and lifts the snackbar and a
and hides it where a rail takes over. `<x-scaffold>` does both, and lifts the snackbar and a
`fab` button above it through `--material-bottom-bar`.
`label` names the landmark ("Main" by default); `tall` picks the 80px container;
@@ -29,7 +29,7 @@
- `modal` collapsed in the layout; the menu button (or `$store.rail.show()` from anywhere)
opens it expanded over a scrim, holding focus until Escape, the scrim, the menu button or
leaving the page closes it (Compose's ModalWideNavigationRail).
- `adaptive` — what `<x-app-shell>` uses, one rail per M3 window size class: on a compact
- `adaptive` — what `<x-scaffold>` uses, one rail per M3 window size class: on a compact
window (below `medium`, 600px) nothing until `$store.rail.show()` slides it in as a modal;
at `medium` (600839) collapsed in the layout, opening as a modal; at `expanded` (8401199)
a standard rail, collapsed until its menu button expands it in place; from `large` (1200)
@@ -1,17 +1,20 @@
{{-- The adaptive app shell: navigation that changes shape with the window, around the page.
{{-- The scaffold: M3's structure of bars, rails and panes around the page, and the FAB, with
navigation that changes shape with the window. M3 and Compose call it the scaffold
(docs/reference/m3/foundations.md § Layout Scaffold); before 2.0.0 it was `<x-app-shell>`.
<x-app-shell :destinations="[
<x-scaffold :destinations="[
['title' => 'Shares', 'icon' => 'folder_shared', 'url' => route('shares'), 'active' => request()->routeIs('shares*'), 'badge' => 3],
['title' => 'Upload', 'icon' => 'upload', 'url' => route('upload')],
['title' => 'Users', 'icon' => 'group', 'url' => route('users'), 'section' => 'Admin', 'bar' => false],
]">
<x-slot:brand><a href="/" wire:navigate class="type-title-lg">SealShare</a></x-slot:brand>
<x-slot:top>…the page's app bar…</x-slot:top>
<x-slot:fab><x-fab icon="add" tooltip="New share" /></x-slot:fab>
…the page…
</x-app-shell>
</x-scaffold>
The navigation is M3's per window size class (docs/reference/m3/foundations.md § Layout and
The navigation is M3's per breakpoint (docs/reference/m3/foundations.md § Layout and
foundations-supplement.md § Breakpoints), and only those four numbers:
- **Compact**, below `medium` (600px): a navigation bar with the destinations marked `bar`,
@@ -28,14 +31,15 @@
From `expanded` the choice the menu button makes is remembered and applied before the first
paint (`$store.rail`, <x-theme-script>), so the rail never paints one width and snaps to the
other. A visitor who has chosen keeps that choice in both bands; one who never has gets the
class's own default.
breakpoint's own default.
`--material-margin` is M3's window margin 16px on a compact window, 24px from `medium` and
the content region is padded with it, so a page inside the shell writes no gutters of its own.
the content region is padded with it, so a page inside the scaffold writes no gutters of its own.
Something meant to reach the window's edges opts out with `-mx-(--material-margin)`.
Two panes side by side are M3's from `expanded`: `<x-drawer pane>` is the second one, 360dp
wide, in an `expanded:flex expanded:items-start expanded:gap-6` row inside the page.
Panes are `<x-pane>`, and two side by side are M3's canonical layouts from `expanded`,
`<x-list-detail>` and `<x-supporting-pane>`; inside the content region they draw no margin of
their own (resources/css/layout/scaffold.css sets `--md-layout-margin` there).
`destinations` is a list of arrays: `title`, `icon` (a Material Symbol), `url`, and optionally
`active` (by default: the URL is the page's; during a Livewire update request, the page the
@@ -51,8 +55,8 @@
rail morphs between a FAB and an extended FAB as it opens), `rail-footer` (at the foot of the
rail: footer destinations, an account), `actions`
(a row of icon buttons at the very foot, stacked when the rail is collapsed: a theme toggle,
sign out), `top` (the page's own bar, above the page and *beside* the rail) and the page
itself. The rail is one element at every width, so what is in it is also in the modal rail a
sign out), `top` (the page's own bar, above the page and *beside* the rail), `fab` (the
page's floating action button, see below) and the page itself. The rail is one element at every width, so what is in it is also in the modal rail a
phone opens. `label` names both navigation landmarks ("Main"); `rail-width` is the expanded
rail's width; `tall-bar` picks M3's 80px navigation bar over the 64px one, and the bottom
offset every pinned thing reads follows it; `hide-bar-on-scroll` lets the bar leave the window
@@ -66,16 +70,28 @@
search, one account menu, the same on every page spans the window and the rail starts under
it; a bar that titles the page belongs to the page, beside the rail. Put an app bar in one or
the other, never both. A banner that pins itself to the top of the window says how tall it is
`style="--material-banner: 4rem"` on `<x-app-shell>` so the rail sticks under it instead
`style="--material-banner: 4rem"` on `<x-scaffold>` so the rail sticks under it instead
of behind it.
The page is `<main id="content">` with `wire:transition.navigate`, behind a skip link that is
the first thing a keyboard reaches. The snackbar host (`<x-toast />`) is part of the shell;
the first thing a keyboard reaches. The snackbar host (`<x-toast />`) is part of the scaffold;
on a compact window it, and a `fab` button, sit above the bottom bar through `--material-bottom-bar`:
the bar's 64px, the bottom safe area (`--material-safe-bottom`, else the device's inset) and
`--material-bottom-extra` (0px unless the application docks something, an offline banner, on
top of the bar).
`fab` places an `<x-fab>` as Compose's Scaffold places its floating action button: fixed at the
bottom-end corner, 16px from the window's edges on a compact window and 24px from `medium`
(M3's margins, foundations-supplement.md § Breakpoints; the FAB's own adaptive placement is
the lower trailing corner, components-actions-communication-containment.md § FAB), above the
navigation bar and the bottom safe area, and lifted over a snackbar while one shows
M3: a snackbar appears above a FAB, never in front of or behind one. Compose raises the
snackbar over the FAB instead; here the snackbar host is one fixed element every page shares,
so the FAB is what moves (resources/css/layout/scaffold.css). Use it or `rail-header`'s FAB,
not both: M3 says not to show more than one FAB on a screen. It comes after the page's bar and
before the page in focus order, where M3 puts a FAB (§ FAB Accessibility: "the FAB should be
prioritized in the page's focus order").
`max-expanded:overflow-x-clip` on the content region is the backstop under every page, and it stays
`clip`: `overflow-x: hidden` would force `overflow-y` to `auto`, turn the region into a scroll
container and break every `position: sticky` inside it (an app bar, a list-detail pane). Below
@@ -120,6 +136,7 @@
<div
data-app-shell
data-md-scaffold
@class([
'flex min-h-dvh flex-col bg-surface text-on-surface [--material-margin:1rem] medium:[--material-margin:1.5rem]',
'max-medium:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]' => $barItems->isNotEmpty() && ! $tallBar,
@@ -176,6 +193,10 @@
<div class="flex min-w-0 flex-1 flex-col">
{{ $top ?? '' }}
@isset($fab)
<div data-md-scaffold-fab>{{ $fab }}</div>
@endisset
<main id="content" tabindex="-1" wire:transition.navigate class="min-w-0 flex-1 px-(--material-margin) outline-none max-expanded:overflow-x-clip max-medium:pb-(--material-bottom-bar)">
{{ $slot }}
</main>
@@ -31,7 +31,7 @@
rail's width is CSS keyed on it (the `rail-collapsed:` variant). Set any later, a collapsed
rail would paint wide and snap shut on every load. `$store.rail` (resources/js/navigation.js)
changes it. <html data-rail-auto> rides with it and says nothing was stored — the value is only
`rail.default`, not a choice — so `<x-app-shell>`'s adaptive rail can start collapsed in the
`rail.default`, not a choice — so `<x-scaffold>`'s adaptive rail can start collapsed in the
expanded class (8401199) and expanded from large, as M3 asks, while still obeying a visitor
who has chosen. `$store.rail` drops it the first time they do.
+1 -1
View File
@@ -25,7 +25,7 @@
A docked toolbar and a navigation bar occupy the same region of the screen and M3 says never to
show both at once: the bar belongs on a primary page, the toolbar on a secondary or contextual
one. Either way a toolbar placed at `bottom` clears `--material-bottom-bar`, so it is never
buried under `<x-app-shell>`'s bar.
buried under `<x-scaffold>`'s bar.
Put `<x-button icon="…" tooltip="…" />` controls in the slot (`:selected` for toggles). It is a
`role="toolbar"`: the arrow keys move between its controls (resources/js/toolbar.js,