Files
livewire-material/docs/audits/m3-alignment/navigation.md
T
Andreas Reinhold / reiniandClaude Fable 5.1 651a513d1e Plan the Material 3 alignment, with the audits and Google's references
Every foundations, styles and components page of m3.material.io (238, from the
sitemap) extracted into docs/reference/m3, five audit reports with 142 findings in
docs/audits/m3-alignment, and the 2.0.0 plan in docs/plans/material-3-alignment.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 03:53:40 +02:00

638 lines
49 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Audit: navigation
Scope: `app-bar`, `toolbar`, `tabs`/`tab`, `navigation-bar(-item)`, `navigation-rail(-item|-section)`,
`app-shell`, `section-nav`, `account-menu`, `theme-toggle`, `scheme-picker`, their CSS/JS, the showcase
usage and the matching SKILL.md sections.
Sources abbreviated below: **RCB** = `docs/reference/m3/reference-components-b.md`; **RF** =
`docs/reference/m3/reference-foundations.md`; **raw/** = `docs/reference/m3/raw/<page>.md`; **tok/** =
`docs/reference/m3/tokens/<File>.kt` (androidx-main). Anything fetched live is named with its URL.
## Summary
The component-level work is unusually faithful: every numeric token I checked for the app bar
(64/112/136/120/152dp heights, title-large → headline-medium → display-small, 4dp edge spacing), the
navigation bar (64dp, 56×32 indicator, 40dp horizontal pill, 16dp indicator spaces, and even Compose's
`calculateCenteredContentHorizontalPadding` formula reproduced as `calc(10% * (count + 3))`), the rail
(96dp collapsed, 220360dp expanded, 44dp top space, 40dp header space, 56dp expanded item, modal =
surface-container + elevation 2 + `CornerLarge` inner edge) and both toolbars (64dp, 16dp/432dp docked,
8dp/4dp + `CornerFull` floating) matches the androidx token files exactly. The catalogue is also right
about Expressive: the deprecated bottom app bar and navigation drawer are absent, the flexible app bars
and the flexible navigation bar are what is implemented, and the expanded rail correctly plays the
drawer's role. The real problems are three: **the adaptive breakpoint map is Tailwind's, not M3's**`sm`
640 stands in for 600 and, much worse, `lg` 1024 stands in for 840, so every window from 840 to 1023px is
denied the standard expanded rail and gets a modal that re-closes on every navigation; **two hand-rolled
buttons (`theme-toggle`, `account-menu`) are 40×40 with no 48px target**, below M3's stated minimum, while
the library's own `<x-button>` and the rail's menu button both do it correctly; and **a few placement
rules are simply not wired up** — a `place="bottom"` toolbar sits on top of the app shell's navigation
bar, a rail-nested FAB keeps elevation 3 where M3 says level 0, and `theme-toggle mode="picker"`
hand-rolls the Expressive-deprecated segmented button even though the library already ships its
successor (`<x-group>`).
## Findings
### N-01 · theme-toggle, account-menu · 40×40 controls with no 48px interaction target
- Severity: must-fix
- M3 says: "Touch target minimum **48 × 48dp**"; "Web default target size: at least 48 × 48 **CSS
pixels**"; and, precisely for this case, "The size of state layers is 40dp while the interactive target
size is 48dp" (RF §Accessibility Values, §States/state-layer mechanics). RF §Layout/Density repeats:
"Interaction targets must stay ≥48×48dp even when the visible element is scaled down."
- Library does: `resources/views/components/theme-toggle.blade.php:65` renders `inline-flex size-10` (40px)
with no target extension; `resources/views/components/account-menu.blade.php:41` renders
`inline-flex size-10 … overflow-hidden` (40px), likewise none. The library already knows the fix: the
icon button applies `after:absolute after:top-1/2 after:left-1/2 after:size-full after:min-h-12
after:min-w-12 after:-translate-x-1/2 after:-translate-y-1/2` at `xs`/`sm`
(`resources/views/components/button.blade.php:171-172`, comment "Below 48px the touch target reaches
past the button, as M3 requires"), and the rail's menu button repeats it at
`resources/views/components/navigation-rail.blade.php:108` (`after:size-12`).
- Fix: add the same `after:` pseudo-target to both class lists. For `account-menu`, `overflow-hidden` on
the button would clip the pseudo-element — move the clipping to the `<img>` (`class="size-full
rounded-corner-full object-cover"`) and drop `overflow-hidden` from the button.
- Effort: S
- Breaks API? no
### N-02 · toolbar, app-shell · a `place="bottom"` toolbar sits on top of the navigation bar
- Severity: must-fix
- M3 says: "Docked toolbar and navigation bar occupy the same screen position — **never show both
simultaneously**" (RCB §Toolbars/Behaviour; raw/components_toolbars_guidelines.md). For the floating
case: "FAB, when present, floats above the nav bar, right-aligned — **must never overlap/cover it**"
(RCB §Navigation Bar/Behaviour), and the toolbar occupies the same rail region as a FAB
(raw/foundations_layout_scaffold_rails.md: "On mobile, a toolbar can float in the rail region" — the
rail region sits *above* the navigation bar, item 1: "A toolbar sits above the navigation bar").
- Library does: `resources/css/components/toolbar.css:75-81` pins the toolbar at
`bottom: calc(1rem + var(--material-safe-bottom, env(safe-area-inset-bottom)))` — it ignores
`--material-bottom-bar`, the variable the shell sets for exactly this purpose
(`resources/views/components/app-shell.blade.php:88`). The library's FAB
(`resources/views/components/button.blade.php:177`,
`max-sm:bottom-[calc(var(--material-bottom-bar,0px)+1rem)]`) and the snackbar
(`resources/views/components/toast.blade.php:36`) both do use it. A docked
`variant="docked" place="bottom"` toolbar (`toolbar.css:91-96`, `bottom: 0`) lands squarely on the
navigation bar with no warning anywhere in the code or in SKILL.md:754-764.
- Fix: in `toolbar.css`, change `[data-toolbar-place="bottom"]`'s `bottom` to
`calc(var(--material-bottom-bar, 0px) + 1rem + var(--material-safe-bottom, env(safe-area-inset-bottom)))`
(mirroring the FAB/toast rule, which already folds the safe area into `--material-bottom-bar`), and for
`[data-toolbar][data-variant="docked"][data-toolbar-place="bottom"]` `bottom: var(--material-bottom-bar, 0px)`.
Add one line to SKILL.md's `<x-toolbar>` section: a docked toolbar and a navigation bar must never be on
screen together — show the bar on primary pages, the toolbar on secondary/contextual ones.
- Effort: S
- Breaks API? no
### N-03 · navigation-rail · a FAB nested in the rail keeps elevation 3
- Severity: must-fix
- M3 says: "When nested within another component, such as the navigation rail, the FAB's resting
elevation should be **level 0**" (raw/components_navigation-rail_guidelines.md:213; RCB §Navigation
Rail/Behaviour: "the site explicitly states this contrasts with a standalone FAB's normal elevation").
- Library does: `resources/views/components/fab.blade.php:49` always applies
`shadow-elevation-3 hover:shadow-elevation-4`; `resources/css/components/navigation.css` contains no
rule for a FAB inside a rail (grep for `fab` in it returns nothing). The documented pattern
(`navigation-rail.blade.php:7-9`, SKILL.md:717-719, `showcase/shell.blade.php:48-49`) puts a FAB in the
`header` slot, so every documented use is at elevation 3.
- Fix: the FAB has no attribute hook today (`fab.blade.php:65` renders `<{{ $tag }} {{ $attributes }}>`
with classes only), so add `data-fab` to its merged attributes, then add an **unlayered** rule to
`navigation.css``shadow-elevation-3` is a Tailwind utility, so a `@layer components` rule would lose,
exactly as `toolbar.css:10-12` already explains for its vibrant recolouring:
`[data-navigation-rail-header] [data-fab], [data-navigation-rail-header] [data-fab]:hover { box-shadow: none; }`.
- Effort: S
- Breaks API? no
### N-04 · theme-toggle · `mode="picker"` renders the Expressive-deprecated segmented button
- Severity: must-fix
- M3 says: segmented buttons "are deprecated in the expressive update"; "connected button groups should
replace the baseline segmented button" (`docs/reference/m3/reference-components-a.md:343-345, :224, :257`).
- Library does: `resources/views/components/theme-toggle.blade.php:22-48` hand-rolls one — a 40px
(`h-10`) outlined stadium (`rounded-corner-full border border-outline`) whose segments share dividing
borders (`not-first:border-s`) and whose selected segment is `bg-secondary-container` with a check icon.
That is the `OutlinedSegmentedButtonTokens` design (40dp, `CornerFull`, 1dp outline, label-large),
point-for-point. Its own header comment says so: "picker: M3's segmented buttons for the three
choices". Meanwhile the library ships the successor twice over:
`resources/views/components/group.blade.php:1-2` ("An M3 Expressive connected button group — the
successor of the segmented button", native radios, arrow-key movement, selected segment rounds fully)
and `resources/views/components/button-group.blade.php` (`connected`). The showcase puts the deprecated
control in its own chrome at `showcase/layout.blade.php:148`.
- Fix: re-implement `mode="picker"` on top of `<x-group>` with
`:options="[['id' => 'light', 'name' => __('Light'), 'icon' => 'light_mode'], …]"` and an `x-model`-ish
binding onto `$store.theme.choice` (a small `x-data` wrapper writing `$store.theme.set()` on change). The
selected-segment shape morph, the radiogroup semantics and the arrow keys then come for free — which
also resolves N-17.
- Effort: M
- Breaks API? no (the `mode="picker"` prop name and the `data-theme-toggle="picker"` /
`data-theme-option` hooks can be kept; the internal markup changes, so any app CSS targeting the inner
buttons would move)
### N-05 · tabs · every panel is visible until Alpine initialises
- Severity: must-fix
- M3 says: tabs show one destination at a time; the active indicator plus a single visible panel is the
whole mechanism (RCB §Tabs/Anatomy, /Behaviour). Exposing all panels also breaks the ARIA tabs
contract — only the panel for the selected tab may be rendered/visible.
- Library does: `resources/views/components/tab.blade.php:10` hides the panel with `x-show` only, and
there is no `x-cloak` or server-side `hidden`/`style="display:none"`. `[x-cloak]{display:none!important}`
*is* defined (`resources/css/tokens/state.css:68-70`) but unused here. Until Alpine boots, all panels
stack vertically (the showcase's 4-tab example renders all four bodies), and a screen reader reaching
the page early sees four `role="tabpanel"` regions at once. The same line binds `x-bind:id`, so
`aria-controls="…-panel"` on every tab button (`tabs.blade.php:65`) dangles until Alpine runs.
- Fix: in `tab.blade.php`, render the id server-side the way the tab buttons already do (pass the `$id`
down, or reuse `tabsId` via a `@aware`/slot prop) and add `x-cloak` plus a server-rendered
`@if ($name !== $initial) style="display: none" @endif` — or simplest: keep `x-show`, add `x-cloak`, and
add a CSS rule that un-cloaks the initially selected panel. Any of these removes both the flash and the
dangling `aria-controls`.
- Effort: S
- Breaks API? no
### N-06 · app-shell, navigation-rail · `lg` (1024px) stands in for M3's expanded breakpoint (840dp)
- Severity: should-fix
- M3 says: breakpoints are Compact <600, Medium 600839, **Expanded 8401199**, Large 12001599,
Extra-large ≥1600 (RF §Layout/Breakpoints). For Expanded the recommended navigation is "**Modal or
standard expanded navigation rail**", and the swap table gives Expanded a "Standard expanded navigation
rail" (RF §Layout/Breakpoints tables). The rail page: "Expanded → extra-large → use a rail (never a
bar); choose Standard vs. Modal based on space and destination count" (RCB §Navigation Rail/Behaviour).
- Library does: the threshold is Tailwind's `lg` = 64rem = **1024px**, in three places that must agree —
`resources/js/navigation.js:16` (`const WIDE = '(min-width: 64rem)'`), `resources/css/components/navigation.css:51`
and `:57` (the `rail-collapsed` variant) and `:184-188` (the adaptive rail's 96px layout width). Below
it, `materialNavigationRail.modal` is true (`navigation.js:116-118`) and `expanded` ignores
`$store.rail.collapsed` (`:124-130`).
**What a user sees at 8401023px** (a landscape tablet, a half-screen desktop window, a 12.9" iPad):
a 96px collapsed rail; pressing its menu button opens the expanded rail *as a modal over a scrim* with a
focus trap; `document.addEventListener('livewire:navigating', … hide())` (`navigation.js:86`) closes it
on every destination click, so the rail must be re-opened for each navigation; and the remembered
`$store.rail` = expanded preference is silently ignored. M3 lists a standard (in-layout) expanded rail
for this whole band.
- Fix: change `WIDE` to `'(min-width: 52.5rem)'` (840px) and the three `64rem` media queries in
`navigation.css` to `52.5rem`. If 840px feels too narrow for a 256px rail beside content, expose the
threshold instead of hard-coding it: a `--material-rail-expanded-at` custom property or a `wide-at` prop
on `<x-app-shell>`/`<x-navigation-rail mode="adaptive">`, defaulting to 840px. Update SKILL.md:665 and
:735, which both say "to `lg`" / "from `lg`".
- Effort: M
- Breaks API? no (unless an app relies on the 1024px switch point visually)
### N-07 · app-shell, navigation-bar · `sm` (640px) stands in for M3's compact/medium boundary (600dp)
- Severity: should-fix
- M3 says: Compact is "Under 600dp"; Medium is "600839dp" (RF §Layout/Breakpoints). The navigation bar
is for "compact (<600dp) and medium (600839dp)" (RCB §Navigation Bar/Behaviour); at Medium the swap
table gives "Collapsed navigation rail" as the primary navigation (RF §Layout/Breakpoints).
- Library does: two different numbers for the same boundary. The **bar's own item layout** switches at the
right place — `@container (width >= 37.5rem)` = 600px (`navigation.css:123`, `:465`, `:519`, `:525`,
`:555`, `:562`), correct and matching `ShortNavigationBarArrangement.Centered`. The **shell**, however,
switches at `sm` = 40rem = 640px: `app-shell.blade.php:142` (`sm:hidden` on the bar wrapper), `:87`
(`sm:flex`), `:88` and `:136` (the `--material-bottom-bar` padding), and `navigation.css:190-194` /
`:235-279` (the adaptive rail is width 0 and `display:none` below 40rem).
**What a user sees at 600639px**: the navigation bar is still the only navigation, but it has already
flipped to the medium horizontal (icon-beside-label, centred) configuration — so the Expressive medium
bar exists in a 40px-wide window band and then disappears entirely at 640px, where the collapsed rail
takes over. The two switch points should be the same number.
- Fix: replace the `sm:`/`max-sm:` utilities in `app-shell.blade.php` (lines 87, 88, 136, 142) with
arbitrary variants at 600px (`max-[37.5rem]:` / `min-[37.5rem]:`), or define a `compact`/`medium`
custom variant in the theme so the whole package has one name for 600px; change the two `40rem` media
queries in `navigation.css` to `37.5rem`. Also update the documented wrapper in
`navigation-bar.blade.php:4` and SKILL.md:700-707, which teach `sm:hidden`.
- Effort: M
- Breaks API? no (the documented `sm:hidden` wrapper idiom changes in the docs)
### N-08 · navigation-bar-item · the horizontal (medium) item label is label-large, not label-medium
- Severity: should-fix
- M3 says: `NavigationBarTokens.LabelTextFont = TypographyKeyTokens.LabelMedium`
(tok/NavigationBarTokens.kt). There is no per-layout label font: `NavigationBarHorizontalItemTokens`
defines only `ActiveIndicatorHeight`/`LeadingSpace`/`TrailingSpace`/`IconSize`, and Compose's
`ShortNavigationBarItem` passes `labelTextStyle = NavigationBarTokens.LabelTextFont.value` for *both*
icon positions (androidx `ShortNavigationBar.kt:220`, fetched from
https://raw.githubusercontent.com/androidx/androidx/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ShortNavigationBar.kt).
- Library does: `resources/css/components/navigation.css:147-148` sets
`font: var(--md-sys-typescale-label-lg)` inside `@container (width >= 37.5rem)`. (The rail's horizontal
item *is* label-large — `NavigationRailHorizontalItemTokens.LabelTextFont = LabelLarge` — which is
probably where this came from; the nav bar has no such token.)
- Fix: `navigation.css:147-148` → keep `label-md` (delete the two lines; the base `[data-navigation-pill]`
rule at `:105-106` already sets label-medium).
- Effort: S
- Breaks API? no
### N-09 · app-bar · the search variant's container does not follow the 312dp / 50% growth rule
- Severity: should-fix
- M3 says: "The search container of the search app bar should fill 100% of the space between leading and
trailing app bar elements **until it reaches 312dp. Then, it should only grow further to fill 50% of
that space**" (raw/components_app-bars_guidelines.md:454; RCB §Top App Bar/Behaviour).
- Library does: `resources/css/components/app-bar.css:133-137``[data-app-bar-search] { flex: 1 1 0%; }`,
i.e. 100% of the available space at every width. On a 1600px window the search bar spans ~1500px. The
showcase works around it by hand (`showcase/layout.blade.php:60`, `class="mx-auto w-full max-w-2xl"`),
which is evidence the component's own behaviour is wrong, and `max-w-2xl` (672px) is not the M3 curve
either.
- Fix: in `app-bar.css`, give the search slot the M3 curve:
`[data-app-bar-search] { flex: 1 1 0%; max-width: calc(19.5rem + (100% - 19.5rem) / 2); margin-inline: auto; }`
(19.5rem = 312px; below 312px `max-width` never binds so it still fills 100%). Then drop the manual
`max-w-2xl` in the showcase layout.
- Effort: S
- Breaks API? no
### N-10 · tabs · scrollable tabs have no 52dp leading offset
- Severity: should-fix
- M3 says: "When using scrollable tabs, the first visible tab should be offset by **52dp** from the left
side of the device for both web and mobile"; "Do — Offset the first scrollable tab 52dp from the leading
edge so it's clear that more content is available" (raw/components_tabs_guidelines.md:150, :157; RCB
§Tabs/Variants).
- Library does: `resources/css/components/tabs.css:50-52` only sets `flex: none` on the tabs when
`[data-scrollable]` is present; there is no leading padding anywhere on `[data-tabs-bar]`. The showcase's
scrollable example (`showcase/sections/bars.blade.php:101-105`) starts "January" flush against the
container edge, so nothing signals that the set scrolls.
- Fix: `tabs.css`, add
`[data-tabs-bar][data-scrollable] { padding-inline-start: 3.25rem; }` (52px) — logical property, so RTL
mirrors for free.
- Effort: S
- Breaks API? no
### N-11 · app-bar · the centred headline uses fixed 56px insets and collides with two trailing actions
- Severity: should-fix
- M3 says: the centred configuration is the Expressive replacement for the center-aligned bar ("Merged
into **small**. Use centered-text configuration.", RCB §Top App Bar/Status), and trailing elements are
"up to 2 icon buttons after headline" (RCB §Top App Bar/Behaviour). The headline must not be truncated
(raw/components_app-bars_guidelines.md:337).
- Library does: `resources/css/components/app-bar.css:122-130` absolutely positions the centred headline
with `inset-inline: 3.5rem` — a hard-coded 56px on each side, enough for exactly one 48px icon button.
With the M3-sanctioned two trailing buttons (≈96px plus the 4px row padding), the headline box extends
~40px underneath them; a long title ellipsises *behind* the buttons instead of before them. Nothing in
the code adapts the inset to what the `navigation`/`actions` slots actually contain.
- Fix: replace the absolute positioning with a three-column grid on the row for the centred variant —
`[data-app-bar][data-variant="center"] [data-app-bar-row] { display: grid; grid-template-columns: 1fr auto 1fr; }`
with the leading slot in column 1, the headline (centred, `justify-self: center`, `min-width: 0`) in
column 2 and the trailing slot in column 3 (`justify-self: end`) — the headline then shrinks against
whatever is actually beside it. Failing that, measure the two slots in `app-bar.js` (which already runs
a `ResizeObserver`) and publish `--app-bar-lead`/`--app-bar-trail`.
- Effort: M
- Breaks API? no
### N-12 · toolbar · a vertical floating toolbar placed at `end` uses a 16px margin, M3 wants ≥24dp
- Severity: should-fix
- M3 says: "Horizontal toolbars should have a minimum 16dp margin from the edge of the window";
"**Vertical toolbars should have a minimum 24dp margin**" / "Maintain at least a 24dp margin for
vertical toolbars" (raw/components_toolbars_guidelines.md:334, :346, :350; RCB §Toolbars/Specs).
- Library does: `resources/css/components/toolbar.css:83-89`
`[data-toolbar-place="end"] { inset-inline-end: calc(1rem + var(--material-safe-right, …)); }`, 16px for
every toolbar placed at the end edge, and `place="end"` is documented as the placement "for a vertical
one" (`toolbar.blade.php:6`).
- Fix: `toolbar.css`, split the rule:
`[data-toolbar][data-vertical][data-toolbar-place="end"], [data-toolbar-group][data-vertical][data-toolbar-place="end"] { inset-inline-end: calc(1.5rem + var(--material-safe-right, env(safe-area-inset-right))); }`
(24px) while horizontal placements keep 16px.
- Effort: S
- Breaks API? no
### N-13 · toolbar · standard (non-filled, non-tonal) buttons inside a standard toolbar are on-surface-variant, M3 says Primary
- Severity: should-fix (with a caveat — see both readings)
- M3 says: standard toolbar colour list, verbatim: "1. Surface container / 2. Filled button (Primary, On
primary) / 3. Toggle tonal button (Secondary container, On secondary container) / **4. Standard button
(Primary)**" (raw/components_toolbars_specs.md:228-234; RCB §Toolbars/Specs). The vibrant list gives
"Standard button (On primary container)" (:253) — which the library *does* honour.
- Library does: `resources/css/components/toolbar.css:49-61` recolours buttons only in the vibrant case.
In a standard toolbar the icon buttons keep the library's default icon-button ink,
`text-on-surface-variant` (`resources/views/components/button.blade.php:116` `$quietInk`, :123 for
icon-only text buttons) — the container's own `color: var(--md-sys-color-on-surface-variant)`
(`toolbar.css:28`, `:37`) cannot reach them because they carry a utility.
**The other reading**: M3's own icon-button spec gives an unselected standard icon button
`on-surface-variant`, and the toolbar colour list is annotated from an image showing text/standard
buttons rather than icon buttons. If the library treats the toolbar list as applying to *label* buttons
only, its current choice is defensible. I lean to the finding because the same list's vibrant row *is*
implemented, so the two rows are being read inconsistently.
- Fix: if adopting the spec, add next to the vibrant rules (same unlayered block):
`[data-toolbar]:not([data-vibrant]) [data-icon-button]:not([aria-pressed="true"]) { color: var(--md-sys-color-primary); }`.
If keeping the current behaviour, say so in `toolbar.css`'s header comment with the reason, so the next
reader does not re-litigate it.
- Effort: S
- Breaks API? no
### N-14 · app-shell · the app bar sits inside the content pane, beside the rail, not across the window
- Severity: should-fix (structural; see both readings)
- M3 says: the scaffold is bars → rails → panes, in that nesting order. "Bars can frame the page";
"**Rails create the perimeter space surrounding panes**"
(raw/foundations_layout_scaffold_overview.md:54-58); "Rails are the next level in layout **after bars**,
filling the perimeter space surrounding panes"; "Rails occupy the spaces immediately adjacent to bars"
(raw/foundations_layout_scaffold_rails.md:56, :64). And for the bar itself: "Container always spans 100%
of window width at its default height" (RCB §Top App Bar/Behaviour).
- Library does: `resources/views/components/app-shell.blade.php:97-139` puts `<x-navigation-rail>` as a
flex sibling *before* a column that contains `{{ $top }}` and `<main>`. From `sm` the app bar therefore
starts 96px (or 256px) in from the window's leading edge and is a pane-level bar, not a page-level one.
There is no slot that renders above the rail, so an application cannot opt into the M3 arrangement
without abandoning `<x-app-shell>`.
**The other reading**: `NavigationSuiteScaffold` in Compose nests the whole content — top bar
included — beside the rail, and M3's canonical-layout imagery shows both arrangements. If the library
regards the app bar as a pane bar, the current structure is consistent; it is still worth a documented
decision rather than an accident of markup order.
- Fix: add a `window-top` (or `banner`) slot to `<x-app-shell>` rendered as the first child of
`[data-app-shell]` with the shell root becoming `flex flex-col` and the rail+content row nested inside,
so an application can choose either. Document which slot means which in SKILL.md:663-695.
- Effort: M
- Breaks API? no (additive slot)
### N-15 · account-menu · the avatar trigger has no state layer (no hover or pressed state)
- Severity: should-fix
- M3 says: the six states are Enabled, Disabled, Hover, Focused, Pressed, Dragged, and "States have two
visual indicators to ensure accessibility"; the state layer's colour equals the content's "on" colour,
at 8% hover / 10% focus / 10% pressed (RF §Interaction/States).
- Library does: `resources/views/components/account-menu.blade.php:41`
`focus-ring inline-flex size-10 … rounded-corner-full bg-primary-container … text-on-primary-container`.
It has the focus ring but no `state-layer`, so there is no hover and no pressed feedback at all. Every
comparable trigger in the library (the rail menu button at `navigation-rail.blade.php:108`, the theme
toggle at `theme-toggle.blade.php:65`, every `<x-button>`) carries `state-layer`.
- Fix: add `state-layer` to the class list. Note the ordering constraint from
`resources/css/tokens/state.css:13-27` (the utility sets `position: relative; isolation: isolate` and
paints a `::before` at `z-index: -1`) — with `overflow-hidden` removed per N-01 this composes cleanly.
- Effort: S
- Breaks API? no
### N-16 · section-nav · secondary tabs wrapped onto a 3/4-column grid break the tab bar's divider and indicator
- Severity: should-fix
- M3 says: "Container always spans full width, divided into equal sections; **bottom-edge divider
separates it from content below**" (RCB §Tabs/Behaviour). M3's stated answer to "too many tabs" is
scrollable tabs — "scrollable allows longer labels and more tabs" (RCB §Tabs/Variants) — and its
accessibility page explicitly blesses them: "Horizontal scrolling tabs meet accessibility requirements
because they need to increase in width to respond to label text without affecting the layout"
(raw/components_tabs_accessibility.md:95).
- Library does: `resources/views/components/section-nav.blade.php:31-35, :60` reuses
`[data-tabs-bar][data-variant="secondary"]` but applies `sm:grid sm:grid-cols-3` / `sm:grid-cols-4`, so
from 5 items the "tab bar" becomes two or more rows. `tabs.css:19` puts the 1px outline-variant divider
on the *bar*, so only the last row gets one and the upper rows' 2px active indicators float in mid-air
against nothing. `[data-tab] { min-width: 5.625rem }` (`tabs.css:36`) also fights `grid-cols-4` in a
narrow column.
- Fix: either keep `sm:flex` with `[data-scrollable]` semantics (`flex: none` + the 52dp offset from
N-10), which is what M3 prescribes and what the library already implements for `<x-tabs scrollable>`;
or, if the grid is kept deliberately, move the divider to each row
(`[data-section-nav] [data-tabs-bar] > li { border-bottom: 1px solid var(--md-sys-color-outline-variant); }`
with the bar's own border removed) and drop the `min-width` inside the grid. See also "Deliberate
deviations" — the stated reason for the grid does not survive M3's text.
- Effort: M
- Breaks API? no
### N-17 · theme-toggle · the picker's radiogroup keyboard handling is incomplete
- Severity: should-fix
- M3 says: a single-select segmented control's role is "Radiogroup"
(`docs/reference/m3/reference-components-a.md:392`); RF §Interaction/Inputs and the per-component
accessibility pages defer to the APG pattern, in which a radio group handles Up/Down as well as
Left/Right and wraps from last to first.
- Library does: `resources/views/components/theme-toggle.blade.php:38-39` handles only
`keydown.arrow-right` / `keydown.arrow-left`, via `$el.nextElementSibling` / `previousElementSibling`,
so there is **no wrap** (Right on "System" and Left on "Light" do nothing), **no Up/Down**, and **no
Home/End**. `aria-checked="false"` is also rendered on all three server-side (`:33`), so before Alpine
boots the group reports nothing checked.
- Fix: fixed by N-04 — `<x-group>` uses native radios in a fieldset, where the browser supplies wrap,
both axes and the roving tab stop. If N-04 is not taken, add `arrow-down`/`arrow-up`, wrap with
`nextElementSibling ?? firstElementChild`, and render the initial `aria-checked` from the server
default.
- Effort: S (as part of N-04)
- Breaks API? no
### N-18 · tabs · the primary active indicator is missing its 2dp side inset
- Severity: should-fix
- M3 says: "Primary tab active indicators are additionally **inset 2dp on each side**" (RCB §Tabs/Specs,
from tabs/specs page text); "Active indicator minimum length 24dp".
- Library does: `resources/css/components/tabs.css:119-129``inset-inline: 0` on
`[data-tab-indicator]`, which for a primary tab is scoped to `[data-tab-content]`
(`min-width: 1.5rem` = the 24dp minimum ✓, `tabs.css:108`). The 3px height, the `3px 3px 0 0` corners
and the 2px/square secondary variant are all correct; only the inset is absent.
- Fix: `tabs.css`, in the primary case: `[data-tabs-bar]:not([data-variant="secondary"]) [data-tab-indicator] { inset-inline: 2px; }`.
- Effort: S
- Breaks API? no
### N-19 · navigation-bar-item · the state layer uses on-surface where the rail uses on-secondary-container
- Severity: should-fix
- M3 says: "The state layer's color equals the content's **'on' color**" (RF §Interaction/States,
state-layer mechanics). For navigation items androidx is explicit and uses one colour for active *and*
inactive: `ItemActiveHoveredStateLayer = ItemActiveFocusedStateLayer = ItemActivePressedStateLayer =
ItemInactiveHoveredStateLayer = ItemInactiveFocusedStateLayer = ItemInactivePressedStateLayer =
OnSecondaryContainer` (tok/NavigationRailColorTokens.kt). `NavigationBarTokens.kt` ships no state-layer
tokens, so the rail's are the only stated ones for a navigation item.
- Library does: two different answers for the same kind of item. The rail is right —
`navigation.css:544-552` paints both `::before` layers with
`var(--md-sys-color-on-secondary-container)`. The navigation bar is not: `navigation.css:504-517` leaves
`background-color: var(--md-sys-color-on-surface)` for `[data-navigation-bar-item]`'s indicator and pill
layers. On an active bar item the pill is secondary-container, so the hover layer is an on-surface wash
over secondary-container rather than the on-secondary-container the content uses.
- Fix: `navigation.css`, add `[data-navigation-bar-item] :is([data-navigation-indicator],
[data-navigation-pill])::before { background-color: var(--md-sys-color-on-secondary-container); }` after
the shared rule, matching the rail's treatment two blocks below.
- Effort: S
- Breaks API? no
### N-20 · tabs · the focus ring is drawn inside the tab (`outline-offset: -3px`) instead of M3's 2px out
- Severity: nice-to-have
- M3 says: Foundations calls the indicator only "ring-like" and gives no number (RF §Interaction/States:
"No thickness, offset, or color token is specified anywhere in Foundations"), but the library has picked
one convention for the whole package — `focus-ring` = 3px secondary at `outline-offset: 2px`
(`resources/css/tokens/state.css:46-53`), and the navigation bar/rail items follow it
(`navigation.css:554-583`).
- Library does: `resources/css/components/tabs.css:81-85` uses `outline-offset: -3px`, drawing the ring
inside the tab's own box. (The likely reason is `overflow-x: auto` on the bar clipping an outward ring;
the file says nothing about it.)
- Fix: either keep it and record the reason in `tabs.css`'s header comment, or give the bar
`padding-block: 3px` / `scroll-padding-inline: 3px` room and switch to `outline-offset: 2px` so tabs
match every other focusable thing in the library.
- Effort: S
- Breaks API? no
### N-21 · tabs · `aria-current="page"` gets the indicator but not the active label colour in the primary variant
- Severity: nice-to-have
- M3 says: primary tabs' active label is Primary; secondary tabs' is On surface (RCB §Tabs/Specs).
- Library does: `resources/css/components/tabs.css:87-93` — the active colour rule for the primary variant
matches only `[aria-selected="true"]`, while the secondary rule matches
`:is([aria-selected="true"], [aria-current="page"])` and the indicator rule (`:141`) matches both. So a
link-based tab bar (the pattern `section-nav` uses, `section-nav.blade.php:65-70`) built with
`variant="primary"` would show the indicator but keep the label on-surface-variant.
- Fix: `tabs.css:87` → `[data-tab]:is([aria-selected="true"], [aria-current="page"])`.
- Effort: S
- Breaks API? no
### N-22 · navigation-rail · no vertical divider and no way to turn the container fill off
- Severity: nice-to-have
- M3 says: "Optional vertical divider separates rail from content, placed on the content-adjacent edge";
"container fill can be turned off (transparent) as long as items keep ≥3:1 contrast"; "If a layout
scrolls horizontally, the rail can scroll off-screen or remain fixed. To distinguish that content is
scrolling underneath the rail, use a divider or add elevation to the rail"
(RCB §Navigation Rail/Behaviour; raw/components_navigation-rail_guidelines.md:456).
- Library does: `navigation.css:196-215` always paints `background-color: var(--md-sys-color-surface)` and
draws no divider; there is no prop for either (`navigation-rail.blade.php:60-65` has only `mode`,
`label`, `width`, `menu`).
- Fix: add a `divider` boolean prop rendering `border-inline-end: 1px solid
var(--md-sys-color-outline-variant)` on `[data-navigation-rail-panel]`, and a `fill` prop (default
`true`) that sets `background-color: transparent` when false.
- Effort: S
- Breaks API? no (additive props)
### N-23 · navigation-rail · the FAB → extended FAB transition is a display swap, not a morph
- Severity: nice-to-have
- M3 says: "Rail transitions from collapsed↔expanded should animate the FAB into/out of an Extended FAB"
(RCB §Navigation Rail/Behaviour).
- Library does: the documented pattern is two FABs swapped by `display`
(`navigation-rail.blade.php:42-43`, SKILL.md:737, `showcase/shell.blade.php:48-49`) — the collapsed one
appears at once while the rail's width is still springing. The rail's own width *is* animated on the
spatial spring (`navigation.css:171`), so the mismatch is visible.
- Fix: a single `<x-fab>` whose label is width-animated (`grid-template-columns: 0fr → 1fr` on the label
span, `ease-spatial-default`) inside the `rail-collapsed` variant would morph instead of swap; or at
minimum mention the limitation in SKILL.md.
- Effort: M
- Breaks API? no
### N-24 · navigation-rail · `mode="collapsible"` has no width floor of its own
- Severity: nice-to-have
- M3 says: "Compact → don't use a standard rail, use a navigation bar" (RCB §Navigation Rail/Behaviour).
- Library does: only `mode="adaptive"` is width-aware (`navigation.css:184-194`). A `collapsible` or
`expanded` rail keeps `clamp(13.75rem, 16rem, 22.5rem)` at 360px too — two-thirds of a phone screen.
The docs point applications at `<x-app-shell>` for adaptivity, and the showcase only uses `collapsible`
inside a bounded demo box, so nothing in-repo hits it; an application wiring a bare
`<x-navigation-rail mode="collapsible">` into a page layout will.
- Fix: a one-line guard in `navigation.css` —
`@media (width < 37.5rem) { [data-navigation-rail='collapsible'], [data-navigation-rail='expanded'] { width: 6rem; } }`
plus the matching `rail-collapsed` variant arm — or an explicit sentence in SKILL.md:735 that these two
modes are fixed-width by design and belong behind a `min-[37.5rem]:` wrapper.
- Effort: S
- Breaks API? no
## Deliberate deviations
- **Expanded rail items are full-width pills, not label-hugging** — `navigation-rail-item.blade.php:18-19`:
"Compose's expanded item hugs its label; the package draws the full-width pill, which leaves room for
the count at the end." **Holds up.** M3 says exactly this is allowed: the indicator "hugs the label text
by default (**can be overridden to fill the full container width**, resembling the old navigation drawer
style); target area always spans the full rail width" (RCB §Navigation Rail/Behaviour).
- **No drop shadow on a scrolled app bar, colour change only** — `app-bar.css:12`, `:59-61`. **Holds up,
and is the right reading.** androidx has `AppBarTokens.OnScrollContainerElevation = Level2`, but the
site is explicit: "On scroll: **No drop shadow**, instead a color fill creates separation from content"
(raw/components_app-bars_overview.md:137).
- **Medium/large flexible height keyed on whether a subtitle is present** — `app-bar.css:33-47` maps
medium 112/136 and large 120/152 to without/with subtitle. **Holds up**: those are exactly
`ContainerHeight`/`LargeContainerHeight` in tok/AppBarMediumFlexibleTokens.kt and
tok/AppBarLargeFlexibleTokens.kt, and Compose's flexible app bars pick the taller one when a subtitle is
supplied.
- **Rail container is `surface`, modal is `surface-container`** — `navigation.css:210`, `:229`. **Holds
up**: tok/NavigationRailCollapsedTokens.kt `ContainerColor = Surface`;
tok/NavigationRailExpandedTokens.kt `ModalContainerColor = SurfaceContainer`, `ModalContainerElevation =
Level2`, `ModalContainerShape = CornerLarge` — all three reproduced exactly. (The site's colour list
says "Surface container (optional container fill)"; androidx is the more specific source and the library
follows it.)
- **Every toolbar control stays in the Tab order rather than a roving tabindex** — `toolbar.js:4-5`: "Every
control stays in the Tab order, so a control added by a Livewire render is reachable without any
bookkeeping." **Holds up against M3** (though not against APG's toolbar pattern): M3's own accessibility
page says "Initial focus lands on the first interactive element; **Tab (or Arrows) moves between all
others**" (RCB §Toolbars/Accessibility).
- **Section nav wraps onto a grid instead of scrolling** — `section-nav.blade.php:15-16`: "tabs that scroll
hid the last sections on a tablet." **Does not hold up.** M3 prescribes scrollable tabs for exactly this
case and its accessibility page defends them explicitly ("Horizontal scrolling tabs meet accessibility
requirements…", raw/components_tabs_accessibility.md:95), while the grid breaks the tab bar's divider
and strands the indicators (N-16). If the grid is kept for product reasons, it should stop presenting
itself as `[data-tabs-bar]`.
- **Small app-bar titles truncate with an ellipsis** — `app-bar.css:104-110`. M3 says "Don't truncate the
headline text" *and* "Don't wrap text in a small app bar"
(raw/components_app-bars_guidelines.md:337, :347); its actual answer is "If headline text is long, use a
medium flexible or large flexible app bar" (:342). Truncation is the only remaining web-sane behaviour,
so this is a reasonable forced choice — but it is undocumented. Worth one line in the header comment
pointing authors at `variant="medium"`.
## Aligned
- **App bar**: 64 / 112 / 136 / 120 / 152px heights; title-large + label-medium (small), headline-medium +
label-large (medium flexible), display-small + title-medium (large flexible); 4px leading/trailing space;
0 gap between icon buttons; 16px title inset without a leading button; surface → surface-container on
scroll; on-surface title / on-surface-variant subtitle and trailing icons / on-surface leading icon;
square container. The pure-CSS collapse (negative sticky `top` + sticky inner row) is elegant and
produces no layout shift; the flexible variants' row headline is `aria-hidden` so the real `<h1>` is
never announced twice.
- **Navigation bar**: 64px, surface-container, square, items 0 apart, 56×32 `CornerFull` indicator, 4px
iconlabel space, 6px item vertical padding, 40px horizontal indicator with 16px leading/trailing,
24px icons, on-secondary-container active icon, secondary-container indicator, secondary active label
(vertical) / on-secondary-container (horizontal), on-surface-variant inactive — all correct. The medium
arrangement reproduces `calculateCenteredContentHorizontalPadding` (items occupy `10 × (count + 3)`% of
the bar) exactly, and it switches on a **container** query at 600px, so a bar in a narrow column keeps
the compact layout.
- **Navigation rail**: 96px collapsed, `clamp(220px, width, 360px)` expanded, 44px top space, 40px header
space (32 + 8), 4px between collapsed items, 64px collapsed item, 56px expanded item, 8px/4px iconlabel
spaces, 56×32 collapsed indicator, `CornerFull`. Modal = fixed, surface-container, elevation 2,
`CornerLarge` on the inner edge, 32% scrim, `x-trap.inert.noscroll`, Escape, scrim click, focus returned
to the menu button, closed on `livewire:navigating`. Badges move from the icon (collapsed) to the label
end (expanded), which is M3's stated rule. State layers use on-secondary-container per
NavigationRailColorTokens.
- **Catalogue vs. Expressive**: no bottom app bar, no navigation drawer, no baseline medium/large app bar,
no baseline navigation bar — all four correctly skipped, and the expanded rail (standard + modal) is
what stands in for the drawer, exactly as M3 Expressive directs.
- **Tabs**: 48px (64px stacked), 24px icons, 1px outline-variant divider, 3px primary indicator with
`3px 3px 0 0` corners, 2px square secondary indicator, 24px minimum indicator length, 8px iconlabel gap,
title-small labels, primary/on-surface active colours, 8%/10% state layers, 38% disabled. Real tablist
semantics, arrow keys + Home/End, disabled tabs skipped, `aria-selected`/`tabindex` rendered on the
server, and the indicator moves in a view transition on the spatial spring with a reduced-motion escape.
- **Toolbars**: docked 64px / square / surface-container / 16px ends / 432px between items (as a
`clamp`); floating 64px / `CornerFull` / surface-container / 8px ends / 4px between; vibrant =
primary-container with on-primary-container ink and a surface-container + on-surface selected button —
all matching tok/DockedToolbarTokens.kt and tok/FloatingToolbarTokens.kt. `role="toolbar"` +
`aria-orientation` + RTL-aware arrow keys.
- **Motion**: all durations collapse to 0 under `prefers-reduced-motion`
(`resources/css/tokens/motion.css:63-75`); spatial springs for size/position, effects easing for colour
and opacity, throughout this group.
- **Safe areas**: the app bar pads the top inset, the navigation bar and rail the bottom inset, the docked
toolbar the bottom inset — matching M3's safety-region rule.
- **Links vs. buttons**: navigation items are real `<a>`s with `aria-current="page"` (plus the filled icon
and the indicator — the two-indicator rule) and degrade to `<button>` without a `link`; the section nav
uses links with `aria-current`, not fake tabs.
## Missing
M3 variants/configurations in this group with no implementation at all:
- **Navigation bar, tall container (80dp)** — `NavigationBarTokens.TallContainerHeight = 80.0.dp`
(tok/NavigationBarTokens.kt). Only the 64px short bar exists (`navigation.css:76`); no prop selects the
tall one.
- **Navigation bar, hide-on-scroll** — "Scrolling: hides on scroll-down, reappears on scroll-up — but never
hide it while a screen reader is active" (RCB §Navigation Bar/Behaviour). Not implemented and not
mentioned.
- **Navigation rail, narrow collapsed width (80dp)** — `NavigationRailCollapsedTokens.NarrowContainerWidth
= 80.0.dp`. Only 96px (`navigation.css:174`).
- **Navigation rail, centre alignment** — "Alignment: top or center; on tablets prefer center alignment for
reach. Menu icon and FAB are always top-aligned" (RCB §Navigation Rail/Behaviour). The rail is
top-aligned only.
- **Navigation rail, "hide when collapsed" as an explicit configuration** — it is in M3's configuration
table ("Expanded behavior | Hide when collapsed | — | Available"). The library gets it implicitly below
`sm` in `mode="adaptive"`, but there is no mode that hides a rail in an immersive wide layout.
- **Docked toolbar with a FAB** — M3's configuration table lists "Other elements | With FAB" for both
toolbars; `toolbar.blade.php:26` gates the `fab` slot on `$variant === 'floating'`.
- **App bar, trailing-action overflow at small widths** — "trailing actions collapse into an overflow menu
at smaller breakpoints and reappear at larger ones" (RCB §Top App Bar/Behaviour). Not implemented (and
M3 also tells you not to put an overflow menu in the app bar in the first place, so this is genuinely
optional).
- **Toolbar, large-screen treatments** — "On web/large screens it can gain rounded corners and use dividers
to organize many actions"; "medium+ breakpoints allow centering all elements or centering one key
action while pinning others to the edges" (RCB §Toolbars/Behaviour). The toolbar has no breakpoint
behaviour at all.
Correctly absent (deprecated in M3 Expressive, do **not** add): bottom app bar, navigation drawer,
baseline medium/large top app bar, baseline navigation bar. `theme-toggle mode="picker"` is the one place
a deprecated design slipped back in — see N-04.
## Breakpoint map
M3's classes: Compact <600, Medium 600839, Expanded 8401199, Large 12001599, Extra-large ≥1600
(RF §Layout/Breakpoints). Tailwind's: `sm` 640, `md` 768, `lg` 1024, `xl` 1280, `2xl` 1536.
| Component / behaviour | Library breakpoint used | M3 class it stands in for | Gap |
| --- | --- | --- | --- |
| `app-shell` — bottom navigation bar shown; rail hidden, modal-only | `max-sm` < 640px (`app-shell.blade.php:142`, `:87-88`, `:136`; `navigation.css:190-194`, `:235-279`) | Compact (<600dp) | **+40px.** 600639px is M3 Medium but is driven as Compact |
| `navigation-bar` — item layout vertical → horizontal | container 600px (`navigation.css:123`, `:465`, `:519`, `:525`, `:555`, `:562`) | Medium (600dp) | **exact** — and a container query, so it also works in a narrow column |
| `app-shell` — collapsed rail appears (96px), bar disappears | `sm` ≥ 640px | Medium (600839dp) | **+40px late**; disagrees with the line above, so the horizontal bar layout only exists in a 40px band |
| `app-shell` / `navigation-rail mode="adaptive"` — rail becomes a standard, in-layout collapsible rail; menu button toggles instead of opening a modal | `lg` ≥ 1024px (`js/navigation.js:16`; `navigation.css:51`, `:57`, `:184`) | Expanded (≥840dp) | **+184px.** 8401023px gets a collapsed rail whose expansion is modal, scrimmed, focus-trapped and re-closed by every `wire:navigate`; the remembered "expanded" preference is ignored |
| `app-shell` — content region `max-lg:overflow-x-clip` | `lg` 1024px (`app-shell.blade.php:136`) | Expanded (840dp) | +184px (a clipping backstop, no M3 rule attached) |
| `section-nav` — menu picker → secondary-tab row | `sm` 640px (`section-nav.blade.php:40`, `:59`) | Compact/Medium boundary (600dp) | +40px |
| `section-nav` — 3/4-column grid → single row | `xl` 1280px (`section-nav.blade.php:33-34`) | Large (1200dp) | +80px |
| showcase chrome — theme picker → cycle button | `md` 768px (`showcase/layout.blade.php:148-149`) | none (no M3 rule) | n/a — showcase only |
| `app-bar`, `toolbar`, `tabs` | none | Medium+ adaptive guidance (toolbar centring/rounded corners, app-bar action overflow) | not implemented (see Missing) |
**What a user actually sees in the three bands the brief asks about**
- **600639px** (M3 Medium; e.g. a small tablet in portrait, a resized desktop window): no navigation rail
at all — it is `width: 0; display: none`. The bottom navigation bar is still pinned, but it has already
flipped to the Expressive *medium* configuration (icon beside label in a 40px secondary-container pill,
items grouped in the middle at `10 × (count + 3)`% of the width, label-large instead of label-medium per
N-08). Any destination marked `'bar' => false` is reachable only by an application-supplied menu button
calling `$store.rail.show()`. At 640px the bar vanishes outright and a 96px collapsed rail appears — a
jump, at the wrong number.
- **8401023px** (M3 Expanded; a landscape tablet, a half-screen desktop browser): a 96px collapsed rail
with icon-over-label items. The menu button does **not** expand it in place — `materialNavigationRail.modal`
is still true, so it opens the 256px rail *over* the page on a 32% scrim with a focus trap, and
`livewire:navigating` closes it again, so choosing a destination means re-opening the rail every time.
`$store.rail.collapsed === false` (the visitor's remembered choice, applied to `<html data-rail>` before
first paint) has no effect here. M3 lists a standard expanded rail for this whole band.
- **12001279px** (M3 Large): **no mismatch** — `lg` (1024px) is long past, so the rail is already the
standard collapsible one, expanded by default and remembered in localStorage, which is what M3 wants at
Large. The only thing that changes at 1280px is `section-nav`'s grid → row switch (N-07's `xl` row
above), which is 80px later than M3's 1200dp Large boundary. Nothing in the group does anything
different at 1600px (Extra-large), where M3 suggests considering a third pane.