Files
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

49 KiB
Raw Permalink Blame History

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'ssm 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.cssshadow-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:41focus-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-129inset-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-huggingnavigation-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 onlyapp-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 presentapp-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-containernavigation.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 tabindextoolbar.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 scrollingsection-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 ellipsisapp-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 mismatchlg (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.