From 4a4982b1efae9d03a82e44d9e21b87988b2dca58 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 05:52:09 +0200 Subject: [PATCH 01/16] Reach M3's 48px target from every 40px navigation control The theme toggle and the account-menu avatar are drawn 40px and had no target extension; both now carry `touch-target`, and the avatar's clipping moves from the button (which would have cut the pseudo-target off) to the . The rail's hand-rolled `after:` target becomes the same utility. Plan: docs/plans/material-3-alignment.md, step 14 (navigation N-01). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/views/components/account-menu.blade.php | 4 ++-- resources/views/components/navigation-rail.blade.php | 2 +- resources/views/components/theme-toggle.blade.php | 2 +- tests/Feature/Components/AppBarTest.php | 7 ++++++- tests/Feature/Components/NavigationRailTest.php | 2 ++ 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/resources/views/components/account-menu.blade.php b/resources/views/components/account-menu.blade.php index b5417baf..00c968a9 100644 --- a/resources/views/components/account-menu.blade.php +++ b/resources/views/components/account-menu.blade.php @@ -38,10 +38,10 @@ type="button" aria-label="{{ $label }}" data-account-menu - class="focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-corner-full bg-primary-container type-label-lg text-on-primary-container" + class="touch-target focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-corner-full bg-primary-container type-label-lg text-on-primary-container" > @if ($image) - + @elseif (filled($initials)) {{ $initials }} @else diff --git a/resources/views/components/navigation-rail.blade.php b/resources/views/components/navigation-rail.blade.php index 469d530c..c9b4fa6a 100644 --- a/resources/views/components/navigation-rail.blade.php +++ b/resources/views/components/navigation-rail.blade.php @@ -109,7 +109,7 @@ x-on:click="menu()" x-bind:aria-label="expanded ? @js(__('Collapse navigation')) : @js(__('Expand navigation'))" x-bind:aria-expanded="expanded.toString()" - class="state-layer focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-corner-full text-on-surface-variant after:absolute after:top-1/2 after:left-1/2 after:size-12 after:-translate-x-1/2 after:-translate-y-1/2" + class="state-layer touch-target focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-corner-full text-on-surface-variant" > diff --git a/resources/views/components/theme-toggle.blade.php b/resources/views/components/theme-toggle.blade.php index 47181f7a..a364c132 100644 --- a/resources/views/components/theme-toggle.blade.php +++ b/resources/views/components/theme-toggle.blade.php @@ -93,7 +93,7 @@ x-bind:aria-pressed="($store.theme.resolved === 'dark').toString()" x-on:click="$store.theme.toggle()" @endif - {{ $attributes->class(['state-layer focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-corner-full text-on-surface-variant transition-[border-radius] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast active:rounded-corner-sm']) }} + {{ $attributes->class(['state-layer touch-target focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-corner-full text-on-surface-variant transition-[border-radius] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast active:rounded-corner-sm']) }} > @if ($mode === 'cycle') diff --git a/tests/Feature/Components/AppBarTest.php b/tests/Feature/Components/AppBarTest.php index db85e50b..95760299 100644 --- a/tests/Feature/Components/AppBarTest.php +++ b/tests/Feature/Components/AppBarTest.php @@ -86,6 +86,8 @@ it('switches the theme through the store in three shapes', function () { expect((string) $this->blade('')) ->toContain('data-theme-toggle="toggle"') ->toContain('aria-label="Dark theme"') + // 40px drawn, 48px reached: M3's minimum target (N-01). + ->toContain('touch-target') ->toContain('$store.theme.toggle()') ->and((string) $this->blade('')) ->toContain('data-theme-toggle="cycle"') @@ -107,12 +109,15 @@ it('opens an account menu from the initials of a name', function () { expect($html) ->toContain('aria-label="Account"') ->toContain('data-account-menu') + // The 40px avatar reaches 48px, and nothing clips the pseudo-target (N-01). + ->toContain('touch-target') + ->not->toContain('overflow-hidden') ->toMatch('/>\s*AM\s*<\/button>/') ->toContain('anna@example.com') ->toContain('Settings') ->toContain('data-account-theme') ->toContain('Sign out') ->and((string) $this->blade('')) - ->toContain('toContain('') ->not->toContain('data-account-theme'); }); diff --git a/tests/Feature/Components/NavigationRailTest.php b/tests/Feature/Components/NavigationRailTest.php index cc571388..99223e02 100644 --- a/tests/Feature/Components/NavigationRailTest.php +++ b/tests/Feature/Components/NavigationRailTest.php @@ -22,6 +22,8 @@ it('gives the collapsible, modal and adaptive rails a menu button and the store' ->toContain('data-navigation-rail-menu') ->toContain('aria-label="Collapse navigation"') ->toContain('aria-expanded="true"') + // The 40px menu button reaches M3's 48px target through the shared utility (N-01). + ->toContain('touch-target') ->not->toContain('data-navigation-rail-scrim') ->not->toContain('x-trap') ->and((string) $this->blade('')) From 700869ded4b732dcebdc806e02ecb6cd39c3acee Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 05:53:37 +0200 Subject: [PATCH 02/16] Lift a bottom-placed toolbar above the navigation bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `place="bottom"` ignored --material-bottom-bar, so a floating toolbar overlapped the shell's navigation bar and a docked one landed squarely on it. Both now clear it — max(), not a sum, since that height already swallows the bottom safe area — and the docs say what M3 says: never show a docked toolbar and a navigation bar together. Plan: docs/plans/material-3-alignment.md, step 14 (navigation N-02). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../skills/livewire-material-development/SKILL.md | 2 ++ resources/css/components/toolbar.css | 13 ++++++++++--- resources/views/components/toolbar.blade.php | 5 +++++ tests/Feature/Components/AppBarTest.php | 9 +++++++++ 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 38ecc56c..2f2afe86 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -789,6 +789,8 @@ M3 Expressive toolbar, `role="toolbar"` (arrow keys move between controls). `var ``` +A docked toolbar and a navigation bar occupy the same screen region and must never be on screen together: show the bar on a primary page and the toolbar on a secondary or contextual one. A `place="bottom"` toolbar clears `--material-bottom-bar` if a bar is there anyway, so nothing is buried. + ### ``, `` M3 tabs with a server-rendered tablist (arrow keys, Home/End, disabled tabs skipped, the indicator moves in a view transition). `tabs`: `['name', 'label', 'icon', 'badge', 'disabled']`; panels are `` in the slot. Bind with `wire:model` (entangled), or `selected` / `x-model` without Livewire. `variant` `primary` (default) or `secondary`; `stacked` (icon over label), `scrollable`. Give two identical tab sets on one page distinct `id`s. diff --git a/resources/css/components/toolbar.css b/resources/css/components/toolbar.css index 512e5c42..0372a20e 100644 --- a/resources/css/components/toolbar.css +++ b/resources/css/components/toolbar.css @@ -71,10 +71,17 @@ flex-direction: column; } -/* Placed over the page: centred above the bottom edge, or centred against the end edge. */ +/* Placed over the page: centred above the bottom edge, or centred against the end edge. + * + * A toolbar at the bottom shares the screen region with a navigation bar, so it clears the one + * publishes as --material-bottom-bar, exactly as the FAB and the snackbar do — + * `max()`, not a sum, because that height already swallows the bottom safe area; without a bar + * the safe area alone applies. A *docked* toolbar and a navigation bar must never be on screen + * together at all (docs/reference/m3/components-navigation-selection-inputs.md § Toolbars); the + * offset is the backstop, not a licence. */ [data-toolbar-place="bottom"] { position: fixed; - bottom: calc(1rem + var(--material-safe-bottom, env(safe-area-inset-bottom))); + bottom: calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + 1rem); left: 50%; z-index: 30; translate: -50% 0; @@ -90,7 +97,7 @@ [data-toolbar][data-variant="docked"][data-toolbar-place="bottom"] { inset-inline: 0; - bottom: 0; + bottom: var(--material-bottom-bar, 0px); left: 0; translate: none; } diff --git a/resources/views/components/toolbar.blade.php b/resources/views/components/toolbar.blade.php index c0451eb3..61b6ecd0 100644 --- a/resources/views/components/toolbar.blade.php +++ b/resources/views/components/toolbar.blade.php @@ -7,6 +7,11 @@ toolbar sits where it is written. A `fab` slot sets an `` beside a floating toolbar. `label` names it for screen readers. + 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 ``'s bar. + Put `` controls in the slot (`:selected` for toggles). It is a `role="toolbar"`: the arrow keys move between its controls (resources/js/toolbar.js, resources/css/components/toolbar.css). --}} diff --git a/tests/Feature/Components/AppBarTest.php b/tests/Feature/Components/AppBarTest.php index 95760299..f084b7fa 100644 --- a/tests/Feature/Components/AppBarTest.php +++ b/tests/Feature/Components/AppBarTest.php @@ -69,6 +69,15 @@ it('draws floating and docked toolbars', function () { ->not->toContain('data-toolbar-group'); }); +it('keeps a toolbar at the bottom clear of the navigation bar', function () { + $css = file_get_contents(__DIR__.'/../../../resources/css/components/toolbar.css'); + + expect($css) + // Never a sum: --material-bottom-bar already swallows the bottom safe area (N-02). + ->toContain('bottom: calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + 1rem);') + ->toContain('bottom: var(--material-bottom-bar, 0px);'); +}); + it('offers M3\'s three contrast levels, marking the one in force', function () { expect((string) $this->blade('')) ->toContain('data-theme-toggle="contrast"') From 4d4862189add32e866e9610ed6b6979679547aa3 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 05:54:09 +0200 Subject: [PATCH 03/16] Mark a FAB's root with data-fab A FAB nested in another component has to be restyled from that component's stylesheet, and its root carried no hook. Added here rather than with the rule that needs it, so the actions stream's own use of the hook merges cleanly. Plan: docs/plans/material-3-alignment.md, step 14 (navigation N-03). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/views/components/fab.blade.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/resources/views/components/fab.blade.php b/resources/views/components/fab.blade.php index 91c02076..a074052a 100644 --- a/resources/views/components/fab.blade.php +++ b/resources/views/components/fab.blade.php @@ -9,6 +9,9 @@ `
`. For a create action that is a FAB on a phone and a header button above, use ``. + `data-fab` marks the root, so a component that nests a FAB can restyle it from its own + stylesheet — a rail drops its elevation to 0, as M3 asks of a nested FAB. + Sizes, corners and elevation from FabBaseline/Medium/LargeTokens and ExtendedFab*Tokens (androidx Compose Material 3, Apache-2.0). --}} @@ -63,7 +66,7 @@ ], fn ($value): bool => $value !== null)); @endphp -<{{ $tag }} {{ $attributes }}> +<{{ $tag }} data-fab {{ $attributes }}> @if ($icon) @endif From 98afef7b45fd543d50bdbb969eba2b9db0b08804 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 05:54:52 +0200 Subject: [PATCH 04/16] Rest a FAB nested in the rail at elevation 0 M3 puts a FAB nested in another component at level 0, and the documented pattern puts one in the rail's header, so every documented use sat at elevation 3. The rule is unlayered: the FAB's shadow is a utility and a layer would lose to it. Plan: docs/plans/material-3-alignment.md, step 14 (navigation N-03). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/css/components/navigation.css | 13 +++++++++++++ .../views/components/navigation-rail.blade.php | 3 ++- tests/Feature/Components/NavigationRailTest.php | 15 +++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/resources/css/components/navigation.css b/resources/css/components/navigation.css index a2f1db1b..6d2d9ba1 100644 --- a/resources/css/components/navigation.css +++ b/resources/css/components/navigation.css @@ -41,6 +41,9 @@ * rail in the layout rather than one that opens over a scrim; 75rem (1200px) is `large`, where the * rail starts expanded instead of collapsed. The bar's own item layout is a *container* query at * the same 37.5rem, so a bar in a narrow column lays out by its own width. + * + * Everything here is in `@layer components` except the last block, which restyles a FAB the + * application put in the rail: that one has to beat a utility, and a layer never does. */ @custom-variant rail-collapsed { @@ -601,3 +604,13 @@ } } } + +/* A FAB in the rail's header rests at elevation 0, not the 3 a standalone FAB has + * (docs/reference/m3/components-navigation-selection-inputs.md § Navigation rail: "when nested + * within another component, such as the navigation rail, the FAB's resting elevation should be + * level 0"). Unlayered on purpose: the FAB draws its shadow with a utility, and a rule in any + * layer loses to a utility — the same reason toolbar.css gives for its vibrant recolouring. */ +[data-navigation-rail-header] [data-fab], +[data-navigation-rail-header] [data-fab]:hover { + box-shadow: none; +} diff --git a/resources/views/components/navigation-rail.blade.php b/resources/views/components/navigation-rail.blade.php index c9b4fa6a..1f712275 100644 --- a/resources/views/components/navigation-rail.blade.php +++ b/resources/views/components/navigation-rail.blade.php @@ -39,7 +39,8 @@ Slots: `brand` beside the menu button, only while expanded; `header` under it — a FAB, drawn as an extended FAB when expanded (`rail-collapsed:` below); the destinations in the default slot, which alone scroll when the window is too short; `footer`, pinned to the foot. Header and - footer never scroll, so nothing in them is cut off by the scroller's edge. + footer never scroll, so nothing in them is cut off by the scroller's edge. A FAB in the header + rests flat: M3 puts a nested FAB at elevation 0, not the 3 a standalone one has. Anything inside can take both shapes with the `rail-collapsed:` variant, which applies while the rail is drawn collapsed for whatever reason: diff --git a/tests/Feature/Components/NavigationRailTest.php b/tests/Feature/Components/NavigationRailTest.php index 99223e02..4f0c54e3 100644 --- a/tests/Feature/Components/NavigationRailTest.php +++ b/tests/Feature/Components/NavigationRailTest.php @@ -51,6 +51,21 @@ it('keeps the header and footer out of the scrolling destinations', function () ->toMatch('/data-navigation-rail-header.*Brand.*FAB.*data-navigation-rail-destinations.*Inbox.*data-navigation-rail-footer.*Account/s'); }); +it('flattens a FAB nested in the rail header to elevation 0', function () { + $html = (string) $this->blade(<<<'BLADE' + + + + BLADE); + + // The hook the unlayered rule in navigation.css needs (N-03). + expect($html)->toContain('data-fab'); + + expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css')) + ->toContain('[data-navigation-rail-header] [data-fab],') + ->toContain('[data-navigation-rail-header] [data-fab]:hover {'); +}); + it('draws a destination in both shapes, with its count on the icon and at the end', function () { $html = (string) $this->blade(''); From a99fbd37941247a436688ac8711b30d7b53a27e1 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 06:00:07 +0200 Subject: [PATCH 05/16] Build the theme and contrast rows on the connected button group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mode="picker"` hand-rolled an outlined segmented button, point for point the design M3 Expressive deprecates, and its own arrow keys neither wrapped nor handled Up/Down. Both rows are now : native radios in a fieldset, so the browser supplies the radiogroup, both axes, the wrap and the roving tab stop, and the store is bound through an x-model accessor — a contrast row still marks the resolved level but writes through setContrast(). A new `label` prop legends the row. Plan: docs/plans/material-3-alignment.md, step 14 (navigation N-04, N-17). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 2 +- .../views/components/theme-toggle.blade.php | 109 +++++++++--------- tests/Browser/BarsTest.php | 4 +- tests/Browser/ThemeTest.php | 8 +- tests/Feature/Components/AppBarTest.php | 31 +++-- 5 files changed, 85 insertions(+), 69 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 2f2afe86..a471bf96 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -821,7 +821,7 @@ An avatar that opens a menu: `name`, `email`, `avatar` (image URL or initials; d ### `` -Switches `$store.theme`: `mode="toggle"` (default, light/dark icon button), `cycle` (light → dark → system), `picker` (a row of three for settings pages), `contrast` (the same row for M3's standard, medium and high levels, marking the one in force). Every toggle on a page shares the store. +Switches `$store.theme`: `mode="toggle"` (default, light/dark icon button), `cycle` (light → dark → system), `picker` (a row of three for settings pages), `contrast` (the same row for M3's standard, medium and high levels, marking the one in force). Every toggle on a page shares the store. Both rows are `` — a connected button group over native radios, so the arrow keys, the wrap and the roving tab stop are the browser's; `label` adds a visible legend, and without one the row is still named for a screen reader. Nothing is checked until Alpine has read the store, because the theme is known only in the browser. ### `` diff --git a/resources/views/components/theme-toggle.blade.php b/resources/views/components/theme-toggle.blade.php index a364c132..206762a8 100644 --- a/resources/views/components/theme-toggle.blade.php +++ b/resources/views/components/theme-toggle.blade.php @@ -6,76 +6,79 @@ you — a sun while the page is dark, a moon while it is light — and it is a toggle button, "Dark theme", pressed while dark. - `cycle`: an icon button that steps light → dark → system, showing the choice it is on. - - `picker`: M3's segmented buttons for the three choices, for a settings page. + - `picker`: the three choices as a connected button group, for a settings page. - `contrast`: the same row for M3's three contrast levels — standard, medium (3:1) and high (7:1), the three the scheme is generated in. The row marks the level in force (`resolvedContrast`), so the operating system's setting shows while the choice is `system`, and choosing one is an explicit choice from then on. + The two rows are `` — M3 Expressive's connected button group, the successor of the + segmented button, which the expressive update deprecates. Its segments are native radios, so + the browser supplies the radiogroup semantics, both arrow axes, the wrap and the roving tab + stop; the store is bound through an `x-model` accessor on the wrapper, since the level a + contrast row marks is the resolved one but the level it writes goes through `setContrast()`. + `label` adds a visible legend (a settings page); without one the row is still named for a + screen reader. `class` lands on the button or on the group. + The theme is the visitor's, known only in the browser, so the parts that depend on it wait for - Alpine (`x-cloak`) rather than render a guess. `class` lands on the button or the group. --}} + Alpine (`x-cloak`, an unchecked radio) rather than render a guess. --}} @props([ 'mode' => 'toggle', + 'label' => null, ]) @php $mode = in_array($mode, ['toggle', 'cycle', 'picker', 'contrast'], true) ? $mode : 'toggle'; + + $row = [ + 'picker' => [ + 'name' => __('Theme'), + 'field' => 'material-theme', + 'read' => '$store.theme.choice', + 'write' => '$store.theme.set(value)', + 'options' => [ + ['id' => 'light', 'name' => __('Light'), 'icon' => 'light_mode'], + ['id' => 'dark', 'name' => __('Dark'), 'icon' => 'dark_mode'], + ['id' => 'system', 'name' => __('System'), 'icon' => 'brightness_auto'], + ], + ], + 'contrast' => [ + 'name' => __('Contrast'), + 'field' => 'material-contrast', + 'read' => '$store.theme.resolvedContrast', + 'write' => '$store.theme.setContrast(value)', + 'options' => [ + ['id' => 'standard', 'name' => __('Standard'), 'icon' => 'contrast'], + ['id' => 'medium', 'name' => __('Medium'), 'icon' => 'contrast_circle'], + ['id' => 'high', 'name' => __('High'), 'icon' => 'contrast_square'], + ], + ], + ][$mode] ?? null; @endphp -@if ($mode === 'contrast') +@if ($row !== null)
class(['inline-flex h-10 rounded-corner-full border border-outline']) }} + role="group" + aria-label="{{ $label ?? $row['name'] }}" + data-theme-toggle="{{ $mode }}" + x-data="{ + get chosen() { + return this.{{ $row['read'] }} + }, + set chosen(value) { + this.{{ $row['write'] }} + }, + }" > - @foreach (['standard' => ['Standard', 'contrast'], 'medium' => ['Medium', 'contrast_circle'], 'high' => ['High', 'contrast_square']] as $level => [$text, $icon]) - - @endforeach -
-@elseif ($mode === 'picker') -
class(['inline-flex h-10 rounded-corner-full border border-outline']) }} - > - @foreach (['light' => ['Light', 'light_mode'], 'dark' => ['Dark', 'dark_mode'], 'system' => ['System', 'brightness_auto']] as $choice => [$text, $icon]) - - @endforeach +
@else