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
@@ -94,7 +94,7 @@ it('hides on a scroll down and springs back on a scroll up', function () {
// The spatial spring, which reduced motion zeroes along with every other duration token.
->toMatch('/\[data-navigation-bar\]\[data-hide-on-scroll\] \{\s+transition: translate var\(--md-sys-motion-spatial-default-duration\) var\(--md-sys-motion-spatial-default\);/')
->toMatch('/\[data-navigation-bar\]\[data-hide-on-scroll\]\[data-hidden\] \{\s+translate: 0 100%;/')
// Unlayered, or the utility <x-app-shell> publishes the offset with would win.
// Unlayered, or the utility <x-scaffold> publishes the offset with would win.
->toContain('[data-app-shell]:has([data-navigation-bar][data-hide-on-scroll][data-hidden]) {')
->and(Str::of(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css'))->after('[data-app-shell]:has(')->toString())
->not->toContain('@layer');
@@ -5,7 +5,7 @@ use Illuminate\Support\Facades\Route;
use Livewire\Component;
use Livewire\Livewire;
function shellDestinations(): array
function scaffoldDestinations(): array
{
return [
['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'badge' => 4],
@@ -16,7 +16,7 @@ function shellDestinations(): array
}
it('draws the adaptive rail, the bar, the content region and the snackbar host', function () {
$html = (string) $this->blade('<x-app-shell :destinations="$destinations">The page</x-app-shell>', ['destinations' => shellDestinations()]);
$html = (string) $this->blade('<x-scaffold :destinations="$destinations">The page</x-scaffold>', ['destinations' => scaffoldDestinations()]);
expect($html)
->toContain('data-navigation-rail="adaptive"')
@@ -30,7 +30,7 @@ it('draws the adaptive rail, the bar, the content region and the snackbar host',
});
it('puts every destination in the rail and only those marked for the bar in the bar', function () {
$html = (string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]);
$html = (string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]);
$rail = substr($html, strpos($html, 'data-navigation-rail='), strpos($html, 'data-app-shell-bar') - strpos($html, 'data-navigation-rail='));
$bar = substr($html, strpos($html, 'data-app-shell-bar'));
@@ -45,7 +45,7 @@ it('puts every destination in the rail and only those marked for the bar in the
});
it('speaks a destination\'s badge in its own words when it has them', function () {
$html = (string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => [
$html = (string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => [
['title' => 'Get started', 'icon' => 'rocket_launch', 'url' => '/start', 'badge' => '0/3', 'badgeLabel' => '0 of 3 done'],
['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'badge' => 4],
]]);
@@ -55,7 +55,7 @@ it('speaks a destination\'s badge in its own words when it has them', function (
});
it('marks the destination at the current URL when none says it is active', function () {
Route::get('/shell-probe/inbox', fn () => Blade::render('<x-app-shell :destinations="$destinations" />', ['destinations' => [
Route::get('/shell-probe/inbox', fn () => Blade::render('<x-scaffold :destinations="$destinations" />', ['destinations' => [
['title' => 'Inbox', 'icon' => 'inbox', 'url' => url('/shell-probe/inbox')],
['title' => 'Sent', 'icon' => 'send', 'url' => url('/shell-probe/sent')],
]]));
@@ -67,7 +67,7 @@ it('marks the destination at the current URL when none says it is active', funct
});
it('keeps the destination at the page\'s URL current while a Livewire component on it updates', function () {
Livewire::component('app-shell-probe', new class extends Component
Livewire::component('scaffold-probe', new class extends Component
{
public string $page = '';
@@ -78,11 +78,11 @@ it('keeps the destination at the page\'s URL current while a Livewire component
public function render(): string
{
return '<div><x-app-shell :destinations="[[\'title\' => \'Inbox\', \'icon\' => \'inbox\', \'url\' => $page], [\'title\' => \'Sent\', \'icon\' => \'send\', \'url\' => \'/sent\']]" /></div>';
return '<div><x-scaffold :destinations="[[\'title\' => \'Inbox\', \'icon\' => \'inbox\', \'url\' => $page], [\'title\' => \'Sent\', \'icon\' => \'send\', \'url\' => \'/sent\']]" /></div>';
}
});
$probe = Livewire::test('app-shell-probe');
$probe = Livewire::test('scaffold-probe');
expect(substr_count($probe->html(), 'aria-current="page"'))->toBe(2)
->and(substr_count($probe->call('$refresh')->html(), 'aria-current="page"'))->toBe(2)
@@ -90,42 +90,42 @@ it('keeps the destination at the page\'s URL current while a Livewire component
});
it('lifts the snackbar above the bar only when there is a bar', function () {
expect((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
expect((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]))
->toContain('max-medium:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]')
->and((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => [['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'bar' => false]]]))
->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => [['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'bar' => false]]]))
->not->toContain('data-app-shell-bar')
->not->toContain('--material-bottom-bar:');
});
it('passes M3\'s tall bar through, and the bottom offset grows with it', function () {
expect((string) $this->blade('<x-app-shell :destinations="$destinations" tall-bar />', ['destinations' => shellDestinations()]))
expect((string) $this->blade('<x-scaffold :destinations="$destinations" tall-bar />', ['destinations' => scaffoldDestinations()]))
->toMatch('/data-navigation-bar\s+data-tall\s/')
->toContain('max-medium:[--material-bottom-bar:calc(5rem+')
->not->toContain('[--material-bottom-bar:calc(4rem+')
->and((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]))
->not->toContain('data-tall')
->toContain('max-medium:[--material-bottom-bar:calc(4rem+');
});
it('lets the bar leave the window while the page scrolls down', function () {
expect((string) $this->blade('<x-app-shell :destinations="$destinations" hide-bar-on-scroll />', ['destinations' => shellDestinations()]))
expect((string) $this->blade('<x-scaffold :destinations="$destinations" hide-bar-on-scroll />', ['destinations' => scaffoldDestinations()]))
->toContain('data-hide-on-scroll')
->toContain('x-data="materialNavigationBar"')
->and((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]))
->not->toContain('data-hide-on-scroll');
});
it('exposes the rail that hides when collapsed', function () {
expect((string) $this->blade('<x-app-shell :destinations="$destinations" hide-rail-when-collapsed />', ['destinations' => shellDestinations()]))
expect((string) $this->blade('<x-scaffold :destinations="$destinations" hide-rail-when-collapsed />', ['destinations' => scaffoldDestinations()]))
->toContain('data-hide-when-collapsed')
->toContain("materialNavigationRail('adaptive', true)")
->and((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]))
->not->toContain('data-hide-when-collapsed')
->toContain("materialNavigationRail('adaptive', false)");
});
it('reads the safe area and anything docked on the bar through variables an application can set', function () {
$html = (string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]);
$html = (string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]);
expect($html)
->toContain('+var(--material-bottom-extra,0px))]')
@@ -135,7 +135,7 @@ it('reads the safe area and anything docked on the bar through variables an appl
it('places each slot once', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-app-shell :destinations="$destinations">
<x-scaffold :destinations="$destinations">
<x-slot:banner><header>WINDOW BAR</header></x-slot:banner>
<x-slot:brand><span>BRAND</span></x-slot:brand>
<x-slot:rail-header><span>FAB</span></x-slot:rail-header>
@@ -143,8 +143,8 @@ it('places each slot once', function () {
<x-slot:actions><span>ACTIONS</span></x-slot:actions>
<x-slot:top><header>APP BAR</header></x-slot:top>
PAGE
</x-app-shell>
BLADE, ['destinations' => shellDestinations()]);
</x-scaffold>
BLADE, ['destinations' => scaffoldDestinations()]);
foreach (['WINDOW BAR', 'BRAND', 'FAB', 'FOOTER', 'ACTIONS', 'APP BAR', 'PAGE'] as $slot) {
expect(substr_count($html, $slot))->toBe(1);
@@ -155,20 +155,56 @@ it('places each slot once', function () {
it('spans the window with a banner, above the rail, and renders none without the slot', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-app-shell :destinations="$destinations">
<x-scaffold :destinations="$destinations">
<x-slot:banner><header>WINDOW BAR</header></x-slot:banner>
PAGE
</x-app-shell>
BLADE, ['destinations' => shellDestinations()]);
</x-scaffold>
BLADE, ['destinations' => scaffoldDestinations()]);
// Bars, then rails, then panes: the banner is outside the row the rail and the page share.
expect($html)
->toMatch('/data-app-shell-banner.*WINDOW BAR.*data-navigation-rail=.*<main/s')
->and((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]))
->not->toContain('data-app-shell-banner');
});
it('serves the showcase\'s app shell pages', function () {
it('marks the scaffold, and draws the FAB after the page\'s bar and before the page', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-scaffold :destinations="$destinations">
<x-slot:top><header>APP BAR</header></x-slot:top>
<x-slot:fab><button>NEW</button></x-slot:fab>
PAGE
</x-scaffold>
BLADE, ['destinations' => scaffoldDestinations()]);
// M3 puts a FAB early in the focus order: after the bar, before the content.
expect($html)->toMatch('/^\s*<div\s+data-app-shell\s+data-md-scaffold\b/')
->toMatch('/APP BAR.*<div data-md-scaffold-fab><button>NEW<\/button><\/div>\s*<main id="content".*PAGE/s')
->and(substr_count($html, 'NEW'))->toBe(1)
->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]))
->not->toContain('data-md-scaffold-fab');
});
it('places the FAB at the bottom-end corner, clear of the bar and of a snackbar, in the layout layer', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/scaffold.css');
expect($css)->toContain('@layer material.layout')
->toContain('--md-scaffold-fab-margin: var(--md-sys-measurement-space200);')
->toContain("@media (width >= 600px) {\n --md-scaffold-fab-margin: var(--md-sys-measurement-space300);")
->toContain('max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom)))')
->toContain('+ var(--material-snackbar-height, 0px)')
->toContain('+ min(var(--material-snackbar-height, 0px), var(--md-sys-measurement-space200))')
// The content region already has the window margin, so a pane inside draws none.
->toContain("[data-md-scaffold] main {\n --md-layout-margin: 0px;")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/layout.css'))->toContain("@import './layout/scaffold.css';");
});
it('is the only name for it: there is no app shell to fall back on', function () {
expect(fn () => $this->blade('<x-app-shell :destinations="[]" />'))->toThrow(Exception::class)
->and(file_exists(__DIR__.'/../../../resources/views/components/app-shell.blade.php'))->toBeFalse();
});
it('serves the showcase\'s scaffold pages', function () {
$html = $this->withoutVite()
->get('/material/shell/starred')
->assertOk()