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
+56
View File
@@ -407,3 +407,59 @@ it('lays a feed out in one column below medium and in columns of its minimum wid
->assertScript(layoutColumns('#feed').' === 4')
->assertScript(layoutRect('#item-5', 'left').' === '.layoutRect('#item-1', 'left'));
});
function scaffoldFabPage(int $width): mixed
{
$body = <<<'BLADE'
<x-scaffold :destinations="[
['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'active' => true],
['title' => 'Sent', 'icon' => 'send', 'url' => '/sent'],
['title' => 'Drafts', 'icon' => 'draft', 'url' => '/drafts'],
]">
<x-slot:fab><x-fab icon="edit" tooltip="Compose" /></x-slot:fab>
<p style="block-size: 2000px">The page</p>
</x-scaffold>
BLADE;
return layoutPage($body, $width, 800);
}
const SCAFFOLD_FAB = '[data-md-scaffold-fab]';
/** The navigation bar the scaffold pins to the bottom: the one navigation landmark at the window's foot. */
const SCAFFOLD_BAR = "[...document.querySelectorAll('[data-md-scaffold] nav')].find((nav) => nav.checkVisibility() && Math.round(nav.getBoundingClientRect().bottom) >= window.innerHeight - 1)";
it('places the scaffold\'s FAB at the bottom-end corner, clear of the navigation bar and of a snackbar', function () {
$page = scaffoldFabPage(599)
->assertScript(layoutStyle(SCAFFOLD_FAB, 'position')." === 'fixed'")
->assertScript(SCAFFOLD_BAR.' !== undefined')
->assertScript('window.innerWidth - '.layoutRect(SCAFFOLD_FAB, 'right').' === 16')
// 16px above the bar's top edge.
->assertScript('Math.round('.SCAFFOLD_BAR.'.getBoundingClientRect().top) - '.layoutRect(SCAFFOLD_FAB, 'bottom').' === 16');
$page->script("window.eval(\"materialToast('Draft saved', { sticky: true })\")");
$page->wait(1)
->assertScript("document.documentElement.style.getPropertyValue('--material-snackbar-height') !== ''")
// Lifted above the snackbar, which sits 16px above the bar, by M3's 16px margin.
->assertScript("Math.round(document.querySelector('[x-data=\"materialSnackbar\"] > div').getBoundingClientRect().top) - ".layoutRect(SCAFFOLD_FAB, 'bottom').' === 16')
->assertScript(layoutRect(SCAFFOLD_FAB, 'bottom')." <= Math.round(document.querySelector('[x-data=\"materialSnackbar\"] > div').getBoundingClientRect().top)");
scaffoldFabPage(600)
->assertScript('window.innerWidth - '.layoutRect(SCAFFOLD_FAB, 'right').' === 24')
->assertScript('window.innerHeight - '.layoutRect(SCAFFOLD_FAB, 'bottom').' === 24')
->assertNoJavaScriptErrors();
});
it('draws no second margin for a pane or a canonical layout inside the scaffold\'s content region', function () {
$body = <<<'BLADE'
<x-scaffold :destinations="[['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'active' => true]]">
<x-pane id="pane"><p id="text">Body</p></x-pane>
</x-scaffold>
BLADE;
// The content region's own 24px beside the 96px collapsed rail, and nothing more.
layoutPage($body, 700)
->assertScript(layoutRect('#text', 'left').' - '.layoutRect('main', 'left').' === 24')
->assertScript(layoutStyle('#pane [data-md-pane-body]', 'paddingLeft')." === '0px'");
});
+2 -2
View File
@@ -39,7 +39,7 @@ function firstPaintProbe(): void
@livewireStyles
</head>
<body>
<x-app-shell :destinations="[
<x-scaffold :destinations="[
['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/rail-probe', 'active' => true],
['title' => 'Sent', 'icon' => 'send', 'url' => '/rail-probe'],
]">
@@ -51,7 +51,7 @@ function firstPaintProbe(): void
};
</script>
<p>The page</p>
</x-app-shell>
</x-scaffold>
@livewireScripts
</body>
</html>