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
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 03:53:40 +02:00
co-authored by Claude Fable 5.1
parent b1fc0c9cfa
commit 651a513d1e
13 changed files with 8402 additions and 0 deletions
+811
View File
@@ -0,0 +1,811 @@
# Audit: actions and communication
Scope: `<x-button>`, `<x-button-group>`, `<x-group>`, `<x-split-button>`, `<x-fab>`, `<x-fab-menu>`,
`<x-fab-menu-item>`, `<x-menu>`/`<x-menu-item>`/`<x-menu-group>`/`<x-menu-separator>`, `<x-badge>`,
`<x-alert>`, `<x-progress>`, `<x-loading>`, `<x-toast>`, `<x-tooltip>`, `<x-rich-tooltip>`,
`<x-stat>`, `<x-empty-state>`, plus `resources/css/components/{groups,menu}.css`,
`resources/css/tokens/{state,motion,shape}.css`, `resources/js/{menu,snackbar,tooltip,rich-tooltip,progress,figure}.js`
and `src/Concerns/Toasts.php`.
Sources are named per finding. `RC-A` = `docs/reference/m3/reference-components-a.md`, `RF` =
`reference-foundations.md`, `RS` = `reference-styles.md`, `tokens/X.kt` = the androidx Compose
Material 3 token file in `docs/reference/m3/tokens/`. Two numbers were fetched live from androidx-main
during this audit (`Button.kt`, `ButtonGroup.kt`) and are marked as such.
## Summary
The button family is the strongest part of this library: all five Expressive sizes, the icon-button
width matrix, the corner/press-morph scale, the outline widths, the per-variant toggle colours and
the per-size type roles match the androidx tokens *exactly*, digit for digit — I could not find a
single wrong number in `<x-button>`'s own size tables, and the icon-button widths (narrow/default/wide
× five sizes, fifteen values) are all correct. The problems are concentrated elsewhere: the split
button reuses the connected button group's corner variables, so its inner corners are wrong at four of
five sizes and morph in the *opposite* direction to M3 on press; the snackbar's live region is created
together with its message (so the announcement is unreliable) and auto-dismisses even when it carries
an action, which M3's accessibility page forbids outright; a spinner inside a filled button is drawn
in `text-primary` on a `bg-primary` container, i.e. invisible; `<x-group>`'s segments carry no 48 px
touch target; and `<x-menu>` has no maximum height, so a long menu runs off the viewport with no way
to scroll it. Colour is animated on the overshooting *spatial* spring in four components, which the
library's own `motion.css` comment says must never happen. Nothing in this group implements a
deprecated M3 component — segmented buttons and the small FAB are correctly absent — and the two
non-M3 pieces (alert, stat, empty state) stay inside M3's colour roles, type scale and shape scale.
Counts: 5 must-fix, 17 should-fix, 14 nice-to-have (36 findings).
## Findings
### ACT-01 · button / loading · A button's spinner is invisible on a filled or tonal button
- Severity: must-fix
- M3 says: "when embedded in another component (e.g. a button), the active indicator should match the
label/icon color and the **track should be removed** in that context" (RC-A, Progress indicators →
Accessibility; same rule repeated for the loading indicator: "when embedded in another component
(e.g. a button), the indicator needs ≥3:1 against that component").
- Library does: `resources/views/components/button.blade.php:207` renders
`<x-loading :class="$iconSize" :label="false" />` — only a size class. `loading.blade.php:22`
detects a caller colour with `preg_match('/(^|\s)text-(?!…)/')`, which `size-5` does not match, so
`loading.blade.php:31` adds `text-primary`. The SVG is `fill="currentColor"`
(`resources/svg/loading-indicator/*.svg`), so a `spinner` on `<x-button variant="filled">`
(`bg-primary`) paints primary on primary — contrast 1:1. Same on `tonal` (primary on
secondary-container) and on `variant="filled" color="error"`.
- Fix: in `button.blade.php:207` pass `:class="$iconSize.' text-current'"``text-current` satisfies
`loading.blade.php`'s `$coloured` test, so no `text-primary` is added and the indicator inherits the
button's label colour.
- Effort: S
- Breaks API? no
### ACT-02 · toast · The snackbar's live region is created together with its message
- Severity: must-fix
- M3 says: "Announcement: use a **live region with a 'polite'/queued** announcement (not assertive) on
Android and web" (RC-A, Snackbar → Accessibility).
- Library does: `resources/views/components/toast.blade.php:41` wraps the whole snackbar in
`<template x-if="current">`, and the element carrying `role` and `aria-live="polite"` is inside it
(`toast.blade.php:45-46`). The region therefore enters the DOM with its text already present; a
live region must exist beforehand and have its *contents* change for assistive technology to
announce it reliably. The outer wrapper (`toast.blade.php:33`) is permanent but carries no
`aria-live`. Secondary issue on the same element: `x-bind:role` sets `alert` (implicitly assertive)
while `aria-live="polite"` is hard-coded beside it; the explicit `aria-live` wins, so the intent
("errors interrupt") is not realised anyway — and M3 asks for polite regardless.
- Fix: move `aria-live="polite"` and `aria-atomic="true"` onto the permanent wrapper at
`toast.blade.php:33`, drop `x-bind:role` / `aria-live` from the inner div (or keep `role="status"`
on the wrapper). The text nodes then change inside a region that was already there.
- Effort: S
- Breaks API? no
### ACT-03 · toast / snackbar.js · A snackbar with an action still auto-dismisses
- Severity: must-fix
- M3 says: "**Snackbars with an action must never auto-dismiss** — let people read/act at their own
pace; snackbars without actions may auto-dismiss (common range 4-10 seconds)" (RC-A, Snackbar →
Accessibility). The guidelines page repeats it as a design requirement: "make the snackbar
actionable so it doesn't auto-dismiss".
- Library does: `resources/js/snackbar.js:60` sets `timeout: sticky || toast.timeout === 0 || … ? 0 :
(toast.timeout ?? DEFAULT_TIMEOUT_MS)` — the presence of `action` is not considered, so
`materialToast('Share deleted', { action: { label: 'Undo', … } })` (exactly the showcase example,
`showcase/sections/communication.blade.php:22`) disappears after 4 s. Hover/focus pauses the timer
(`snackbar.js:97-117`), which helps a mouse user but not a screen-reader or keyboard user who has
not yet reached it.
- Fix: in `snackbar.js:55-63`, treat an entry with `action` as untimed: `timeout: sticky ||
toast.action || toast.timeout === 0 … ? 0 : …`. The close button already appears for an actioned
toast (`toast.blade.php:76`), so dismissal stays possible. Let an explicit `timeout` still override
it if the caller insists.
- Effort: S
- Breaks API? no (behaviour change only; `timeout` keeps working)
### ACT-04 · menu · A long menu has no maximum height and cannot be scrolled
- Severity: must-fix
- M3 says: "Scrolling: a menu that can't fit all items **scrolls and shows a persistent scrollbar**"
(RC-A, Menus → Behaviour and guidelines).
- Library does: `resources/views/components/menu.blade.php:70` sets
`min-w-28 max-w-70 overflow-visible … [inset:auto]` — there is no `max-block-size` and overflow is
explicitly *visible*. A popover positioned with `position-area` and `inset: auto` is sized
`fit-content`; it is not clipped to the position area, so a menu with (say) 20 items extends past
the viewport and the items beyond the edge cannot be reached — the top layer does not scroll with
the page. The library's own dropdown CSS already does this correctly:
`resources/css/components/menu.css:24-25` (`max-block-size: 18rem; overflow-y: auto`) and
`menu.css:99` for `::picker(select)`.
- Fix: on `menu.blade.php:70` replace `overflow-visible` with
`max-h-72 overflow-y-auto` (18rem, matching `.field-menu`), or
`max-h-[min(18rem,calc(100vh-2rem))]`. Verify in Chrome that `position-try-fallbacks` still flips
before the cap bites.
- Effort: S
- Breaks API? no
### ACT-05 · group / button-group · Connected segments miss M3's 48 dp target and 48 dp minimum width
- Severity: must-fix
- M3 says: "Each button needs a minimum **48x48dp** target; XS/S groups get extra inner padding
specifically to preserve this — **don't reduce it**" and "Minimum widths: XS and S connected button
groups have a 48dp target area and a **48dp minimum width**" (RC-A, Button groups → Accessibility
and → Specs). RF:78 repeats the 48 × 48 rule, RF:82 gives "at least 48 × 48 **CSS pixels**" for web.
- Library does: `<x-button>` solves this for itself with the pseudo-element at
`button.blade.php:172` (`after:min-h-12 after:min-w-12` on `xs`/`sm`). `<x-group>` does **not**: its
segments are plain `<label>`s (`group.blade.php:75-83`) sized `h-8` (xs) or `h-10` (sm)
(`group.blade.php:48-49`) with no target expansion, so the whole `<x-group size="sm">` — the
default, and the only form in the showcase — offers a 40 px-tall target. Separately, nothing
enforces the 48 dp *minimum width*: a connected `<x-button-group connected>` of `sm` icon buttons
is 40 px wide per segment (`button.blade.php:141`), and `groups.css` has no `min-width`.
- Fix: (a) add the same pseudo-element to `group.blade.php:75-83` for `xs`/`sm`
(`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`); (b) in `groups.css`, add
`[data-button-group='connected'][data-size='xs'] > *, [data-button-group='connected'][data-size='sm'] > * { min-inline-size: 3rem; }`.
- Effort: S
- Breaks API? no
### ACT-06 · split-button · Inner corners are wrong at four of five sizes and morph the wrong way
- Severity: should-fix
- M3 says (RC-A, Split button → Specs, from `SplitButton{Size}Tokens.kt`, cross-checked against the
site's own "inner corner radius" figure):
| Size | Inner corner (rest) | Inner corner (hover/press) |
| --- | --- | --- |
| XS | 4dp | 8dp |
| S | 4dp | 12dp |
| M | 4dp | 12dp |
| L | 8dp | 20dp |
| XL | 12dp | 20dp |
Verified directly: `tokens/SplitButtonSmallTokens.kt` — `InnerCornerCornerSize =
CornerValueExtraSmall`, `InnerHoveredCornerCornerSize = InnerPressedCornerCornerSize =
CornerValueMedium`; `tokens/SplitButtonMediumTokens.kt` — the same pair. The inner corner **grows**
under the finger.
- Library does: `resources/views/components/split-button.blade.php:39` emits
`data-button-group="split"`, and `resources/css/components/groups.css:54-66` gives `[data-split]`
the *connected group's* variables — `--group-inner` (rest) and `--group-inner-pressed` (pressed).
Those are 8 px/4 px at `sm` and `md`, 16 px/12 px at `lg`, 20 px/16 px at `xl`
(`groups.css:32-36`). So rest is 8/8/16/20 px where M3 wants 4/4/8/12, and pressed *shrinks* to
4/4/12/16 px where M3 wants it to *grow* to 12/12/20/20. Only `xs` is right at rest (4 px), and its
pressed value (2 px, `groups.css:32`) should be 8 px.
- Fix: give `[data-split]` its own variables in `groups.css` rather than reusing `--group-inner*`,
e.g. `[data-button-group='split'][data-size='sm'] { --split-inner: var(--md-sys-shape-corner-xs);
--split-inner-pressed: var(--md-sys-shape-corner-md); }` per size, and point the `[data-split]` /
`[data-split]:active` rules (`groups.css:54-66`) at them.
- Effort: M
- Breaks API? no
### ACT-07 · split-button · The trailing menu icon is the wrong size at every size step
- Severity: should-fix
- M3 says: trailing icon 22dp (XS), 22dp (S), 26dp (M), 38dp (L), 50dp (XL) (RC-A, Split button →
Specs; `tokens/SplitButtonSmallTokens.kt` `TrailingIconSize = 22.0.dp`,
`SplitButtonMediumTokens.kt` `= 26.0.dp`).
- Library does: `split-button.blade.php:56-66` renders the trailing half as a plain icon-only
`<x-button>`, which picks its icon size from the icon-button table
(`button.blade.php:155`): 20 / 24 / 24 / 32 / 40 px. Every step is undersized (S is 24 vs 22 —
oversized — and M/L/XL are 2/6/10 px small). The trailing *button widths* are all correct
(48/48/56/96/136 px, `split-button.blade.php:36` plus the icon-button defaults), so only the glyph
is off.
- Fix: pass an explicit icon size from `split-button.blade.php`, e.g. add
`['xs' => 'size-5.5', 'sm' => 'size-5.5', 'md' => 'size-6.5', 'lg' => 'size-9.5', 'xl' => 'size-12.5'][$size]`
to the trailing button's `:class` (Tailwind's 4 px scale gives 22 px as `size-5.5`).
- Effort: S
- Breaks API? no
### ACT-08 · fab · The extended FAB's icon↔label gap is too small at md and lg
- Severity: should-fix
- M3 says: icon↔label gap 8dp (small), **16dp** (medium), **20dp** (large) — RC-A, Extended FAB →
Specs; verified in `tokens/ExtendedFabMediumTokens.kt` (`IconLabelSpace = 16.0.dp`) and
`tokens/ExtendedFabLargeTokens.kt` (`IconLabelSpace = 20.0.dp`).
- Library does: `resources/views/components/fab.blade.php:39` — `gap-2` (8 px) small ✓,
`gap-3` (12 px) medium ✗, `gap-4` (16 px) large ✗.
- Fix: `gap-4` for `md` and `gap-5` for `lg` in `fab.blade.php:39`. (Heights, corners, icon sizes and
the 26 px/28 px paddings on the same line are all correct.)
- Effort: S
- Breaks API? no
### ACT-09 · button · The xs button's horizontal padding is 12 px, not 16 dp
- Severity: should-fix
- M3 says: XS leading/trailing padding = **16dp** (RC-A, Buttons → Specs;
`tokens/ButtonXSmallTokens.kt` `LeadingSpace = 16.0.dp`, `TrailingSpace = 16.0.dp`). The variants
table on the same page makes the point twice: 24 dp is "Not recommended. Use 16dp", and 16 dp is
the Expressive value.
- Library does: `button.blade.php:147` — `'xs' => 'h-8 gap-2 px-3 …'` (12 px). The same 12 px is
written into the group spacing (`groups.css:32`, `--group-pad: 0.75rem`) and into `<x-group>`'s
segments (`group.blade.php:48`), so all three are consistently 4 px short.
- Fix: `px-4` in `button.blade.php:147` and `group.blade.php:48`; `--group-pad: 1rem` in
`groups.css:32`.
- Effort: S
- Breaks API? no (visual width of xs buttons changes by 8 px)
### ACT-10 · button-group · A standard group is allowed to wrap onto a second line
- Severity: should-fix
- M3 says: "Adaptive/resizing: groups move through layouts as **a single line and never wrap to a
second line**; multiple groups can stack vertically but don't interact with each other" (RC-A,
Button groups → Behaviour and guidelines).
- Library does: `button-group.blade.php:35` adds `flex-wrap` to every non-connected group. On a
narrow window a three-button group breaks into two rows, and the press-expansion rules in
`groups.css:38-48` (which only look at `+` siblings) then compress a neighbour on a different line.
- Fix: drop `'flex-wrap' => ! $connected` from `button-group.blade.php:35`. If overflow is a concern,
M3's answer is a smaller size or fewer buttons, not wrapping.
- Effort: S
- Breaks API? no (a class hook disappears from the rendered output)
### ACT-11 · menu-item · Leading and trailing space is 12 px where the Expressive menu wants 16 dp
- Severity: should-fix
- M3 says: `tokens/SegmentedMenuTokens.kt` — `ItemLeadingSpace = 16.0dp`, `ItemTrailingSpace = 16.0dp`,
`ItemBetweenSpace = 12.0dp`. (The site publishes no numbers for the Expressive vertical menu — RC-A,
Menus → Specs says the measurements section is diagram-only — so the token file is the only source,
and it is the one the component's own header comment cites.)
- Library does: `menu-item.blade.php:46` — `px-3` (12 px) with `gap-3` (12 px). The gap is right; the
side padding is the *baseline* menu's 12 dp (RC-A, Menus → Specs, baseline table), not the
Expressive one. `menu-group.blade.php:6` and `menu-separator.blade.php:3` inherit the same 12 px.
- Fix: `px-4` on `menu-item.blade.php:46`, and `px-4`/`mx-4` on the group label and the separator so
the list stays aligned.
- Effort: S
- Breaks API? no
### ACT-12 · menu-separator · Divider padding is 4 px, half of M3's 8 dp
- Severity: should-fix
- M3 says: "Divider top/bottom padding | **8dp**; Divider height | 1dp" (RC-A, Menus → Specs).
- Library does: `menu-separator.blade.php:3` — `my-1` (4 px) with `h-px` ✓ and
`bg-outline-variant` ✓.
- Fix: `my-2` in `menu-separator.blade.php:3`.
- Effort: S
- Breaks API? no
### ACT-13 · menu.js · Disabled menu items cannot be focused
- Severity: should-fix
- M3 says: "Interactability: **disabled items can still receive focus** (just aren't selectable);
dividers and gaps can never receive focus" (RC-A, Menus → Accessibility). The guidelines page adds
"Disable a menu item that's conditionally unavailable rather than removing it" — the point of
keeping it is that people can find out it exists.
- Library does: `resources/js/menu.js:183` filters them out —
`.filter((item) => item.getAttribute('aria-disabled') !== 'true')` — so arrow keys, Home/End and
typeahead skip them entirely, and `menu-item.blade.php:53` also sets `pointer-events-none`. A
disabled item is announced only if the user happens to read the menu with a virtual cursor.
- Fix: keep disabled items in `items()` and instead refuse activation (the check already exists in
`menu.js:237`). Two lists are cleanest: `items()` for focus movement, `items().filter(enabled)` for
activation. `menu-item.blade.php:53` can keep `pointer-events-none` for the pointer.
- Effort: S
- Breaks API? no
### ACT-14 · fab · The FAB's icon is drawn outlined
- Severity: should-fix
- M3 says: "Icon (should be **filled**, not outlined…)" (RC-A, FAB → Anatomy) and "icon should be
filled (not outlined) and unambiguous" (→ Behaviour and guidelines). The same rule is why
`<x-button>` fills a *selected* toggle's icon.
- Library does: `fab.blade.php:68` renders `<x-icon :name="$icon" :class="$iconSize" />` with no
`filled`, so the Material Symbol's outlined face is used. `fab-menu.blade.php:53` has the same
issue for the FAB-menu trigger, and `fab-menu-item.blade.php:43` for its items.
- Fix: add `filled` to `fab.blade.php:68` and `fab-menu.blade.php:53`. (Leave the FAB-menu *items*
outlined only if you decide they read as list items rather than FABs; M3's FAB-menu anatomy shows
filled icons there too.)
- Effort: S
- Breaks API? no
### ACT-15 · button / icon button · A default (non-toggle) icon button uses an outlined icon
- Severity: should-fix
- M3 says: "**Default icon buttons should use filled icons**; toggle buttons use outlined icon
unselected → filled icon selected" (RC-A, Icon buttons → Behaviour and guidelines).
- Library does: `button.blade.php:213` — `:filled="$selected === true"`. For an icon button that is
not a toggle (`$selected === null`) that evaluates to `false`, so the outlined face is drawn where
M3 asks for the filled one. The toggle half of the rule is implemented correctly.
- Fix: `:filled="$selected === true || ($iconOnly && $selected === null)"` in `button.blade.php:213`.
This is a visible, opinionated change across every icon button in an application — if the library
prefers outlined icon buttons, say so in the header comment as a deliberate deviation rather than
leaving it implicit.
- Effort: S
- Breaks API? no (visual change)
### ACT-16 · fab · `disabled` exists on a component M3 says must never be disabled
- Severity: should-fix
- M3 says: "**Never disable a FAB** — if its action is unavailable, remove the FAB entirely instead"
(RC-A, FAB → Accessibility). RF:389 repeats it from the states side: "'if the action… is
unavailable, the FAB shouldn't appear' (don't just disable it) — a Don't example specifically about
FABs". The same page's inheritance table lists FABs under "Disabled … NOT inherited by" (RF:381).
- Library does: `fab.blade.php:23` declares a `disabled` prop and `fab.blade.php:52` paints the full
disabled treatment (`disabled:bg-on-surface/10 disabled:text-on-surface/38 disabled:shadow-none`).
Nothing in the header comment or in SKILL.md (`### <x-fab>`, line 277) warns against it — SKILL.md
simply lists `disabled` among the props.
- Fix: cheapest honest fix is documentation — say in `fab.blade.php`'s header and in SKILL.md that M3
forbids a disabled FAB and that the prop exists only for a form-submit FAB that must block a double
submit. Removing the prop is the M3-pure option and would break callers.
- Effort: S
- Breaks API? no (if documented), yes (if removed)
### ACT-17 · toast / button · The snackbar covers the `fab` button on a phone
- Severity: should-fix
- M3 says: "Placement: bottom of the UI, in front of main content; **nudge upward to avoid
overlapping FABs**/docked toolbars… Snackbars should appear **above** FABs — never in front of or
behind one" (RC-A, Snackbar → Behaviour and guidelines).
- Library does: both sit at the same offset. `toast.blade.php:36` —
`fixed inset-x-4 z-50 bottom-[calc(var(--material-bottom-bar,0px)+1rem)]`; `button.blade.php:177`
(the `fab` prop, below `sm`) — `max-sm:fixed max-sm:end-4
max-sm:bottom-[calc(var(--material-bottom-bar,0px)+1rem)] max-sm:z-30`. The snackbar is full width
below `sm` (`sm:w-auto` only applies from 640 px up, `toast.blade.php:51`), so it lands *on top of*
the FAB, which is exactly the "never in front of" case. `<x-fab>` placed by the application has the
same problem, since the toast knows nothing about it.
- Fix: mirror the existing `--material-bottom-bar` mechanism — have `<x-button fab>` (and the
documented wrapper for `<x-fab>`) publish `--material-fab: 4.5rem`, and make `toast.blade.php:36`
read `bottom-[calc(var(--material-bottom-bar,0px)+var(--material-fab,0px)+1rem)]`. Document the
variable next to `--material-bottom-extra` in SKILL.md's "Safe areas".
- Effort: M
- Breaks API? no (a new opt-in CSS variable)
### ACT-18 · toast / alert · 40 px controls inside the snackbar and the alert miss the 48 px target
- Severity: should-fix
- M3 says: web interaction targets are "at least 48 × 48 **CSS pixels**" (RF:82), and "Interaction
targets must stay ≥48×48dp even when the visible element is scaled down… a 36dp-tall button can
keep a 48dp target" (RF:561-563).
- Library does: `toast.blade.php:73` (the action, `h-10` = 40 px), `toast.blade.php:77` (the close
button, `size-10` = 40 px) and `alert.blade.php:61` (the dismiss button, `size-10`). None of them
has the pseudo-element trick `<x-button>` uses at `button.blade.php:172`. The snackbar container is
`min-h-12` (48 px) so there is no room to grow vertically without the pseudo-element.
- Fix: add `relative 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` to those three buttons (they already
carry `state-layer`, which sets `position: relative`).
- Effort: S
- Breaks API? no
### ACT-19 · button / group / menu-item / fab-menu · Colour is animated on the overshooting spatial spring
- Severity: should-fix
- M3 says: "Two spring **styles**: **Spatial** (movement — x/y position, rotation, size, rounded
corners; overshoots and bounces into place) and **Effects** (color, opacity; **no overshoot**)"
(RS:472-473). `resources/css/tokens/motion.css:11-13` states the same rule in the library's own
words: "effects — colour and opacity. Critically damped; **a colour must never overshoot**."
- Library does: four components put colour on the spatial spring, whose fast variant peaks at 1.094
(`motion.css:28-30`):
- `button.blade.php:163` — `transition-[border-radius,background-color,color,box-shadow,padding,margin] … ease-spatial-fast`
- `group.blade.php:77` — `transition-[border-radius,background-color,color] … ease-spatial-fast`
- `menu-item.blade.php:48` — `transition-[border-radius,background-color] … ease-spatial-fast`
- `fab-menu.blade.php:49` — `transition-[border-radius,background-color,color,box-shadow] … ease-spatial-default`
- Fix: split each into two transitions, e.g. on `button.blade.php:163`
`transition-[border-radius,padding,margin] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast`
plus a second declaration for `background-color,color,box-shadow` with
`duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast`. Tailwind cannot emit two
`transition` shorthands on one element, so this wants a small utility in
`resources/css/components/` (e.g. `.state-transition`) rather than utility classes.
- Effort: M
- Breaks API? no
### ACT-20 · toast · The snackbar draws a status icon, which M3 tells you not to
- Severity: should-fix
- M3 says: "**Avoid icons in a snackbar** (use a dialog instead if an icon feels necessary); avoid
stylized text/inline links" (RC-A, Snackbar → Behaviour and guidelines). The specs page does list an
optional icon slot, but it is the *close* affordance ("2. Icon (optional close affordance) — …the
guidelines page anatomy instead lists 'Close button (optional)' in the same slot", RC-A, Snackbar →
Anatomy).
- Library does: `toast.blade.php:53-65` draws a `check_circle` / `error` / `warning` / `info` glyph
for every typed toast, and `Toasts.php:19-37` makes typing the *only* API — `$this->success(…)`
always sets `type: 'success'`. The glyphs are painted in the library's own
`inverse-success/error/warning/info` roles (`scheme.css:76-79`), which is a sound extension of
`inverse-primary`, so the colour is not the problem; the icon's presence is.
- Fix: either drop the leading icon and let the type only choose the announcement role, or document
the deviation in `toast.blade.php`'s header (it currently presents the icon as M3-sanctioned: "A
type draws its state icon in the inverse state colour"). If the icon stays, keep it out of the
accessible name — it is already `aria-hidden` via `<x-icon>`.
- Effort: S
- Breaks API? no (if documented), yes for the visual contract (if removed)
### ACT-21 · fab-menu · The trigger has no accessible name unless `label` is passed
- Severity: should-fix
- M3 says: "Labeling (Web): the FAB menu is a FAB + Menu combo — follow FAB accessibility guidance for
the trigger… **the FAB's own accessibility label should describe what menu it opens**"; and for the
close button "Label 'Toggle menu', Role Button, State Expanded/Collapsed" (RC-A, FAB menu →
Accessibility).
- Library does: `fab-menu.blade.php:46` writes `aria-label` only `@if ($label)`. `label` has no
default (`fab-menu.blade.php:21`), and the only content of the button is two `<x-icon>`s, which are
`aria-hidden="true"` by default (`icon.blade.php:27`). A `<x-fab-menu>` without `label` is therefore
an unnamed button. `<x-menu>` has the same shape but delegates naming to the trigger the caller
supplies, so the gap is specific to the FAB menu, which builds its own button.
- Fix: make `label` required, or default it to `__('Toggle menu')` in `fab-menu.blade.php:21` and
always emit `aria-label`. Note the expanded/collapsed state is already handled — `menu.js:140`
writes `aria-haspopup`/`aria-controls`/`aria-expanded` onto the button it finds in the trigger.
- Effort: S
- Breaks API? no
### ACT-22 · rich-tooltip · The bubble is never associated with its trigger
- Severity: should-fix
- M3 says: "Assistive tech must be able to **receive the tooltip's message** and activate it via
keyboard/switch input" (RC-A, Tooltips → Accessibility); "Role: **Tooltip** (or an equivalent role)
on the container".
- Library does: `rich-tooltip.blade.php:41-56` gives the bubble `role="tooltip"` (or `dialog` when
`persistent`) and an `aria-label` of the title, but nothing points at it: the trigger gets no
`aria-describedby`, and for `persistent` no `aria-haspopup="dialog"`/`aria-expanded` either. A
screen-reader user focusing the trigger hears only the trigger's own label; the explanation — the
whole point of a rich tooltip — is never announced. (`<x-tooltip>` is deliberately `aria-hidden`
and documents why, which is defensible for an icon button whose `aria-label` *is* the tooltip text;
it is not defensible for the standalone form, e.g.
`showcase/sections/communication.blade.php:30-32`, where the tip says something the trigger does
not.)
- Fix: in `rich-tooltip.blade.php`, put `aria-describedby="material-rich-tooltip-{{ $key }}"` on the
wrapper's focusable child (or document that the caller must), and for `persistent` add
`aria-haspopup="dialog"` + an `aria-expanded` written by `rich-tooltip.js`. For the standalone
`<x-tooltip>`, either drop `aria-hidden` and describe the trigger, or document in SKILL.md that the
trigger must carry the same words itself.
- Effort: M
- Breaks API? no
### ACT-23 · badge · The floating badge sits 24 px off M3's anchor geometry
- Severity: nice-to-have
- M3 says: "Small badge: distance from top-trailing icon corner to bottom-leading badge corner (H×W)
| **6×6dp**"; "Large badge: … | **14×12dp**" (RC-A, Badges → Specs). For a 24 dp icon that puts a
small badge flush in the icon's top-trailing corner (top 0, end 0) and a large badge at
top 2 px / start `calc(100% - 12px)`.
- Library does: `badge.blade.php:79-80` — `absolute top-0.5 end-0.5` for the dot (2 px in on both
axes, where M3 wants 0) and `absolute -top-1 start-[calc(100%-0.75rem)]` for the count (the `start`
is exactly right; the top is 4 px where M3 wants 2 px).
- Fix: `top-0 end-0` for the dot and `-top-0.5` for the count in `badge.blade.php:79-80`.
- Effort: S
- Breaks API? no
### ACT-24 · badge · The `outline` status badge draws its edge in `outline-variant`
- Severity: nice-to-have
- M3 says: a badge "must be ≥**3:1**, same rule whether default or custom color" (RC-A, Badges →
Accessibility). `outline-variant` is M3's *decorative* boundary role (dividers); `outline` is the
role for a boundary that must be seen.
- Library does: `badge.blade.php:68` — `'border-outline-variant text-on-surface-variant'`. On
`surface` in the library's own scheme `outline-variant` is around 1.5:1, so the badge's shape is
barely visible. (`<x-button variant="outlined">` uses the same role, but there the M3 table
explicitly names it: "Outlined container … **Outline variant (outline)**" — RC-A, Buttons → colour
table — so the button is inside spec and the badge, which has no M3 table, is not.)
- Fix: `border-outline` in `badge.blade.php:68`.
- Effort: S
- Breaks API? no
### ACT-25 · tooltip.js · A tooltip disappears the instant the pointer leaves
- Severity: nice-to-have
- M3 says: "tooltips are **transient by default** — both plain and rich tooltips disappear **1.5
seconds** after the cursor/focus leaves the target region; opening a new tooltip immediately closes
any other open one (**only one tooltip visible at a time**)" (RC-A, Tooltips → Specs).
- Library does: `resources/js/tooltip.js:40-43` hides on `pointerleave`, `pointerdown` and
`focusout` with no delay; `rich-tooltip.js:43` uses a 200 ms grace instead of 1500 ms. Nothing
closes other open tooltips — because the bubble is `popover="manual"`
(`tooltip.blade.php:35`), a tooltip held open by keyboard focus stays up while a second one opens
on hover.
- Fix: `hide(1500)` in `tooltip.js` (add the same `delay` parameter `rich-tooltip.js:37` already
has), and keep a module-level `let open = null` that `show()` hides before showing its own.
- Effort: S
- Breaks API? no
### ACT-26 · menu · The open/close transition is a plain fade
- Severity: nice-to-have
- M3 says: "Motion: menus use an **enter/exit transition tying them visually to their trigger**; the
trigger shows a pressed state while the menu is open" (RC-A, Menus → Behaviour and guidelines).
- Library does: `menu.blade.php:72` transitions `opacity` only — `translate` is in the
`transition-[…]` list but no translate or scale value is ever set, so the menu simply fades in
place. The library's own `::picker(select)` does it properly (`menu.css:116-128`: opacity + scale
0.95 from `transform-origin: top`, on the spatial spring).
- Fix: mirror `menu.css:116-128` on `menu.blade.php:72` — add
`origin-top scale-95 open:scale-100 starting:open:scale-95` and put the scale on the spatial spring
while opacity stays on effects.
- Effort: S
- Breaks API? no
### ACT-27 · menu-item · A selected item is told by colour and shape only
- Severity: nice-to-have
- M3 says: "by default, selected items change **both shape and color**; default contrast between
selected/unselected items is 3:1; it's **recommended to add yet another cue** on top (e.g. a
checkmark) so selection isn't conveyed by color/shape alone" (RC-A, Menus → Accessibility).
- Library does: `menu-item.blade.php:51` changes container colour and corner, and
`menu-item.blade.php:87` fills the leading icon; `aria-checked` is set
(`menu-item.blade.php:57`), so assistive tech is fine. No visible tick. Again the library's own
select menu does it — `menu.css:179-188` draws a `::checkmark` at the row's end.
- Fix: when `$selected === true` and no `iconRight` is given, render a trailing `check` icon in
`menu-item.blade.php:105-107`.
- Effort: S
- Breaks API? no
### ACT-28 · menu-item · 44 px rows, where the rest of the library's menus use 48 px
- Severity: nice-to-have
- M3 says: `tokens/SegmentedMenuTokens.kt` `Item = 44.0.dp` (what the component's header cites, and it
is correct), while the baseline menu's published spec is "List item height | **48dp**" (RC-A, Menus
→ Specs) and the same page's accessibility section asks for "≥**48×48dp** targets" inside menu item
slots.
- Library does: `menu-item.blade.php:46` — `min-h-11` (44 px). `resources/css/components/menu.css:55`
(`.field-option`) and `menu.css:134` (native `option`) both use `min-block-size: 3rem` (48 px), so
a `<x-menu>` and a `<x-select>` dropdown in the same form do not match.
- Fix: either `min-h-12` on `menu-item.blade.php:46` (consistent, and clears the 48 px target), or
keep 44 px and note the inconsistency deliberately. Both readings are defensible; the token is the
more specific source, the accessibility page the more binding one.
- Effort: S
- Breaks API? no
### ACT-29 · split-button · The trailing chevron is not nudged off centre
- Severity: nice-to-have
- M3 says: "Menu icon offset from center when unselected (site, specs page): XS/S = **1dp**, M =
2dp, L = 3dp, XL = 6dp" (RC-A, Split button → Specs) — the chevron sits slightly towards the
leading button so the pair reads as one control.
- Library does: `split-button.blade.php:56-66` centres the icon (`justify-center` from
`button.blade.php:162`).
- Fix: add a per-size negative `ms-px`/`-me-*` or `translate-x` to the trailing button's `:class` in
`split-button.blade.php:36`.
- Effort: S
- Breaks API? no
### ACT-30 · split-button · The chevron rotates on the expressive spring
- Severity: nice-to-have
- M3 says: "The trailing (menu) button uses the **standard motion scheme, not expressive**, when
rotating; it rotates 180° inward on open/close" (RC-A, Split button → Behaviour and guidelines).
- Library does: `groups.css:85-87` — `transition: rotate var(--md-sys-motion-spatial-fast-duration)
var(--md-sys-motion-spatial-fast)`, i.e. the expressive spring, which overshoots past 180° and
comes back. The 180° rotation itself (`groups.css:89-90`) is right.
- Fix: the library has no standard-scheme spring token; `--md-sys-motion-easing-standard` with
`--md-sys-motion-duration-short` (`motion.css:52,58`) is the closest published equivalent.
- Effort: S
- Breaks API? no
### ACT-31 · fab · The small extended FAB's minimum width is 56 px, not 80 dp
- Severity: nice-to-have
- M3 says: baseline extended FAB "Container width | Dynamic, **80dp min**" (RC-A, Extended FAB →
Specs). `ExtendedFabSmallTokens.kt` publishes no minimum, and the small extended FAB is the
baseline's replacement, so 80 dp is the only number on record.
- Library does: `fab.blade.php:39` — `min-w-14` (56 px) for `sm`, `min-w-20`/`min-w-24` for md/lg
(which match those sizes' heights rather than any published minimum).
- Fix: `min-w-20` (80 px) for the small extended FAB in `fab.blade.php:39`.
- Effort: S
- Breaks API? no
### ACT-32 · fab-menu · Margins do not grow in large windows, and items cannot scroll
- Severity: nice-to-have
- M3 says: "Menu/FAB margin from screen edge | 16dp (compact/medium windows), **24dp** (large/extra-large
windows)"; "Extra bottom margin when opened from medium FAB | 40dp… large FAB | 56dp"; "On short
viewports (e.g. landscape phone), items can **scroll — behind the close button**, which stays
fixed" (RC-A, FAB menu → Specs and → Behaviour).
- Library does: `<x-fab-menu>` does not place itself (by design), and both the header comment
(`fab-menu.blade.php:3`) and SKILL.md line 283 show a fixed `end-4 bottom-4` (16 px) with no
large-window variant. The list (`fab-menu.blade.php:69-75`) has no `max-height`/`overflow`, so on a
landscape phone six 56 px items plus gaps (~360 px) push the menu off screen.
- Fix: document `sm:end-6 sm:bottom-6`-style margins in the header and SKILL.md (Tailwind has no
1200 px breakpoint by default — see the breakpoint map), and add
`max-h-[calc(100vh-8rem)] overflow-y-auto` to `fab-menu.blade.php:70`.
- Effort: S
- Breaks API? no
### ACT-33 · fab-menu / menu · Items enter but never exit
- Severity: nice-to-have
- M3 says: "Motion: FAB transforms into the close button; items animate in/out via an **enter-exit
transition**, originating from one of the FAB's trailing corners" (RC-A, FAB menu → Behaviour).
- Library does: `fab-menu-item.blade.php:27` has `starting:translate-y-2 starting:opacity-0` for the
entry, but the popover is hidden with `display: none` on close and the items carry no
`transition-discrete`/`@starting-style` exit pair, so they vanish instantly. `<x-menu>` fades out
correctly (`menu.blade.php:72` includes `display,overlay` and `transition-discrete`).
- Fix: add `transition-[translate,opacity,display,overlay] transition-discrete` to
`fab-menu-item.blade.php:27` and drive the closed state from the popover's `:not(:popover-open)`.
- Effort: M
- Breaks API? no
### ACT-34 · toast · Escape does not dismiss a focused snackbar
- Severity: nice-to-have
- M3 says: "Keyboard: Tab moves between interactive elements; **Esc dismisses the focused snackbar**";
and "on web, provide a documented keyboard shortcut (e.g. Alt+G) to jump focus to a snackbar with an
action" (RC-A, Snackbar → Accessibility).
- Library does: `snackbar.js` binds no key handler; `toast.blade.php:41-82` binds only pointer and
focus events. Escape does nothing, and there is no way to reach an actioned snackbar from the
keyboard except by tabbing past the whole page (the host is the last element in `<body>`).
- Fix: add a `keydown` listener in `snackbar.js:38` that calls `dismiss()` on Escape while the
snackbar contains `document.activeElement`; optionally a documented shortcut that focuses
`[data-toast-action]`.
- Effort: S
- Breaks API? no
### ACT-35 · toast · The description line is dimmed with an opacity instead of a role
- Severity: nice-to-have
- M3 says: the snackbar's roles are "container = **Inverse surface**; supporting text/icon = **Inverse
on surface**; action label = **Inverse primary**" (RC-A, Snackbar → Specs) — there is no fourth
role, and supporting text is one block of up to two lines.
- Library does: `toast.blade.php:69` — `type-body-md opacity-80` on the description, i.e.
inverse-on-surface at 80 %, a colour outside the roles that also drops the contrast of the longer
half of the message.
- Fix: drop `opacity-80` and let both lines be `inverse-on-surface`; M3 distinguishes them by position,
not tone.
- Effort: S
- Breaks API? no
### ACT-36 · alert · `role="alert"` on content that is present at page load
- Severity: nice-to-have
- M3 says: for the nearest equivalent (the snackbar) "use a **live region with a 'polite'/queued**
announcement (**not assertive**)" (RC-A, Snackbar → Accessibility). M3 publishes no alert/banner
component at all — the current site has no banner page; the only survivals are legacy mentions in
the states inheritance list (RF:384) and the elevation table (RS:320).
- Library does: `alert.blade.php:38` — `role="{{ in_array($color, ['error','warning']) ? 'alert' :
'status' }}"`. `role="alert"` is an assertive live region; when the alert is part of the rendered
page (the normal case — the showcase renders four of them statically,
`showcase/sections/communication.blade.php:47-53`) some screen readers announce it over the page
title on load, and a Livewire morph that re-renders the region can re-announce it.
- Fix: default both to `role="status"` and add an opt-in prop (`assertive`) for the case where the
alert really is injected in response to an action.
- Effort: S
- Breaks API? yes (the rendered role changes; tests asserting `role="alert"` would need updating)
## Deliberate deviations
- **The text button's label is `primary`, not `on-surface-variant`** (`button.blade.php:20-22`,
`:100-127`). Holds up. The M3 specs table reads "Text icon & label | **Primary** | -- | --" (RC-A,
Buttons → colour table), and the M2→M3 notes say "neutral text button no longer recommended". The
library is right, and it is also right that an *icon-only* text button (M3's "standard icon button")
takes `on-surface-variant` — "Standard icon | On surface variant | On surface variant | Primary"
(RC-A, Icon buttons → colour table). Both readings are implemented in the same `match` at
`button.blade.php:123-126`, which is exactly correct.
- **A selected round toggle button turns square** (`button.blade.php:132-136`). Ambiguous, and the
library picked the site's reading. The guidelines page says "By default, toggle buttons change from
**round to square when selected**" (RC-A, Buttons → Behaviour), but `tokens/ButtonSmallTokens.kt`
and `ButtonMediumTokens.kt` both set `SelectedContainerShapeRound = CornerFull`, i.e. a selected
round *label* button stays round in Compose. For *icon* buttons the tokens agree with the site
(`SmallIconButtonTokens.SelectedContainerShapeRound = CornerMedium`,
`…ShapeSquare = CornerFull`; `LargeIconButtonTokens`/`XLargeIconButtonTokens` are the same pattern
with ExtraLarge), and the library implements that swap exactly. Worth a line in the header comment
saying which source won for label buttons.
- **A selected text button takes the tonal container** (`button.blade.php:18`, `:123-126`). Holds up as
an extension: M3 gives text buttons no toggle states at all, so anything is an invention, and
borrowing the tonal selected colours keeps it inside the roles and satisfies the two-indicator rule.
- **Press expansion is a fixed step per size, not 15 % of width** (`groups.css:9`, `:32-48`). Holds up.
`ButtonGroupDefaults.ExpandedRatio = 0.15f` and the layout applies `expandedRatio * width / 2` per
side, clamped to the neighbour's padding (fetched live from androidx-main `ButtonGroup.kt:186`,
`:482`, `:500-511`). CSS cannot express a percentage of an intrinsic width, and every size's
`--group-grow` (4/6/8/16/20 px) is safely below its `--group-pad`, so the neighbour never reaches
negative padding. The animation uses `ease-spatial-fast`, matching Compose's
`MotionSchemeKeyTokens.FastSpatial` (`ButtonGroup.kt`, defaultAnimationSpec).
- **`--group-full` is half the height rather than `corner-full`** (`groups.css:19-23`, `:32-36`).
Holds up, and the values are right: 16/20/28/48/68 px are exactly half of 32/40/56/96/136 dp, which
is what `ConnectedButtonGroupSmallTokens.SelectedInnerCornerCornerSizePercent = 50%` means.
- **The `fab` prop on `<x-button>`** (`button.blade.php:177`). Inside spec as far as the *component*
goes: an extended FAB at 56 px, corner 16 dp, title-medium, primary-container, elevation 3 and a
16 px margin, which is the small extended FAB exactly (`ExtendedFabSmallTokens.kt`). Two caveats:
M3 says an extended FAB needs "**No tooltip** — the visible label already serves that role
(explicit Don't)" while `button.blade.php:225` will happily attach one; and the elevation never
reaches Level 4 on hover because `max-sm:shadow-elevation-3` outranks `hover:shadow-elevation-1`
in Tailwind's variant order.
- **`<x-group>` binds native radios/checkboxes, so the arrow keys move the choice**
(`group.blade.php:9-10`, `:84-91`). Ambiguous. M3's button-group accessibility says "Keyboard: **Tab**
navigates to the next button; Space or Enter activates/selects the focused button" (RC-A, Button
groups → Accessibility), which is roving-tabindex-free but also not a radio group. The library's
choice matches WAI-ARIA's radiogroup pattern and gives `wire:model` for free; it is a defensible
reading of "single-select, selection-required", and the `multiple` form (checkboxes) does behave
exactly as M3 describes.
- **The alert exists at all** (`alert.blade.php:1-10`). Holds up, with a caveat. M3 publishes no
banner and no alert; its own guidance routes in-page notices to a snackbar (transient, low priority)
or a dialog (blocking, high priority) — and an "your storage is almost full" notice is neither. The
component stays inside the foundations: container roles only, `rounded-corner-md`, title-small +
body-medium, text buttons in the actions slot. The header comment should add *when not to* use it
(a confirmation belongs in `<x-modal>`, a result of an action in `<x-toast>`), since the M3 site
will not tell a reader that.
- **`<x-stat>` and `<x-empty-state>`** (`stat.blade.php:3-8`, `empty-state.blade.php:8-11`). Hold up.
Both draw only from M3's roles (`surface-container`, `secondary-container`/`on-secondary-container`),
the shape scale (`rounded-corner-lg`), the Expressive emphasized type roles
(`type-emphasized-headline-md`, which `type.css` does define) and the Expressive shape library
(`cookie-9`). The counting figure honours reduced motion by reading
`--md-sys-motion-spatial-slow-duration`, which `motion.css:63-74` zeroes (`figure.js:59-64`, `:75`).
Nothing here draws a colour outside the roles.
- **`<x-progress>`'s non-primary track colours** (`progress.blade.php:64-68`). Holds up. M3 fixes the
track at `secondary-container` for the primary indicator (RC-A, Progress indicators → Specs), which
the library does for `primary` and `secondary`; the extra colours take their own container, which is
the only consistent extension.
- **`thick` grows the container beyond M3's `WaveHeight`** (`progress.blade.php:11-13`,
`:154-162`). Holds up: M3 publishes 10 dp only for the 4 dp stroke, "Track thickness | Configurable"
is an Expressive configuration with no published table, and adding the extra stroke to the container
keeps the 3 dp amplitude intact (`progress.js:1333`, `waveHeight = height - stroke`).
## Aligned
Verified correct — no need to re-check:
- **Button sizes**: 32/40/56/96/136 px heights, 8/8/8/12/16 px gaps, 20/20/24/32/40 px icons,
16/24/48/64 px padding at sm/md/lg/xl, 1/1/1/2/3 px outlines (`button.blade.php:147-158` against
RC-A Buttons → Specs and `tokens/Button*.kt`).
- **Button corners**: square 12/12/16/28/28 px and pressed 8/8/12/16/16 px, and the pressed value is
applied to round buttons too, as M3 requires ("round and square buttons should have the same pressed
shape") — `button.blade.php:129-136`, `:166`.
- **Button type roles per size**: label-large / label-large / title-medium / headline-small /
headline-large. Confirmed live against androidx-main `Button.kt:1645-1648`
(`buttonHeight < mediumHeight -> labelLarge; < largeHeight -> titleMedium; < xLargeHeight ->
headlineSmall; else -> headlineLarge`).
- **Every toggle colour pair**, all four variants × three states, matches the specs table exactly
(`button.blade.php:118-127`): filled unselected = surface-container/on-surface-variant, tonal
selected = secondary/on-secondary, outlined selected = inverse-surface/inverse-on-surface, elevated
selected = primary/on-primary, standard icon button selected = primary.
- **Icon-button widths**: all fifteen values (narrow/default/wide × XSXL) derive exactly from the
token paddings — 28/32/40, 32/40/52, 48/56/72, 64/96/128, 104/136/184 px (`button.blade.php:140-144`).
- **Icon-button selected shape swap** (round ⇄ square, `button.blade.php:133`) matches
`Small/Large/XLargeIconButtonTokens.SelectedContainerShape*` at every size. (RC-A's note that the
swap is "inverted for XS/S/M vs L/XL" is wrong — the token files are consistent, and the library is
right.)
- **Disabled**: content at 38 %, container at 10 % (`button.blade.php:174-175`) matches
`FilledButtonTokens/TonalButtonTokens/ElevatedButtonTokens.DisabledContainerOpacity = 0.1f` and
`DisabledLabelTextOpacity = 0.38f`; elevation removed when disabled ✓.
- **Elevation**: filled/tonal rest 0 → hover Level 1; elevated rest Level 1 → hover Level 2
(`button.blade.php:169-170`); FAB rest Level 3 → hover Level 4 (`fab.blade.php:49`); menu Level 2
(`menu.blade.php:70`); snackbar Level 3 (`toast.blade.php:51`); rich tooltip Level 2
(`rich-tooltip.blade.php:50`); plain tooltip none (`tooltip.blade.php:40`). All match.
- **48 px touch target on xs/sm buttons** (`button.blade.php:172`).
- **State layer**: content colour at 8 % hover / 10 % focus / 10 % press, hover gated behind
`@media (hover: hover)`, suppressed when disabled (`state.css:13-44`) — matches RF:367-375 exactly,
including "the state layer's color equals the content's 'on' color" (it uses `currentColor`).
- **Focus ring**: 3 px `secondary`, 2 px offset, keyboard-only (`state.css:46-53`), and
`SegmentedMenuTokens.ItemFocusIndicatorColor = Secondary` confirms the role for menus.
- **Standard group spacing**: 18/12/8/8/8 px (`button-group.blade.php:36-38`).
- **Connected group**: 2 px gaps and inner corners 4/8/8/16/20 px, pressed smaller, selected at 50 %
of the height (`button-group.blade.php:34`, `groups.css:32-36`, `:54-71`).
- **Split button**: 2 px between halves, leading padding 12/10 (xs) and 16/12 (sm) px, symmetric
24/48/64 px at md/lg/xl, trailing button widths 48/48/56/96/136 px, outer corners full, trailing
half rounds fully while open, chevron rotates 180°, `text` variant excluded, menu 4 px away,
`aria-expanded` on the trailing button, default label "More options"
(`split-button.blade.php:33-66`, `groups.css:68-90`).
- **FAB**: 56/80/96 px with 16/20/28 px corners and 24/28/32 px icons; primary/secondary/tertiary
container by default with the Expressive non-container styles under `variant="filled"`; no small FAB
and no surface style (`fab.blade.php:34-42`).
- **FAB menu**: 56 px close button, full corner when open, 20 px close icon, 8 px to the first item,
56 px items with full corners, 24 px icons, 8 px icon gap, 24 px padding, 4 px between items,
elevation 3 throughout, trailing-edge alignment (`fab-menu.blade.php:48-75`,
`fab-menu-item.blade.php:25`) — every number matches `tokens/FabMenuBaselineTokens.kt`.
- **Menu container**: surface-container-low / tertiary-container for `vibrant`, 16 px corner,
elevation 2, 112280 px width (`menu.blade.php:70-79`) — matches
`StandardMenuTokens.ContainerColor`, `VibrantMenuTokens`, `SegmentedMenuTokens.ContainerShape =
CornerLarge`, `ContainerElevation = Level2`, and the baseline width table.
- **Menu item**: body-large label, 20 px icons, 4 px corners opening to 12 px at the ends, selected =
tertiary-container with a 12 px corner, body-medium description, label-small trailing text — all
five confirmed against `SegmentedMenuTokens` (`ItemLabelTextFont = BodyLarge`, `ItemLeadingIconSize
= 20dp`, `ItemShape = CornerExtraSmall`, `ItemFirstChildShape = CornerMedium`, `ItemSelectedShape =
CornerMedium`, `ItemSupportingTextFont = BodyMedium`, `ItemTrailingSupportingTextFont = LabelSmall`).
- **Menu keyboard**: Enter/Space/Down open on the first item, Up on the last, arrows/Home/End move,
typeahead, Tab closes, Escape closes and returns focus to the trigger, activation closes unless
`keep-open` (`menu.js:153-242`, `menu.blade.php:53-55`) — the WAI-ARIA menu-button pattern, and it
matches M3's own keyboard table apart from submenus.
- **`current` uses `secondary-container`** (`menu-item.blade.php:52`) — the navigation-indicator role,
correctly distinguished from a checked choice.
- **Badge**: 6 px dot, 16 px tall count with a 16 px minimum width and 4 px padding, label-small,
full corners, error/on-error by default, `aria-hidden` unless labelled, `max` overflow to "999+"
within M3's four-character limit (`badge.blade.php:74-85`) — matches `tokens/BadgeTokens.kt`.
- **Progress**: 4 px (8 px thick) stroke, 40 px circular (48 px wavy), 4 px track gap, 4 px stop
indicator on linear determinate only, primary indicator with a secondary-container track, 40/20/15 px
wavelengths, 3 dp linear amplitude, round caps, reduced-motion handling, `role="progressbar"` with
`aria-valuenow` only while determinate (`progress.blade.php:70-197`, `progress.js:61-79`, `:1333`).
- **Loading indicator**: 48 px container, 38 px shape, primary by default,
on-primary-container-on-primary-container when `contained`, `role="progressbar"`, rests under
reduced motion (`loading.blade.php:27-42`) — matches `LoadingIndicatorTokens` and the contained
colour rule exactly.
- **Snackbar colours and type**: inverse-surface container, inverse-on-surface text, inverse-primary
label-large action rendered as a text button, body-medium supporting text, 4 px corner, elevation 3,
48 px single-line height, one at a time, 4 s default inside M3's 410 s range, hover/focus pauses,
never steals focus (`toast.blade.php:51-73`, `snackbar.js:13`, `:84-117`) — matches
`tokens/SnackbarTokens.kt` value for value.
- **Plain tooltip**: inverse-surface, inverse-on-surface, body-small, 4 px corner, 8 px horizontal
padding giving a 24 px container, 4 px from the target, no elevation, flips when there is no room
(`tooltip.blade.php:40-45`).
- **Rich tooltip**: surface-container, 12 px corner, elevation 2, 12/8/16 px padding, title-small
subhead and body-medium text in on-surface-variant, label-large primary actions, persistent opens on
press and light-dismisses, the pointer can travel onto the bubble to reach the actions
(`rich-tooltip.blade.php:50-65`, `rich-tooltip.js:20-46`).
- **Motion springs**: damping 0.6 / stiffness 800 fast-spatial and 1.0 / 3800 fast-effects
(`motion.css:28-42`) match RS:524-525 exactly, and every duration is zeroed under reduced motion.
## Missing
- **Submenus** (`<x-menu>`): M3's Expressive vertical menu specifies submenus, the Left/Right arrow
keys that open and close them, and the shape morph that marks the active one ("the focused
submenu's corners become more rounded while others become less rounded"). Nothing in the library
implements them; `menu.js:201-219` has no Left/Right case.
- **Grouped menu layout by gap**: M3's Expressive "Grouped" layout separates clusters with a *gap*
(`SegmentedMenuTokens.SegmentedGap = 2dp`, `GroupPadding = 4dp`, `GroupShape = CornerSmall`).
`<x-menu-group>` implements the labelled form and `<x-menu-separator>` the divider form — which is
what M3 recommends for web ("on web, use dividers to separate items") — but the gap layout itself,
and the 2 px gap between adjacent items that `ItemShape = 4dp` corners imply, are absent.
- **Menus as a filtering surface / embedded text field** ("autocomplete", RC-A Menus → Behaviour).
Partly covered by `<x-choices searchable>` in another group.
- **Square-by-default button groups**: M3 lists "Default shape | Round, **square**" as a button-group
configuration. `<x-button-group>` has no `shape` prop; the corner variables in `groups.css` assume a
round group, so a group of `shape="square"` buttons gets round outer corners.
- **Selection-required / multi-select semantics on `<x-button-group connected>`**: M3 lists
single-select, multi-select and selection-required as configurations. `<x-group>` covers
single/multi with real inputs; the plain `<x-button-group connected>` leaves selection entirely to
the caller's `aria-pressed`.
- **Snackbar keyboard shortcut** to jump focus to an actioned snackbar (M3 suggests Alt+G on web).
- **Two-line snackbar height** (68 dp in `SnackbarTokens.TwoLinesContainerHeight`, 64 dp in the site's
prose) is not expressed — the container grows organically from `min-h-12`, which lands close but is
not pinned.
- **FAB ↔ extended FAB scroll collapse** ("an extended FAB can collapse to a FAB on scroll and
re-expand at the bottom of the view"). `<x-button fab>` swaps on window width only.
- **Adaptive menu → bottom sheet at compact** (M3: "at compact breakpoints, consider swapping a menu
for a bottom sheet"). `<x-bottom-sheet>` exists, but nothing connects the two.
- Deliberately absent and correctly so: segmented buttons (deprecated in favour of the connected
button group, which `<x-group>` provides), the small FAB ("Not recommended. Use a larger size"), the
baseline extended FAB ("Not recommended. Use small extended FAB"), surface-coloured FABs ("no longer
recommended").
## Breakpoint map
| Component | Library breakpoint used | M3 window size class it stands in for | Gap |
| --- | --- | --- | --- |
| `<x-button fab>` (`button.blade.php:177`) | `max-sm` (< 640 px) → extended FAB pinned bottom-end; ≥ 640 px → filled button | Compact (< 600) gets the FAB; medium+ (≥ 600) gets the inline button | 40 px: windows 600639 px are "medium" in M3 but still get the phone FAB |
| `<x-button responsive>` (`button.blade.php:218`) | `max-lg` (< 1024 px) hides the label | M3 has no label-hiding rule; the nearest is "on large screens, buttons can reposition" at expanded (≥ 840) | 184 px, and the rule itself is an invention — an icon-only button at 8401023 px loses its visible label while keeping its `aria-label` |
| `<x-toast>` (`toast.blade.php:36-51`) | `sm` (≥ 640 px) switches from full-width to `w-auto min-w-86`, and `bottom-start` moves to `start-6` | Compact (< 600) full-width fixed insets; medium+ (≥ 600) scales horizontally for line length | 40 px. Also: M3 allows a full-width snackbar only "when the UI has no persistent nav components", and `<x-app-shell>` shows a bottom navigation bar in exactly that range |
| `<x-fab-menu>` margins (documented, `fab-menu.blade.php:3`, SKILL.md:283) | none — a fixed `end-4 bottom-4` (16 px) in the docs | 16 dp at compact/medium, **24 dp** at large (≥ 1200) / extra-large (≥ 1600) | The 24 dp step is missing; Tailwind's `xl` is 1280 px and `2xl` is 1536 px, so neither lines up with 1200/1600 |
| `<x-fab>` size choice | none — the caller picks `sm`/`md`/`lg` | M3: medium FAB "most recommended" for compact/medium, large FAB for expanded+ | No adaptive guidance in the component or SKILL.md; the default is the 56 px baseline FAB, which M3 calls the *smallest* of the three |
| `<x-menu>` | none | M3: "at compact breakpoints, consider swapping a menu for a bottom sheet" | Not implemented (see Missing) |
| `<x-button-group>`, `<x-group>`, `<x-split-button>`, `<x-badge>`, `<x-progress>`, `<x-loading>`, `<x-tooltip>`, `<x-rich-tooltip>`, `<x-alert>`, `<x-stat>`, `<x-empty-state>` | none | M3 defines no width-dependent behaviour for these (button groups are "fixed or flexible" by author choice; `<x-group>` already fills its row, which is M3's connected-group rule) | none |
+597
View File
@@ -0,0 +1,597 @@
# Audit: containment
## Summary
The containment group is the most accurate part of the library I have measured: the card variants, the
dialog (28dp corner, 24dp padding, 560/280 width, surface-container-high, 32% scrim, headline-small), the
divider (1px outline-variant, 16dp inset), the list heights (56/72/88) and leading sizes (24/40/56), the
bottom sheet's 32×4 handle / 28dp top corner / 640px cap, and the carousel's ported Compose keylines all
match the published numbers or the androidx token files. The failures are concentrated in three places:
**accessibility of selection and disabling in lists** (colour-only selection with no `aria-selected`, a
`disabled` item whose link is still focusable and activatable), **the bottom-sheet drag handle** (a 32×4px
touch target where M3 requires 48dp, achieved by its 22dp padding), and **adaptive breakpoints** (the
list-detail pane opens at `xl`/1280 where M3 puts two panes from expanded/840). Beyond those, a handful of
measurable deviations: a cascade-layer bug that kills the state layer on segmented list rows, a basic
dialog whose headline and action row scroll away, side-sheet actions right-aligned where M3 says left,
carousel end padding of 0 where M3 says 16dp, a full-screen carousel that scrolls horizontally where M3's
scrolls vertically, and cards with no per-state elevation at all. Collapse, the error pages and the mail
theme are clean against M3's foundations and styles; the mail theme reproduces the typescale correctly in
px and the error pages use the roles and typescale properly.
## Findings
### C-01 · bottom-sheet · The drag handle is a 32×4px target where M3 requires 48dp
- Severity: must-fix
- M3 says: "drag handle has an accessible **48dp hit target**" and the specs table gives "Drag handle
padding top/bottom | 22dp" (reference-components-a.md § Bottom sheets → Specs; raw
`components_bottom-sheets_specs.md`). Confirmed in androidx: `SheetDefaults.kt` line 788,
`private val DragHandleVerticalPadding get() = 22.dp`, applied as
`modifier.padding(vertical = DragHandleVerticalPadding)` around the 32×4 handle → 4 + 22 + 22 = 48dp.
Foundations also require a ≥48×48 touch target for any interactive element.
- Library does: `resources/views/components/bottom-sheet.blade.php:60` — the `<button>` that is the handle
is `class="h-1 w-8 …"` (4×32px) with no padding of its own; the padding lives on the wrapper `<div>` at
line 59 (`py-4` = 16px, not 22px), and the wrapper is not the control. The clickable/focusable target is
therefore 32×4px, and the whole handle region is only 36px tall.
- Fix: move the padding onto the button: `class="h-1 w-8 box-content py-[22px] …"` (or wrap with
`py-[22px]` and give the button `before:absolute before:inset-x-0 before:-inset-y-[22px]`), and change
the wrapper at line 59 from `py-4` to `py-0`. Total sheet-top region becomes 48px, matching M3.
- Effort: S
- Breaks API? no
### C-02 · list-item · A `disabled` item's link stays keyboard-focusable and activatable
- Severity: must-fix
- M3 says: disabled list items carry `ItemDisabled*Opacity = 0.38` and a disabled state layer
(`ListTokens.kt:70,75`); foundations' states model treats disabled as "not interactive". A control that
looks disabled but still responds to Enter is an interaction bug.
- Library does: `resources/views/components/list-item.blade.php:50` adds only
`'pointer-events-none text-on-surface/38' => $disabled`. `pointer-events: none` blocks the pointer but
not the keyboard; the `<a href="…" data-list-open …>` at lines 73-78 is still rendered, still in the tab
order and still navigates on Enter. No `aria-disabled` is emitted, so a screen reader announces the item
as an ordinary link.
- Fix: in `list-item.blade.php`, when `$disabled` render the title as the `<p>` branch (skip the `<a>`
entirely, or add `tabindex="-1" aria-disabled="true"` and strip `href`), drop `data-list-row` when
disabled, and add `aria-disabled="true"` to the row `<div>` at line 41.
- Effort: S
- Breaks API? no
### C-03 · list-item · `selected` is colour-only and is never announced
- Severity: must-fix
- M3 says: "**Indicate selection with more than color** … don't rely on color as the only visual cue" and
"Use two visual cues to show a list item is selected, like a leading checkmark and filled color"
(raw `components_lists_accessibility.md:26-44`). Role mapping, same page: single-select and multi-select
lists on **Web** → container role **List box**, item role **Option**, state **Selected / Not-selected**.
- Library does: `list-item.blade.php:44` emits only `data-selected`; `resources/css/components/list.css:46-49`
paints `secondary-container` / `on-secondary-container`. There is no `aria-selected`, no `aria-current`,
no icon or checkmark, and the container stays `role="list"` / `role="listitem"`
(`list.blade.php:19`, `list-item.blade.php:41`), where `listitem` cannot carry a selected state at all.
- Fix: two parts. (a) Add a `selectable` (or `selection="single"|"multi"`) prop to `<x-list>` that switches
the container to `role="listbox"` and each item to `role="option" aria-selected="true|false"`; keep
`role="list"` as the default for non-selectable lists and use `aria-current="true"` there instead of
nothing. (b) Render a second cue when `selected` — e.g. a trailing `check` icon in
`on-secondary-container` — or document that callers must supply one (a `leading` checkbox/radio).
- Effort: M
- Breaks API? yes (a new prop; `role` on the container changes for selectable lists)
### C-04 · drawer · No close affordance by default, which M3 states as a requirement
- Severity: must-fix
- M3 says: "Material **requires** a close affordance (e.g. close icon button) to always be present —
without one, users can't predict the sheet's open/close flow or tell if it's transient or permanent"
(reference-components-a.md § Side sheets → Accessibility). Anatomy lists the close icon button for both
the standard and the modal side sheet.
- Library does: `resources/views/components/drawer.blade.php:33``'withCloseButton' => false`. With the
default, a side sheet renders with a headline and no close control at all; the only exits are Escape
(which `close-on-escape=false` removes) and the scrim (which `without-backdrop-close` removes). With both
of those off, the sheet is undismissable.
- Fix: flip the default to `'withCloseButton' => true` in `drawer.blade.php:33`, and make the close button
unconditional (ignore the prop) when `closeOnEscape` is false or `withoutBackdropClose` is set. Same for
`pane` mode, which has no scrim and, by default, no Escape.
- Effort: S
- Breaks API? yes (the default rendering of every existing `<x-drawer>` gains a close button)
### C-05 · carousel · Reduced motion still resizes items
- Severity: must-fix
- M3 says: "When reduced motion settings are turned on, the parallax effect should be removed and carousel
items should no longer expand as they come into view. **All items are the same size**" (quoted verbatim
in reference-styles.md § Motion → Accessibility requirements, from
`components/carousel/accessibility`; also reference-components-a.md § Carousel → Accessibility).
- Library does: `resources/js/carousel.js:966` sets `const pinned = state.reducedMotion.matches` and then
uses it only for the content pin (`const pin = pinned ? … : 0`, line 979). The mask itself
(`const inset = clamp((size - keyline.size) / 2, 0, size / 2)`, line 978) is still written to
`--material-carousel-inset` on every frame, so items keep growing and shrinking between keylines — the
exact behaviour M3 says to switch off. Only the parallax half of the rule is honoured.
- Fix: in `render()` (carousel.js ~line 966-995), when `state.reducedMotion.matches`, write `inset = 0`
(and `shift = 0`, `opacity = 1`) for every item so all items stay at `strategy.itemSize`; keep the
keyline maths for snap positions. M3's extra note for hero under reduced motion — "the small item shows
only partially" — then falls out of the scroll position rather than the mask.
- Effort: M
- Breaks API? no
### C-06 · modal · A scrolling dialog scrolls its headline and its action row away
- Severity: should-fix
- M3 says: "Scrolling: dialog content generally shouldn't scroll; if it must, **the title stays pinned at
top and buttons pinned at bottom**, and the dialog never scrolls with background content"
(reference-components-a.md § Dialogs → Behaviour and guidelines).
- Library does: `resources/views/components/modal.blade.php:66` puts `overflow-y-auto` on the outer box,
which contains the header block (lines 81-100), the body (line 102) and the actions (lines 105-112). The
body wrapper at line 80 is `min-h-0 flex-1` with no overflow of its own, so once the content exceeds
`max-h-[calc(100dvh-3rem)]` everything scrolls together. Only the `fullscreen` branch pins correctly
(`max-sm:overflow-y-auto` on the inner div, line 80).
- Fix: in `modal.blade.php`, remove `overflow-y-auto` from line 66 (keep `overflow-hidden`), give the
header block `shrink-0`, and move `overflow-y-auto` onto the `min-h-0 flex-1` wrapper at line 80 for all
cases (not just `max-sm:`). The actions already have `shrink-0`. Move `p-6` from the box to the three
regions so the pinned header/footer keep their 24dp padding.
- Effort: S
- Breaks API? no (`box-class` callers that relied on the outer scroll would change)
### C-07 · drawer · The list-detail pane opens at 1280px; M3 puts two panes from 840px
- Severity: should-fix
- M3 says: list-detail visible panes — "Compact (0599): 1 pane; Medium (600839): 1 (recommended) or 2;
**Expanded (840+): 2**; Large (12001599): 2; Extra-large (1600+): 2"
(reference-foundations-supplement.md § Canonical layout examples → List-detail).
- Library does: `drawer.blade.php:55` (`window.matchMedia('(min-width: 80rem)')`) and the `xl:` classes at
lines 64-65 and 100 — the pane appears only from 1280px. Between 840 and 1279px (the whole expanded class
and most of large) the detail still opens as a modal sheet over a scrim with the list inert, which is
M3's compact behaviour.
- Fix: change `80rem` to `52.5rem` (840px) in `drawer.blade.php:55` and swap the `xl:` prefixes for a
custom `expanded:` variant defined as `@custom-variant expanded (@media (min-width: 52.5rem))` in
`resources/css/tokens/theme.css`; update `SKILL.md:463` and the drawer header comment. If that is too
aggressive for narrow laptop layouts, make the threshold a prop (`pane-from`) with 840 as the default.
- Effort: M
- Breaks API? yes (pages laid out with `xl:flex xl:items-start xl:gap-6` around the drawer would need the
same breakpoint changed; that wrapper is documented in `SKILL.md:463`)
### C-08 · list.css · Segmented list rows lose their hover and press state layer to the cascade
- Severity: should-fix
- M3 says: list items show Hovered / Focused / Pressed / Dragged states (reference-components-a.md § Lists
→ Specs), and "Cursor: hover shows a visible cue that the item is interactive" (§ Accessibility). State
layer opacities: hover 8%, focus 10%, pressed 10% (reference-foundations, states).
- Library does: `resources/css/components/list.css:42-44` sets
`[data-list='segmented'] > [data-list-item] { background-color: var(--md-sys-color-surface-container); }`
**unlayered**, while the hover (line 22-24), focus (27-31) and press (33-35) rules live inside
`@layer components`. `resources/css/material.css` imports `list.css` without wrapping it, so the
unlayered declaration wins over every layered one regardless of the `:where()` specificity — a segmented
row gets no background state layer on hover or press. Only the corner morph (lines 93-100, unlayered)
and the focus outline (a different property) still show.
- Fix: move the `[data-list='segmented'] > [data-list-item]` background rule (lines 42-44, a duplicate of
the selector at 79-81) into `@layer components` alongside the state rules, or paint the state layer with
a `::before`/`background-image` instead of `background-color` so the two never collide.
- Effort: S
- Breaks API? no
### C-09 · bottom-sheet · Drag handle colour is on-surface-variant at 40%, not the role colour
- Severity: should-fix
- M3 says: "drag handle = On surface variant" (specs page colour roles, reference-components-a.md §
Bottom sheets). `SheetBottomTokens.kt:30``DockedDragHandleColor get() = ColorSchemeKeyTokens.OnSurfaceVariant`;
`SheetDefaults.kt:576` uses it undiluted (`color: Color = SheetBottomTokens.DockedDragHandleColor.value`),
with no opacity multiplier.
- Library does: `bottom-sheet.blade.php:60``bg-on-surface-variant/40`. The component's own header comment
(line 10) says "a 32×4px drag handle in on-surface-variant", so the code contradicts its own doc.
- Fix: `bg-on-surface-variant` in `bottom-sheet.blade.php:60`.
- Effort: S
- Breaks API? no
### C-10 · drawer · Bottom actions are right-aligned; the side-sheet spec says left
- Severity: should-fix
- M3 says: side sheet specs table (both standard and modal): "Bottom actions alignment (horizontal) |
**Left**"; "Bottom actions height 72dp; top padding 16dp; bottom padding 24dp"
(raw `components_side-sheets_specs.md:125` and `:199`).
- Library does: `drawer.blade.php:135``flex shrink-0 flex-wrap items-center justify-end gap-2 pt-6`
(right-aligned, 24px top padding, no bottom padding of its own beyond the sheet's `p-6`).
- Fix: `justify-start` in `drawer.blade.php:135`, and `pt-4 pb-0` inside a 72px-min row
(`min-h-18 pt-4`) to match 16/24/72. Note this deliberately differs from the dialog, whose actions are
trailing-aligned — M3 specifies them differently.
- Effort: S
- Breaks API? yes (visual position of every existing drawer action row)
### C-11 · carousel · The full-screen layout scrolls horizontally; M3's scrolls vertically
- Severity: should-fix
- M3 says: "The full-screen carousel layout shows one edge-to-edge large item at a time and **scrolls
vertically**"; "This layout works best with content that is taller than it is wide, and scrolls
vertically. It only works in **portrait orientation in compact and medium breakpoints**. Don't use this
layout in landscape orientation." (raw `components_carousel_guidelines.md:31,177-183`). Specs table:
full-screen padding 0dp all round, 16dp between elements, edge-to-edge with no item radius.
- Library does: `carousel.blade.php:127-131` renders every layout, full-screen included, as a horizontal
`overflow-x-auto` row; `carousel-item.blade.php:35` gives every item `rounded-corner-xl` and the 28px
clip-path. The header comment (line 22-23) describes full-screen as "one item the width of the carousel
at a time", i.e. horizontal by design.
- Fix: either (a) add a vertical mode for `layout="full-screen"` (`flex-col`, `overflow-y-auto`,
`snap-y snap-mandatory`, items `h-full w-full` with no corner radius, arrow keys Up/Down) and clamp it to
compact/medium widths, or (b) rename the layout so it does not claim to be M3's full-screen carousel and
say so in `SKILL.md:471`. Also drop the 28px corner and the 8px gap for this layout (M3: edge-to-edge,
16dp between elements).
- Effort: L
- Breaks API? yes if renamed; no for (a) if `layout="full-screen"` keeps its name
### C-12 · carousel · Leading/trailing padding defaults to 0 where M3 specifies 16dp
- Severity: should-fix
- M3 says: specs table — Multi-browse / Hero / Center-aligned hero: "Leading/trailing padding **16dp**,
Top/bottom padding **8dp**, Padding between elements 8dp"; Uncontained: "16dp (leading only)"
(reference-components-a.md § Carousel → Specs).
- Library does: `carousel.blade.php:55``'padding' => 0`; the scroller at lines 127-131 has no vertical
padding at all. Every showcase example (`resources/views/showcase/sections/carousel.blade.php`) therefore
renders with 0 end padding, so items sit flush against the container edge.
- Fix: default `'padding' => 16` in `carousel.blade.php:55` (and pass leading-only for `uncontained`, 0 for
`full-screen`); add `py-2` to the scroller class list at line 128. Update `SKILL.md:471` where it
documents "`padding` (px at the ends, 0)".
- Effort: S
- Breaks API? no (a default value changes; explicit `padding="0"` still works)
### C-13 · card · No elevation change on hover, focus, press or drag
- Severity: should-fix
- M3 says: per-state elevation from `ElevatedCardTokens.kt` / `FilledCardTokens.kt` / `OutlinedCardTokens.kt`
(reference-components-a.md § Cards → cross-check table): elevated 1dp rest → **3dp hover** → 1dp
focus/pressed → **8dp dragged**; filled 0 → **1dp hover** → 0 → **6dp dragged**; outlined 0 → **1dp
hover** → 0 → 6dp dragged. The specs page shows Hovered / Focused / Pressed / Dragged / Disabled for all
three variants (raw `components_cards_specs.md:78,128,180`).
- Library does: `card.blade.php:34-36` sets one elevation and never changes it
(`shadow-elevation-1` for elevated, none for filled/outlined). The only interactive response is in
`resources/css/components/list.css:52-72`, and that is a state layer plus a corner morph — no
`box-shadow` level change. There is no dragged state anywhere in the group.
- Fix: add hover/focus elevation to `card.blade.php` for interactive cards — e.g. in `list.css`
`[data-card][data-list-row]:hover { box-shadow: var(--md-sys-elevation-2), inset … }` for the elevated
variant and `var(--md-sys-elevation-1)` for filled/outlined. Needs a variant marker on the element
(`data-card="elevated|filled|outlined"` instead of the bare `data-card` at `card.blade.php:31`).
- Effort: M
- Breaks API? no (`data-card` gains a value; the bare attribute selector still matches)
### C-14 · list-item · Leading/trailing gap is 12px where M3 uses 16dp
- Severity: should-fix
- M3 says: `ListItem.kt` (androidx-main) lines 1269 and 1273 —
`internal val LeadingContentEndPadding = 16.dp`, `internal val TrailingContentStartPadding = 16.dp`;
`ListTokens.kt:171,338``ItemLeadingSpace = 16.dp`, `ItemTrailingSpace = 16.dp`.
- Library does: `list-item.blade.php:46``flex items-center gap-3 px-4` (12px gap). The container padding
(`px-4` = 16px) is correct; only the internal gaps are short.
- Fix: `gap-4` in `list-item.blade.php:46`.
- Effort: S
- Breaks API? no
### C-15 · list-item · Three-line items are middle-aligned; M3 top-aligns them
- Severity: should-fix
- M3 says: "Alignment: elements are middle-aligned by default; **top-aligned if the item is 88dp+ or has 3+
lines of text**" (reference-components-a.md § Lists → Specs, from the overview page). Compose confirms
the vertical padding also changes: `ListItem.kt:1259,1261`
`ListItemVerticalPadding = 8.dp`, `ListItemThreeLineVerticalPadding = 12.dp`.
- Library does: `list-item.blade.php:46``items-center` for every case; padding is `py-2` (8px) for one
line and `py-2.5` (10px) for both two- and three-line items (lines 47-49), where M3 wants 8 and 12.
- Fix: in `list-item.blade.php:45-51`, add `'items-start' => $lines === 2` (alongside `items-center` for
the others) and change `'min-h-22 py-2.5' => $lines === 2` to `'min-h-22 py-3' => $lines === 2`; leave
`py-2` for `$lines === 0` and use `py-2` for `$lines === 1` too.
- Effort: S
- Breaks API? no
### C-16 · list · Dividers between items run edge to edge; the list token insets them 16dp
- Severity: should-fix
- M3 says: `ListTokens.kt:30,36``DividerLeadingSpace = 16.dp`, `DividerTrailingSpace = 16.dp` (top/bottom
space 0). Guidelines: inset dividers "separate related content within one section (e.g. emails in a
list); indented equally from both sides by default; pair with anchoring elements like icons/avatars"
(reference-components-a.md § Divider).
- Library does: `list.blade.php:25``'divide-y divide-outline-variant' => $dividers && ! $segmented`,
which draws a full-bleed 1px rule between items. `<x-divider>` has `inset` and `middle` props, but the
list never uses them and there is no way to ask for an inset list divider.
- Fix: in `list.blade.php`, render the divide with a 16px inset — e.g. add
`[&>[data-list-item]:not(:last-child)]:after` or keep `divide-y` and add `mx-4` to the rule via a small
CSS block in `resources/css/components/list.css`: `[data-list='plain'][data-dividers] > [data-list-item]:not(:last-child) { box-shadow: inset 0 -1px 0 … }` inset by 16px. Alternatively give `<x-list>` a
`dividers="full"|"inset"|"middle"` value instead of a boolean.
- Effort: M
- Breaks API? no if `dividers` stays boolean-compatible
### C-17 · modal · Full-screen dialog header is 64px where M3 specifies 56dp
- Severity: should-fix
- M3 says: full-screen dialog specs table — "Header height | **56dp**"; "Bottom action bar height | 56dp";
"Top/left/right padding 24dp" (reference-components-a.md § Dialogs → Specs).
- Library does: `modal.blade.php:71``flex h-16 shrink-0 items-center gap-1 px-1 sm:hidden` (64px), and
the bottom action bar at line 108 is `max-sm:px-6 max-sm:py-4` around a 40px button ≈ 72px.
- Fix: `h-14` (56px) at `modal.blade.php:71`; `max-sm:min-h-14 max-sm:py-2` at line 108. Keep `px-1` on the
bar so the close icon button's 48px target still reaches the 24dp text margin (M3 aligns the header
headline to 24dp from the edge; the icon button's own padding supplies it).
- Effort: S
- Breaks API? no
### C-18 · carousel · Tab lands on the container, which M3 explicitly tells you not to do
- Severity: should-fix (two readings — see below)
- M3 says: "When navigating to a carousel using assistive technology, use **Tab** to place initial focus on
the **first carousel item**"; and the caption under the Don't image: "**Avoid focusing on the carousel
container**" (raw `components_carousel_accessibility.md:130,146`). Keyboard table: "Tab or Arrows — Moves
to the previous or next carousel item; Space or Enter — Activates the focused carousel item".
- Library does: `carousel.blade.php:123-126` gives the scroller `role="region"`,
`aria-roledescription="carousel"` and **`tabindex="0"`**; the items (`carousel-item.blade.php:23-32`) are
`role="group"` with no `tabindex`, so they are never focusable and Space/Enter cannot activate one. The
arrow keys only work while the container itself has focus (`carousel.js:1065-1068` returns early unless
`event.target === this.$refs.scroller`). The header comment (lines 37-43) says this is deliberate:
"WAI-ARIA's carousel pattern: the row is a focusable `region`".
- Both readings: the library's choice satisfies WCAG 2.1.1 for a scrollable region whose content may be
non-focusable (an `<img>`-only slide), which is the ARIA-APG "scrollable region" practice; M3's rule
assumes every item is itself an actionable target. They conflict; M3's is the stated rule here.
- Fix: give each `<x-carousel-item>` `tabindex="0"` and handle Arrow/Home/End/Space/Enter on the focused
item (roving tabindex), keeping the container out of the tab order — or, if the current pattern is kept,
record it in the "deliberate deviations" of `SKILL.md:471` with the M3 quote so a reviewer does not
re-litigate it.
- Effort: M
- Breaks API? no
### C-19 · bottom-sheet · Default height is 90dvh; M3 caps a modal sheet's initial position at 50%
- Severity: should-fix
- M3 says: "Modal: … Initial vertical position is **capped at 50% of screen height**; if content exceeds
that, it can be pulled to full screen and scrolled internally." Specs table: "Top margin 72dp; Top margin
(window width > 640dp) 56dp; Start/end margin (window width > 640dp) 56dp"
(reference-components-a.md § Bottom sheets).
- Library does: `bottom-sheet.blade.php:17``'height' => '90dvh'`, applied as
`max-h-(--sheet-max-height)` (line 55). There is one height and no preset-height cycling, so a sheet may
open at 90% of the viewport where M3 would open at 50% and let the user pull it up. The 56dp side margin
above 640px is also absent (`mx-auto … max-w-160`, line 55, with no horizontal margin).
- Fix: default `'height' => '50dvh'` with a `max-height` ceiling of `calc(100dvh - 72px)` in
`bottom-sheet.blade.php:17,55`, and add `sm:px-14` (56px) to the wrapper or `sm:max-w-[calc(100vw-7rem)]`.
A second preset height, cycled by the drag handle's click, would complete M3's "selecting the drag handle
toggles preset heights" rule; today the click only closes.
- Effort: M
- Breaks API? yes (default sheet height changes)
### C-20 · modal · No `role="alertdialog"` on a basic dialog
- Severity: should-fix (two readings)
- M3 says: "On web, basic dialogs should have the **alert dialog** role"; "Basic dialogs are known as alert
dialogs on web" (raw `components_dialogs_accessibility.md:130,134`).
- Library does: `modal.blade.php:41` uses a native `<dialog>` opened with `showModal()`
(line 51), which the browser maps to `role="dialog"` + `aria-modal="true"`. No `role` is set, and no
`aria-describedby` points at the supporting text (line 93).
- Both readings: ARIA-APG restricts `alertdialog` to dialogs that "interrupt … to communicate an important
message" and requires an `aria-describedby` message; applying it to every `<x-modal>` (including forms,
which the `fullscreen` variant is explicitly for) would over-announce. A middle path matches both.
- Fix: add an `alert` boolean prop to `modal.blade.php` that sets `role="alertdialog"` plus
`aria-describedby="{{ $id }}-body"`, and use it in the showcase's destructive-confirmation example
(`showcase/sections/containment.blade.php:73`). Add `aria-describedby` for the subtitle unconditionally.
- Effort: S
- Breaks API? no
### C-21 · list · Segmented items use `surface-container`; the token says `Surface`
- Severity: should-fix
- M3 says: `ListTokens.kt:201``ItemSegmentedContainerColor get() = ColorSchemeKeyTokens.Surface`
(`ItemContainerColor` is also `Surface`). `SegmentedGap = 2.0.dp` (line 353).
- Library does: `resources/css/components/list.css:43` and `:79-81`
`background-color: var(--md-sys-color-surface-container)`. The 2px gap (`list.blade.php:24`,
`gap-0.5`) and the 4px/16px corner morph are right; only the fill is a tone off.
- Fix: `var(--md-sys-color-surface)` in `list.css:43`. Note this only reads as "segmented" when the page
behind it is a container tone; if the library prefers the stronger tone, say so in the file comment,
because the header currently claims "each item its own surface-container tile … (ListTokens)".
- Effort: S
- Breaks API? no
### C-22 · list.css · A focused segmented row does not morph to the large corner
- Severity: nice-to-have
- M3 says: "Interaction-state expressive shapes: hovered = Medium (12dp); **focused**/pressed/dragged/
selected-any-state = Large (16dp)" (reference-components-a.md § Lists → Specs, cross-checked against
`ListTokens.kt``ItemPressedContainerExpressiveShape` = CornerLarge,
`ItemDraggedContainerExpressiveShape` = CornerLarge).
- Library does: `resources/css/components/list.css:93-100` covers `:hover` (→ md) and
`:is([data-selected], [data-list-row]:active)` (→ lg). `:focus-visible` is not in either selector, so a
keyboard-focused segmented row keeps the 4px corner.
- Fix: extend the selector at `list.css:99` to
`[data-list='segmented'] > [data-list-item]:is([data-selected], [data-list-row]:active, [data-list-row]:has([data-list-open]:focus-visible))`.
- Effort: S
- Breaks API? no
### C-23 · card · The corner morphs 12→16 on hover, which M3 cards do not do
- Severity: nice-to-have
- M3 says: cards have one shape — "Shape | 12dp corner radius" for all three variants, with no press or
hover shape listed in `ElevatedCardTokens.kt` / `FilledCardTokens.kt` / `OutlinedCardTokens.kt`
(reference-components-a.md § Cards → Specs). Shape morph is specified for buttons, FABs and list items,
not cards.
- Library does: `resources/css/components/list.css:59-62`
`[data-card][data-list-row]:hover { border-radius: var(--md-sys-shape-corner-lg); … }`. The
`card.blade.php` header (lines 10-14) documents this: "It answers with a state layer and its corner
opening a step."
- Fix: either drop the `border-radius` line at `list.css:60` (keeping the state layer, which M3 does
require for a directly-actionable card) or keep it and note in the card header that this is an Expressive
extension M3 does not specify for cards. The state layer itself is correct and should stay.
- Effort: S
- Breaks API? no
### C-24 · collapse · `interpolate-size` is set but nothing animates the height
- Severity: nice-to-have
- M3 says: n/a — collapse is not an M3 component; foundations require only that motion respect reduced
motion, which the token system already does (`resources/css/tokens/motion.css:63-75`).
- Library does: `collapse.blade.php:49` adds `[interpolate-size:allow-keywords]` and the header comment
(lines 7-8) promises "where the browser supports animating `details` content (`interpolate-size`) — a
height that eases open". There is no `transition: height` and no `::details-content` rule anywhere in
`resources/css/` (grepped: `interpolate-size` and `details-content` appear only in this file), so the
content snaps open. `interpolate-size` on its own changes nothing.
- Fix: add to a component stylesheet:
`details.group\/collapse::details-content { block-size: 0; overflow: hidden; transition: block-size var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast), content-visibility var(--md-sys-motion-spatial-fast-duration) allow-discrete; }`
and `details[open].group\/collapse::details-content { block-size: auto; }` — or delete the
`interpolate-size` utility and the sentence in the header comment.
- Effort: S
- Breaks API? no
### C-25 · carousel-item · The overlay label is `text-white`, outside the role set
- Severity: nice-to-have
- M3 says: "Layering text/icons on images is **not recommended**; if necessary, add a translucent scrim or
a bounding shape behind the text/icon to guarantee accessible contrast" (reference-components-a.md §
Cards → Behaviour; the same rule is the reason the carousel's item content is art). Colour must come from
a scheme role.
- Library does: `carousel-item.blade.php:46``type-title-md text-white` over
`bg-linear-to-t from-scrim/60` (line 44). The scrim is right; `white` is a literal, not a role, so it
does not follow a scheme or a high-contrast profile.
- Fix: `text-inverse-on-surface` (which is near-white in a light scheme and dark in a dark one — check the
intent) or add an explicit `--md-sys-color-on-scrim` style token if a fixed light-on-dark is wanted for
both schemes; a literal `white` is defensible over a 60% black scrim but should be stated as such in the
file comment.
- Effort: S
- Breaks API? no
### C-26 · list-item · Leading icon stays 24px in a segmented (expressive) list
- Severity: nice-to-have
- M3 says: `ListTokens.kt:153,156,332,335``ItemLeadingIconExpressiveSize = 20.dp` /
`ItemLeadingIconSize = 24.dp`; `ItemTrailingIconExpressiveSize = 20.dp` / `ItemTrailingIconSize = 24.dp`.
The library's segmented list is explicitly the expressive variant (`list.blade.php:4-6`).
- Library does: `list-item.blade.php:64` and `:97``size-6` (24px) in both cases, regardless of the
parent list's mode.
- Fix: the item does not know its parent, so use CSS:
`[data-list='segmented'] > [data-list-item] svg { width: 20px; height: 20px }` in
`resources/css/components/list.css`, scoped to the leading/trailing icons only.
- Effort: S
- Breaks API? no
### C-27 · mail · The card corner is 24px, a value not on M3's shape scale
- Severity: nice-to-have
- M3 says: the corner scale is 0 / 4 / 8 / 12 / 16 / 20 / 28 / 32 / 48 (reference-styles.md § Shape; the
library's own `resources/css/tokens/shape.css:11-20` reproduces it). 24 is not a step.
- Library does: `resources/views/mail/theme.blade.php` `.inner-body { border-radius: 24px; }`; the panel
uses `16px` (on-scale) and `code` uses `4px` (on-scale).
- Fix: `border-radius: 28px` (extra-large) on `.inner-body`, matching the dialog/bottom-sheet surface tone
this card stands in for. Everything else in the theme is on-scale.
- Effort: S
- Breaks API? no
### C-28 · mail · The header app name is title-large at weight 500, not 400
- Severity: nice-to-have
- M3 says: title-large = weight 400 (Regular), 22/28, tracking 0 — the library's own
`resources/css/tokens/type.css:39-41` has `--md-sys-typescale-title-lg` at regular and
`--md-sys-typescale-emphasized-title-lg` at medium.
- Library does: `resources/views/mail/theme.blade.php` `.header a { font-size: 22px; font-weight: 500; … }`,
while the section comment says "the app name in title-lg". `h2` right below it uses 22/28 at weight 400,
so the two disagree within the same file.
- Fix: either set `font-weight: 400` on `.header a`, or change the comment to say
title-large-**emphasized** — which is the more likely intent for a brand line and is a real M3 role.
- Effort: S
- Breaks API? no
## Deliberate deviations
- **`card.blade.php:16-17` — "Do not pass a `bg-*` class … it races the card's own in Tailwind's emit
order."** True and correctly explained; the CSS in `list.css` is unlayered for the same reason
(`list.css:10-11`). Holds up, and is the cause of C-08 (the same unlayered trick applied to the segmented
background, where it is not needed).
- **`card.blade.php:10-14` / `list-rows.js` — the `data-list-row` + `data-list-open` contract instead of a
stretched link or a wrapping `<a>`.** M3's accessibility page for cards says exactly this: "on a
**directly actionable card** Tab moves to the next card container; on a **non-actionable card with
actionable elements**, Tab moves through each actionable element inside before moving to the next card"
and forbids stacking an action on an already-actionable surface. The library implements the second case
with a single real control. Holds up, and is a better answer than M3 gives for the web.
- **`drawer.blade.php:9-11` — "it enters on emphasized decelerate rather than a spring — a sheet anchored
to the edge that overshot would open a gap."** M3 Expressive's motion scheme is springs for spatial
change, but `styles/motion` keeps the emphasized-decelerate curve for exactly this kind of entrance and
the reference records no rule against it. Holds up.
- **`modal.blade.php:24` — "It opens on the fast spatial spring and closes at once, as M3's do."** The
first half is fine; the second is not supported by Google's text, which says a dialog "appears via an
**enter/exit** transition" (reference-components-a.md § Dialogs → Behaviour). Native `<dialog>` makes an
exit transition awkward (`@starting-style` only covers entry), which is the real reason — worth saying so
instead. Folded into no finding of its own; it is a doc accuracy point.
- **`list.blade.php:8``role="list"` rather than `listbox`.** Correct for a plain list of links; it is
only wrong once `selected` is used, which is C-03.
- **`carousel.blade.php:37-43` — the WAI-ARIA carousel pattern rather than M3's "focus the first item".**
See C-18; a genuine standards conflict, but M3's Don't is explicit and is not acknowledged in the code.
- **`mail/theme.blade.php:9-26` — hexes only, light only, no `@media`, no elevation.** Every reason given
is correct (CssToInlineStyles' `doCleanup()` does strip `@media`; Outlook does drop alpha). Accepted by
the brief and by the constraints.
- **`collapse.blade.php:1-2` — "Not an M3 component; built on the native `<details>`."** Correct; M3 has
expandable list items and menu expansion but no standalone disclosure. Judged against foundations it is
sound: 48px summary (`min-h-12`), the 8/10% state layer, the 3px secondary focus ring, reduced motion via
the duration tokens, and the native `aria-expanded`.
## Aligned
- **Card**: filled `surface-container-highest`, elevated `surface-container-low` + elevation 1, outlined
`surface` + 1px `outline-variant` — all three match the specs page and the token files. `rounded-corner-md`
= 12dp; `p-4` = the 16dp left/right padding; `overflow-hidden` + a full-bleed `figure` slot is the media
anatomy. (Card typography is not specified by M3 at all — the specs page lists no type roles — so
`type-title-md` / `type-body-md` cannot be marked wrong.)
- **List heights and leading sizes**: `min-h-14` / `min-h-18` / `min-h-22` = 56 / 72 / 88dp exactly
(`ListTokens.kt:180,323,347`); avatar `size-10` = 40dp (`ItemLeadingAvatarSize`), image `size-14` = 56dp
(`ItemLeadingImageWidth`) with `rounded-corner-sm` = the expressive `CornerSmall`; icon `size-6` = 24dp
(baseline `ItemLeadingIconSize`); `px-4` = 16dp container padding.
- **List type roles**: title `type-body-lg` (`ItemLabelTextFont` = BodyLarge), description `type-body-md`
(`ItemSupportingTextFont` = BodyMedium), overline and trailing text `type-label-sm`
(`ItemOverlineFont` / `ItemTrailingSupportingTextFont` = LabelSmall), `line-clamp-2` on the description.
- **Selected colours**: `secondary-container` / `on-secondary-container` is right —
`ListTokens.kt:204,281,284` (`ItemSelectedContainerColor` = SecondaryContainer,
`ItemSelectedLabelTextColor` / `ItemSelectedLeadingIconColor` = OnSecondaryContainer). (The reference's
specs-page note about "Primary container" is contradicted by the token file; the library follows the
tokens.) Disabled content at 38% matches `ItemDisabled*Opacity = 0.38f`.
- **Segmented list geometry**: 2px gap = `SegmentedGap`; 4px item corner = `ItemContainerExpressiveShape`
(CornerExtraSmall); 16px at the list's ends and while pressed/selected = `ContainerShape` /
`ItemSelectedContainerExpressiveShape` (CornerLarge); 12px on hover = the specs page's Medium.
- **`data-list-row` state layer**: hover 8%, focus 10%, pressed 10% on `on-surface`, hover gated behind
`@media (hover: hover)`; the focus ring is 3px `secondary` (matching `ListTokens.kt:39`
`FocusIndicatorColor` = Secondary); the opener's own ring is suppressed so the row shows one indicator.
- **Divider**: 1px (`DividerTokens.Thickness`), `outline-variant`, `inset` = 16px start / 0 end,
`middle` = 16px both, a vertical variant, `role="separator"` with `aria-orientation`, and `decorative` to
hide it — a complete match to the specs table.
- **Dialog**: `surface-container-high` + `rounded-corner-xl` (28dp) + `shadow-elevation-3` =
`DialogTokens` exactly; `p-6` = 24dp all round; `max-w-[35rem]` / `min-w-70` = 560 / 280dp;
`backdrop:bg-scrim/32` = `ScrimTokens.ContainerOpacity = 0.32f`; `type-headline-sm` =
`DialogTokens.HeadlineFont`; `type-body-md` on-surface-variant = `SupportingTextFont`; a 24px
`text-secondary` hero icon that centres the headline (M3: "Alignment with icon: Center-aligned");
`gap-2` = the 8dp between buttons; `mt-4` = the 16dp title↔body and icon↔title gaps; `pt-6` = the 24dp
body↔actions gap; `justify-end` = trailing-edge actions, and the showcase orders Cancel before Delete
(`showcase/sections/containment.blade.php:75-76`), which is M3's "dismissive to the left of confirming".
`wire:ignore.self` + native `showModal()` gives top-layer, inert background, focus-in/focus-return and
Escape for free.
- **Bottom sheet**: `surface-container-low` (`DockedContainerColor`), `rounded-t-corner-xl` (28dp top,
`CornerExtraLargeTop`), `shadow-elevation-1` (`DockedModalContainerElevation` = Level1),
`max-w-160` = the 640dp max width, 32×4px handle geometry, 32% scrim, `x-trap.inert.noscroll`,
dismissal by scrim / Escape / downward drag, and a single-pointer alternative to the drag (the handle is
a real `<button>` with an accessible name and `role=button`, which is M3's "label only the drag handle").
- **Side sheet**: `surface-container-low`, a 16px corner on the inner edge only
(M3's "16dp corner radius for modal side sheets"), 400px default width = the specs max-width, `p-6` =
24dp start/end padding, end placement by default (M3: "usually the right"), `role="dialog"` (M3's stated
role), full height, independent vertical scroll and no horizontal scroll, `x-trap.inert.noscroll`, a
container query on the body so contents lay out by the sheet's width.
- **Carousel**: the Compose keyline maths is ported with attribution and a commit hash; small items clamp
to 40-56dp (`MIN_SMALL_ITEM_SIZE` / `MAX_SMALL_ITEM_SIZE`, carousel.js:64-65); 28px item corner
(`CarouselDefaults`); 8px between items (`gap-2` = the specs' "Padding between elements 8dp"); snap for
multi-browse / hero / full-screen and free scroll for uncontained, exactly M3's recommendation;
per-item "n of m" labels; controls placed **below** the row, never over it (M3's explicit Don't); RTL
mirroring; re-measure on resize and after a morph.
- **Collapse**: 48px summary, the shared state layer and focus ring, a chevron on the fast spatial spring,
reduced motion via zeroed duration tokens, native disclosure semantics, `wire:ignore.self` for morphs.
- **Error pages**: `bg-surface` / `text-on-surface`, `type-emphasized-display-lg` in `on-primary-container`
over a `primary-container` shape (a correct contrast pair), `type-headline-md`/`lg` for the headline,
`type-body-lg` in `on-surface-variant` for the message, a filled primary action with a text secondary
(M3's action hierarchy), 24px page gutters, and the decorative shape's rotation gated behind
`prefers-reduced-motion: no-preference`.
- **Mail theme**: the typescale is reproduced correctly in px — h1 24/32/400/0 (headline-small),
h2 22/28 (title-large), h3 16/24/500/0.15 (title-medium), p 16/24/400/0.5 (body-large),
`p.sub` and table cells 14/20/0.25 (body-medium), table head 14/20/500/0.1 (title-small),
footer 12/16/0.4 (body-small), the button 16/24/500/0.15 (title-medium, the Expressive medium button's
label) with a full corner. Roles are used properly throughout (`on-surface` for emphasis,
`on-surface-variant` for body, `outline-variant` for every rule, `surface-container-lowest` for the card
against `surface-container` for the page), and separation is by tone rather than shadow, as M3 does.
## Missing
- **Lists**: leading **video** slot (56×100dp small, 64×114dp large — `ListTokens.kt:129,132,177`); the
**expand/collapse** list-item interaction (M3: "items containing nested items can expand/collapse …
container-transform"); explicit **selection modes** (single-select / multi-select / single-action /
multi-action) with their role and keyboard mappings; **inset and middle dividers** between list items
(only a full-bleed `dividers` boolean exists); a **dragged** state (16% layer, elevation 4) for
reorderable lists.
- **Cards**: per-state **elevation** (C-13) and the **dragged** state; a first-class "directly actionable
card" that takes a `button`/`link` role — M3 says such a card gets one, and the `data-list-row` pattern
deliberately keeps the role on the inner opener instead.
- **Dialogs**: no divider pinned between a scrolling body and the header/actions (the `separator` prop
scrolls with the content); no 56dp edge-margin rule for custom-positioned dialogs on large screens; no
"discard unsaved changes" confirmation helper for the full-screen variant, which M3 requires of that
variant.
- **Bottom sheets**: **preset heights** and the handle's "cycle through heights on activation" behaviour
(M3 requires a non-drag alternative whenever more than one height exists); a peek/collapsed height for
the `standard` variant; the 56dp side margin above a 640dp window; the swap to a side sheet at expanded
widths that M3 recommends.
- **Side sheets**: a genuine **standard** (co-planar, non-modal, 0dp elevation) variant — `pane` is close
but is scoped to list-detail and starts at `xl`; the **back icon button** in the modal anatomy; a divider
above the action row; the 16dp "detached" inset M3 allows.
- **Carousel**: the **uncontained multi-aspect-ratio** layout (added November 2025 — items from 9:16 to
16:9); a **"Show all"** affordance opening a vertical list of every item, which M3 requires on
vertically-scrolling pages; a vertically-scrolling full-screen layout (C-11).
- **Divider**: the divider-with-text / subheader configuration (4dp gap to the supporting text, 8dp right
and bottom margins in the specs table).
## Breakpoint map
| Component | Library breakpoint used | M3 window size class it stands in for | Gap |
| --- | --- | --- | --- |
| `<x-modal fullscreen>` — full-screen below, basic above (`modal.blade.php:61,67,71,80,83,89,93,108`) | `max-sm` → < 640px | Compact (< 600dp): "full-screen dialogs are used only in compact breakpoints" | 40px too wide — a 600-639px window (small tablet portrait, split-screen) gets a full-screen dialog where M3 wants a basic one |
| `<x-drawer pane>` — pane vs modal sheet (`drawer.blade.php:55,64,65,100`; `xl:` = 1280px) | `xl` → ≥ 1280px | Expanded (≥ 840dp): list-detail shows **2 panes** from expanded through extra-large | 440px late — the entire expanded class (840-1199) and the bottom of large get the compact single-pane modal behaviour |
| `<x-drawer>` — fixed width and inner corner switch on (`drawer.blade.php:97-99`) | `sm` → ≥ 640px | Compact (< 600dp) is where a modal side sheet should be full-bleed; medium (600-839) onwards it has its fixed width | 40px too wide — a 600-639px window still gets a full-width sheet |
| `<x-bottom-sheet>` — 640px cap applied at every width (`bottom-sheet.blade.php:55`, `max-w-160`) | none (a `max-width`, not a breakpoint) | Specs: "Width: full width, up to max-width 640dp"; above a 640dp window also "Start/end margin 56dp" and "Top margin 56dp" | The 640 cap is right; the 56dp side/top margins above 640dp are missing entirely |
| `<x-modal>` — dialog gutters (`modal.blade.php:58`, `w-[calc(100vw-3rem)]`, `max-h-[calc(100dvh-3rem)]`) | none | Guidelines: custom-positioned dialogs on larger screens "must respect a 56dp margin from screen edges" | 24px used where 56dp is specified — only binding for custom-positioned dialogs, which the component does not offer, so informational |
| `<x-carousel controls>` (`carousel.blade.php:139`, `pointer-fine:`) | input-media query, not a width | M3 gives no breakpoint for carousel controls; it only says put them above or below the row | No gap — an input-capability query is a reasonable substitute and the placement rule is followed |
| `<x-carousel layout="full-screen">` (`carousel.blade.php:62`) | none — rendered at every width | Compact and medium only, portrait only ("Don't use this layout in landscape orientation") | Unbounded — nothing stops the layout above 840dp or in landscape |
+292
View File
@@ -0,0 +1,292 @@
# Audit: core (tokens, scheme, theme, icons, shapes, guard, showcase foundations, AI docs)
Ground truth: `docs/reference/m3/styles.md`, `docs/reference/m3/foundations.md`, raw pages under `m3/raw/`.
Library paths are relative to `/Users/surtic86/Privat/Development/livewire-material`.
## Summary
The core is in good shape: every 2025-spec colour role (the `*-dim` roles and the fixed roles included) is generated and mapped, the legacy `surface-tint`/`surface-variant` are correctly left out, the corner scale is M3 Expressive's ten steps, the five shadow levels are Google's web geometry, the six springs carry the exact Compose damping/stiffness constants, the state layer uses M3's 8/10/10 % and hover is pointer-only, the typescale sizes/line heights/weights match, and theme switching honours both the OS and the visitor's choice. The 35 shapes and the full Symbols set are ported from Google's sources.
The gaps are around the edges of the system rather than in it. (1) The three custom state colours (`success`/`warning`/`info`) are built with the 2021 `customColor` recipe, so they ignore the `--contrast` level and the 2025 spec's tone rules that `error` follows. (2) Only the standard contrast level exists; M3 (May 2025) makes standard/medium/high part of the colour system. (3) The semantic ink utilities (`text-meta` 60 %, `text-quiet` 38 %, `border-divider` 40 %) are M2-style opacity emphasis, which M3 replaced with `on-surface-variant`/`outline-variant`, and 38 % collides with M3's disabled opacity. (4) Tailwind's own radius, shadow, type-size, weight, easing and duration utilities still compile, so an application (or an AI agent) can silently leave the M3 scale; only colours were cleared. (5) There are no tokens or variants for M3's five breakpoints, no `dragged` state, and no shared touch-target utility. (6) Type tracking follows the older material-web numbers rather than Compose's, and the emphasized styles reuse baseline tracking. (7) The AI docs list the utilities but explain none of M3's rules for choosing between them; that is the "core concept" document the user asked for.
Counts: must-fix 1 · should-fix 12 · nice-to-have 11.
## Findings
### C1 · scheme command · custom colours ignore the contrast level and the 2025 spec
- Severity: must-fix
- M3 says: "Custom components support contrast levels simply by using Material color roles… the role's resolved value changes per contrast level automatically" (reference-styles §Color/Contrast levels; site `styles/color/roles`). Aug 2024: on-container roles became "more colourful while remaining accessible"; the 2025 spec that the library asks for (`--spec=2025`) applies those curves to `error`. `define-new-colors`: custom colours should behave like the built-in roles.
- Library does: `resources/node/scheme.mjs` (tail) builds `success`/`warning`/`info` with material-color-utilities' `customColor()` (`pe(...)`, `blend:false`): fixed tones 40/100/90/10 in light and 80/20/30/90 in dark, no contrast curve, no spec awareness. So `php artisan material:scheme --contrast=1` raises every M3 role but leaves the three state colours at standard contrast, and their `on-*-container` pairs are tone 10/90 while `on-error-container` (2025) is `#6e0523`-style tone ~25. Visible in `resources/css/tokens/scheme.css:66-77` vs `:62-65`.
- Fix: in `bin/scheme.mjs` (source of the bundle), define the three colours as `DynamicColor`s on their own `TonalPalette` exactly as `color_spec_2025.js` defines `error`/`onError`/`errorContainer`/`onErrorContainer` (same `ContrastCurve`s and `ToneDeltaPair`s), evaluated against the same `DynamicScheme` (so `contrastLevel`, `isDark`, `specVersion`, platform all apply). Rebuild `resources/node/scheme.mjs`; regenerate `tokens/scheme.css`, `scheme.json`, the workbench scheme. Add a `SchemeCommandTest` case: contrast 1 changes `success`.
- Effort: M
- Breaks API? no (role names unchanged; values change)
### C2 · scheme · only the standard contrast level is generated
- Severity: should-fix
- M3 says: "Three levels of contrast: standard, medium (3:1 minimum), high (7:1)… contrast settings apply automatically in both light and dark theme" (reference-styles §Color; `styles/color/roles`, "What's new May 2025"). Accessibility principle "Honor individuals — build in customizable features" (reference-foundations §Accessibility).
- Library does: `SchemeCommand` (`src/Console/SchemeCommand.php:24`) takes `--contrast` once and writes a single light and dark block; nothing at runtime can raise contrast; `theme.js` has no contrast state; no `@media (prefers-contrast: more)`.
- Fix: have `material:scheme` also emit `[data-contrast="medium"]` (contrast 0.5) and `[data-contrast="high"]` (1.0) blocks for light and dark (and per profile), plus `@media (prefers-contrast: more) { :root:not([data-contrast]) {…} }` as the OS default; add `contrast` to `$store.theme` (`resources/js/theme.js`) and to the head script's attribute set (`theme-script.blade.php`, kept across `wire:navigate` like `data-theme`); `<x-theme-toggle mode="picker">` or a new `<x-contrast-picker>` exposes it. Document in SKILL.md §Theme.
- Effort: M
- Breaks API? no (opt-in)
### C3 · theme.css · M2-style opacity inks and lines instead of M3 roles
- Severity: should-fix
- M3 says: "Default typography color is on surface (or on surface variant as a strong alternative)"; dividers use **outline variant**, not a lighter mix; "Always apply color roles, never static hex values"; 0.38 is the **disabled** opacity (reference-foundations §States values; reference-styles §Color rules, §Typography accessibility).
- Library does: `resources/css/tokens/theme.css:99-106` defines `--color-meta` = on-surface 60 %, `--color-quiet` = on-surface 38 %, `--color-chrome` = outline-variant 60 %, `--color-divider` = outline-variant 40 % via `color-mix()`. `text-quiet` therefore looks exactly like disabled text (`text-on-surface/38`, used 21× in components), and `border-divider` is lighter than M3's divider. None of these utilities is used by a package component (grep: only the showcase swatches and one `border-divider`/`divide-divider`), so they exist for ReStride's templates.
- Fix: keep the names, repoint them to roles: `--color-body` → on-surface-variant (already), `--color-meta` → on-surface-variant, `--color-quiet` → outline, `--color-structure` → outline-variant (already), `--color-chrome` → outline-variant, `--color-divider` → outline-variant. Say in SKILL.md that emphasis is a role, not an opacity, and that 38 % means disabled. If ReStride needs the lighter divider, it overrides the variable in its own `@theme`.
- Effort: S
- Breaks API? no (names stay; rendered colour changes in ReStride)
### C4 · theme.css · Tailwind's default radius, shadow, type-size, weight, easing and duration utilities still compile
- Severity: should-fix
- M3 says: "Do use design tokens instead of hardcoded values" (reference-foundations §Design tokens); the corner scale, elevation levels, typescale and motion tokens are the only values (reference-styles §Shape, §Elevation, §Typography rule "avoid changing type size", §Motion).
- Library does: `theme.css:19-25` clears `--color-*` only. `rounded-lg` (Tailwind: 8 px, M3 "large" is 16 px), `rounded-xl` (12 px vs 28 px), `shadow-md`, `text-sm`/`text-lg`, `font-bold`, `leading-*`, `tracking-*`, `ease-in-out`, `duration-300` all still compile with Tailwind's values. The package's own views are clean (only `rounded-full` ×4 and `rounded-none` ×1, both fine), but consuming apps and AI agents are not stopped. `DesignGuard` does not look for them.
- Fix: two steps. (a) Now, non-breaking: `DesignGuard` gains a "value outside the M3 scale" check for `rounded-(xs|sm|md|lg|xl|2xl|3xl|4xl)`, `shadow-(2xs|xs|sm|md|lg|xl|2xl)`, `text-(xs|sm|base|lg|xl|\dxl)`, `font-(thin|…|black)`, `leading-*`, `tracking-*`, `ease-(in|out|in-out)`, `duration-\d+`, `blur-*`? (no), each with the M3 utility to use instead. (b) In the next major: `@theme { --radius-*: initial; --shadow-*: initial; --ease-*: initial; --text-*: initial; --font-weight-*: initial; --leading-*: initial; --tracking-*: initial; }` before re-declaring the M3 ones, mirroring what was done for colour (keep `--radius-full`/`rounded-none` behaviour by declaring `--radius-full: 9999px`).
- Effort: S (guard) + S (theme, but a major-version change)
- Breaks API? (a) no · (b) yes for applications using Tailwind defaults
### C5 · type.css · tracking follows material-web, not Compose; emphasized styles reuse baseline tracking
- Severity: should-fix
- M3 says (reference-styles §Typography, `TypeScaleTokens.kt`): Display Large 0.2 sp; Title Medium 0.2 sp; Body Medium 0.2 sp; emphasized: Display Large 0, Title Medium 0.15, Body Large 0.15, Body Medium 0.25 (others unchanged).
- Library does: `resources/css/tokens/type.css:21` display-lg tracking 0.015625 rem (0.25 px, material-web); `:39` title-md 0.009375 rem (0.15 px, M3: 0.2 sp = 0.0125 rem); `:48` body-md 0.015625 rem (0.25 px, M3: 0.2 sp); every `type-emphasized-*` utility (`:101` onward) uses the baseline `-tracking` variable, so emphasized display-lg keeps 0.25 px instead of 0, emphasized body-lg keeps 0.5 px instead of 0.15.
- Fix: add `--md-sys-typescale-emphasized-*-tracking` variables with Compose's values and use them in the emphasized utilities; correct the three baseline values to Compose's (sp/16 rem). Sub-pixel, but it is what the Expressive token file says and the header comment cites material-web, which is pre-Expressive.
- Effort: S
- Breaks API? no
### C6 · type.css · regular styles do not reset `font-variation-settings`
- Severity: nice-to-have
- M3 says: emphasized styles are applied deliberately, per element (reference-styles §Typography rules).
- Library does: `type-emphasized-*` sets `font-variation-settings: "ROND" 100` (`type.css:107`), an inherited property; `type-*` utilities set none, so `type-body-md` text inside an element carrying `type-emphasized-title-md` (a card slot under an emphasized wrapper) renders fully rounded.
- Fix: every regular utility sets `font-variation-settings: normal` (or `"ROND" 0`).
- Effort: S
- Breaks API? no
### C7 · font · variable axes of the subset are unverified here
- Severity: nice-to-have (verification)
- M3 says: brand/plain typefaces are the product's choice; Roboto is the default (reference-styles §Typography fonts). Google Sans Flex is Google's Expressive typeface (the site's shape page: "M3 shapes and Google Sans Flex share roundness attributes"), so the choice is aligned.
- Library does: `font.css` claims weight 400700 and `ROND` 0100 are kept. I could not open the woff2 (no fontTools on this machine).
- Fix: add a test (or a `bin/` check) that reads the `fvar` table and asserts the `wght` and `ROND` axes exist, so a future re-subset cannot silently drop the axis the emphasized styles depend on.
- Effort: S
- Breaks API? no
### C8 · motion.css · spring durations differ from the web equivalents Google publishes
- Severity: should-fix (with a counter-argument)
- M3 says (`styles/motion/overview/specs`, reference-styles §Motion "Web curve equivalents"): expressive spatial fast/default/slow = 350/500/650 ms, effects = 150/200/300 ms, each with a cubic-bezier; "springs aren't natively supported on the web, so Web should use these approximated curves with matching duration".
- Library does: `motion.css:33-52` samples the real springs into `linear()` (more faithful than Google's cubic-bezier) but stops at the settle time: 360/440/600 ms and 150/240/330 ms. Default spatial is 12 % quicker than Google's web number, slow effects 10 % slower.
- Fix: either (a) sample each spring over Google's published web duration (the curve reaches 1 earlier and holds, so the bounce timing is unchanged) so the `*-duration` tokens read 350/500/650/150/200/300, or (b) keep the settle times and record why in the header. Also add the six published cubic-bezier curves as `--md-sys-motion-spring-*-bezier` fallbacks for `@supports not (animation-timing-function: linear(0,1))` (Safari < 17.2 is below the floor, so this is documentation more than need). (a) is "what Google says".
- Effort: S
- Breaks API? no
### C9 · motion.css · only the Expressive motion scheme exists
- Severity: nice-to-have
- M3 says: two preset schemes, Expressive (default) and Standard ("minimal bounce, for utilitarian products"), swappable product-wide (reference-styles §Motion principles; `StandardMotionTokens.kt`: fast spatial 0.9/1400, default 0.9/700, slow 0.9/300; effects identical).
- Library does: six springs, Expressive only.
- Fix: sample the three Standard spatial springs too and let `[data-motion="standard"]` on `<html>` (or a config flag rendered by the theme script) swap the `--md-sys-motion-spatial-*` variables. Effects are the same in both schemes.
- Effort: S
- Breaks API? no
### C10 · state.css · no `dragged` state, no disabled tokens, no shared touch target
- Severity: should-fix
- M3 says: state layer opacities hover 0.08, focus 0.10, pressed 0.10, **dragged 0.16**; disabled content 0.38; "the size of state layers is 40dp while the interactive target size is 48dp"; target spacing 8 dp (reference-foundations §States, §Accessibility values).
- Library does: `state.css:26-40` covers hover/focus/active; nothing for dragged (cards, chips, list items and the slider are the components M3 gives a dragged state); no `--md-sys-state-*` custom properties, so components hard-code `/38`, `/12`, `/10` (21, 10 and 4 occurrences); the 48 px target is built ad hoc in `button.blade.php:167` with an `after:` pseudo-element and nowhere shared.
- Fix: in `state.css` declare `--md-sys-state-hover-opacity: 0.08`, `focus 0.10`, `pressed 0.10`, `dragged 0.16`, `disabled-content 0.38`, `disabled-container 0.12` and use them in `state-layer` (add `&[data-dragged]::before { opacity: var(--md-sys-state-dragged-opacity) }`); add a `touch-target` utility (the `after:` pseudo-element from the button, `min 48×48`) so small icon buttons, chips, checkboxes, radios and switch share one implementation.
- Effort: S
- Breaks API? no
### C11 · state.css · focus ring spec has no source on the M3 site
- Severity: nice-to-have (documentation)
- M3 says: only "a ring-like keyboard focus indicator" (reference-foundations §States; the site defines no thickness/offset/colour — see its "Cross-cutting gaps" 1).
- Library does: `focus-ring` = 3 px `secondary`, offset 2 px (`state.css:47-53`), which is material-web's `md-focus-ring` default (3 px, outward offset 2 px, secondary).
- Fix: none to the code; cite material-web in the header so nobody "corrects" it to the site's silence.
- Effort: S
- Breaks API? no
### C12 · core · no tokens or variants for M3's breakpoints
- Severity: should-fix
- M3 says: five breakpoints — compact < 600, medium 600839, expanded 8401199, large 12001599, extra-large ≥ 1600 dp — with the navigation component, pane count and dialog/menu choice per breakpoint (reference-foundations §Layout breakpoints tables).
- Library does: nothing in the tokens; components switch on Tailwind's `sm` 640 / `md` 768 / `lg` 1024 / `xl` 1280 (the navigation audit maps each use). An application cannot write "at the medium breakpoint" at all.
- Fix: `theme.css` adds `--breakpoint-medium: 600px; --breakpoint-expanded: 840px; --breakpoint-large: 1200px; --breakpoint-extra-large: 1600px` (Tailwind 4 turns these into `medium:`, `expanded:`, `large:`, `extra-large:` variants; `max-medium:` etc. come for free), keeps `sm/md/lg/xl` for now, and the components migrate to the M3 variants in their own findings. Add pane tokens as custom properties: `--md-sys-layout-pane-fixed: 360px` / `412px`, `--md-sys-layout-side-sheet-max: 400px`, margins 16 px compact / 24 px otherwise (older M3 layout page values; the current site gives no numeric margin table — mark as such).
- Effort: S (tokens) — the component migration is counted in the other audits
- Breaks API? no
### C13 · DesignGuard · no check for values outside the M3 scale, hex colours, or `white`/`black`
- Severity: should-fix
- M3 says: "Always apply color roles, never static hex values or raw tonal-palette values" (reference-styles §Color rules).
- Library does: `src/Testing/DesignGuard.php:26-33` flags the Tailwind palette and daisyUI colours; it does not flag arbitrary values (`bg-[#1d7afc]`, `text-[rgb(…)]`), `bg-white`/`text-black` (re-added in `theme.css:21-22` and not M3 roles; the M3 white is `surface-container-lowest` in light), or any of the non-token utilities in C4.
- Fix: add patterns for `(bg|text|border|…)-\[#`, `-\[rgb`, `-\[hsl`, `-\[oklch`, and an opt-in `forbidAbsolutes()` for `white`/`black`; add the C4 list with a hint per match ("`rounded-lg``rounded-corner-lg`").
- Effort: S
- Breaks API? no
### C14 · theme-toggle · the picker is drawn as M3's deprecated segmented button
- Severity: should-fix
- M3 says: segmented buttons are "M3 only", replaced in M3 Expressive by the connected button group (reference-components-a §Segmented buttons / §Button groups; the plan doc lists segmented button among the six deprecated components).
- Library does: `resources/views/components/theme-toggle.blade.php:27-49` (`mode="picker"`) draws an outlined 40 px group with `border-outline` and `aria-checked` fills — the segmented-button anatomy — while `<x-group>` (the connected button group) already exists.
- Fix: render the picker with `<x-livewire-material::group>` (three `wire:model`-less radio options bound to `$store.theme`), or reuse its classes; keep `role="radiogroup"` and the arrow-key behaviour.
- Effort: S
- Breaks API? no (`data-theme-option` hooks can stay)
### C15 · scheme · harmonisation of the custom colours is not offered
- Severity: nice-to-have
- M3 says: "you can choose to harmonize your static colors to the scheme's primary color… while retaining the semantic meaning"; "colors can stay completely static and forgo harmonization if their values are tied to literal sources such as brand colors" (`styles/color/advanced/define-new-colors`).
- Library does: `customColor(..., blend:false)` always (scheme.mjs tail); no option.
- Fix: `--harmonize` flag on `material:scheme` (and `harmonize` per profile) passed as `blend:true`; default off, as today. Do together with C1.
- Effort: S
- Breaks API? no
### C16 · icons · one weight/grade/optical size for every context
- Severity: nice-to-have
- M3 says: grade 25 "for a light icon on dark background"; optical size 20 for 20 dp icons in dense/desktop layouts; 40/48 for display pairing (reference-styles §Icons axes table).
- Library does: Material Symbols Rounded 400/0/24 only (`bin/fetch-symbols`, `icon.blade.php` header); buttons `xs`/`sm` draw 20 px icons from the 24-opsz outlines (`button.blade.php:147-149`), so strokes are ~17 % thinner than a true opsz-20 glyph; dark theme keeps grade 0.
- Fix: none that is cheap — each extra cut is another 4,135 SVGs (~2.6 MB). Record the trade-off in the icon header and SKILL.md; if ever needed, ship `opsz20` for the outlined set only and let `<x-icon size="20">` pick it.
- Effort: L
- Breaks API? no
### C17 · scheme · `background`/`on-background` are emitted, `surface-tint`/`surface-variant` are not
- Severity: nice-to-have (informational, aligned)
- M3 says: background/on-background are legacy roles with the same values as surface/on-surface; surface-variant was superseded by the surface-container family (Feb 2023); surface tint is deprecated (reference-styles §Color roles table).
- Library does: emits and maps `background`/`on-background` (`scheme.css:16-17`, `theme.css:29-30`), omits the two deprecated ones. Correct. Consider marking `bg-background` as "same as `bg-surface`; prefer surface" in SKILL.md so agents do not treat them as two surfaces.
- Effort: S
- Breaks API? no
### C18 · scheme · the package default is the 2025 rendering of the baseline seed, not the site's baseline table
- Severity: nice-to-have (informational)
- M3 says: the baseline palette table (`PaletteTokens.kt`, site `styles/color/static/baseline`) is the 2021 spec: primary 40 = `#6750A4`, on-primary = white.
- Library does: `tokens/scheme.css` is `#6750a4` tonal-spot through the 2025 spec (`primary #655789`, `on-primary #fdf7ff`). Both are Google's; the header says so. Nothing to fix; the showcase colour page could say "spec 2025" so a reader comparing with the site's swatches is not puzzled.
- Effort: S
- Breaks API? no
### C19 · elevation · no expression of "which component rests at which level"
- Severity: nice-to-have (documentation)
- M3 says: level 3 = dialogs, FAB, pickers, search; level 2 = menus, scrolled app bar, navigation bar, rich tooltip, toolbar; level 1 = elevated button/card/chip, modal sheets; level 0 = everything else; +1 level on hover (reference-styles §Elevation tokens table); scrim 32 %.
- Library does: shadow tokens only (`elevation.css`); the showcase shows five boxes with no mapping; components pick levels individually (correctly where I looked: menu 2, FAB 3, elevated button 1→2 on hover, `bg-scrim/32` ×4).
- Fix: put the table in the new guideline/skill and in the showcase elevation section, with M3's rule "tonal separation first, shadows for floating things and interaction".
- Effort: S
- Breaks API? no
### C20 · showcase · the foundation pages show tokens but not M3's rules for them
- Severity: nice-to-have
- M3 says: (as in C19, plus) surface container hierarchy by emphasis; outline vs outline-variant; type roles and when emphasized is used; spatial vs effects; corner scale per component class; optical roundness (outer padding = inner).
- Library does: `showcase/sections/{colour,type,shape,elevation,motion,icons}.blade.php` list every utility with one sentence each; the colour page groups "Ink and lines" as if they were roles (see C3).
- Fix: one short "when to use" paragraph per section, drawn from the same guideline text (C24), so the showcase and the AI docs cannot drift.
- Effort: S
- Breaks API? no
### C21 · theme script · `data-theme` only, no `prefers-color-scheme` in CSS — aligned
- Severity: nice-to-have (no change)
- M3 says: light/dark is a user preference; honour the OS and the person's choice (reference-foundations §Accessibility principles; §Color "built-in dark theme").
- Library does: resolves `system` before first paint, follows OS changes while `system`, keeps the attributes across `wire:navigate`, declares `color-scheme` per block. Correct.
- Fix: none. (C2 adds contrast to the same mechanism.)
### C22 · reduced motion · stricter than the site, aligned with its intent
- Severity: nice-to-have (no change)
- M3 says: no global reduced-motion rule on `styles/motion`; component pages ask for parallax/expansion to be removed under reduced motion (reference-styles §Motion accessibility).
- Library does: zeroes every duration token (`motion.css:66-77`); carousel, progress, tabs and figure check the media query themselves. Correct; the header's "anything that animates without them is a bug" is a good rule to repeat in the guideline.
### C23 · icons · accessibility of `<x-icon>` — aligned
- Severity: nice-to-have (no change)
- M3 says: decorative icons hidden; meaningful icons labelled; don't put the role in the label (reference-foundations §Accessibility labelling).
- Library does: `aria-hidden` by default, `role="img"` + `aria-label` when `label` is given, `focusable="false"` (`icon.blade.php:27-33`). Correct.
### C24 · AI docs · the M3 core concepts are not written down anywhere an agent reads
- Severity: should-fix (this is the user's explicit request)
- M3 says: see the list under "Concepts missing from the AI docs" below.
- Library does: `resources/boost/guidelines/core.blade.php` (6 bullets: what the package is, no maryUI, theme script, showcase, error pages/mail) and SKILL.md §Tokens/§Theme/§Conventions list the utilities and three rules (elevation is for floating things; pair easing with duration; `dark:` follows `data-theme`). Nothing says what a role, a container level, a corner size, a type role or a spring is *for*, nor any layout/accessibility rule.
- Fix: a second always-on guideline `resources/boost/guidelines/material-3.blade.php` (short: the rules that change what an agent writes) and a fuller `resources/boost/skills/material-3-design/SKILL.md` (the concept reference with the tables), both generated from one source or tested against each other; Boost 2.8 loads every file under `resources/boost/guidelines` and every skill directory (verified in `laravel/boost/src/Install/GuidelineComposer.php:320-351`, `SkillComposer.php:127-141`). Add the drift test.
- Effort: M
- Breaks API? no
## Deliberate deviations
| Where | Deviation | Reason given | Holds up? |
| --- | --- | --- | --- |
| `shape.css` | `corner-full` = 9999px instead of material-web's `50cqmin` | container units need a declared container | Yes. Compose's `CircleShape` is 50 % of the smaller dimension = a stadium; 9999px gives the same on rectangles, `50%` would not. |
| `elevation.css` | `color-mix()` instead of `hsl(from …)` | browser floor | Yes; same result. |
| `motion.css` | springs sampled to `linear()` rather than Google's cubic-bezier approximations | closer to the physics | Yes for the curve; the durations differ from Google's published web numbers (C8). |
| `motion.css` | reduced motion zeroes durations | one place instead of per component | Stricter than the site; consistent with "Honor individuals". |
| `theme.css` | colour blocks are `@theme inline` | nested `data-theme` sections | Sound; verified by a browser test per the plan doc. |
| `theme.css` | white/black re-added | absolutes | M3 has no such roles; white is `surface-container-lowest` (light). Harmless, but the guard should at least be able to flag them (C13). |
| `theme.css` | `--color-meta/quiet/divider/chrome` opacity mixes | ReStride's templates | Does **not** hold against M3's text (C3): M3 dropped opacity-based emphasis with M3; 38 % is disabled. |
| `type.css` | Google Sans Flex for brand and plain, `ROND` 100 on emphasized | Expressive's typeface | Holds: the site names Google Sans Flex on the shape page and leaves the typeface to the product; `ROND` is that font's own axis, not an M3 token. |
| scheme | `surface-tint`/`surface-variant` omitted | superseded/deprecated | Holds (reference-styles §Color roles). |
| scheme | success/warning/info via `customColor`, unharmonised | "harmonisation off" | Off is allowed; the 2021 recipe and contrast-blindness are not (C1, C15). |
| `icon` | one Symbols cut, 400/0/24, SVG not font | any name works, no runtime download | Holds for weight; grade/optical size are lost (C16). |
| `theme-script` | OS never read by CSS | the script resolves `system` | Holds. |
## Aligned (keep as is)
- All 2025-spec colour roles generated and mapped, `*-dim` and fixed roles included; `scheme.json` and `Scheme.php` fill missing roles from the default; every pair (`x` / `on-x`) comes from Google's algorithm.
- Corner scale: none 0, xs 4, sm 8, md 12, lg 16, lg-increased 20, xl 28, xl-increased 32, xxl 48, full — exactly M3 Expressive's ten steps, same names.
- Elevation: five levels with material-web's shadow geometry; tonal separation preferred; scrim at 32 % where used.
- Motion: spatial 0.6/800, 0.8/380, 0.8/200; effects 1.0/3800, 1.0/1600, 1.0/800 — the Compose Expressive constants; legacy easing tokens present; reduced motion handled centrally and in the four scripted components.
- Typescale: all 15 sizes, line heights and weights match `TypeScaleTokens.kt`, emphasized set present (weights right).
- State layer: 8/10/10 %, content colour, hover only on `(hover: hover)`, disabled removes it; focus ring only on `:focus-visible`.
- Theme: `data-theme` resolved before paint, OS followed while `system`, choice persisted, legacy keys adopted, attributes survive `wire:navigate`, `color-scheme` declared, `dark:` variant on `data-theme`.
- Shapes: the 35 Expressive shapes ported from androidx, all fill the same box, decorative and hidden from AT.
- Symbols: Google's own 24 px Rounded files (not the 48-opsz npm cut), outlined and filled, `currentColor`, hidden unless labelled.
- Safe areas: `--material-safe-*` = M3's safety region.
- DesignGuard already enforces "roles, not palette" and "no maryUI/daisyUI".
## Missing (M3 things the core does not offer at all)
- Contrast levels medium/high (C2).
- Breakpoint tokens/variants and pane/margin tokens (C12).
- Dragged state, disabled tokens, a shared touch target (C10).
- Standard motion scheme (C9).
- Harmonisation option for custom colours (C15).
- An M3 concept guideline for agents (C24).
## Breakpoint map (core only)
| Item | Library | M3 breakpoint | Gap |
| --- | --- | --- | --- |
| tokens | none; Tailwind `sm` 640 / `md` 768 / `lg` 1024 / `xl` 1280 / `2xl` 1536 | compact <600 / medium 600 / expanded 840 / large 1200 / extra-large 1600 | no M3 breakpoint is expressible; nearest Tailwind values are 4088 px off (see navigation audit for each component) |
## Concepts missing from the AI docs (input for the "Material 3 core concept" guideline)
Each line: what M3 says → whether the package docs say it today.
Colour
- Roles are "paint-by-number" slots; a colour is always a role, never a hex or palette tone → docs say "every colour class names an M3 role" (yes) but not why or the hex rule.
- Pair `x` with `on-x` only; a container is a fill, "on" is for text/icons on it; don't mix pairs (primary + secondary-container + on-surface) → no.
- Primary = high-emphasis actions; secondary = less prominent, tonal fills; tertiary = complementary accent; error = static semantic → no.
- Surface = page; surface-container-lowest…highest = emphasis hierarchy, not elevation; navigation on surface-container; same mapping for a region across breakpoints → no.
- `outline` for boundaries that must read (text fields), `outline-variant` for dividers and cards; never `outline` on dividers → no.
- Fixed/dim roles: "if you aren't sure, you probably shouldn't"; never where contrast matters → no.
- Inverse roles are for the snackbar/inverse surfaces only → no.
- success/warning/info are the package's custom roles, built like error; semantic colours are never dynamic-coloured away → no.
- Low emphasis is `on-surface-variant`, 38 % means disabled; links are primary **and** underlined → no (the opposite: `text-quiet` is documented as decoration).
- Contrast targets 4.5:1 small text, 3:1 large text/graphics, 3:1 clustered elements; disabled exempt → no.
- Three contrast levels exist → no.
Elevation
- Tonal separation first; shadows for floating elements and interaction only; the level table per component; +1 on hover; scrim 32 % → partly ("for what floats over content").
Shape
- Which corner for what: full for buttons/chips/FAB-menu, xs 4 for text fields/snackbar, sm 8 for chips, md 12 for cards, lg 16 for FAB, xl 28 for dialogs/sheets/menus in Expressive, xxl 48 for large containers → no.
- Optical roundness: inner radius = outer radius padding → no.
- Shapes are decoration, never semantic; use sparingly; press morph is the interaction cue → no.
Typography
- Role purposes: display (big, short), headline (short, high emphasis), title (medium, short), body (paragraphs), label (inside components; buttons use label-lg) → no.
- Emphasized styles are opt-in, for selection, primary actions, headlines, badges — not decoration → no.
- 4060 characters per line; tabular numbers where values change; don't change sizes when customising → no (only "never assemble text-* by hand").
Motion
- Spatial springs for position/size/shape (they overshoot), effects for colour/opacity (never overshoot); fast for small elements, default for most, slow for large → partly (the pairing rule, one sentence).
- Enter = decelerate, permanent exit = accelerate, temporary exit = emphasized; exits shorter than enters → no.
- Reduced motion: everything through the tokens goes instant; parallax/expansion removed → no.
States
- Six states; two visual indicators per state; hover 8 % / focus 10 % / press 10 % / drag 16 %; disabled 38 % content, 12 % container, no hover; state layer takes the content colour → no (utilities only).
- 48 × 48 target, 8 dp between targets, never below 48 by default, density is opt-in → no.
Layout
- Five breakpoints and what changes at each (navigation bar → collapsed rail → expanded rail; 1 → 2 → 3 panes; bottom sheet → menu; full-screen dialog → basic dialog) → no.
- Scaffold: bars, rails, panes; safety regions; pane widths 360/412, side sheet ≤ 400 → partly (safe areas documented).
- RTL: leading/trailing, mirror directional icons, use logical utilities (`ps-`, `ms-`, `start-`) → no.
Accessibility
- Landmarks (one main/banner/contentinfo; label repeated navs; no role in the label); headings in order, one H1; dialog focus in/out; labels for icon-only controls; decorative images hidden; keyboard shortcuts need a modifier → no.
Icons
- Filled = active/selected; keep one weight per group; 24 default, 20 in dense UI; label complex icons below 20; 48 target → partly (`filled` documented).
+692
View File
@@ -0,0 +1,692 @@
# Audit: inputs
Scope: text fields and their chrome (`form`, `field`, `input`, `password`, `textarea`, `select`, `file`),
selection controls (`checkbox`, `radio`, `toggle`), `slider`, `chip` / `chip-set` / `choices`,
`datepicker`, `timepicker`, `search`, and the data pieces (`table`, `sort-header`, pagination views).
Sources cited below: `docs/reference/m3/reference-components-b.md` (RB) and `docs/reference/m3/reference-foundations.md`
(RF) — both built from m3.material.io; raw page dumps `docs/reference/m3/raw/components_*_specs.md`; androidx
token files in `docs/reference/m3/tokens/*.kt`. `reference-styles.md` did not exist while this audit ran, so
shape/type/elevation numbers come from the raw `styles_*` dumps and the token files.
## Summary
This is an unusually faithful M3 implementation. The text-field chrome, the three selection controls, the
chip family and both pickers reproduce the androidx token numbers almost exactly — the switch matches
`SwitchTokens` in every dimension, the date picker matches `DatePickerModalTokens` (360 × 120 header,
40 px day in a 48 px cell, today's 1 px primary outline), and the time picker matches the site's own
spec table for the dial, the selector handle and the time-selector boxes. The biggest problems are
accessibility rather than geometry: `<x-search>` announces nothing when results appear and puts
`aria-expanded` on a plain `<input type="search">` with orphan `role="listitem"` results; a disabled text
field still lights its outline on hover because the hover declaration sits on a descendant of the element
carrying the disabled value; and a label-less checkbox, radio or switch has an 1852 px hit target instead
of 48 px. Measurable deviations cluster in the slider (the M-size handle is 44 px where M3 says 52 px, the
value indicator is a 32 × 36 pill where M3 says 44 × 48, and the stop/tick/icon colours are inverted from
the site's roles) and in the search view (it draws the deprecated *divided* style's divider and uses
surface-container-high where the full-screen layout should be surface-container-low). Missing entirely:
the slider's Expressive vertical orientation, the full-screen range date picker, and any width bound on
the search bar or a text field at medium/expanded widths. Several of the library's own androidx-token
citations in header comments name numbers that the M3 site's spec tables contradict — all listed in
**Token-citation disagreements** below.
## Findings
### IN-01 · search · results appear with no announcement, and the input's ARIA is invalid
- Severity: must-fix
- M3 says: "Screen reader must announce when suggestions/results appear (autosuggest change)" and
"Suggestions/results use the list component and are announced as a list by screen readers (follow list
accessibility guidelines for item labels)" — RB §Search/Accessibility (m3.material.io/components/search/accessibility).
- Library does: `resources/views/components/search.blade.php:47-64` puts `aria-controls` and
`aria-expanded` on a bare `<input type="search">` with no `role="combobox"`. ARIA 1.2 does not list
`aria-expanded` as supported on `textbox`, so the state is dropped by conforming AT. The view
(`search.blade.php:75-90`) has no role and no live region, and `[data-search-results]`
(`search.blade.php:86`) is a plain `<div>` whose children are `<x-list-item>`s rendered as
`role="listitem"` (`resources/views/components/list-item.blade.php:41`) — an orphan role with no
`role="list"` owner. `grep -n 'aria-live' resources/views/components/search.blade.php` returns nothing.
`<x-choices>` already does this correctly (`choices.blade.php:124-130,154-157`: `role="combobox"`,
`aria-autocomplete="list"`, `role="listbox"`/`role="option"`, `aria-activedescendant`).
- Fix: in `search.blade.php`, add `role="list"` to the `[data-search-results]` div (or wrap the slot in
`<x-list>`), and either (a) mirror `<x-choices>`: `role="combobox" aria-autocomplete="list"` on the
input and roving `aria-activedescendant` over the results, or (b) keep the plain search field, drop
`aria-expanded`, and add a visually hidden `<span aria-live="polite">` that `search.js` fills with the
result count whenever `[data-search-results]` mutates (the `MutationObserver` plumbing already exists in
`resources/js/chips.js:99-117` as a pattern).
- Effort: M
- Breaks API? no
### IN-02 · field · a disabled text field still lights its outline on hover
- Severity: must-fix
- M3 says: "**Disabled**: communicated via color change + reduced elevation; not focusable, draggable, or
pressable; **no hover/other state layer**" — RF §States (foundations/interaction/states/applying-states).
- Library does: `resources/css/components/field.css:390-392` declares
`.field-box:hover { --field-edge: var(--md-sys-color-on-surface); }` **on `.field-box`**, while the
disabled value at `field.css:423-425` is declared on `.field` and only *inherits* down. A direct
declaration beats an inherited one regardless of selector specificity, so hovering a disabled outlined
field paints a full-opacity `on-surface` outline instead of `on-surface/12`. The rule is also outside
`@media (hover: hover)`, unlike the filled variant's background at `field.css:458-462`, so the state
sticks after a tap on touch. (The filled background *is* correctly suppressed — `field.css:514-519`
wins on specificity there; only `--field-edge` breaks.)
- Fix: `resources/css/components/field.css:390` → wrap in `@media (hover: hover)` and scope to
`.field:not(:has(.field-control:disabled)) .field-box:hover`.
- Effort: S
- Breaks API? no
### IN-03 · checkbox / radio / toggle · a control without a label has an 1852 px hit target
- Severity: must-fix
- M3 says: checkbox "Target size 48dp", radio "Target size 48dp", switch "Target | Size | 48dp"
(RB §Checkbox/Specs, §Radio Button/Specs, §Switch/Specs); "The size of state layers is 40dp while the
interactive target size is 48dp" (RF §States); and for all three, "Don't apply density by default —
this drops targets below the 48x48 CSS px minimum" (RB §Checkbox/Accessibility, §Radio/Accessibility,
§Switch/Accessibility).
- Library does: in `resources/views/components/checkbox.blade.php:27-55` the `<label>` wraps only
`<span data-checkbox>` when `label` and `hint` are both blank, and `[data-checkbox]` is `1.125rem`
square (`resources/css/components/selection.css:105-108`). The 40 px state layer is a `::before` with
`pointer-events: none` (`selection.css:51-59`), so it adds nothing to the target. Result: an 18 × 18
target. `[data-radio]` is 20 × 20 (`selection.css:169-172`) and `[data-switch]` is 52 × 32
(`selection.css:225-231`) — 32 px tall, also under 48. A label-less checkbox is a real use (the
select-all in a table header, a row checkbox), and `<x-toggle>` documents `aria-label` as the label-less
path (`toggle.blade.php:5`), so the case is expected.
- Fix: in `resources/css/components/selection.css`, add a pointer-catching pseudo-element rather than
growing the visual box — e.g.
`[data-checkbox]::after { content:""; position:absolute; inset:-0.9375rem; }` (18 → 48),
`[data-radio]::after { inset:-0.875rem; }` (20 → 48), and
`[data-switch]::after { content:""; position:absolute; inset:-0.5rem 0; }` (32 → 48). Keep them behind
the input so clicks still reach it, or put `min-block-size:3rem` on the `[data-selection]` row.
- Effort: S
- Breaks API? no
### IN-04 · slider · the M-size handle is 44 px where M3 Expressive specifies 52 dp
- Severity: should-fix
- M3 says: sliders/specs, "Handle height | 44dp | 44dp | **52dp** | 68dp | 108dp" for XS/S/M/L/XL
(RB §Sliders/Specs, verbatim site table).
- Library does: `resources/views/components/slider.blade.php:247-250` maps
`'md' => 'h-11 group-data-focused/thumb:h-9.5'` — 44 px, the same as XS and S. The header comment at
`slider.blade.php:44` states the intent as "handles of 44, 44, 44, 68 and 108px", citing MDC-Android's
`md.comp.slider.*` tokens, which disagrees with the site table for M.
- Fix: `slider.blade.php:248``'md' => 'h-13 group-data-focused/thumb:h-11.5'` (52 px, 46 px focused,
keeping the 6 px focus-ring decrease the comment describes), and correct the header comment.
- Effort: S
- Breaks API? no
### IN-05 · slider · the value indicator is a 32 × 36 pill where M3 Expressive specifies 44 × 48
- Severity: should-fix
- M3 says: sliders/specs, "Label container height | 44dp (all sizes)" and "Label container width | 48dp
(all sizes)" (RB §Sliders/Specs). Container `Inverse surface`, label `Inverse on surface`, font
`Label large` (`SliderTokens.ValueIndicatorContainerColor / ValueIndicatorLabelTextColor /
ValueIndicatorLabelTextFont`).
- Library does: `resources/views/components/slider.blade.php:346``h-8 min-w-9 ... px-2.5` = 32 px tall,
36 px minimum width. The colours and font are right (`bg-inverse-surface text-inverse-on-surface
type-label-lg`). The header at `slider.blade.php:39-41` explains the 32 px as Flutter's
`RoundedRectSliderValueIndicatorShape`, i.e. a non-Expressive source.
- Fix: `slider.blade.php:346``h-11 min-w-12`; then re-derive the `$labelBottom` offsets at
`slider.blade.php:252` (they are hard-coded against a 32 px pill: `calc(50%+1.625rem)` = handle half +
4 px gap + 32 px). Note M3 also gives `ValueIndicatorActiveBottomSpace = 12dp` in `SliderTokens.kt`
where the library uses 4 px — worth reconciling in the same change.
- Effort: M
- Breaks API? no
### IN-06 · slider · stop indicators and the inset icon use inverted colour roles
- Severity: should-fix
- M3 says: the 9 slider colour roles, in anatomy order, are "1. Inverse surface 2. Inverse on surface
3. Primary 4. **On primary** 5. Primary 6. Secondary container 7. **On secondary container**
8. On secondary container 9. On primary" — `docs/reference/m3/raw/components_sliders_specs.md:150-160`
(verbatim). That is: a stop/icon **on the active track** is `On primary`; a stop/icon **on the inactive
track** is `On secondary container`.
- Library does: `resources/views/components/slider.blade.php:227-238` inverts both —
`$tickInk['primary'] = 'bg-primary data-active:bg-secondary-container'` (so an inactive-track tick is
`primary` and an active-track tick is `secondary-container`), and `$iconInk['primary'] =
'text-primary data-active:text-secondary-container'`. The end stop indicators at
`slider.blade.php:311` use `$activeInk` (`bg-primary`) even though they sit on the inactive track.
Contested reading: `SliderTokens.kt` has `StopIndicatorColor = SecondaryContainer` and
`StopIndicatorColorSelected = SecondaryContainer`, which would make the active-track stop
secondary-container (the library's choice) and the inactive-track stop invisible. The site's own colour
list is the more coherent of the two and is the newer Expressive text.
- Fix: in `slider.blade.php:227-238`, swap to
`'primary' => 'bg-on-secondary-container data-active:bg-on-primary'` (and the parallel
`text-on-…` for `$iconInk`), and give `$stops` at `slider.blade.php:311` `bg-on-secondary-container`.
Repeat for the `secondary`/`tertiary`/`error`/`success`/`warning`/`info` rows.
- Effort: S
- Breaks API? no
### IN-07 · search · the view draws the *divided* style's divider, which M3 Expressive deprecates
- Severity: should-fix
- M3 says: "| Style | Contained | -- | Available | | | Divided | Available | **Not recommended. Use
contained.** |" and "In the divided (baseline) style, **a divider separates the search bar and
results**" — `docs/reference/m3/raw/components_search_specs.md:106-107,134`. The divided style "Doesn't have
the latest visual style, motion, or flexibility" (ibid. :46).
- Library does: `resources/css/components/search.css:160`
`[data-search-results] { border-top: 1px solid var(--md-sys-color-outline); }`. The file's own header
(`search.css:6-9`) describes the contained style. Second reading: Compose's `SearchBar` does draw a
`HorizontalDivider` between the input and the content, so this may be tracking the Compose
implementation rather than the site; the site's contained-style images show no divider.
- Fix: `resources/css/components/search.css:160` → drop the `border-top` (keep `padding-block: 0.5rem`).
If a separation cue is wanted, use `outline-variant`, not the darker `outline` role.
- Effort: S
- Breaks API? no
### IN-08 · search · the full-screen layout uses the docked layout's container colour
- Severity: should-fix
- M3 says: "Full-screen search color roles used in light and dark themes: 1. **Surface container low**
2. On surface variant 3. On surface variant 4. Surface container high 5. On surface variant 6. On
surface" vs "Docked search color roles: 1. **Surface container high** …" —
`docs/reference/m3/raw/components_search_specs.md:196-232`. RB §Search/Behaviour also warns: "Never use
Surface container high on a Surface container background (contrast too low, blends in) — keep container
roles at least one step apart."
- Library does: `resources/css/components/search.css:140` sets
`[data-search-view] { background-color: var(--md-sys-color-surface-container-high); }` for both
presentations; `search.css:174-181` changes only geometry for `[data-full-screen]`.
- Fix: add
`[data-search][data-full-screen] [data-search-view] { background-color: var(--md-sys-color-surface-container-low); }`
to `resources/css/components/search.css` around line 174.
- Effort: S
- Breaks API? no
### IN-09 · search · the docked view has no scrim
- Severity: should-fix
- M3 says: "**Docked** (opens a results list below the bar **with a scrim over the rest of the content**
— best for medium/expanded windows)" — RB §Search/Variants and configurations.
- Library does: `resources/views/components/search.blade.php:27-37` raises the root to `z-index: 50`
when open (`search.css:24-26`) and closes on `pointerdown.outside`, but paints nothing over the page.
The full-screen presentation covers the page instead (`search.css:174-181`), which is correct for
compact.
- Fix: in `search.blade.php`, add a `<div data-search-scrim x-show="open && ! fullScreen">` before
`[data-search-bar]` and style it in `search.css` as
`position: fixed; inset: 0; z-index: -1; background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);`
— the same 32 % the datepicker backdrop uses (`datepicker.css:111-113`).
- Effort: M
- Breaks API? no
### IN-10 · timepicker · the 24-hour vertical time selector stays 96 px where M3 specifies 114 dp
- Severity: should-fix
- M3 says: time-pickers/specs, "Time selector container | Width | 96dp" and "| Width (24h vertical) |
**114dp**" (RB §Time Pickers/Specs); confirmed by
`docs/reference/m3/tokens/TimePickerTokens.kt``TimeSelector24HVerticalContainerWidth = 114.0.dp`.
- Library does: `resources/css/components/timepicker.css:106-107` sets
`[data-timepicker-box] { width: 6rem; height: 5rem; }` unconditionally. The dial already carries the
cycle as `data-cycle="24"` on `[data-timepicker-dial]` (`timepicker.blade.php:265`), but the display
boxes never read it. The header comment (`timepicker.blade.php:33`) names only "96×80 time selector
boxes", so the 114 dp token was not carried over.
- Fix: put the cycle on the display too (`timepicker.blade.php:214``x-bind:data-cycle="is24 ? '24' : '12'"`)
and add `[data-timepicker-display][data-cycle="24"] [data-timepicker-box] { width: 7.125rem; }` in
`timepicker.css`, scoped out of the landscape block at `timepicker.css:403-447`.
- Effort: S
- Breaks API? no
### IN-11 · timepicker · the selected AM/PM button is primary-container, not tertiary-container
- Severity: should-fix
- M3 says: the dial colour list includes "Tertiary container, On tertiary container" (RB §Time
Pickers/Specs, 17 site roles), and `docs/reference/m3/tokens/TimePickerTokens.kt` gives
`PeriodSelectorSelectedContainerColor = TertiaryContainer`,
`PeriodSelectorSelectedLabelTextColor = OnTertiaryContainer`,
`PeriodSelectorOutlineColor = Outline`, `PeriodSelectorOutlineWidth = 1.0.dp`,
`PeriodSelectorContainerShape = CornerSmall`. The time **selector** (hour/minute), separately, is
`PrimaryContainer` / `OnPrimaryContainer`.
- Library does: `resources/css/components/timepicker.css:160-169` gives the selected period button
`background-color: var(--md-sys-color-primary-container); color: var(--md-sys-color-on-primary-container)`
— identical to the selected hour/minute box at `timepicker.css:120-124`, so the two different M3
emphases collapse into one. It also drops the 1 dp `Outline` stroke and replaces the shared
corner-small container with two separate pills (full → 12 px on selection). The header
(`timepicker.blade.php:37-39`) justifies this as Compose's current `isUpdatedTimepickerToggleEnabled`
default and mentions "not the outlined pair its tokens still describe" — but not the tertiary→primary
colour swap, which is the part a reader would not expect.
- Fix: either restore `tertiary-container` / `on-tertiary-container` at `timepicker.css:165-167` (keeping
the shape morph, which is a defensible Expressive reading), or document the colour swap explicitly in
the header comment. Recommend the former: it restores the hour/minute vs. AM/PM distinction M3 draws.
- Effort: S
- Breaks API? no
### IN-12 · checkbox · the tick and dash icons are 16 px where M3 specifies 18 dp
- Severity: should-fix
- M3 says: checkbox/specs, "Icon size | 18dp"; `docs/reference/m3/tokens/CheckboxTokens.kt`
`IconSize = 18.0.dp` (= `ContainerSize`, i.e. the icon fills the box).
- Library does: `resources/views/components/checkbox.blade.php:41-42` renders both
`<x-icon name="check" class="size-4" />` and `name="remove" class="size-4"` — 16 px inside an 18 px box
(`selection.css:105-108`). `resources/css/components/selection.css:1-3` cites `CheckboxTokens` as the
source, so the 18 dp figure was rounded to Tailwind's nearest step.
- Fix: `checkbox.blade.php:41-42``class="size-4.5"` (Tailwind 4's 4.5 = 18 px; the chip already uses
`size-4.5` for its 18 dp icons, `chip.blade.php:233,254,282`).
- Effort: S
- Breaks API? no
### IN-13 · chip-set · chips are Tab stops only; M3's arrow-key navigation is missing
- Severity: should-fix
- M3 says: chips/accessibility keyboard table — "Tab | Moves focus to enabled chip or chip group;
Space or Enter | Activates, selects, or deselects the focused chip; Backspace or Delete | Removes
currently focused input chip; **Arrows | Moves focus between chips**" (RB §Chips/Accessibility), and
"only one chip can be in focus even though many can be selected".
- Library does: `resources/views/components/chip-set.blade.php:31-54` renders `role="group"` with every
chip its own natural tab stop; `resources/js/chips.js` implements Backspace/Delete
(`chips.js:38-52`) and focus hand-off on removal (`chips.js:60-79`) but binds no arrow keys. A ten-chip
filter set therefore costs ten Tab presses to pass.
- Fix: add an `x-on:keydown.arrow-right/left/home/end` handler to the `[data-chip-set]` div in
`chip-set.blade.php` (both the `scroll` and wrap branches) that moves focus across
`[data-chip]` using the existing `CONTROLS` selector from `chips.js:13`, with a roving `tabindex`
so the set is one tab stop. Note the trade-off: a filter chip is a real `<input type="checkbox">` in a
`<label>` (`chip.blade.php:181-189`), so roving tabindex must be applied to the inputs, not the labels.
- Effort: M
- Breaks API? no
### IN-14 · chip (input) · the remove button's target is 34 px wide against M3's 48 dp minimum
- Severity: should-fix
- M3 says: input chip specs, "Target size for close icon | **Min 48dp**"; and "minimum target size 48dp
regardless of placement/density (target may extend beyond the visible chip container)" —
RB §Chips/Specs and §Chips/Behaviour.
- Library does: `resources/views/components/chip.blade.php:250` gives the remove button
`after:-inset-x-2 after:-inset-y-3.75` on an 18 px icon (`size-4.5`, line 247) → 18 + 2×15 = 48 px tall
but 18 + 2×8 = **34 px wide**.
- Fix: `chip.blade.php:250``after:-inset-x-3.75 after:-inset-y-3.75` (48 × 48). The chip's own 48 px
target strip (`chip.blade.php:93`) already overlaps the label, so the extra 7 px each side only
overlaps the label's target, which M3 explicitly permits ("target may extend beyond the visible chip
container"); the remove button is later in source order and sits above it.
- Effort: S
- Breaks API? no
### IN-15 · pagination · page and step controls are 40 px targets
- Severity: should-fix
- M3 says: "The size of state layers is 40dp while the interactive target size is 48dp" (RF §States);
accessibility: keep targets ≥ 48 × 48 CSS px (RF §Accessibility/Values). Pagination is not an M3
component, so foundations are the yardstick per the brief.
- Library does: `resources/views/pagination/livewire/tailwind.blade.php:22`
`$step = 'grid size-10 place-items-center …'` (40 px) is used for the page buttons, the prev/next
buttons and the current page, with `gap-1` between them (line 35); same at
`resources/views/pagination/laravel/tailwind.blade.php:7-9`. (The two `simple-tailwind` views are not
affected — they use `<x-button variant="outlined">`, whose sizing belongs to the buttons group.) The
file's own header (line 7) calls them "40px icon-button targets", conflating M3's 40 dp *state layer*
with its 48 dp *target*.
- Fix: keep the 40 px visual and add the target — in the two full pagination views change `$step` to
`'relative grid size-10 place-items-center after:absolute after:-inset-1 …'`, or wrap each button in a
48 px grid cell. Also correct the header comment.
- Effort: S
- Breaks API? no
### IN-16 · table · density is applied by default on every fine-pointer device
- Severity: should-fix
- M3 says: "**Don't apply density by default** — this drops targets below the 48x48 CSS px minimum;
offer an explicit density opt-in instead, keeping opt-out targets at ≥48x48 CSS px" — RF
§Accessibility/Rules, repeated on checkbox, chips, radio, switch and text-field accessibility pages.
(Data tables themselves have no M3 spec — RB §Data Tables confirms the component is absent from
m3.material.io — so foundations govern.)
- Library does: `resources/css/components/table.css:37-45` tightens `--cell-y` from `0.75rem` to
`0.5rem` under `@media (pointer: fine)` with no opt-out, and to `0.25rem` at `data-size="xs"`. With
`body-md`'s 20 px line box that is a 36 px row (28 px at `xs`). Even on a coarse pointer the default row
is 12 + 20 + 12 = 44 px, under 48. Rows are clickable (`data-list-row`,
`resources/js/list-rows.js`), so the row *is* a target.
- Fix: `resources/css/components/table.css` — raise the coarse-pointer default to
`--cell-y: 0.875rem` (14 px → 48 px rows) and turn the fine-pointer step into an opt-in
(`[data-table][data-density="compact"]`) rather than a media query, exposed as a `density` prop on
`resources/views/components/table.blade.php:10-12`.
- Effort: M
- Breaks API? yes — adds a prop and changes default row height; existing `size="xs"` keeps its meaning.
### IN-17 · sort-header · the sort button's hit target is about 20 px tall
- Severity: should-fix
- M3 says: 48 × 48 minimum target, no density by default (RF §Accessibility/Values and /Rules).
- Library does: `resources/views/components/sort-header.blade.php:21-30` renders
`class="group/sort focus-ring inline-flex cursor-pointer items-center gap-1 rounded-corner-xs"` with no
padding of its own. Its box is the `title-sm` line box (20 px, `resources/css/tokens/type.css:45`) plus
a 16 px icon — roughly 20 px tall. The surrounding `<th>` padding (`table.css:47-51`) belongs to the
cell, not the button, so it is not part of the target.
- Fix: `sort-header.blade.php:26` → add `-my-2 py-2 -mx-1 px-1` (or `min-h-12` with the `<th>` padding
removed for sorting cells) so the button covers its cell.
- Effort: S
- Breaks API? no (the `class` prop still lands on the `<th>`)
### IN-18 · field · the error state has only one visual indicator
- Severity: should-fix
- M3 says: "**States have two visual indicators to ensure accessibility**" (RF §States/Principles), and
for text fields specifically: error text "strongly recommended to pair with a **trailing error icon**
for a second, non-color-dependent cue" (RB §Text Fields/Behaviour); a non-actionable error icon gets the
accessibility label "Error." (RB §Text Fields/Accessibility).
- Library does: `resources/views/components/field.blade.php:39-45` sets only `data-invalid`, and
`resources/css/components/field.css:403-415,446-448` changes only colours — outline, label and support
text all move to `error`. No icon is added. `<x-input>`'s `icon-right` slot is free, so the hook exists.
- Fix: in `field.blade.php`, when `$messages !== []` and no `$trailing` is set, render
`<x-icon name="error" class="field-trailing size-(--field-icon)" aria-label="{{ __('Error') }}" />` into
the trailing position; suppress it at `data-size="xs"` where there is no room.
- Effort: S
- Breaks API? no (additive; callers passing `icon-right` keep theirs)
### IN-19 · field · a read-only field is drawn with a dashed outline
- Severity: nice-to-have
- M3 says: "**Read-only fields**: same visual style as an editable field, but clearly labeled read-only"
— RB §Text Fields/Behaviour.
- Library does: `resources/css/components/field.css:419-421`
`.field[data-readonly] .field-outline { border-style: dashed; }`, set from
`input.blade.php:38`, `textarea.blade.php:27` and `datepicker.blade.php:161`. The comment at
`field.css:417-418` explains the intent (a date picker makes its own input read-only). The native
`readonly` attribute does carry the state to AT, so the accessibility half of M3's rule is met; only
"same visual style" is not.
- Fix: either drop the dashed rule, or keep it and record it in the header comment as a deliberate
departure. If kept, note that the date picker's *docked* field is **not** read-only
(`datepicker.blade.php:182-189` binds typing), so only `mode="modal"`/`"input"` and caller-set
`readonly` fields are affected.
- Effort: S
- Breaks API? no
### IN-20 · search · the bar has no minimum or maximum width and does not widen on focus
- Severity: nice-to-have
- M3 says: search bar "Container | Width | Min: 360dp, max: 720dp"; docked container the same; and the
Expressive update lists "Motion: the search bar grows wider when focused" — RB §Search/Specs and
§Search/Status.
- Library does: `resources/css/components/search.css:28-41` sizes `[data-search-bar]` only by height;
width is the parent's. `[data-search-view]` (`search.css:130-147`) is `inset-inline: 0` with
`max-height: min(40rem, 70dvh)` and no `min-height` (M3: docked height "Min: 240dp, max: 2/3 of screen
height" — 70 dvh vs 66.7 % is close enough to ignore). No focus-widening transition.
- Fix: `search.css:28` → add `max-width: 45rem;` (720 px) to `[data-search-bar]` and `[data-search-view]`,
and `min-height: 15rem` (240 px) to the docked view. The focus-widen motion would need a
`--search-width` custom property toggled by `[data-open]` and animated on the effects-default spring.
- Effort: S (widths) / M (motion)
- Breaks API? no
### IN-21 · search · the unfocused leading padding is 16 px where M3 gives 24 dp
- Severity: nice-to-have
- M3 says: unfocused search bar "Leading padding | Unfocused: 24dp, focused: 12dp" and "Trailing padding |
Unfocused: 24dp, focused: 12dp"; contained focused style "Leading padding | 16dp | Trailing padding |
16dp" — `docs/reference/m3/raw/components_search_specs.md:338-346,376-386`.
- Library does: `resources/css/components/search.css:28-35` gives the bar `padding-inline: 0.25rem` and
`resources/css/components/search.css:53-61` gives the leading/clear boxes `width: 3rem` — so the icon
glyph starts 4 + 12 = 16 px from the container edge in every state.
- Both readings: the site's measurement diagram is an image, so "leading padding" may mean edge-to-tap-
target (24 + 12 = 36 px to the glyph, which looks wrong in a 56 dp bar) or edge-to-glyph (24 px). Under
the second reading the focused/contained value (16 dp) is exactly what the library does and only the
unfocused state is 8 px short.
- Fix: if pursued, `search.css:28``padding-inline: 0.75rem` when `:not([data-open])` and `0.25rem`
when `[data-open]`, transitioned on the effects-fast duration.
- Effort: S
- Breaks API? no
### IN-22 · datepicker · Shift+M and Shift+Y do not reach the month/year dropdowns
- Severity: nice-to-have
- M3 says: date-pickers/accessibility keyboard table — "Shift + M | Moves to the month list dropdown;
Shift + Y | Moves to the year list dropdown" (RB §Date Pickers/Accessibility).
- Library does: `resources/js/datepicker.js:578-600` handles Arrows, Home, End, PageUp/PageDown (with
Shift for ±12 months), Space and Enter; there is no Shift+M / Shift+Y branch. The menu buttons exist
and are focusable (`datepicker.blade.php:287-296,309-318,328-337`).
- Fix: add two cases to the `gridKey` map in `resources/js/datepicker.js:585-592` that focus
`[data-datepicker-menu-button]` (the month one in the docked layout, the combined one in the modal).
- Effort: S
- Breaks API? no
### IN-23 · choices · the combobox has no Home/End keys
- Severity: nice-to-have
- M3 says: nothing directly — M3 has no combobox component. WAI-ARIA APG's combobox-with-listbox pattern,
which the component already follows (`role="combobox"`, `aria-autocomplete`, `aria-activedescendant`),
specifies Home/End within the popup.
- Library does: `resources/views/components/choices.blade.php:140-145` binds ArrowDown, ArrowUp, Enter,
Escape and Tab only.
- Fix: add `x-on:keydown.home.prevent` / `.end.prevent` at `choices.blade.php:142` setting
`active = 0` / `active = filtered.length - 1` and calling `reveal()`.
- Effort: S
- Breaks API? no
### IN-24 · slider · Space+Arrows (the large-interval step) is not implemented
- Severity: nice-to-have
- M3 says: sliders/accessibility keyboard table — "Space & Arrows | Increase/decrease the value by one
**interval** or one stop indicator" (RB §Sliders/Accessibility), on top of Arrows (one value) and
Home/End.
- Library does: the native `<input type="range">` supplies Arrows and Home/End
(`slider.blade.php:357-379`); the SKILL documents PageUp/PageDown as a tenth of the range
(`SKILL.md:562`), which is an extension, not M3's Space+Arrows.
- Fix: add a `keydown` handler in `resources/js/slider.js` that, while Space is held, multiplies the arrow
step. Low value — PageUp/PageDown already covers the need and is the more common web idiom.
- Effort: S
- Breaks API? no
### IN-25 · chips · no dragged state
- Severity: nice-to-have
- M3 says: chips are the only component in this group whose state list includes **Dragged**
(RB §Chips/Specs, "States (site, all 4 variants): Enabled, Disabled, Hovered, Focused, Pressed,
**Dragged**"); dragged state layer opacity is 0.16 (RF §States/Values). Input chips also support
"reordering, moving between fields ('To' → 'CC')" (RB §Chips/Behaviour).
- Library does: `resources/views/components/chip.blade.php:158` and the `state-layer` utility
(`resources/css/tokens/state.css:13-45`) cover hover 8 %, focus 10 % and active 10 % only. No chip is
draggable.
- Fix: only worth doing alongside a reordering feature — then add
`[draggable]:active`/`[data-dragged]` at 16 %.
- Effort: L
- Breaks API? no (additive)
### IN-26 · timepicker · the AM/PM buttons are toggle buttons, not radios
- Severity: nice-to-have
- M3 says: time-pickers/accessibility labelling table — "AM/PM selection | AM or PM | **Radio button (in
list)** (Wiz/Compose) | Checkbox (in list) (Android Views)" (RB §Time Pickers/Accessibility).
- Library does: `resources/views/components/timepicker.blade.php:239-251` and `:333-345` render two
`<button aria-pressed>` in a `role="group"`. A screen reader hears "AM, toggle button, pressed" rather
than "AM, radio button, selected, 1 of 2"; both convey the state, and `aria-pressed` avoids needing
arrow-key roving, so this is defensible. Noted because the M3 table is explicit.
- Fix: if changed, `role="radiogroup"` on the wrapper and `role="radio" aria-checked` on the buttons,
with Left/Right arrow handling.
- Effort: S
- Breaks API? no
### IN-27 · timepicker · the disabled period button uses a 10 % container instead of 12 %
- Severity: nice-to-have
- M3 says: disabled container opacity is **0.12**, disabled content 0.38 (RF §States/Values). M3 defines
no disabled state for the period selector at all (its state list is Enabled, Hover, Focus, Pressed —
RB §Time Pickers/Specs), so this is the library's own `min`/`max` extension and only has to stay inside
M3's rules.
- Library does: `resources/css/components/timepicker.css:172-174`
`background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent)` with the content
at 38 % (correct).
- Fix: `timepicker.css:173``12%`.
- Effort: S
- Breaks API? no
### IN-28 · radio · `inline` lays radios in a row, which M3 cautions against
- Severity: nice-to-have
- M3 says: radio buttons are "Always vertically stacked (never horizontal — flagged as a 'Caution', not
an outright 'Don't')" (RB §Radio Button/Behaviour); also "Recommended for five or fewer options" and
"One option should always be pre-selected in a radio group".
- Library does: `resources/views/components/radio.blade.php:18,39` offers
`inline``sm:flex sm:flex-wrap sm:gap-x-6`, and `value` (the pre-selection) is optional
(`radio.blade.php:19`). Neither is wrong — M3's is a caution — but the SKILL entry
(`SKILL.md:552`) presents `inline` without the caveat.
- Fix: add one sentence to `SKILL.md:552` and to the `radio.blade.php` header noting M3 prefers a vertical
stack, and that a group should normally ship a pre-selected `value`.
- Effort: S
- Breaks API? no
### IN-29 · password · the reveal button carries both a changing label and `aria-pressed`
- Severity: nice-to-have
- M3 says: "Interactive trailing icons need a functional label (e.g., 'Show password' / 'Hide password')"
— RB §Text Fields/Accessibility. It says nothing about a pressed state.
- Library does: `resources/views/components/password.blade.php:41-44` binds *both*
`aria-label` (flipping between "Show password" and "Hide password") **and** `aria-pressed`. Screen
readers then announce e.g. "Hide password, toggle button, pressed" — the state twice, once inverted
relative to the label's promise. M3 names only the label pattern; the repo's own recent commit
"Keep aria-pressed off a selected link button" shows the same concern elsewhere.
- Fix: `password.blade.php:42,44` → drop `aria-pressed` and keep the flipping label (the M3-documented
pattern), or keep `aria-pressed` with a fixed "Show password" label. Not both.
- Effort: S
- Breaks API? no
### IN-30 · table · the row divider is a colour outside the role set
- Severity: nice-to-have
- M3 says: colour is applied through the roles; RF §Design tokens frames system tokens as the sanctioned
vocabulary and the styles pages give no "60 % of outline variant" value. Opacity modifiers in M3 are
reserved for state layers (8/10/10/16 %) and disabled (12/38 %).
- Library does: `resources/css/components/table.css:66-68`
`border-bottom: 1px solid color-mix(in srgb, var(--md-sys-color-outline-variant) 60%, transparent)`.
(The header rule at `table.css:58` correctly uses full `outline-variant`.) Data tables have no M3 spec,
so this is a foundations-level nit, not a spec breach.
- Fix: `table.css:67``var(--md-sys-color-outline-variant)`, and let the heavier header rule read as
heavier by keeping the header's `title-sm` weight contrast, or use `surface-container-highest` if a
lighter rule is wanted.
- Effort: S
- Breaks API? no
## Deliberate deviations
The code explains these; the verdict column is whether the stated reason survives Google's text.
| Where | Deviation and its stated reason | Holds up? |
|---|---|---|
| `field.css:8-12` | The outlined field's notch is MUI's `<fieldset>`/`<legend>` technique so the gap is right on any background | Yes. M3 does not prescribe a mechanism; the rendered result (4 dp corners, 1 dp / 2 dp focus outline, `body-small` floated label) matches `OutlinedTextFieldTokens` exactly. |
| `field.blade.php:9-13` | `size="sm"` (40 px) / `xs` (32 px) for unlabelled toolbar controls | Yes — M3's rule is "don't apply density **by default**; offer an explicit opt-in" (RF §Accessibility). These are opt-in and documented as unlabelled-control sizes. |
| `field.css:417-421` | Read-only fields get a dashed outline "marked on the field, not read off the control" | Partly — see IN-19; M3 says a read-only field keeps the same visual style. |
| `select.blade.php:1-8` | The native `<select>` is kept "on purpose", with the M3 menu only where `appearance: base-select` exists | Yes. M3's exposed-dropdown guidance is about the rendered result, and the native control gives the keyboard, type-ahead and AT semantics for free. |
| `file.blade.php:1-13` | "M3 has no file field" — the native input is kept and only `::file-selector-button` is restyled as a tonal pill | Yes. RB confirms no file-input component exists; the pill uses `secondary-container` / `on-secondary-container` with 8 % hover and 12 %/38 % disabled — inside M3's rules. |
| `chip.blade.php:29-30` | "input chips are flat only, as in M3" | Partly. RB §Chips/Variants says "**All** chips can be elevated (default elevation 0)"; Compose has no `ElevatedInputChip`, which is presumably the real source. The restriction is safe but the stated justification is the implementation, not the spec. |
| `chip.blade.php:88-89` | Padding is 1 px short of the token because a CSS border takes room where Compose draws it inside | Yes — a correct compensation; the rendered box matches 16/8/12/4 dp. |
| `timepicker.blade.php:37-39` | The period selector follows Compose's `isUpdatedTimepickerToggleEnabled` default — "two separate shape-morphing toggle buttons in primary-container, not the outlined pair its tokens still describe" | Partly — see IN-11. The shape morph is a plausible Expressive reading; the tertiary→primary colour swap is undocumented and the site still lists Tertiary container. |
| `datepicker.js:18-19` | "The keyboard is WAI-ARIA's date picker dialog: … Home and End go to the start and end of the week" | Defensible. M3's own table says "Home/End | Move to the first day of the month" (RB §Date Pickers/Accessibility) — a row the reference itself flags as terse/ambiguous. The APG behaviour is the stronger web convention; worth a one-line note in the header that M3's table differs. |
| `slider.blade.php:39-41` | The value indicator is "M3's as Flutter draws it (`RoundedRectSliderValueIndicatorShape`): a 32px inverse-surface pill" because "Compose leaves the value label to the app" | No — see IN-05. M3 Expressive's sliders/specs table gives an explicit 44 dp × 48 dp label container that neither source was consulted for. |
| `search.css:83-87` | `[data-search-bar] button:focus-visible { outline-offset: -3px }` instead of the package's `+2px` | Yes, in context — the ring is drawn inside a 48 px box that is flush with the 56 px bar; a +2 px ring would clip. |
| `table.css:7-10` | Density is keyed "on the pointer, not the width, so a touch tablet in landscape keeps rows a finger can hit" | Partly — the pointer key is a good idea, but the coarse-pointer default is still 44 px (IN-16). |
| `chip-set.blade.php:12-14` | `scroll` keeps chips on one line that scrolls sideways "as M3 lays chips out on a narrow screen" | Yes. RB §Chips/Behaviour: "place inline as a single row, wrapping to new lines when needed, or **scroll horizontally** if the field is only one row tall". |
### Token-citation disagreements
Places where a header comment's androidx/MDC figure contradicts the M3 site's spec table:
| File:line | Comment claims | M3 site says | Effect in code |
|---|---|---|---|
| `slider.blade.php:44` | "handles of 44, 44, **44**, 68 and 108px" (md.comp.slider.* tokens) | 44 / 44 / **52** / 68 / 108 (sliders/specs) | Yes — `slider.blade.php:248` is 44 px (IN-04) |
| `slider.blade.php:40-41` | value indicator "a **32px** inverse-surface pill … 4px above the handle" (Flutter) | Label container **44 dp** high, **48 dp** wide, all sizes; `ValueIndicatorActiveBottomSpace = 12dp` | Yes — `slider.blade.php:346,252` (IN-05) |
| `timepicker.blade.php:33` | "96×80 time selector boxes" | 96 dp, **and 114 dp for the 24-hour vertical layout** | Yes — `timepicker.css:106` (IN-10) |
| `timepicker.blade.php:37-39` | period selector "in **primary-container**, not the outlined pair its tokens still describe" | `PeriodSelectorSelectedContainerColor = TertiaryContainer`; site colour list includes Tertiary container / On tertiary container | Yes — `timepicker.css:165-167` (IN-11) |
| `selection.css:1-3` | cites `CheckboxTokens` | `IconSize = 18.0.dp` | Yes — `checkbox.blade.php:41-42` uses `size-4` (16 px) (IN-12) |
| `search.css:1-10` | cites `SearchBarTokens` / `SearchViewTokens`; "surface-container-high" for the view | Docked: Surface container high ✓; **Full-screen: Surface container low** | Yes — `search.css:140` (IN-08) |
| `pagination/livewire/tailwind.blade.php:7` | "Page numbers are **40px icon-button targets**" | 40 dp is the state layer; the target is 48 dp (RF §States) | Yes — `size-10` (IN-15) |
| `chip.blade.php:35-38` | 32 px, small corner, label-large, 18 px icons, 24 px avatar, 16/8/8 and 12/8/4 padding | identical (chips/specs, all four variants) | No — all correct |
| `field.css:8-12` | `FilledTextFieldTokens` / `OutlinedTextFieldTokens` | 56 dp height, `CornerExtraSmall` = 4 dp, outline 1/2 dp, indicator 1/2 dp, disabled 12 %/38 %/4 %, `BodyLarge` input, `BodySmall` supporting | No — all correct |
| `datepicker.blade.php:39-45` | 360 px, extra-large (modal) / large (docked) corner, elevation 3, surface-container-high, 40 px days in 48 px cells, today outlined primary, range middle secondary-container | `DatePickerModalTokens`: 360 × 568, `CornerExtraLarge`, Level3, Surface container high, cell 40 × 40, today outline 1 dp, range indicator 40 dp | No — all correct (the 48 px cell is the library's own target addition, which M3's 48 dp target rule requires) |
| `selection.css:219-223` | switch handle centres at 16 px / 36 px, sizes 16 / 24 / 28 px "measured inside the track's 2px outline" | `SwitchTokens`: track 32 × 52, outline 2, handles 16 / 24 / 28 | Numbers correct; the phrase "inside the track's 2px outline" is wrong (the handle is positioned against the track's outer box, `selection.css:249-251`) — comment only |
## Aligned
Verified correct; no need to re-check.
- **Text field geometry** — 56 px container, 16 px side padding (12 px with icons via `--field-start`),
24 px leading icon, 4 dp corner, 1 px → 2 px outline/indicator on focus, `body-large` input,
`body-small` floated label and supporting text, 4 px supporting-text top padding
(`field.css:27-73,295-344,439-444`).
- **Text field colours and states** — outline `outline`, hover `on-surface`, focus `primary`, filled
container `surface-container-highest`, filled resting indicator `on-surface-variant`, filled hover
`on-surface` 8 %, disabled outline 12 % / content 38 % / filled container 4 % — all match
`OutlinedTextFieldTokens` / `FilledTextFieldTokens` (`field.css:33-44,390-437,450-520`).
- **Error replaces the hint, never stacks** (`field.blade.php:73-81`), with `role="alert"` on the error —
exactly RB §Text Fields/Behaviour and /Accessibility.
- **Required asterisk is in the label** (`field.css:346-349`, in both the label and the notch legend) —
RB §Text Fields/Accessibility, "the label must include the asterisk".
- **Date input is never masked while typing** — `datepicker.js:350-366` only commits a whole, valid date;
`parse` (`datepicker.js:274-300`) accepts any separator and optional leading zeros. This is the exact
rule in RB §Date Pickers/Accessibility.
- **Switch** — every dimension matches `SwitchTokens`: 52 × 32 track, 2 px outline, 16 / 24 / 28 px handle,
40 px state layer, 16 px icons, and the full colour map (selected track `primary`, handle `on-primary`,
hover/focus/pressed handle `primary-container`, unselected track `surface-container-highest`, handle
`outline`, disabled 12 %/38 %) — `selection.css:219-343`.
- **Checkbox / radio boxes** — 18 px box with 2 px `on-surface-variant` outline and 2 dp corner, 20 px
radio with a 2 px ring, both in 40 px state layers, `primary` when selected, error variants
(`selection.css:103-217`). Indeterminate uses a dash icon and is kept in sync through the
`indeterminate` DOM property (`field.js:32-34,49-80`) — the only correct way to do it in HTML.
- **State-layer opacities everywhere in the group** — 8 % hover (always behind `@media (hover: hover)`),
10 % focus, 10 % press, tinted with the content's "on" colour: `selection.css:73-101`,
`field.css:268-282`, `datepicker.css:250-268`, `chip.blade.php:158,249`, `state.css:13-45`.
- **Focus ring** — 3 px `secondary` at +2 px offset, consistently (`state.css:46-53`, `field.css:274-278`,
`selection.css:98-101,241-244`, `datepicker.css:270-275`, `timepicker.css:207-208`,
`slider.blade.php:335`).
- **Chips** — 32 px height, 8 dp corner, `label-large`, 18 px icons, 24 px avatar, 16/8 dp label padding
(1 px short for the CSS border), 8 dp between elements, 8 px chip-to-chip gap, a 48 px `::after` target
strip, filter selected `secondary-container`/`on-secondary-container`, stroke `outline-variant`
(the Aug-2024 update), elevated on `surface-container-low` at elevation 1 → 2 on hover, disabled
12 %/38 % (`chip.blade.php:91-125,197-198`, `chip-set.blade.php:51`).
- **Chip keyboard removal** — Backspace/Delete on a focused input chip removes it and moves focus to the
previous/next chip, with a `wire:key`-aware re-focus across Livewire morphs (`chips.js:38-118`). Remove
buttons are named "Remove {label}" (`chip.blade.php:242`). Exactly RB §Chips/Accessibility.
- **Date picker anatomy and geometry** — 360 px container, 120 px header (128 px for range),
`headline-large` headline (`title-large` for range), `surface-container-high` at elevation 3,
`corner-xl` modal / `corner-lg` docked, 40 px day in a 48 px cell, `body-large` day and weekday labels,
today outlined 1 px `primary`, selected `primary`/`on-primary`, range band
`secondary-container`/`on-secondary-container` 40 px tall, 72 × 36 year cells, divider under the header
(`datepicker.css:64-436`).
- **Date picker semantics** — `<table role="grid">` with `<th scope="col">`, per-cell `aria-label` naming
the full date, `aria-selected`, `aria-current="date"`, roving `tabindex`, `aria-live` month announcement,
and both entry methods always available — matching M3's labelling table
(`datepicker.blade.php:284,344-378`).
- **Time picker geometry** — 24 dp container padding, 96 × 80 time selectors in `display-large` with a
`corner-small` container, 52 × 80 (vertical) / 216 × 38 (horizontal) period selector, 256 px dial in
`surface-container-highest` with `body-large` numbers, 48 px selector handle, 8 px centre, 2 px track,
96 × 72 input fields in `display-medium` — every number in RB §Time Pickers/Specs
(`timepicker.css:50-460`).
- **Slider** — track heights 16/24/40/56/96, corners 8/8/12/16/28, 4 px handle narrowing to 2 px on
press/focus, 6 px active-handle spacing, 4 px stop indicators, 24/24/32 px inset icons restricted to
M/L/XL standard sliders only (never range or centered, exactly RB §Sliders/Variants), `primary` active
track and handle, `secondary-container` inactive track, `inverse-surface`/`inverse-on-surface` value
indicator in `label-large`, disabled 38 %/12 % (`slider.blade.php:132-252,283-354`).
- **Slider accessibility** — real `<input type="range">`s, so Arrows/Home/End and AT come free; a range
slider is `role="group"` with `aria-labelledby` and "Range start"/"Range end" handle labels; the
drawing is `aria-hidden` (`slider.blade.php:265-379`).
- **Reduced motion** — all six spring/easing duration tokens collapse to 0 ms under
`prefers-reduced-motion: reduce` (`resources/css/tokens/motion.css:63-75`), and every component in this
group animates through those tokens, so nothing needs a per-component opt-out.
- **`<x-choices>` combobox semantics** — `role="combobox"`, `aria-autocomplete="list"`, `aria-controls`,
`aria-expanded`, `aria-activedescendant`, `role="listbox"`/`role="option"`/`aria-selected`,
`aria-disabled` (`choices.blade.php:121-179`). This is what `<x-search>` should look like (IN-01).
- **Search geometry** — 56 px pill, `corner-full`, `surface-container-high`, 8 % hover, `body-large`
input, 48 px leading/clear targets, docked view `corner-xl` at elevation 3, 72 px full-screen header
with safe-area padding, `corner-none` full-screen — all matching `SearchBarTokens` / `SearchViewTokens`
(`search.css:20-181`). Escape releases focus, the back arrow dismisses, and the full-screen view traps
focus and locks scroll (`search.blade.php:29-32`, `search.js:39-97`).
- **Pagination colour choice** — the current page is `secondary-container`/`on-secondary-container`, never
the action colour, with `aria-current="page"` (`pagination/livewire/tailwind.blade.php:55`). This is the
right reading of RF §Interaction/Selection ("active indicator" components).
- **Table roles** — headers `title-small` `on-surface-variant` over an `outline-variant` rule, selected
row `secondary-container`/`on-secondary-container` keyed on `aria-selected="true"`, `aria-sort` on the
sorted `<th>` (`table.css:53-77`, `sort-header.blade.php:20`). RF §Interaction/Selection lists data
tables among the components that inherit selection styling, and this is that styling.
## Missing
M3 components, variants or configurations in this group that the library does not implement.
- **Slider — vertical orientation.** RB §Sliders/Variants: the Expressive configuration table lists
"Orientation | Horizontal (default) | Available | Available; Vertical | -- | Available". The library is
horizontal only (`slider.blade.php` has no orientation prop). M3's caveat that range sliders must stay
horizontal would apply.
- **Date pickers — the full-screen range picker.** RB §Date Pickers/Anatomy describes a 14-element
full-screen range-selection picker with a close (×) icon button and a **Save** confirmation, used at
compact breakpoints. `<x-datepicker range>` always uses the 360 px modal dialog
(`datepicker.css:64-129`), which on a phone is cramped for a two-month range scroll.
- **Search — the other two entry points.** RB §Search/Behaviour names three: search bar (implemented),
**search app bar** (a top app bar whose whole row is the search field, opening the Search view), and
**search icon button** (search as a secondary action that expands). Only the first exists.
- **Search — the Expressive focus-widen motion** ("the search bar grows wider when focused",
RB §Search/Status). See IN-20.
- **Text fields — the character counter.** RB §Text Fields/Variants lists "supporting text + character
counter" as a configuration of both variants, with its own accessibility label ("Character count,
5/20"). No `<x-input>`/`<x-textarea>` prop provides it; `maxlength` passes through to the control but
nothing is rendered.
- **Text fields — width bounds at medium/expanded.** RB §Text Fields/Behaviour: "compact breakpoints can
let a text field span full width; medium/expanded should bound it with flexible margins/other
containers — **never let it span the full width of a large screen**." `<x-form>`
(`form.blade.php:14`) is an unbounded one-column grid; nothing in the package caps a field's width.
Currently the caller's job, undocumented in `SKILL.md:521-535`.
- **Checkbox — the ≥ 840 dp grouping rule.** RB §Checkbox/Behaviour: "in expanded breakpoints (≥840dp),
group checkboxes inside a contained region (e.g., a side sheet)". No affordance or documentation.
- **Chips — the horizontal-overflow affordances.** RB §Chips/Accessibility prescribes either a leading
"Show all" filter chip that reflows the list downward, or a leading button opening a menu of all chip
options. `<x-chip-set scroll>` fades the scrollable edge instead — a reasonable web idiom, but neither
of M3's two named patterns is offered.
- **Time picker — the 24-hour inner/outer ring is implemented** (`timepicker.blade.php:126-129`), so this
is *not* missing; noted because it is easy to assume otherwise.
## Breakpoint map
| Component | Library breakpoint used | M3 window size class it stands in for | Gap |
|---|---|---|---|
| `<x-search>` full-screen ↔ docked | `(max-width: 39.99rem)` = < 640 px (`resources/js/search.js:11`) | Compact < 600 dp (full-screen), medium/expanded ≥ 600 dp (docked) | 40 px late — 600639 px gets the full-screen view where M3 wants docked |
| `<x-datepicker>` docked → modal dialog | `(max-width: 39.99rem)` = < 640 px (`resources/js/datepicker.js:23`) | Compact < 600 dp (M3: "Used at compact breakpoints as a full-screen dialog"; docked "Used at medium/expanded") | 40 px late; also M3 wants *full-screen* at compact, the library shows a 360 px centred dialog |
| `<x-timepicker>` landscape layout | `(orientation: landscape) and (min-width: 37rem)` = ≥ 592 px (`timepicker.css:403`) | No width class — M3 keys this on device orientation and **viewport height** (RB §Time Pickers/Behaviour) | 8 px under 600; the height-based dial shrink at `max-height: 22.75rem` / `21.625rem` (`timepicker.css:449,455`) is Compose's `ClockFaceSizeModifier`, not an M3 window class — acceptable |
| `<x-radio inline>` | `sm:` = ≥ 640 px (`radio.blade.php:39`) | Compact < 600 dp stacks; medium+ may lay out horizontally (M3 cautions against horizontal at any width) | 40 px late, and the direction of the rule is a caution either way (IN-28) |
| Pagination numbers vs "Page 2 of 7" | `max-sm:` / `sm:` = 640 px (`pagination/livewire/tailwind.blade.php:31-32,48,53`) | Compact < 600 dp | 40 px late — 600639 px loses the page numbers where M3's medium class would keep them |
| `<x-table>` density | `@media (pointer: fine)` (`table.css:37`) | None — M3 has no pointer-keyed density rule; density is an explicit user opt-in at any size | Not a size-class substitution; the problem is that it is on by default (IN-16) |
| `<x-field>` / `<x-form>` width | none | Medium/expanded should bound a text field's width (RB §Text Fields/Behaviour) | Not implemented at all — see **Missing** |
| `<x-chip-set scroll>` | none (a prop, not a breakpoint) | M3 expects wrap-or-scroll chosen by available width | Caller decides; no adaptive switch |
| `<x-chip type="filter">` trailing icon | none | M3: a filter chip's trailing icon is for medium/expanded; at compact the whole chip must be the tap target | Satisfied incidentally — the whole chip is always the target (`chip.blade.php:200`) |
| `<x-search>` docked max height | `min(40rem, 70dvh)` (`search.css:136`) | Docked container height min 240 dp, max 2/3 of screen height | 70 dvh vs 66.7 % — negligible; the missing 240 dp minimum is the real gap (IN-20) |
Note on the Tailwind scale generally: this group consistently uses `sm` (640 px) where M3's compact
boundary is 600 dp. The fix is one shared media query — a `--material-compact: (max-width: 37.49rem)`
custom media (or a `xs`-ish Tailwind screen at 600 px) referenced by `search.js:11`, `datepicker.js:23`,
`radio.blade.php:39` and the pagination views — rather than five separate edits.
+637
View File
@@ -0,0 +1,637 @@
# 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.
+37
View File
@@ -0,0 +1,37 @@
# Material Design 3 reference (extracted 2026-09-13)
What Google's documentation says, condensed for auditing this library against it. Every file
keeps the wording of the site where a rule is stated, the tables verbatim, and a source URL per
section. Numbers the site only draws in pictures were taken from androidx Compose Material 3's
token files (`compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/`)
and the Material Components Android component docs, and are marked with their source.
| File | Covers |
|---|---|
| `foundations.md`, `foundations-supplement.md` | accessibility, content design, customisation, design tokens, interaction (gestures, inputs, selection, states), layout (window size classes, scaffold, grids and spacing, canonical layouts, RTL), usability, glossary |
| `styles.md`, `styles-supplement.md` | colour (roles, system, dynamic, custom colours, contrast), elevation, icons, motion (Expressive springs, easing and duration, transitions), shape (corner scale, shapes, morph), spacing, typography |
| `components-actions-communication-containment.md` | buttons, icon buttons, button groups, split button, segmented buttons, FAB, extended FAB, FAB menu, badges, progress indicators, loading indicator, snackbar, tooltips, bottom sheets, cards, carousel, dialogs, divider, lists, side sheets, menus |
| `components-navigation-selection-inputs.md` | app bars, navigation bar, navigation rail, navigation drawer, tabs, toolbars, search, checkbox, chips, radio button, sliders, switch, text fields, date pickers, time pickers, data tables |
| `pages.txt` | the 238 pages under `/foundations`, `/styles` and `/components` in the site's sitemap (watches and XR left out) — the coverage checklist; every one was fetched |
## Refreshing
m3.material.io renders only in a browser. Read it through a reader proxy, one page at a time
(it rate-limits with HTTP 429; retry after a few seconds; add `-H "x-no-cache: true"` for a
stale snapshot):
```bash
curl -sL "https://m3.material.io/sitemap.xml" | grep -o '<loc>[^<]*' | sed 's/<loc>//' # the page list
curl -sL "https://r.jina.ai/https://m3.material.io/components/buttons/specs" # one page
```
The audits that used these files are in `docs/audits/m3-alignment/`.
## Attribution
The text is Google's, from https://m3.material.io, reproduced here so the library can be
checked against it; the token values are from the Android Open Source Project (Apache-2.0, see
`NOTICE`). Google publishes its design documentation under the Creative Commons Attribution 4.0
licence "except as otherwise noted"; confirm the statement in the site's footer before
reproducing these files anywhere else. `docs/` is export-ignored, so they are not part of the
Composer distribution.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+355
View File
@@ -0,0 +1,355 @@
# Material Design 3 — Foundations reference (supplement)
Covers 27 sitemap URLs under `foundations/*` that `reference-foundations.md` did not fetch under their exact
path (per-tab sub-pages the earlier pass reached only via a parent tab, plus a few pages the earlier pass
never visited at all). Only content **not already in `reference-foundations.md`** is written out in full;
pages that turned out to duplicate material already in the reference say so in one line instead of repeating
it. Same structure as the reference: Principles / Rules / Values tables / M3 vs M3 Expressive /
Accessibility / Source URLs, grouped by topic.
---
## Accessibility — assistive technology, designing, writing
### Duplicates (no new content)
- `foundations/designing/overview` — restates the reference's "Do use native elements / Don't use
non-standard elements" dialog example verbatim. One new framing sentence: the designing tabs
(Accessibility markup, Implementing accessibility, ...) exist to **"translate a visual UI into a
text-based, linear user experience that maps to code."** The page only fully rendered two of its stage
tabs (Accessibility markup, Implementing accessibility); a third stage implied by "three stages" was not
captured in this fetch.
### New — Assistive technology (foundations/overview/assistive-technology)
Three assistive-technology categories, quoted:
- **Keyboard**: "Hardware or software directional controllers such as keyboards, a D-pad, or trackball allow
users to jump from selection to selection in a linear fashion."
- **Screen readers**: "A screen reader is a software program that uses either a braille display or reads
text aloud, such as Google's screen reader, TalkBack." Verbalizes visible content (paragraph/button text)
**and** hidden content (alt text for icons, headings). Used by people with vision impairments, reading
difficulty, or temporary reading limitations.
- **Switch input**: "Switches scan the items on your screen, highlighting each item in turn, until you make
a selection. Switch Access lets you interact with your Android device using one or more switches instead
of the touchscreen."
### New — Writing best practices, beyond alt text (foundations/writing/best-practices)
The reference already covers alt text length/rules; this page adds three more image-text mechanisms:
- **Adjacent text**: text in/around an image should present key information about the image (distinct from
alt text and captions).
- **Captions**: text that appears below an image, explaining how it relates to the content; benefits both
sighted and screen-reader users (screen readers **do** read captions, unlike embedded image text).
- **Embedded text in images**: "Screen readers are unable to read text that is embedded in imagery." Any
essential information embedded as image text must be duplicated into the alt text instead. Caution flagged
explicitly for this case.
- Alt text is also framed as an SEO benefit ("Keywords can also improve search engine optimization").
### New — Text resizing (foundations/writing/text-resizing)
**Requirement**: UIs must support a minimum text increase of **200%**.
**Scaling rules** — most components:
- Text and line height scale up proportionally, multiplied by the scale value.
- Padding remains constant at **1x** the default size (e.g., a button keeps 8dp top/bottom and 24dp
left/right padding at 1x, 1.3x, and 2x text scale).
- Spacing between elements in a component remains constant at 1x.
- Components with no text (progress indicators, checkboxes, radio buttons) are **not** affected by text
resizing — don't resize them.
**Multiplier calculation** (when the OS doesn't drive resizing): offer multipliers (e.g. 1.5x, 2x);
`font size = default font size (density 0) × scale value`. Example: 14pt at 1x → 28pt at 2x scale.
**Methods to handle large type** (avoids clipping, overlap, unwanted truncation):
| Method | Rule |
| --- | --- |
| Increase container size | Let containers grow so text doesn't overlap/clip/truncate; design for line-to-line eye tracking |
| Reflow the layout | Stack components (e.g. buttons) that were side-by-side, rather than keeping them fixed-width |
| Enable content to scroll | Prefer vertical scrolling only — never ask users to scroll both vertically and horizontally |
| Touch & hold tooltips | For space-constrained components — **top app bar, navigation bar, navigation rail, and fixed (non-scrolling) tabs** — keep the component's own text at 1x and show the full scaled-up label in a tooltip on touch & hold |
### New — Text truncation (foundations/writing/text-truncation)
**Requirement**: "Content, understandability, and functionality must not be lost when users modify their
type settings." (Exceptions may apply for non-Latin-alphabet languages.)
- **Text wrapping**: wrap text when it's critical, needed for understandability, or space allows. **Don't**
cut off wrapped text without a way to see the rest (e.g., an expand affordance).
- **Height/width of components**: prefer flexible containers that grow to fit content over fixed text-size
limits that don't fit the available space.
- **Ellipsis + hover/link**: truncating to an ellipsis is acceptable **only** if the full text is reachable
via a tooltip or a link that reveals what was truncated. An ellipsis with no reveal mechanism "is not
accessible." Note: this pattern still adds difficulty for some users, so use it deliberately.
### Source URLs
https://m3.material.io/foundations/overview/assistive-technology
https://m3.material.io/foundations/designing/overview
https://m3.material.io/foundations/writing/best-practices
https://m3.material.io/foundations/writing/text-resizing
https://m3.material.io/foundations/writing/text-truncation
---
## Content design — global writing, style guide
### Duplicates (no new content)
- `foundations/content-design/global-writing/overview` — same definition of global writing already quoted
in the reference. One new line: **"This guidance is for all American English that is localized."**
- `foundations/content-design/style-guide/ux-writing-best-practices` — this is the exact content the earlier
pass already captured at `foundations/content-design/style-guide` (explain consequences, scannable
words/formats, sentence case, abbreviations sparingly) — fully identical, word for word.
### New — Grammar and punctuation (foundations/content-design/style-guide/grammar-and-punctuation)
Not covered anywhere in the reference. Rules, quoted/paraphrased:
- **Skip periods on single sentences** — omit in labels, tooltip text, bulleted lists, dialog body text,
hyperlinked text. Use periods for multiple sentences, or long/complex sentences.
- **Use contractions** — easier to scan; exception: spelled-out "do not" can read as more emphatic than
"don't" when caution is needed.
- **Serial (Oxford) comma** — always use it, except before an ampersand. Commas always go inside quotation
marks.
- **Commas in numbers**: use commas for numbers **1,0001,000,000**. Don't use commas when identifying
something (street address, radio frequency, year). Above 1 million, comma use depends on context;
"million" can be abbreviated "M" with rounding when giving a sense of volume rather than an exact count
(e.g. "23M views").
- **Skip colons in headings** — no colon on a heading introducing a list; do use a colon for a list
introduced within body text.
- **Exclamation points sparingly** — fine for greetings/congratulatory moments; avoid for empty states and
routine tasks ("save it for bigger accomplishments").
- **Ellipses sparingly** — indicate an in-progress action or incomplete text (check with engineering, since
truncation ellipses are often automatic). No space before an ellipsis. Omit ellipses from menu items or
buttons that open a dialog or start a process.
- **Parentheses** — only to define acronyms/jargon or cite a source; never for an aside/afterthought (fold
the info into the sentence instead).
- **Ampersands**: "&" is acceptable in headlines, column headers, table headers, navigation labels, and
buttons (space-constrained contexts) — but spelling out "and" improves readability where there's room.
Always spell out "and" in sentences/paragraphs, before the final item in a 3+ item list, and in email
subject lines.
- **Dashes** — three kinds: em dash (—), en dash (), hyphen (-). Avoid em dashes in UX writing (replace with
a comma, period, or new sentence). Use an en dash with no surrounding spaces for a range (e.g. "9 AMNoon").
- **Hyphens** — bind closely related words; also represent negative numbers (e.g. "-100"); never surrounded
by spaces. Defer to AP style when unsure whether a phrase needs one. Hyphenation table:
| Rule | Examples | Why |
| --- | --- | --- |
| Hyphenate adjective phrases | case-by-case basis, best-in-class performance, once-in-a-lifetime opportunity | Multiple words used together as one adjective are hyphenated — but not proper nouns or common phrases ("cell phone number", "chocolate chip cookie") |
| Hyphenate noun phrases | sign-off, drive-through, go-ahead (verb+adverb); higher-up, most-read (adjective-as-noun); jack-of-all-trades, stick-in-the-mud (fixed idioms) | Certain multi-word nouns are always hyphenated |
| Don't hyphenate verb phrases | "look out for falling rocks," "drop off your car," "check in to the room" | A verb+adverb/preposition acting as a verb isn't hyphenated (contrast the noun "check-in"); note "check into" ≠ "check in to" |
- **Italics sparingly** — hard to read; use bold for emphasis instead. Italics are acceptable for a single
word/phrase (a name or title), never a full sentence.
- **No caps blocks** — "Avoid using caps blocks altogether; they're not accessible." Use sentence case for
all product text.
### New — Word choice: pronouns (foundations/content-design/style-guide/word-choice)
Note: this is a **different** page from the already-covered `global-writing/word-choice` (which is about
localization word choice). This one is about person/pronoun choice:
- **Use second person ("you"/"your")** — makes the UI feel like it's speaking to the user about their own
actions; avoid impersonal/robotic phrasing.
- **Don't mix first and second person** in the same context (e.g. "My photos" title next to "Your photos"
description) — pick one address form, or drop the pronoun.
- **Use caution with "I"/"we"** — for a large company like Google, first person can read as robotic or
disconcerting; prefer the user's point of view or remove the pronoun. **Exception**: legal text may use
"I"/"my" to emphasize ownership in an agreement (e.g. "I agree to the terms of service").
### Source URLs
https://m3.material.io/foundations/content-design/global-writing/overview
https://m3.material.io/foundations/content-design/style-guide/grammar-and-punctuation
https://m3.material.io/foundations/content-design/style-guide/ux-writing-best-practices
https://m3.material.io/foundations/content-design/style-guide/word-choice
---
## Design tokens — usage workflow
### Duplicates (no new content)
- `foundations/design-tokens/overview` — restates the reference's token definition, naming scheme,
three-class model (ref/sys/comp), and contexts verbatim (this is the site's single `design-tokens` page,
now also reachable at `/overview`).
### New — Reading a component's token spec table (foundations/design-tokens/overview)
Not in the reference: a documented procedure for finding a token in a component's Specs tab. Tokens there
are grouped first by **state** (enabled, disabled, hover, etc.), then by **element** (the part the token
applies to, e.g. container or label text). Table columns:
| Column | Meaning |
| --- | --- |
| Name | The style aspect the token applies to (e.g. color, font) |
| Token ID | The token defining that style aspect |
| Description | Optional extra info |
| Context/value | The value stored for a given context |
Worked example given on the page: to verify a filled button's label-text color, go to *Common buttons*
*Specs*, find the filled-button token module, locate the "label text" row under elements, and copy the color
token into code (or compare it to the Figma color role).
### New — How to use tokens, a Figma workflow (foundations/design-tokens/how-to-use-tokens)
Entirely new — a step-by-step production workflow, not covered anywhere in the reference:
1. **Install** the Material Theme Builder Figma plugin.
2. **Generate tokens**: Plugins → Material Theme Builder → Open Plugin → Get started. Creates a
`material-theme` artboard with baseline tonal palettes (light/dark) and a default type scale, populated
as Figma styles.
3. **Update token values** — two ways:
- Via the plugin (color only): re-open the plugin, choose new colors; color/text styles update in place.
- Via Figma styles directly: right-click a style → "Go to style definition," hover the style → the
adjust icon (or right-click → Edit style) to change name/description/properties.
4. **Use tokens in mockups**: apply the generated Figma styles to layout elements instead of hand-setting
color/typography, so engineers can trust the design intent transfers.
5. **Use tokens with the Material Design Kit**: duplicate the kit, open the plugin with kit components
selected, and choose **Swap** to replace baseline token values with the custom generated ones.
6. **Export tokens**: plugin's Export tab → choose a target format (Android, Jetpack Compose) → save as a
named `.zip`.
### Source URLs
https://m3.material.io/foundations/design-tokens/overview
https://m3.material.io/foundations/design-tokens/how-to-use-tokens
---
## Interaction: states (per-state one-line definitions)
### Duplicate, with one addition
`foundations/interaction/states/overview` restates the "two visual indicators," "states can combine," and
six-state list already in the reference. New here: the site's own one-line definition for each state
(not quoted in the reference), verbatim:
| State | Definition |
| --- | --- |
| Enabled | "communicates an interactive component or element" |
| Disabled | "communicates an inoperable component or element" |
| Hover | "communicates when a user has placed a cursor above an interactive element" |
| Focused | "communicates when a user has highlighted an element, using an input method such as a keyboard or voice" |
| Pressed | "communicates a user tap" |
| Dragged | "communicates when a user presses and moves an element" |
### Source URL
https://m3.material.io/foundations/interaction/states/overview
---
## Layout — adaptive design, breakpoints per class, canonical layouts
### Duplicates (no new content)
- `foundations/layout/layout-overview/overview` — same 7-parts-of-layout figure and Layout terms glossary
already quoted in the reference (this is the site's `layout-overview` tab, now also at `/overview`).
- `foundations/layout/scaffold/overview` — same Bars/Rails/Panes summary already in the reference.
- `foundations/layout/grids-spacing/overview` — same "grids adapt across breakpoints / spacing groups &
directs attention / density for data-heavy views" summary already in the reference.
- `foundations/layout/breakpoints/overview` — same breakpoint width table, pane/navigation/communication/
action table, five adaptation questions, and common-swappable-components table already fully quoted in
the reference.
- `foundations/usability/overview` and `foundations/usability/applying-m3-expressive` — **both render the
same content** as the reference's Usability section (five NN/g usability aspects, usability-vs-
accessibility distinction, the M3-Expressive design-tactics list — color/contrast, containment, motion,
shape & shape morph, size, typography — and "design based on primary goals"). No new facts on either page.
### New — Adaptive design, defined (foundations/layout/layout-overview/adaptive-design)
Not covered in the reference at all — this is the site's actual definition of the "adaptive design" term
the reference only flagged as a May-2026 rename target:
- **Adaptive vs. responsive**: "While responsive design scales a single layout to fit any screen, adaptive
design customizes a product to optimize the experience on each device." Adapts to **People** (preferences/
settings), **Devices** (watch, phone, foldable, tablet, desktop, XR), and **Usage** (window resize,
orientation, device switch).
- **Three primary experience types** — design starts at mobile and scales up to spatial; all three should
account for touch, pointer, and physical-keyboard input regardless of the device's default input:
| Experience | Notes |
| --- | --- |
| Mobile | Phones, foldables, tablets. Window modes: **full-screen** (default), **split-screen** (2+ apps sharing the screen), **bubbles** (floating multitask windows) |
| Desktop | Free-form windows adapting across breakpoints; split-screen/floating/free-form multitasking. A tablet becomes a desktop experience once a keyboard+mouse connect; an Android phone can become desktop-like via an external monitor |
| Spatial (XR) | Multiple free-form windows in a "virtually limitless" screen. Android XR's immersive **"full space"** mode positions components freely in 3D. A navigation rail can become an **Orbiter**, floating beside the main pane (ties to the glossary's XR "Orbiter" term already in the reference, now with a concrete use case) |
- **Adaptive strategies** (named mechanism, not in the reference): as a pane/window resizes or someone
navigates, panes may change size, enter/exit, or reorganize via three strategies: **show and hide**,
**levitate**, and **reflow**. Panes are "the primary segments of a layout" and can change based on context.
- **Pane display styles**, with sharper definitions/examples than the reference's terse list:
- **Co-planar**: panes displayed side by side.
- **Floating**: a pane displayed above other panes/content, "like a dialog."
- **Docked**: a pane displayed above other content where one edge extends beyond a screen edge, "like a
bottom sheet."
- Compose's **Navigation 3** library lets multiple destinations show on screen simultaneously and adapts
layouts across window sizes/screens automatically.
- **Adapting components** — three component-level adaptation strategies:
| Strategy | Example given |
| --- | --- |
| Resizing | A button may scale with its parent container, or "hug" its content and stay left/right-aligned |
| Showing & hiding | A list item can reveal a description/extra info as its container scales up |
| Presentation changes | A FAB can become an extended FAB; a navigation rail can auto-expand as the window grows |
### New — Breakpoints, per window-size class (foundations/layout/breakpoints/{compact,medium,expanded,large-extra-large})
The reference only has the aggregate breakpoint table; these per-class pages add concrete margins, pane
widths, and navigation placement the reference doesn't state:
| Breakpoint | Width | Margins/spacer | Panes | Navigation | Notes |
| --- | --- | --- | --- | --- | --- |
| Compact | <600dp | **16dp** margins | 1 (always) | Navigation bar or modal expanded navigation rail, placed close to the screen edge for reach | Must transition dynamically on: foldable unfold, portrait→landscape rotation, exiting split-screen, multi-window resize larger, free-form resize |
| Medium | 600839dp | **24dp** margins and spacer | 1 recommended; 2 only for low-density content (e.g. settings) — each pane defaults to 50% width, don't set custom widths; a drag handle can expand either pane to 100% | Single-pane: navigation rail (can hide on secondary destinations if a back button still reaches the primary destination); two-pane: navigation bar (to keep panes at full width) | Reachability: top 25% of screen is hard to reach on horizontal tablets/unfolded foldables — limit interactions there; page defines 3 ergonomic regions (inconvenient reach / comfortable / challenging near bottom edge). Transition triggers: foldable fold, landscape→portrait rotation, full-screen→split-screen, multi-window start, free-form resize |
| Expanded | 8401199dp | **24dp** margins and spacer | 1 (only for dense content like video) or 2 (recommended); fixed-and-flexible layout's fixed pane defaults to **360dp**; a split-pane layout's two flexible panes visually center the spacer, with nav rail + first pane totaling 50% | Navigation rail (collapsed or expanded); hideable on secondary destinations with a back button; use tabs/other in-pane components for secondary/sort/filter nav | Transition triggers: foldable fold, landscape→portrait rotation, full-screen→split-screen, multi-window start, free-form resize |
| Large (12001599dp) & Extra-large (1600dp+) | — | **24dp** margins and spacer | 2 recommended (1 only for dense/media content); fixed-and-flexible fixed pane defaults to **412dp**; extra-large only can add a **standard side sheet as a 3rd pane** (side sheet max width 400dp even though 412dp is the general fixed-pane recommendation) — never exceed 3 panes | Navigation rail, collapsed or expanded depending on content volume; expanded rail suits extra-large best, collapse when space is needed or deep in the hierarchy | Large/extra-large are "most useful for...laptop and desktop devices"; some products may skip them entirely. Watch line length/typography at this size. Transition triggers: full-screen→split-screen, multi-window start, free-form resize |
### New — Canonical layout examples, in detail (foundations/layout/canonical-examples/{overview,feed,list-detail,supporting-pane})
The reference only had a stub (three names + "not fetched, outside foundations/*"); these are the real
`foundations/layout/canonical-examples/*` pages (distinct from the `m3/pages/canonical-examples/*` URLs the
reference declined to follow), each with compact/medium/expanded configurations:
**Overview**
- Three canonical layouts exist: **feed, list-detail, supporting pane** — each with compact/medium/expanded
configurations, meant as a starting point rather than a fixed template.
- Supporting-pane split defined precisely: primary/focus area "typically about two-thirds" of the window;
secondary/supporting area takes the remaining third.
- **Advanced custom layouts**: build on a canonical example or layer scaffold elements directly. The
**"layering"** technique uses the **levitate** adaptive strategy — a pane floats above other content for a
focused, task-oriented moment (examples given: reviewing a shopping basket, responding to comments,
creating a calendar event).
- Availability: Figma M3 Design Kit, Jetpack Compose canonical-layouts sample, Android Views (MDC-Android)
canonical-layouts sample.
**Feed**
- Use case: grid of cards/lists for quick browsing/discovery of large content volumes (news, photos, social
media); grids adapt from single- to multi-column at any size.
- Dividing space: flexible enough to mix proportions/sizes (e.g. small + large cards together) to signal
relationships between items; items reflow on rotation/unfold/multi-window, and item **order is determined
by position**.
- Across breakpoints: **Compact** — stack vertically, one card per row, full pane width. **Medium** — split
into multiple equal-width columns (example shown: 4 columns). **Expanded/large/extra-large** — more
columns than medium, and column width should usually increase as the breakpoint grows.
**List-detail**
- Use case: parent-child content pairings — text message + conversation, file browser + open folder, artist
+ album, settings + category detail, inbox + selected email.
- Visible-panes table (breakpoint dp ranges as stated on this page):
| Breakpoint (dp) | Visible panes |
| --- | --- |
| Compact (0599) | 1 pane |
| Medium (600839) | 1 (recommended) or 2 |
| Expanded (840+) | 2 |
| Large (12001599) | 2 |
| Extra-large (1600+) | 2 |
- **Compact**: single pane only, either list or detail view at a time.
- **Medium**: single pane for information-dense content/deep focus, **or** two-pane to browse and switch
quickly — use a bottom navigation bar or modal navigation rail in two-pane mode to maximize horizontal
space.
- **Expanded/large/extra-large**: two-pane.
- **Behavior**: a **Back button appears in the detail view only for single-pane layouts**; a **selected
state appears in the list view only for two-pane layouts**; use explicit/implicit grouping to direct
visual focus in two-pane layouts. Layouts transition dynamically as available space changes (e.g. rotating
from expanded to medium collapses two panes back to one).
**Supporting pane**
- Use case: secondary content that is "only meaningful in relation to" the primary content (contrast with
list-detail's parent-child relationship — use list-detail instead if that's the actual relationship).
Examples: productivity, document editing + commenting, content/media browsing.
- Placement/width table:
| Supporting-pane placement | Pane width | Breakpoint |
| --- | --- | --- |
| Below the focus pane | Flexible | Compact or Medium |
| Leading or trailing side of the focus pane | Fixed, **360dp** | Expanded |
- **Compact**: supporting pane appears below the focus pane; a bottom sheet is a good way to keep focus on
the primary pane while still giving access to supporting info.
- **Medium**: supporting pane appears below the focus pane (same as compact).
- **Expanded**: supporting pane moves to the leading or trailing side of the focus pane.
### Source URLs
https://m3.material.io/foundations/layout/layout-overview/overview
https://m3.material.io/foundations/layout/layout-overview/adaptive-design
https://m3.material.io/foundations/layout/scaffold/overview
https://m3.material.io/foundations/layout/grids-spacing/overview
https://m3.material.io/foundations/layout/breakpoints/overview
https://m3.material.io/foundations/layout/breakpoints/compact
https://m3.material.io/foundations/layout/breakpoints/medium
https://m3.material.io/foundations/layout/breakpoints/expanded
https://m3.material.io/foundations/layout/breakpoints/large-extra-large
https://m3.material.io/foundations/layout/canonical-examples/overview
https://m3.material.io/foundations/layout/canonical-examples/feed
https://m3.material.io/foundations/layout/canonical-examples/list-detail
https://m3.material.io/foundations/layout/canonical-examples/supporting-pane
https://m3.material.io/foundations/usability/overview
https://m3.material.io/foundations/usability/applying-m3-expressive
+748
View File
@@ -0,0 +1,748 @@
# Material Design 3 — Foundations reference
Extracted verbatim (with light reformatting) from m3.material.io/foundations for auditing an M3 Expressive
component library. Facts not present on the fetched pages are marked "(not on the site; from ...)".
Where the site's own text is ambiguous or inconsistent, both readings are quoted rather than resolved.
---
## Accessibility (foundations/overview, /overview/principles, /designing*, /writing, /building-for-all*)
### Principles
- "Accessibility by default is a core design value for Material." Accessibility requirements and goals are
documented across component pages and guidelines.
- Three principles for accessible design:
- **Honor individuals** — universal defaults rarely meet everyone's needs; build in customizable features
since a person's needs change over time.
- **Learn before, not after** — invest in research across a wide range of abilities before defining
solutions, rather than reacting to problems after launch.
- **Requirements as a starting point** — treat WCAG minimums as a floor for creative solutions, not a
ceiling (dark mode, text-to-speech, speech-to-text all originated as accommodations).
- "Usability focuses on making products intuitive and easy to understand for everyone" while "Accessibility
focuses on making products accessible for people with disabilities. Accessible experiences are
perceivable, operable, understandable and robust, and support people who use assistive technology."
(from foundations/usability)
### Rules (do / don't)
- **Do** use native platform elements/semantic HTML so components automatically carry the right
accessibility markup (e.g., a native `dialog`).
- **Don't** use non-standard elements to fake a standard pattern (e.g., a custom div styled as a dialog) —
it requires extra testing to work with assistive tech.
- Disabled states **do not** need to meet contrast requirements.
- **Landmarks (web only)** — classify page regions with ARIA landmark roles so visual structure is also
conveyed in code. The eight landmark roles: **navigation, search, main, banner, complementary,
contentinfo, region, form**.
- Only one **main**, one **banner**, one **contentinfo** per page.
- Label any landmark role that repeats (e.g., multiple `navigation` regions) with a unique, specific
label; **all regions** should be labeled.
- **Don't repeat the landmark role inside its own label** (e.g., a nav labeled "Navigation").
- **Headings (web only)**:
- Identify headings by content hierarchy, not visual styling.
- "Headings should not skip a level, for example, don't go from H2 to H4 without using an H3."
- Map content to H1H6 in sequential order.
- "A single H1 for the page title is recommended."
- Heading level and visual prominence don't have to match.
- **Focus order / keyboard**:
- Default tab order should follow the DOM and "generally flows from left to right; top to bottom." Use
this default unless a custom pattern requires otherwise.
- Define **initial focus** for a screen and for multi-element components (dialogs, complex cards).
- When a dialog opens, focus moves into it (to a specific interactive element); on close/cancel, focus
returns to the element that triggered it.
- Tab / Shift+Tab move between interactive elements (Shift+Tab reverses); arrow keys navigate within a
component (menu items, grid cells); Enter activates a link/button or submits a focused form.
- Grouped sub-elements can be one tab stop, navigated internally with arrow keys.
- **Keyboard shortcuts**: use two-or-more-key combinations by default. Document all shortcuts (help page
or tutorial). If a shortcut uses a single key, provide at least one of, in preference order:
1. (most preferred) remap to include a non-printable key,
2. (preferred) only activate when the relevant component is focused,
3. (temporary only) allow turning the shortcut off.
- **Labeling elements**:
- Needs a label: interactive icons/buttons with no/insufficient visible text, interactive images,
meaningful visual cues (progress bars, error states), meaningful icons/images, generic links
("Learn more"), buttons with ambiguous/duplicate text ("Save").
- Doesn't need a label: non-interactive UI text (read automatically), buttons with sufficient text
("Download image").
- **Don't include the element's role (button, menu, etc.) in the label text** — the role is added
automatically when the proper role is assigned.
- Decorative icons/images that add nothing for a screen-reader user should be marked decorative/hidden
(e.g., `alt=""`).
### Values
| Item | Value | Source |
| --- | --- | --- |
| Large text/graphics contrast (≥14pt bold / ≥18pt regular) | ≥ 3:1 against background | designing/color-contrast, writing |
| Small text contrast | ≥ 4.5:1 against background | designing/color-contrast, writing |
| Clustered non-text elements (e.g. a group of buttons) vs. background | ≥ 3:1 recommended | designing/color-contrast |
| Standalone elements (e.g. a single FAB) vs. background | not required to meet 3:1 (prominence already differentiates them) | designing/color-contrast |
| Disabled-state contrast | not required to meet contrast requirements | designing/color-contrast, applying-states |
| Touch target minimum | **48 × 48dp** (≈9mm physical; recommended range 710mm) | designing/structure |
| iOS touch target note | iOS recommends 44 × 44dp | designing/structure |
| Pointer (mouse/stylus) target minimum | **44 × 44dp** | designing/structure |
| Target spacing | ≥ 8dp between adjacent targets | designing/structure |
| Web default target size | at least 48 × 48 **CSS pixels** | grids-spacing/density |
| Alt text length | "up to 125 characters" (foundations/writing) **vs.** "recommended length ... is 140 characters" (content-design/alt-text) — site is internally inconsistent; both figures are quoted verbatim | writing, content-design/alt-text |
### Alt text rules (content-design/alt-text, foundations/writing)
- Alt text is read in place of an image by screen readers and shown if an image fails to load.
- **Do** write alt text that conveys context/meaning; **don't** leave the auto-generated filename as alt
text.
- Mark an image `alt=""` (decorative) if removing it loses no information.
- Focus on the meaning/purpose of the image, not exhaustive visual detail.
- **Don't start alt text with "image of"** — screen readers already announce "image".
- Essential vs. non-essential image content: essential info must meet the 3:1 (large) / 4.5:1 (small) contrast
above; decorative/non-essential elements are exempt.
### M3 vs M3 Expressive differences
- No explicit M3-vs-Expressive distinction is drawn on the Accessibility pages themselves; the WCAG-based
contrast and target-size rules are presented as constant across both.
### Accessibility requirements (recap)
- Two-indicator principle for states is asserted only as a bullet: **"States have two visual indicators to
ensure accessibility"** (foundations/interaction/states) — the page does not elaborate further on this
Foundations tier (see Interaction States section below for the gap this leaves).
### Source URLs
https://m3.material.io/foundations/overview
https://m3.material.io/foundations/overview/principles
https://m3.material.io/foundations/designing (tabs: Overview, Color contrast, Structure, Flow, Elements)
https://m3.material.io/foundations/designing/color-contrast
https://m3.material.io/foundations/designing/structure
https://m3.material.io/foundations/designing/flow
https://m3.material.io/foundations/designing/elements
https://m3.material.io/foundations/writing
https://m3.material.io/foundations/building-for-all
https://m3.material.io/foundations/building-for-all/user-needs (identical rendered content to /building-for-all)
https://m3.material.io/foundations/building-for-all/co-design
---
## Content design (foundations/content-design/*)
### Principles
- "UI text should be clear to anyone." Follow **Associated Press (AP) Style** unless Material notes
otherwise.
### Rules (do / don't) — Style guide (content-design/style-guide)
- **Explain consequences** in neutral, direct language; avoid alarming/condescending warnings. Do tell
users what will happen and how to undo it; don't misrepresent consequences to influence a decision.
- **Use scannable words and formats** — specific titles/headings people can skim.
- **Use sentence case.** "Unless otherwise specified, use sentence-style capitalization... All text,
including titles, headings, labels, menu items, navigation components, app bars, and buttons should use
sentence-style capitalization." Products/branded terms may still be capitalized. **Don't use title case.**
- **Use abbreviations sparingly.** Spell out words where possible; avoid Latin abbreviations ("e.g.",
"etc.") — use "for example," "and more" instead.
### Rules — Alt text (see also Accessibility above)
- Recommended alt text length: 140 characters (content-design/alt-text); don't start with "image of".
### Rules — Global writing (content-design/global-writing, /global-writing/word-choice)
- Global writing = writing inclusively for many cultures and optimized for localization/translation.
- **Use global examples; explain local references** — call out (in a message description for translators)
locations, names/nicknames, currencies, temperatures, date formats, and providers (internet/cable) that
don't translate globally.
- Use short, simple sentences; break into bullets/sections with headings.
- Avoid abbreviations; clarify ambiguous pronouns and "this"/"that" references.
- Avoid idiomatic, colloquial, or overly polite expressions (translation risk).
- Reduce technical jargon; clarify ambiguities.
### Rules — Notifications (content-design/notifications)
- Notifications should: be about the user (not the product); be precise, timely, actionable, contextual,
relevant; give easy opt-out controls; never be unsolicited ads.
- Put the most important info first (people skim in an F-shape).
- CTAs should be concise, specific, actionable.
- Make notifications relevant/personal — don't broadcast generically.
- **Avoid dynamic text**, especially in headlines (breaks character limits when translated, doesn't
expand if truncated). If used, pair with no more than one additional word, and prepare a fallback
notification that fits the character budget.
- **Don't use relative day words** ("today"/"tomorrow"/"tonight") — about 20% of users don't see a
notification the day it's sent; use the specific day of the week instead (exception: auto-dismissing
notifications tied to a timestamp).
- Use emoji sparingly; never to accentuate bad news (negative emoji tested poorly); don't replace words
with emoji.
- Don't repeat the app name/logo (already shown by the OS).
- Don't interrupt onboarding with unrelated notifications/emails.
- Give users in-context, clear opt-in/opt-out controls.
### Values
| Element | Character limit |
| --- | --- |
| Notification title | < 29 characters |
| Notification collapsed body | < 40 characters |
| Notification expanded body | < 80 characters (start with the collapsed body and extend it) |
| Notification buttons | 12 buttons, 12 words each |
| SMS, Latin-script languages | < 160 characters |
| SMS, non-Latin-script languages | < 134 characters |
| Alt text | 140 characters recommended (content-design/alt-text) / "up to 125 characters" (foundations/writing) |
### M3 vs M3 Expressive differences
- Not called out on these pages; content-design guidance is presented as version-agnostic.
### Accessibility requirements
- Alt text and captions are the accessibility mechanism this section owns (see Accessibility section for
full alt-text rules); captions benefit both sighted and screen-reader users.
### Source URLs
https://m3.material.io/foundations/content-design/overview
https://m3.material.io/foundations/content-design/alt-text
https://m3.material.io/foundations/content-design/global-writing
https://m3.material.io/foundations/content-design/global-writing/word-choice
https://m3.material.io/foundations/content-design/notifications
https://m3.material.io/foundations/content-design/style-guide
---
## Customization (foundations/customization)
### Principles
- "M3 opens up new possibilities for both brand colors and individual color preferences to converge in
one-of-a-kind experiences."
- Dynamic color lets an app retain brand-defining colors while also honoring a user's device/personal color
preference; works for custom (non-Material) components too.
### Rules
- Build a **custom color scheme** with the M3 color system so the app can respect device/app-level dynamic
color settings; always keep a fallback (non-dynamic) color scheme for users who don't enable dynamic
color.
- Apply dynamic color selectively if desired (e.g., only on a profile screen) rather than app-wide.
### Values
- **Five essential color groups / roles**: Primary, Secondary, Tertiary, Neutral, Neutral Variant. An input
(source) color generates a tonal palette that fills roles such as primary / on-primary / primary
container.
- The Material Theme Builder (Figma plugin) generates **color and type tokens**, exportable to multiple
code formats, including Design System Package (DSP) format.
### M3 vs M3 Expressive differences
- Not explicitly distinguished on this page; dynamic color and the 5-role model are presented as the
current (M3) system without an Expressive-specific callout.
### Accessibility requirements
- "The color system automatically handles critical adjustments that provide accessible color contrast,
legibility, interaction states, and component structure."
### Source URLs
https://m3.material.io/foundations/customization
---
## Design tokens (foundations/design-tokens)
Note: the task brief anticipated separate `/overview`, `/how-to-read-tokens`, `/how-to-use-tokens`
sub-pages; the live site has a **single page** at `foundations/design-tokens` covering all of this content
(confirmed via the site's own top-nav, which links only to `foundations/design-tokens`).
### Principles
- "Design tokens are small, reusable design decisions that make up a design system's visual style. Tokens
replace static values with self-explanatory names."
- A token = a code-like name (e.g. `md.ref.palette.secondary90`) + an associated value (e.g. `#E8DEF8`);
the value can be a color, typeface, measurement, or another token.
- Tokens let design and code stay in sync: "if a designer's mock-ups and an engineer's implementation both
reference the same token... they can be confident that the same [value] is being used in both places,"
even after the underlying value changes.
- Tokens should be used instead of hardcoded values whenever a design choice recurs across components.
### Rules
- **Do** use design tokens instead of hardcoded values.
- Name each token for how/where it's used (e.g. `md.comp.fab.primary.container.color` sets an FAB's
container color).
- **System tokens should point to reference tokens**, not static values, whenever possible.
- **Component tokens should point to a system or reference token**, not hardcoded values, whenever
possible.
- Tokens are recommended when: building a design system from scratch or updating one broadly, applying a
system across a suite of products, wanting to maintain/update styles easily, or wanting dynamic color.
Tokens are **less** helpful for an existing app with values unlikely to change and no design system.
### Values — naming scheme
| Part | Meaning | Example |
| --- | --- | --- |
| System prefix | Always starts with the system name | `md` (Material Design) |
| Class | `ref` = reference, `sys` = system, `comp` = component | `md.ref…`, `md.sys…`, `md.comp…` |
| Role/descriptor | Descriptive words for the token's purpose | `…palette.secondary90`, `…color.secondary-container`, `…fab.primary.container.color` |
### Values — the three token classes
| Class | Prefix | Definition | Example |
| --- | --- | --- | --- |
| Reference tokens | `ref` | "All available tokens with associated values." Point to static values (or other reference tokens); don't change based on context. | `md.ref.palette.secondary90``#E8DEF8`; `md.ref.typeface.plain-medium` → Roboto Medium |
| System tokens | `sys` | "Decisions and roles that give the design system its character" — theming happens here; can point to different ref tokens by context (e.g. light/dark theme). | `md.sys.color.secondary-container``md.ref.palette.secondary90`; `md.sys.typescale.label-medium.font` |
| Component tokens | `comp` | "(in development)" — the design properties assigned to a specific component's elements (container, label, icon, state, size, shape, color, elevation). | Extended FAB container color / label text tokens |
- **Contexts**: conditions under which a token points to a non-default value — "device form factors, dark
theme, dense layouts, and right-to-left writing systems" are the examples given. A context acts like a
tag overriding the default token value.
- Glossary cross-references (foundations/glossary): **Design tokens: Role** = "short nickname describing
the purpose of a design token... Also known as slots" (e.g. "On surface", "Body 1"); **Design tokens:
Value** = "the information defining a design attribute, either stored in a token or hard-coded."
### M3 vs M3 Expressive differences
- Component tokens are explicitly flagged as **"(in development)"** — i.e., not every component's tokens
are finalized/complete as of this fetch.
### Accessibility requirements
- Not directly addressed on this page; contexts (e.g. dark theme, dense layout) are the token mechanism
used elsewhere to satisfy accessibility/adaptation requirements.
### Source URLs
https://m3.material.io/foundations/design-tokens
---
## Interaction: gestures, inputs, selection, states
### Gestures (foundations/interaction/gestures)
**Principles**
- "Gestures help people navigate, take action, or transform content." UI elements should respond to
gestures in real time. Tap, scroll, and swipe are the most common.
**Types (do/don't-style catalogue, no numeric specs given)**
- Tap (navigate/interact), Double tap (zoom in/out), Long press (extra functionality / selection), Scroll
and pan, Swipe (switch peer views like tabs, complete actions, or reveal actions on a list item), Drag,
Pick up and move (long-press + drag to reorder), Pinch (scale), Compound gestures (e.g. pan + pinch on a
map).
- **Predictive back** (Android): swipe left/right to preview going back before committing. Compatible
components: **bottom sheet, navigation bar, navigation rail, search bar, side sheet**.
**Source**: https://m3.material.io/foundations/interaction/gestures
### Inputs (foundations/interaction/inputs)
**Principles**
- "Design for touch, keyboard, and mouse interactions." Embrace multiple input methods within one app.
**Rules / values**
- External input devices: mouse (left/right click, wheel, extra buttons), trackpad (click, gestures,
haptics), physical keyboard (replaces virtual keyboard; media keys; modifier keys), stylus.
- **Hover**: rest a cursor on an interactive element to trigger its hover state (see States below) and, if
applicable, a tooltip.
- **Cursor shapes**: Pointer (default), Hand (links/linked images), Resize arrows (resizable element
edges), I-beam (editable/selectable text). Text-selection click semantics: single click places cursor /
deselects & repositions; double click selects a word; triple click selects a paragraph.
- **Physical keyboard**: show/hide the on-screen (virtual) keyboard based on whether a physical keyboard is
attached (do hide it when a physical keyboard connects; do show it again when disconnected). Enter
typically sends/submits; Spacebar (or media keys) typically play/pause media.
**Source**: https://m3.material.io/foundations/interaction/inputs
### Selection (foundations/interaction/selection)
**Principles**
- Selection can be shown via a check icon, a checkbox, or a surface-color change (or a combination).
- An entire component, or only certain parts of it, can be selected. Selection can be performed by tap,
cursor, keyboard, or voice.
**Values**
- Components that **inherit selection styling**: cards, checkboxes, chips, data tables, icon buttons, list
items, menu items, pickers, radio buttons, segmented buttons, sliders, switch.
- Components that use an **active indicator** instead (only one selected at a time): navigation bar,
navigation drawer, navigation rail, tabs.
**Rules**
- Touch: long-press or two-finger touch (or a shortcut, e.g. tapping an avatar) enters selection mode; tap
additional items to add them; tap each selected item (or a toolbar action) to exit selection mode.
- Long-press + drag can batch-select — **don't** reuse this gesture combo for selection if it's already
used to pick up/move items (e.g. reordering cards).
- Desktop/click: when selection is the primary activity, checkboxes stay visible; when secondary, show a
checkbox on hover for a single item, and for all items once one is selected.
**Source**: https://m3.material.io/foundations/interaction/selection
### States (foundations/interaction/states, /states/state-layers, /states/applying-states)
**Principles**
- "States are visual indicators used to communicate the status of a component or interactive element."
- **"States have two visual indicators to ensure accessibility"** — stated as a bullet on the States
overview page; the site does **not** elaborate on this rule anywhere in Foundations (no page defines
what the two indicators must be, e.g. color + shape/outline). This is a documentation gap to flag when
auditing — Foundations only asserts the principle, not the mechanism.
- States can combine (e.g. selected + hover); apply states consistently across components.
- Six states: **Enabled, Disabled, Hover, Focused, Pressed, Dragged.**
**State layer mechanics (states/state-layers)**
- "A state layer is a semi-transparent covering on an element that indicates its state," providing "a
systematic approach to visualizing states by using opacity." Only one state layer can be applied at a
given time; it can cover the whole element or a circular sub-region.
- The state layer's color equals the **content's "on" color** (e.g. if the container uses **secondary
container** and content uses **on secondary container**, the state layer uses **on secondary
container**).
- **"The size of state layers is 40dp while the interactive target size is 48dp."**
**Values — state layer / disabled opacities** (from the states/state-layers interactive module, exact
figures)
| State | Opacity |
| --- | --- |
| Hover | 0.08 (+8%) |
| Focus | 0.10 (+10%) |
| Pressed | 0.10 (+10%) |
| Dragged | 0.16 (+16%) |
| Disabled | 0.38 |
**Rules — per-state inheritance (applying-states)**
| State | Inherited by | NOT inherited by |
| --- | --- | --- |
| Disabled | Buttons, Cards, Checkboxes, Chips, List items, Radio buttons, Switches, Text fields | App bars, Badges, Dialogs, FABs, Menus, Navigation bar/drawer/rail, Sheets, Tabs, Tooltips |
| Hover | Buttons, Cards, Checkbox, Chips, Date/time pickers, List items, Slider, Switch, Text fields | App bars, Badges, Dialogs, Menus, Navigation bar/drawer/rail, Sheets, Tabs |
| Focused | Buttons, Cards, Checkbox, Chips, Date/time pickers, List items, Selection controls, Text fields | App bars, Badges, Banner, Card, Dialogs, Navigation bar/drawer/rail, Sheets |
| Pressed | Buttons, Cards, Checkbox, Chips, List items, Text fields | App bars, Badges, Bottom navigation, Dialogs, Menus, Sheets, Tabs |
| Dragged | Cards, Chips, List items, Sliders | App bars, Badges, Buttons, Dialogs, Menus, Navigation bar/drawer/rail |
- **Disabled**: communicated via color change + reduced elevation; not focusable, draggable, or pressable;
no hover/other state layer; exempt from contrast requirements; "if the action... is unavailable, the FAB
shouldn't appear" (don't just disable it) — a Don't example specifically about FABs.
- **Hover**: lower-emphasis overlay; appears/disappears with a "low-emphasis animated fade"; only one hover
state active in a layout at a time; can combine with focused/activated/selected/pressed.
- **Focused / keyboard focus indicator**: "When an element is tabbed to, it appears in its focused state
with a ring-like keyboard focus indicator." **No thickness, offset, or color token is specified anywhere
in Foundations** — this page only calls it "ring-like." (Exact focus-ring specs, if they exist, live in
per-component spec pages outside the Foundations section — flag as out-of-scope-for-this-file / a gap
for the audit.) Only one focus state active at a time; initiated by Tab (or equivalent).
- **Pressed**: higher-emphasis than hover; a **ripple overlay** signifies press (can also/alternatively use
an elevation change); only one pressed state active at a time; initiated by tap/click/keyboard/voice
activation.
- **Dragged**: lower-emphasis overlay than pressed, "to avoid distracting users from their task"; can also
use elevation; only one dragged state active at a time; initiated by touch-and-hold + move.
### M3 vs M3 Expressive differences
- Not explicitly called out for interaction/states; the state-layer opacity table and inheritance lists are
presented as the current, single spec (this appears to be the M3 Expressive-era rewrite of the classic M2
state-layer table, but the site does not itself label old vs. new values here).
### Accessibility requirements
- The "two visual indicators" principle (see above) is the accessibility hook for state changes but is left
unelaborated in Foundations.
- Disabled components are fully exempted from contrast requirements.
- Keyboard operability requirements live in Accessibility → Flow (focus order, keyboard shortcuts) above,
and cross-reference into States for the focus indicator.
### Source URLs
https://m3.material.io/foundations/interaction/gestures
https://m3.material.io/foundations/interaction/inputs
https://m3.material.io/foundations/interaction/selection
https://m3.material.io/foundations/interaction/states/overview
https://m3.material.io/foundations/interaction/states/state-layers
https://m3.material.io/foundations/interaction/states/applying-states
---
## Layout (foundations/layout/*)
Note on the task brief's guessed slugs: the live IA does **not** use `/understanding-layout/*` or
`/applying-layout/*`. Real structure discovered via the site's own nav/tab links:
- `layout/layout-overview` (tabs: Overview, **Overview: Parts of layout**`/layout-overview/parts-of-layout`)
- `layout/scaffold` (tabs: Overview, Bars, Rails, Panes)
- `layout/grids-spacing` (tabs: Overview, Grids, Spacing, Density)
- `layout/breakpoints`
- `layout/bidirectionality-rtl`
- `layout/canonical-examples`
### Principles
- "Layout is the visual and strategic arrangement of elements on a screen," used to organize elements,
signal hierarchy, and draw attention to key actions.
- Adapt layouts to the five breakpoints; build from an established canonical layout example; design for
bidirectionality (LTR + RTL); apply consistent arrangement/sizing/spacing.
- Material's components are built with **"leading"/"trailing"** (not left/right) naming so they support RTL
by construction — "extra configuration may be needed to achieve specific RTL situations," though.
- **May-2026 renames** (explicitly called out on the site as new terminology): **"Window size classes
renamed to breakpoints"**; **"Responsive layout renamed to adaptive design."**
### Values — Layout terms glossary (layout-overview)
- **Adaptive design**: techniques letting an interface dynamically respond to user preference, device
type, state, and breakpoints.
- **Bars**: frame the page (app bar, bottom navigation bar).
- **Bidirectionality**: a writing system displaying content RTL.
- **Breakpoints**: "opinionated window sizes where a layout changes to match available space, device
conventions, and ergonomics (previously window size classes)."
- **Column**: one or more vertical blocks of content within a pane.
- **Drag handle**: resizes panes.
- **Fold**: the flexible/hinge area separating two displays on a foldable.
- **Gap**: space between components/elements within a container.
- **Margin**: space between the screen edge and elements inside it.
- **Multi-window mode**: multiple apps sharing one screen simultaneously.
- **Pane**: "a layout container that houses other components and elements within a single app... fixed,
flexible, floating, or semi-permanent."
- **Rails**: perimeter space around panes holding navigation rails, toolbars, pane controls.
- **RTL language**: e.g. Arabic, Hebrew, Farsi — used by "over 2 billion people."
- **Rulers**: opinionated global alignment lines.
- **Safety region**: zones reserved for system UI (status bar, gesture bar) — "shouldn't contain primary
content."
- **Scaffold**: "a fundamental UI design structure that provides a standard platform for assembling key
screen components."
- **Spacer**: space between two panes on a foldable.
- **7 parts of layout** (numbered figure caption): Column, Fold, Margin, Bar, Drag handle, Pane, Rail.
### Values — Breakpoints (layout/breakpoints)
| Breakpoint | Width (dp) | Common devices |
| --- | --- | --- |
| Compact | Under 600dp | Phone in portrait |
| Medium | 600839dp | Tablet in portrait; foldable in portrait (unfolded) |
| Expanded | 8401199dp | Phone/tablet in landscape; foldable in landscape (unfolded); desktop |
| Large | 12001599dp | Desktop |
| Extra-large | 1600dp+ | Desktop; ultra-wide monitors |
- Height breakpoints (compact/medium/expanded) also exist on Android but are rarely needed since most
layouts scroll vertically.
**Breakpoint → recommended panes / navigation / communication / action** (layout/breakpoints)
| Breakpoint | Panes | Navigation | Communication | Action |
| --- | --- | --- | --- | --- |
| Compact | 1 | Navigation bar, modal expanded navigation rail | Simple dialog, full-screen dialog | Bottom sheet |
| Medium | 1 (recommended) or 2 | Navigation bar, modal expanded navigation rail | Simple dialog | Menu |
| Expanded | 1 or 2 (recommended) | Modal or standard expanded navigation rail | Simple dialog | Menu |
| Large | 1 or 2 (recommended) | Modal or standard expanded navigation rail | Simple dialog | Menu |
| Extra-large | 1 to 3 (recommended) | Modal or standard expanded navigation rail | Simple dialog | Menu |
**Common swappable components by breakpoint** (layout/breakpoints)
| Component type | Compact | Medium | Expanded |
| --- | --- | --- | --- |
| Navigation | Navigation bar | Collapsed navigation rail | Collapsed navigation rail |
| Navigation (secondary row) | Modal expanded navigation rail | Modal expanded navigation rail | Standard expanded navigation rail |
| Communication | Basic or full-screen dialog | Basic dialog | Basic dialog |
| Supplemental selection | Bottom sheet | Menu | Menu |
- Pane-count guidance restated: **compact/medium → 1 pane; expanded/large → 2 panes recommended;
extra-large → consider 3 panes.**
- Five questions to ask moving up a breakpoint: what should be **revealed, divided (paned), resized,
repositioned, swapped**. Across breakpoints, keep text to **4060 characters per line**.
- Component-swap example: compact navigation bar → medium/expanded navigation rail; collapsed rail
(medium/expanded) → expanded rail (large/extra-large). **Don't** swap functionally non-equivalent
components (e.g. a button for a menu).
### Values — Scaffold (layout/scaffold, /bars, /rails, /panes)
- Scaffold = **Bars + Rails + Panes**, arranged around a **safety region** that must stay clear of primary
content.
- **Bars**: frame the page; contain app bar (top) or navigation bar (bottom). App bars: title + 12
essential actions (search, back). "**Navigation bars let people switch between 35 primary UI views**"
at compact/medium breakpoints.
- **Rails**: the perimeter layer after bars; hold navigation rails, toolbars, chat inputs, FABs, other
primary controls; on desktop, a leading/trailing rail; on mobile, a toolbar can float in the rail region.
- **Panes**: all content must live in a pane; a layout has **13 panes**, adapting to breakpoint and
language direction (RTL flips navigation to the right).
- **Single-pane** layouts: any breakpoint, recommended at compact/medium.
- **Two-pane** layouts: common at expanded/large/extra-large; fixed-and-flexible panes can appear in
either order.
- **Three-pane** layouts: "Fixed panes at this breakpoint are recommended to be **412dp**, but side
sheets have a default maximum width of **400dp**."
- **Snap widths** when resizing a two-pane layout at expanded/large/extra-large: **360dp**, **412dp**, or
a centered split-pane with spacer.
- **Persistent resizing** (remembers width across sessions/breakpoint changes) is the default
recommendation for most resizable layouts; **temporary resizing** (reverts to default on
close/reopen) is for supporting-pane layouts where resizing is uncommon.
- Multi-pane display styles: **co-planar** (side by side — used for persistent utilities so they stay
accessible), **floating**, **docked**.
- **Accessibility**: coplanar-pane focus order must match visual arrangement; modal floating panes trap
focus (move focus in on open, return it to the trigger on close) and close on outside interaction;
non-modal floating panes let focus move freely in/out and must sit in logical reading order; docked
panes follow the same focus rules as floating panes, plus the coplanar focus-order requirement.
### Values — Grids & spacing (layout/grids-spacing, /grids, /spacing, /density)
- Grids: column count/width/spacing scale with breakpoint (compact fewer columns → expanded/foldable more
columns); **no explicit numeric column-count or gutter table is given on the current site** (this is a
gap versus the classic M2 12-column grid spec — flag if the component library assumes fixed column
counts).
- **Rulers**: Bar & safety rulers (reserve system-UI space), Title ruler (aligns app-bar title), Content
rulers (first ruler = major blocks like hero/headline; secondary rulers = supplementary text/actions).
Margin rulers can be widened/narrowed for expressive effect (e.g., full-bleed photo grid vs. a
wide-margin search bar).
- Spacing is used to **group** (explicit grouping = outlines/dividers/shadows; implicit grouping =
proximity/whitespace only), to **direct attention** (rhythm, similarity, proximity, continuity), and as
**expression** (generous spacing + bright surfaces on the most important content; negative space for
emphasis). No fixed numeric spacing scale (e.g. 4/8/16dp increments) is stated on this page beyond the
8dp target-spacing rule already listed under Accessibility.
- **Density** (component scaling):
- Density scale is numbered starting at **0** (default) and moves negative (**-1, -2, -3**) as space
decreases.
- "Higher density is typically applied by decreasing the top and bottom padding or overall height by
**4dp**" per step.
- Text size should not change as a container scales with density.
- Example figures from the page: 20dp gap between a text field's label and input; a "don't" example shows
a dropdown item shrunk to 38dp of selectable height (too dense — reduces usability).
- **Density must be opt-in, never default**: "People should be able to **opt in** to dense layouts and
components." Settings interactions themselves must keep default target sizes (**48×48 CSS pixels**).
"Don't scale layouts below 48×48dp by default."
- **Interaction targets must stay ≥48×48dp even when the visible element is scaled down** — e.g. a
settings icon can be 24×24dp visually but must keep a 48×48dp target; a 36dp-tall button can keep a
48dp target.
- Don't increase density in focused/high-stakes UI (menus, snackbars, dialogs).
- Pixel density formula: `pixel density = screen width (or height) in px / screen width (or height) in
inches`.
- **Density-independent pixel (dp)**: "A dp is equal to one physical pixel on a screen with a density of
160." Formula: `dp = (width in pixels × 160) / screen density`.
### Values — Bidirectionality / RTL (layout/bidirectionality-rtl)
- Over 2 billion people read/write RTL languages (Arabic, Hebrew, Farsi, Urdu).
- **Mirroring**: LTR ⇄ RTL flips element alignment/order; reading starts top-right in RTL. **Exception**:
graphs/charts stay LTR even in Persian and Urdu.
- **Text rendering** has two parts: alignment (box placement) and directionality (text/element flow).
Common RTL bugs: text entry, cursor position, punctuation, phone numbers, URLs. **Don't** reverse an
email's username/domain order (domain always stays to the right of the username, even though the
username itself can render RTL). **Don't** apply LTR directionality to RTL content (scrambles word
order) — content needs both RTL alignment *and* RTL directionality.
- **Icons**: directional icons (back/forward, send) mirror in RTL. **Exception**: in Hebrew, timelines and
media controls keep LTR directionality; help icons mirror only in some RTL languages (e.g. Urdu,
Persian).
- **Time / progress**: linear progress indicators fill right-to-left in most RTL languages, **except
Hebrew, which stays LTR**. Circular progress indicators always move clockwise regardless of direction.
Media player controls (video/audio) are **always LTR**. Clocks always turn clockwise; on a 12-hour clock
in RTL, the AM/PM symbol moves to the left; the 24-hour clock is common outside English-primary
locales.
- **Canonical layouts in RTL**: list-detail, feed, and supporting-pane layouts are all explicitly "mirrored
in RTL."
- **Component specifics**: badges reposition; toolbars mirror tool order; app bars mirror layout and flip
directional icons; **navigation rail sits on the leading edge — right side in RTL, left in LTR**
(expanded nav rail too); text field leading/trailing icons swap sides; a chip's trailing icon "is always
aligned to the end side of the container" (right for LTR, left for RTL).
- **Gestures**: swipe-to-reveal actions and Android predictive back must mirror their LTR counterparts
(e.g. a delete action revealed by a right-swipe in LTR should be revealed by a left-swipe in RTL).
### Values — Canonical layout examples (layout/canonical-examples)
- Named canonical layouts referenced: **feed**, **list-detail**, **supporting-pane** (each links out to a
dedicated `m3.material.io/m3/pages/canonical-examples/...` page — a legacy/alternate URL namespace not
fetched here since it falls outside `foundations/*`). An "Advanced custom layouts" section exists on the
page but only points onward, with no further numeric content on this page itself.
### M3 vs M3 Expressive differences
- The May-2026 rename ("window size class" → "breakpoint"; "responsive layout" → "adaptive design") is the
only explicit terminology-version marker found in Layout.
- Density (opt-in component scaling) and the layout scaffold (bars/rails/panes model) are presented as the
current single system, introduced/updated as part of the same May-2026 refresh per layout-overview's
"What's new" log ("Introduced layout scaffold... Updated canonical layout examples... Spacing system").
### Accessibility requirements
- Safety regions must stay clear of primary content (keeps content from being obscured by system UI).
- Pane focus-order/trap rules above (coplanar order must match visual order; modal panes trap and restore
focus).
- RTL is treated as an accessibility/global-reach requirement throughout, not an optional locale nicety.
- Minimum 48×48dp interaction targets carry through from Accessibility into density/scaling rules here.
### Source URLs
https://m3.material.io/foundations/layout/layout-overview
https://m3.material.io/foundations/layout/layout-overview/parts-of-layout
https://m3.material.io/foundations/layout/scaffold/overview
https://m3.material.io/foundations/layout/scaffold/bars
https://m3.material.io/foundations/layout/scaffold/rails
https://m3.material.io/foundations/layout/scaffold/panes
https://m3.material.io/foundations/layout/grids-spacing/overview
https://m3.material.io/foundations/layout/grids-spacing/grids
https://m3.material.io/foundations/layout/grids-spacing/spacing
https://m3.material.io/foundations/layout/grids-spacing/density
https://m3.material.io/foundations/layout/breakpoints
https://m3.material.io/foundations/layout/bidirectionality-rtl
https://m3.material.io/foundations/layout/canonical-examples
---
## Usability (foundations/usability)
### Principles
- "Usability focuses on making products intuitive and easy to understand for everyone" — distinct from
Accessibility (see Accessibility section for the exact wording of the distinction).
- Key takeaways: emphasize key actions for visual hierarchy; leverage M3 Expressive design tactics; don't
overwhelm users with visual information; test and iterate.
### Values — Nielsen Norman Group's five usability aspects (quoted directly)
| Aspect | Definition |
| --- | --- |
| Efficiency | Users can efficiently complete tasks and goals |
| Errors | Proper design reduces the likelihood of mistakes, and users can easily correct any errors that do occur |
| Learnability | New users learn to use the product and complete tasks easily, even the first time |
| Memorability | Returning users remember how to use the product |
| Satisfaction | Users are satisfied with the designed experience |
### Rules — Usability design tactics (explicitly framed as **M3 Expressive** tactics)
- **Color & contrast**: use eye-catching primary/secondary colors and contrasting pairs (e.g. purple +
green) for hierarchy; always follow the accessibility contrast rules above.
- **Containment & grouping**: group related elements in subtle containers; break content into sections with
containment/spacing/headings.
- **Motion**: use sparingly to emphasize key moments — "motion can be distracting" if overused.
- **Shape & shape morph**: "The Material shape library has **35 shapes**." Shape adds emphasis/delight,
guides focus, differentiates containers/buttons/animations, signals interaction, sets emotional tone.
Every shape can morph into any other in the set; shape morph communicates interaction states (selected,
tap, swipe, scroll, release, long press) and emphasizes in-progress actions.
- **Size**: the most important action/CTA should be the largest element; larger key actions measurably
improve efficiency, error rate, satisfaction, learnability (per the doc's own claim, no citation given).
- **Typography**: use type scale/weight to separate information hierarchies (largest/most legible text =
primary action; smaller text = secondary/tertiary); group similar content with the same font style.
- Design around **primary / secondary / tertiary goals**: give primary goals the strongest emphasis;
simplify to one primary task per page; make core actions large/reachable; don't stack too many expressive
tactics at once (distracting).
- **Iterate**: test and gather feedback early/often from a range of users/contexts.
### M3 vs M3 Expressive differences
- This entire page's "design tactics" section is explicitly attributed to **M3 Expressive** ("Leverage
[expressive design tactics]... containment, size, shape, color, and typography"), including the 35-shape
library and shape-morph mechanic, which are Expressive-era additions.
### Accessibility requirements
- "Accessibility focuses on making products accessible for people with disabilities... perceivable,
operable, understandable and robust... support people who use assistive technology" (contrasted directly
with usability's broader "intuitive for everyone" framing).
### Source URLs
https://m3.material.io/foundations/usability
---
## Glossary terms that matter for a web component library (foundations/glossary)
Only terms relevant to auditing a component library are excerpted; the full glossary runs AZ (~20KB) and
also defines many component names already covered under Components (out of scope here). Quoted verbatim.
| Term | Definition (verbatim) |
| --- | --- |
| Adaptive design | "A design approach in which the interface changes based on known user, device, or environmental conditions. Adaptive design in Material includes layout and component adaptations." |
| Condition | "A signal that determines when and how an adaptive layout or component should adapt." |
| Contrast | "Difference between colors. For accessibility, contrast refers strictly to the difference in tone. **A difference of 40 in tone guarantees a WCAG contrast ratio ≥ 3.0; a difference of 50 in tone guarantees a contrast ratio ≥ 4.5.**" |
| Customization | "A modification made to a UI reflecting an app, OEM, or individual user's visual preferences and brand." Can be per-element or global (a theme). |
| Dark theme | "A dark theme is a low-light UI that displays mostly dark surfaces." |
| Design tokens | "A design token represents a small, reusable design decision that's part of a design system's visual style. Tokens replace static values with self-explanatory names." |
| Design tokens: Context | "The set of conditions in which tokens can point to non-default values (for example, dark theme, dense layout)." |
| Design tokens: Role | "A shortened version of the system token name (for example, Secondary container color; Headline 1)." |
| Design tokens: Types | Reference tokens (e.g. `md.ref.palette.secondary200`); Component tokens (define a component element's design attributes); System tokens (e.g. `md.sys.color.secondary-container`) |
| Design tokens: Value | "The information defining a design attribute, either stored in a token or hard-coded." |
| Element | (page continues past excerpt boundary — not fully captured; component name only) |
| HCT | "HCT is an abbreviation of hue, chroma, tone. It's the name of the color space that enables dynamic color. HCT is based on CAM16 hue and chroma; the L* construct for luminance from L*a*b* (CIELAB, 1976) is denoted as T for tone." |
| Mode | (component/theming term; not fully excerpted) |
| Orbiter | "Floating UI elements that control the content within spatial panels." (XR-adjacent; likely out of scope) |
| Pane | "The building blocks of a layout. Content and actions are grouped into panes, which adapt the content to best fit the screen." |
| Role | "Short nickname describing the purpose of a design token within a design system. Also known as slots. Examples: On surface; Body 1." |
| Spatial | (XR-adjacent term; out of scope per task instructions) |
| Style | (design-spec term; not fully excerpted) |
| Theme | (not fully excerpted — see "Note: A light scheme is not the same as a light theme" under Color: Scheme) |
### Color-system glossary terms (used throughout Customization/Design tokens)
| Term | Definition (verbatim) |
| --- | --- |
| Color: Baseline scheme | "the group of selected tones that make up the default colors values used for light and dark themes." |
| Color: Dynamic color | "A customization feature in which a user-generated color scheme is mapped to an app's color scheme." Not simply Theme-Builder output or an algorithmic scheme — it's the *presence of a changeable color role*. |
| Color: Extended color | "A color specified (in addition to key colors) in order to fill color roles for custom schemes... brand expression or conventional (semantic) meanings." |
| Color: Key color | "not an extracted color — it's a derivation of the source color... the basis for a tonal palette." |
| Color: Scheme | "Any mapping of color roles to specific tones from a tonal palette." A **dark scheme** ≠ a **dark theme** (scheme vs. theme distinction explicitly noted). |
| Color: Source color | "The single color that's extracted to define all five key colors." |
| Color: Tonal palette | "a 13-tone range that serves as the basis for mapping tones to specific roles." |
| Color: Tone | "colors with the same hue and chroma... degrees of lightness." Code: `Tone.of(hex).get(tone)` or `Tone.of(hue, chroma).get(tone)`. |
| Color: User-generated schemes | colors derived from a user's wallpaper selection or Android preset colors. |
### Source URLs
https://m3.material.io/foundations/glossary
---
## Cross-cutting gaps found (for the auditing agents to be aware of)
1. **Focus indicator specifics** (thickness / offset / color token) are **not defined anywhere in
Foundations** — only "ring-like keyboard focus indicator" is stated (states/applying-states). If the
component library asserts a specific focus-ring spec, that spec's source is not this section of the
site.
2. **The "two visual indicators" accessibility rule for states** is asserted once, as a bullet, with zero
elaboration anywhere in Foundations on what the two indicators must be.
3. **Alt text length is inconsistent across the site itself**: 125 characters (foundations/writing) vs. 140
characters (foundations/content-design/alt-text).
4. **No explicit numeric grid/column/gutter table** exists on the current (M3 Expressive-era) Layout pages,
unlike the classic Material 2 12-column-grid spec; grids/spacing guidance here is qualitative except for
the 8dp target-spacing and pane-width (360/400/412dp) numbers captured above.
5. **Component tokens are explicitly flagged "(in development)"** on the design-tokens page — treat
component-token completeness claims cautiously.
6. Two task-provided sub-page slugs did not exist as separate URLs: `design-tokens/overview`,
`/how-to-read-tokens`, `/how-to-use-tokens` all collapse into the single `foundations/design-tokens`
page; `layout/understanding-layout/*` and `layout/applying-layout/*` do not exist — the real nesting is
`layout/layout-overview/parts-of-layout`, `layout/scaffold/{bars,rails,panes}`, and
`layout/grids-spacing/{grids,spacing,density}` (all fetched and covered above).
+238
View File
@@ -0,0 +1,238 @@
https://m3.material.io/components
https://m3.material.io/components/all-buttons
https://m3.material.io/components/app-bars/accessibility
https://m3.material.io/components/app-bars/guidelines
https://m3.material.io/components/app-bars/overview
https://m3.material.io/components/app-bars/specs
https://m3.material.io/components/badges/accessibility
https://m3.material.io/components/badges/guidelines
https://m3.material.io/components/badges/overview
https://m3.material.io/components/badges/specs
https://m3.material.io/components/bottom-sheets/accessibility
https://m3.material.io/components/bottom-sheets/guidelines
https://m3.material.io/components/bottom-sheets/overview
https://m3.material.io/components/bottom-sheets/specs
https://m3.material.io/components/button-groups/accessibility
https://m3.material.io/components/button-groups/guidelines
https://m3.material.io/components/button-groups/overview
https://m3.material.io/components/button-groups/specs
https://m3.material.io/components/buttons/accessibility
https://m3.material.io/components/buttons/guidelines
https://m3.material.io/components/buttons/overview
https://m3.material.io/components/buttons/specs
https://m3.material.io/components/cards/accessibility
https://m3.material.io/components/cards/guidelines
https://m3.material.io/components/cards/overview
https://m3.material.io/components/cards/specs
https://m3.material.io/components/carousel/accessibility
https://m3.material.io/components/carousel/guidelines
https://m3.material.io/components/carousel/overview
https://m3.material.io/components/carousel/specs
https://m3.material.io/components/checkbox/accessibility
https://m3.material.io/components/checkbox/guidelines
https://m3.material.io/components/checkbox/overview
https://m3.material.io/components/checkbox/specs
https://m3.material.io/components/chips/accessibility
https://m3.material.io/components/chips/guidelines
https://m3.material.io/components/chips/overview
https://m3.material.io/components/chips/specs
https://m3.material.io/components/date-pickers/accessibility
https://m3.material.io/components/date-pickers/guidelines
https://m3.material.io/components/date-pickers/overview
https://m3.material.io/components/date-pickers/specs
https://m3.material.io/components/dialogs/accessibility
https://m3.material.io/components/dialogs/guidelines
https://m3.material.io/components/dialogs/overview
https://m3.material.io/components/dialogs/specs
https://m3.material.io/components/divider/accessibility
https://m3.material.io/components/divider/guidelines
https://m3.material.io/components/divider/overview
https://m3.material.io/components/divider/specs
https://m3.material.io/components/extended-fab/accessibility
https://m3.material.io/components/extended-fab/guidelines
https://m3.material.io/components/extended-fab/overview
https://m3.material.io/components/extended-fab/specs
https://m3.material.io/components/fab-menu/accessibility
https://m3.material.io/components/fab-menu/guidelines
https://m3.material.io/components/fab-menu/overview
https://m3.material.io/components/fab-menu/specs
https://m3.material.io/components/floating-action-button/accessibility
https://m3.material.io/components/floating-action-button/guidelines
https://m3.material.io/components/floating-action-button/overview
https://m3.material.io/components/floating-action-button/specs
https://m3.material.io/components/icon-buttons/accessibility
https://m3.material.io/components/icon-buttons/guidelines
https://m3.material.io/components/icon-buttons/overview
https://m3.material.io/components/icon-buttons/specs
https://m3.material.io/components/lists/accessibility
https://m3.material.io/components/lists/guidelines
https://m3.material.io/components/lists/overview
https://m3.material.io/components/lists/specs
https://m3.material.io/components/loading-indicator/accessibility
https://m3.material.io/components/loading-indicator/guidelines
https://m3.material.io/components/loading-indicator/overview
https://m3.material.io/components/loading-indicator/specs
https://m3.material.io/components/menus/accessibility
https://m3.material.io/components/menus/guidelines
https://m3.material.io/components/menus/overview
https://m3.material.io/components/menus/specs
https://m3.material.io/components/navigation-bar/accessibility
https://m3.material.io/components/navigation-bar/guidelines
https://m3.material.io/components/navigation-bar/overview
https://m3.material.io/components/navigation-bar/specs
https://m3.material.io/components/navigation-drawer/accessibility
https://m3.material.io/components/navigation-drawer/guidelines
https://m3.material.io/components/navigation-drawer/overview
https://m3.material.io/components/navigation-drawer/specs
https://m3.material.io/components/navigation-rail/accessibility
https://m3.material.io/components/navigation-rail/guidelines
https://m3.material.io/components/navigation-rail/overview
https://m3.material.io/components/navigation-rail/specs
https://m3.material.io/components/progress-indicators/accessibility
https://m3.material.io/components/progress-indicators/guidelines
https://m3.material.io/components/progress-indicators/overview
https://m3.material.io/components/progress-indicators/specs
https://m3.material.io/components/radio-button/accessibility
https://m3.material.io/components/radio-button/guidelines
https://m3.material.io/components/radio-button/overview
https://m3.material.io/components/radio-button/specs
https://m3.material.io/components/search/accessibility
https://m3.material.io/components/search/guidelines
https://m3.material.io/components/search/overview
https://m3.material.io/components/search/specs
https://m3.material.io/components/segmented-buttons/accessibility
https://m3.material.io/components/segmented-buttons/guidelines
https://m3.material.io/components/segmented-buttons/overview
https://m3.material.io/components/segmented-buttons/specs
https://m3.material.io/components/side-sheets/accessibility
https://m3.material.io/components/side-sheets/guidelines
https://m3.material.io/components/side-sheets/overview
https://m3.material.io/components/side-sheets/specs
https://m3.material.io/components/sliders/accessibility
https://m3.material.io/components/sliders/guidelines
https://m3.material.io/components/sliders/overview
https://m3.material.io/components/sliders/specs
https://m3.material.io/components/snackbar/accessibility
https://m3.material.io/components/snackbar/guidelines
https://m3.material.io/components/snackbar/overview
https://m3.material.io/components/snackbar/specs
https://m3.material.io/components/split-button/accessibility
https://m3.material.io/components/split-button/guidelines
https://m3.material.io/components/split-button/overview
https://m3.material.io/components/split-button/specs
https://m3.material.io/components/switch/accessibility
https://m3.material.io/components/switch/guidelines
https://m3.material.io/components/switch/overview
https://m3.material.io/components/switch/specs
https://m3.material.io/components/tabs/accessibility
https://m3.material.io/components/tabs/guidelines
https://m3.material.io/components/tabs/overview
https://m3.material.io/components/tabs/specs
https://m3.material.io/components/text-fields/accessibility
https://m3.material.io/components/text-fields/guidelines
https://m3.material.io/components/text-fields/overview
https://m3.material.io/components/text-fields/specs
https://m3.material.io/components/time-pickers/accessibility
https://m3.material.io/components/time-pickers/guidelines
https://m3.material.io/components/time-pickers/overview
https://m3.material.io/components/time-pickers/specs
https://m3.material.io/components/toolbars/accessibility
https://m3.material.io/components/toolbars/guidelines
https://m3.material.io/components/toolbars/overview
https://m3.material.io/components/toolbars/specs
https://m3.material.io/components/tooltips/accessibility
https://m3.material.io/components/tooltips/guidelines
https://m3.material.io/components/tooltips/overview
https://m3.material.io/components/tooltips/specs
https://m3.material.io/foundations
https://m3.material.io/foundations/building-for-all/co-design
https://m3.material.io/foundations/building-for-all/user-needs
https://m3.material.io/foundations/content-design/alt-text
https://m3.material.io/foundations/content-design/global-writing/overview
https://m3.material.io/foundations/content-design/global-writing/word-choice
https://m3.material.io/foundations/content-design/notifications
https://m3.material.io/foundations/content-design/overview
https://m3.material.io/foundations/content-design/style-guide/grammar-and-punctuation
https://m3.material.io/foundations/content-design/style-guide/ux-writing-best-practices
https://m3.material.io/foundations/content-design/style-guide/word-choice
https://m3.material.io/foundations/customization
https://m3.material.io/foundations/design-tokens/how-to-use-tokens
https://m3.material.io/foundations/design-tokens/overview
https://m3.material.io/foundations/designing/color-contrast
https://m3.material.io/foundations/designing/elements
https://m3.material.io/foundations/designing/flow
https://m3.material.io/foundations/designing/overview
https://m3.material.io/foundations/designing/structure
https://m3.material.io/foundations/glossary
https://m3.material.io/foundations/interaction/gestures
https://m3.material.io/foundations/interaction/inputs
https://m3.material.io/foundations/interaction/selection
https://m3.material.io/foundations/interaction/states/applying-states
https://m3.material.io/foundations/interaction/states/overview
https://m3.material.io/foundations/interaction/states/state-layers
https://m3.material.io/foundations/layout/bidirectionality-rtl
https://m3.material.io/foundations/layout/breakpoints/compact
https://m3.material.io/foundations/layout/breakpoints/expanded
https://m3.material.io/foundations/layout/breakpoints/large-extra-large
https://m3.material.io/foundations/layout/breakpoints/medium
https://m3.material.io/foundations/layout/breakpoints/overview
https://m3.material.io/foundations/layout/canonical-examples/feed
https://m3.material.io/foundations/layout/canonical-examples/list-detail
https://m3.material.io/foundations/layout/canonical-examples/overview
https://m3.material.io/foundations/layout/canonical-examples/supporting-pane
https://m3.material.io/foundations/layout/grids-spacing/density
https://m3.material.io/foundations/layout/grids-spacing/grids
https://m3.material.io/foundations/layout/grids-spacing/overview
https://m3.material.io/foundations/layout/grids-spacing/spacing
https://m3.material.io/foundations/layout/layout-overview/adaptive-design
https://m3.material.io/foundations/layout/layout-overview/overview
https://m3.material.io/foundations/layout/layout-overview/parts-of-layout
https://m3.material.io/foundations/layout/scaffold/bars
https://m3.material.io/foundations/layout/scaffold/overview
https://m3.material.io/foundations/layout/scaffold/panes
https://m3.material.io/foundations/layout/scaffold/rails
https://m3.material.io/foundations/overview/assistive-technology
https://m3.material.io/foundations/overview/principles
https://m3.material.io/foundations/usability/applying-m3-expressive
https://m3.material.io/foundations/usability/overview
https://m3.material.io/foundations/writing/best-practices
https://m3.material.io/foundations/writing/text-resizing
https://m3.material.io/foundations/writing/text-truncation
https://m3.material.io/styles
https://m3.material.io/styles/color/advanced/adjust-existing-colors
https://m3.material.io/styles/color/advanced/apply-colors
https://m3.material.io/styles/color/advanced/define-new-colors
https://m3.material.io/styles/color/advanced/overview
https://m3.material.io/styles/color/choosing-a-scheme
https://m3.material.io/styles/color/dynamic/choosing-a-source
https://m3.material.io/styles/color/dynamic/content-based-source
https://m3.material.io/styles/color/dynamic/user-generated-source
https://m3.material.io/styles/color/resources
https://m3.material.io/styles/color/roles
https://m3.material.io/styles/color/static/baseline
https://m3.material.io/styles/color/static/custom-brand
https://m3.material.io/styles/color/system/how-the-system-works
https://m3.material.io/styles/color/system/overview
https://m3.material.io/styles/elevation/applying-elevation
https://m3.material.io/styles/elevation/overview
https://m3.material.io/styles/elevation/tokens
https://m3.material.io/styles/icons/applying-icons
https://m3.material.io/styles/icons/designing-icons
https://m3.material.io/styles/icons/overview
https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration
https://m3.material.io/styles/motion/easing-and-duration/tokens-specs
https://m3.material.io/styles/motion/overview/how-it-works
https://m3.material.io/styles/motion/overview/specs
https://m3.material.io/styles/motion/transitions/applying-transitions
https://m3.material.io/styles/motion/transitions/transition-patterns
https://m3.material.io/styles/shape/corner-radius-scale
https://m3.material.io/styles/shape/overview-principles
https://m3.material.io/styles/shape/shape-morph
https://m3.material.io/styles/spacing/applying-spacing
https://m3.material.io/styles/spacing/overview
https://m3.material.io/styles/spacing/tokens
https://m3.material.io/styles/typography/applying-type
https://m3.material.io/styles/typography/editorial-treatments
https://m3.material.io/styles/typography/fonts
https://m3.material.io/styles/typography/overview
https://m3.material.io/styles/typography/type-scale-tokens
+318
View File
@@ -0,0 +1,318 @@
# Material Design 3 — Styles Supplement (Spacing; Color additions; Motion transition patterns)
Extraction from sitemap pages `reference-styles.md` did not cover (fetched directly, not via in-site
navigation). Same structure as the reference. Only material **not** already in `reference-styles.md` is
included; where a fetched page turned out to be a duplicate of an already-covered page, that is noted
instead of repeating it.
**Duplicate pages found (no new content beyond what's already in the reference):**
- `styles/color/system/overview` — byte-identical to `styles/color/system` (already in the reference's
Color section / Source URLs), *except* it also carries a Resources table not previously captured — see
Color § Values below.
- `styles/color/advanced/overview` — byte-identical to `styles/color/advanced` (already in the reference's
Color § Source URLs; its linked sub-pages apply-colors/define-new-colors/adjust-existing-colors are
already the basis of the reference's "Advanced customization best practices" rules).
- `styles/color/dynamic/choosing-a-source` — byte-identical to `styles/color/dynamic` (already in the
reference's Color § Source URLs), *except* it's the canonical location for the source-selection
decision criteria — see Color § Dynamic sources below (the reference didn't extract these criteria).
- `styles/motion/easing-and-duration/applying-easing-and-duration` — its "Suggested easing and duration
pairs" table, "Choosing an easing set", "Choosing an easing type" (by transition direction), and
"Choosing a duration" (by size / enter vs. exit) sections are already reproduced verbatim in the
reference's Motion § Rules and § Values. The only new fact is an M2-vs-M3 style comparison — see
Motion § Principles below.
---
## Spacing
Absent from `reference-styles.md` entirely — no Spacing section existed. This is a full addition.
### Principles
- Spacing is applied to the margins, padding, and gaps of a component, UI element, or layout.
- Spacing adapts to different values based on context, like mobile vs. desktop, or density settings.
- The spacing system is measured on an **8dp scale**, where **space100 = 8dp**.
- Spacing is applied to the flow of elements (horizontal, vertical) or in relation to elements (leading,
trailing, top, bottom, gap).
- Unlike the color system (which adjusts light/dark theme logic across all components at once), spacing
logic is tailored and built within each component individually — there's no single global spacing
transform.
- Components have padding/margin/gap tokens that map to system spacing tokens, which resolve to final dp
values (component token → system token → value).
### Rules (Google's wording)
- Spacing has three categories: **padding** (space inside an element), **gap** (space between elements in
a grid or container), and **margin** (space outside an element). Position can be vertical, top, bottom,
horizontal, leading, or trailing. **Leading**/**trailing** swap sides in RTL languages.
- **Do** define padding and gaps on the parent container to organize all elements inside.
- **Don't** define margins on child elements — they usually aren't uniform and require more tokens.
- **Use padding & gaps before using margins**: Material rarely uses margins in components; padding/gaps
apply spacing more uniformly. Only use margins for spacing beyond the parent container's padding, or in
layouts.
- Use a horizontal/vertical gap for simple components where gaps are always the same size; complex
components with many different gaps should name them by the elements on each side (e.g. "icon-label
gap") rather than a single generic gap token.
- When text is scaled up to 200%, the same spacing should be preserved by default (spacing does not scale
with text).
- What to use, in priority order:
1. **Pre-tokenized components** — some Material components map to spacing system tokens out of the box
(work is ongoing to hook up all components; this mapping can be customized for form factor/density).
2. **System tokens** — apply these to custom components/layouts, replacing hardcoded values.
3. If the right system token doesn't exist, **customize the system** (see below) and add your own.
- Customizing the system (three approaches, by scope):
- **Customize Material's existing component spacing** — remap a component attribute to a different
system token product-wide (e.g. change "button top padding" from **space125** to **space200** for a
taller default button).
- **Add custom system spacing & patterns** — when you need units beyond what Material provides, or have
a recurring adaptive pattern. Follow the multiplier convention for new tokens (e.g. **space225 = 18dp**
= 8dp × 2.25). Product-specific recurring patterns can get their own pattern token (e.g. if cards and
sheets always adapt horizontal content padding the same way, create a **surface-content padding
horizontal** token for that pattern).
- **Add adaptive layout & density** — map the same component to different system tokens per device type
(mobile vs. desktop), and/or adapt vertical padding to different values per density setting, while
keeping the same core component.
- **Note:** system spacing tokens are currently only used on **Jetpack Compose** (see Availability below)
— this is a stronger platform restriction than color/elevation/shape, which have broader (if uneven)
support.
### Values
#### Availability
| Type | Resource | Status |
| --- | --- | --- |
| Implementation | Android Views (MDC-Android) | Unavailable |
| Implementation | Jetpack Compose | Available |
| Implementation | Web | Unavailable |
#### Spacing scale
The site does not render the token→dp table as text (it's an image diagram); the following is
reconstructed from the images' alt-text and from dp values cited inline elsewhere on the page, and should
be treated as inferred, not a verbatim site table:
| Token | Multiplier | Value |
| --- | --- | --- |
| space25 | 0.25× | 2dp |
| space50 | 0.5× | 4dp |
| space75 | 0.75× | 6dp |
| **space100** | **1×** | **8dp (base unit, `md.sys.measurement.space100`)** |
| space125 | 1.25× | 10dp |
| space200 | 2× | 16dp |
| space300 | 3× | 24dp |
| space400 | 4× | 32dp |
| space500 | 5× | 40dp |
| space600 | 6× | 48dp |
| space700 | 7× | 56dp |
| space800 | 8× | 64dp |
| space900 | 9× | 72dp |
Basis for this reconstruction: the overview page's scale diagram alt-text reads "a spacing scale shows 2,
4, 6, and 8 at the bottom range and 48, 56, 64, and 72 at the top of the range"; the tokens page states the
main range "covers 0x to 9x" of the 8dp base unit and separately defines nested sub-1x units at "0.25x,
0.5x, 0.75x, 1.25x"; the overview page separately lists the same nested dp values ("2dp, 4dp, 6dp, and
10dp"). Values beyond space900 (e.g. the 18dp/space225 example) are explicitly presented as **custom**,
not part of the default set. Material only defines the nested/nonstandard units it actively uses — the
scale is not a complete arithmetic progression at every step.
#### Component spacing — naming convention
| | Going forward | Legacy |
| --- | --- | --- |
| Prefix | `padding`, `margin`, `gap` | `space` |
| Positional modifiers | horizontal, vertical, leading, trailing, top, bottom | leading-space, trailing-space, top-space, bottom-space, between-space |
| Example | "Medium button: leading padding" | "Medium button: leading space" |
#### Parts of spacing — example (Search)
The search container: 8dp vertical padding, 8dp horizontal gaps, 24dp horizontal margins by default
(narrows to 12dp when focused, while padding/gaps stay the same). The 24dp default margin exists
specifically "to ensure accurate placement from the screen edge."
#### Spacing concepts diagrammed on the site (numbered image captions — anatomy, not values)
- Component layout: (1) vertical padding top & bottom, (2) vertical gap, (3) horizontal padding leading &
trailing, (4) horizontal gap.
- Page layout: (1) margin, (2) top padding, (3) horizontal padding leading & trailing, (4) spacer/gap,
(5) vertical gap. Layouts additionally use **panes** (layout containers, which can be fixed, flexible,
floating, or semi-permanent) and **spacers** (space between two panes; contains a drag handle if the
panes are resizable).
### M3 vs M3 Expressive differences
No Expressive-specific spacing changes are called out on these pages (unlike shape/motion/typography,
spacing carries no "M3 Expressive" labelled update section). The system is presented as a single,
version-agnostic linear scale.
### Accessibility
- Spacing must be preserved (not scaled) when text is scaled up to 200%, so layouts stay stable and
legible as text grows — spacing is a support mechanism for text-resizing accessibility, not something
that competes with it.
### Source URLs
- https://m3.material.io/styles/spacing/overview
- https://m3.material.io/styles/spacing/applying-spacing
- https://m3.material.io/styles/spacing/tokens
---
## Color — additions
### Static: Custom brand color scheme
Not previously fetched. Supplements the reference's Color section (which covers baseline/static generally
but not the brand-specific workflow).
**Principles**
- A **brand-based static scheme** is hand-picked by the product team to align with the product's brand
color(s), as opposed to the **baseline** static scheme (Material's own default) or a **dynamic** scheme.
It requires greater ongoing investment since the team creates and maintains it entirely.
- End-users see: an accessible UI with static colors, and "a product that looks like its brand."
**Rules (Google's wording)**
- Brand schemes are still generated through the same tonal-palette machinery as dynamic color: you can
set a **custom source color independently for each of the 6 palette groups** — Primary, Secondary,
Tertiary, Error, Neutral, and Neutral Variant — not just Primary. Setting only Primary and leaving the
rest defaulted is also valid ("use the scheme as-is").
- Workflow (Material Theme Builder Figma plugin): add a new theme, name it (the name becomes the color
role prefix in Figma), set a custom Hex source color for Primary (and optionally Secondary/Tertiary/
Error/Neutral/Neutral Variant) → generates a full custom color scheme with the same role set as baseline.
- Enabling "Generate State Layers" in the plugin settings when creating the theme is called out as
necessary groundwork for interaction-state design (cross-references state layers).
- Point of escalation: "want to further adjust your brand color scheme?" and "need to make adjustments?"
both route to **Advanced customizations** (apply/define/adjust colors) — i.e. custom-brand is the
starting point, advanced customization is the refinement path.
- Develop: export the branded scheme from Material Theme Builder for Jetpack Compose, Android Views,
Flutter, Web, or as a JSON file.
**Source URLs**
- https://m3.material.io/styles/color/static/custom-brand
### Dynamic: choosing and using a source
Two new sub-pages (content-based-source, user-generated-source) plus decision criteria from
choosing-a-source that the reference's Color section states only as a single pipeline description, not as
selection criteria.
**Principles**
- Both user-generated and content-based color go through the same pipeline: the image is digitally
analyzed through **quantization**, a single color is selected as the source color, and tones are chosen
and assigned to each color role (this is the concrete mechanism behind the "dynamic color pipeline"
already summarized in the reference).
- **User-generated color**: sourced from the user's wallpaper.
- **Content-based color**: sourced from in-app content — an album thumbnail, a logo, a video preview.
**Rules (Google's wording) — decision criteria (not previously captured)**
| Source type | Choose it if… |
| --- | --- |
| User-generated | Users would benefit from a personalized experience that's well-tested; you want to showcase the latest Material features |
| Content-based | Content is front-and-center in the product; the team can do some advanced customization; it supports usability of content-centric features like media players; best applied to contained screen elements adjacent to the source image (the source image itself need not stay visible) |
| Multiple sources | Product requirements meet more than one of the above criteria and the team doesn't mind extra customization work; get started with user-generated color first, then customize |
- Example of multiple sources coexisting: a photo app derives content-based color (green) for edit buttons
from the in-app photo, while wallpaper-derived user-generated color (red) drives share buttons elsewhere
in the same screen.
- Content-based color guidance: apply it where it enhances brand identity and personalization (e.g. a
music app deriving color from album artwork; a news app differentiating publications by their content
color).
- QA/testing step for user-generated color (Figma workflow): use the Material Theme Builder's "Dynamic"
mode, add a real image or use the Shuffle icon for a random source color, swap it onto frames, and
**repeat across a range of colors** to get a sense of how the product will appear across different
users' devices/wallpapers — i.e. dynamic-color design review should be done across many source colors,
not just one or two.
- Android implementation reference specifically for content-based color: MDC-Android's
"Content-based dynamic color" doc (separate from the general dynamic-color doc already cited).
**Source URLs**
- https://m3.material.io/styles/color/dynamic/choosing-a-source
- https://m3.material.io/styles/color/dynamic/content-based-source
- https://m3.material.io/styles/color/dynamic/user-generated-source
---
## Motion — Transitions (supplement)
The reference's Motion section already reproduces the easing/duration Rules and Values from
`applying-easing-and-duration` verbatim, and names the six transition patterns in one summary line without
detail. This section supplies the missing detail: what each pattern is for, what it's commonly used with,
platform (Android/iOS) differences, and the accessibility/consistency criteria for transitions overall —
which also fills the gap the reference explicitly flagged ("No general reduced-motion policy was found
stated on `styles/motion` itself").
**Naming note:** the current M3 site does **not** use the older M2 terms "shared axis / fade through /
fade." The current (2026) taxonomy is: **Container transform**, **Forward and backward** (≈ old shared
axis), **Lateral**, **Top level** (≈ old fade through), **Enter and exit** (subsumes old "fade" cases), and
**Skeleton loaders** (new, not an M2 concept). Anyone looking for "shared axis"/"fade through"/"fade" by
name should map to this table.
### Principles — what makes a good transition
- **Follows accessibility settings**: when a platform's reduced-motion setting is on, transitions should
use subtle fades instead of intense sliding/scaling, and disable decorative effects like parallax or
shape morphing. *(This is the missing general reduced-motion policy for `styles/motion` — previously
only found at the component level, e.g. carousel/menus.)*
- **Consistent**: applying the same transition type consistently for the same kind of navigation makes
apps feel cohesive and predictable (e.g. multiple Android apps sharing one forward/backward transition).
- **Stable layouts**: use skeleton loaders (subtle pulsing) so content doesn't shift position or pop in
abruptly as it loads.
- **No jarring jump cuts**: avoid instant, uncued transitions by default — they're disorienting. Exception:
a jump cut may be preferred when pure efficiency is the top priority (e.g. opening a menu in a
productivity app).
- **Coherent spatial model**: transitions should help users understand an app's physical/spatial layout
(e.g. keep a consistent axis when moving between collapsed/expanded carousel views; don't flip between
horizontal and vertical layouts).
- **Unified direction**: elements are grouped and move along one primary axis rather than independently;
only genuinely persistent elements (e.g. a hero image) should remain visible throughout — animating many
persistent elements independently is distracting.
- **Clean fades**: fully fade out old content before fading in new content; avoid overlapping
partially-transparent frames. If a cross-fade is unavoidable, keep it quick and hide it during the
fastest part of the transition (e.g. a dialog entering mid-screen should not slowly fade on top of
content — use a short fade to hide the overlap).
- **Simple style**: transitions are frequent and utility-driven, not a showcase for stylized motion — avoid
overt effects like bouncy springs on common transitions.
### Rules — per-pattern guidance (Google's wording)
| Pattern | Use for | Commonly used with | Do / Don't / Caution |
| --- | --- | --- | --- |
| **Container transform** | Seamlessly transforming an element to show more detail (e.g. a card expanding into a details page); hero moments that should be expressive; shallow hierarchies (expand for detail, then collapse); creating a seamless connection between elements. Most dramatic/expressive pattern — reserve for the right context. | Cards, lists, image galleries, search boxes, sheets, FABs, chips | **Do** use it for hero moments instead of a forward/backward transition. **Don't** use it in apps with deep hierarchies (motion becomes excessive) or in utility-focused navigation (style mismatch). |
| **Forward and backward** | Navigating between screens at consecutive hierarchy levels (e.g. inbox → message thread) | Lists, cards, buttons, links | **Do** use platform defaults — easy to implement, stays current with platform updates. Android fades content as screens slide (reduces motion vs. a full-width slide); iOS uses parallax (background slides slower than foreground). **Caution**: container transform requires custom implementation and can feel excessive if overused for this role. |
| **Lateral** | Navigating between peer content at the same hierarchy level (e.g. swiping tabs in a content library) | Tabs, carousels, image galleries | Elements are grouped and slide in unison with **no fade or parallax**, creating a strong peer relationship and hinting at swipe-ability. **Caution**: fading content while it slides weakens the peer/swipe cue and can be confused with forward/backward. **Don't** use for hierarchical navigation — full-width sliding is excessive and wrongly implies a peer (non-hierarchical) relationship. |
| **Top level** | Navigating between top-level destinations (e.g. tapping a nav bar/rail/drawer item) | Navigation bar, navigation rail, navigation drawer | Exiting screen quickly fades out, then the entering screen fades in — intentionally **no** grouping or persistent elements, since top-level destinations aren't necessarily related. **Don't** use a lateral transition here — it wrongly implies you can swipe between top-level destinations, conflicting with carousel/list swipe gestures. |
| **Enter and exit — within screen bounds** | Introducing/removing a component in the context of the main UI (modal, e.g. a dialog; or non-modal, e.g. a bottom sheet coexisting with content) | FABs, dialogs, menus, snackbars, time pickers, tooltips | Android: expand/collapse along x or y axis only (scale and z-axis motion are avoided — they'd imply an elevation change, which conflicts with M3's reduced-elevation model). iOS: uniform scale to enter, fade to exit. Enter direction is informed by on-screen location, expanding away from the device edge (e.g. a top menu expands downward; a bottom snackbar expands upward). **Don't** use this pattern for navigating hierarchical screens — full-height sliding is excessive and creates an unclear screen-to-screen relationship. |
| **Enter and exit — beyond screen bounds** | Components that slide fully on/off screen | App bars, banners, navigation bar, navigation rail, navigation drawer, sheets | Android: expand/collapse along x or y axis while sliding on/off (adds shape emphasis). iOS: slides on/off without changing shape. Coplanar side sheets shrink the available content area rather than overlaying it. Can be scroll-driven (e.g. a top app bar or nav bar sliding off/on during scroll, to reclaim screen space). Entry/exit location establishes spatial memory: notifications enter from the top (matches the pull-down drawer), a nav drawer enters from the left (matches its off-screen position), a bottom sheet/keyboard enters from the bottom (easiest to reach). |
| **Skeleton loaders** | Transitioning from a temporary loading state to the fully loaded UI | Used in combination with other transition patterns | Subtle pulsing animation indicates indeterminate progress, animating from top-left to bottom-right. Once content loads, it quickly fades in **on top of** the skeleton loader (not instantly popping in). |
### Values
Durations and easing for these patterns are governed by the already-referenced tables (Suggested
easing/duration pairs; legacy duration tokens) — no separate duration table is given per pattern on these
pages. The pattern choice itself is the "value" documented here; see the Rules table above for the
authoritative per-pattern mapping (this *is* the durations/patterns cross-reference the task asked for —
the site does not tie a specific numeric duration to "container transform" etc. beyond the general
begin/end-on-screen vs. enter vs. exit rules already in the reference).
### M2 vs M3 easing note (from `applying-easing-and-duration`; not an M3-vs-Expressive distinction)
- Compared to M2's more utilitarian easing style, **M3 easing is more expressive**: transitions have
"snappy take-offs and very soft landings."
- **M3 durations are slightly longer than M2's**, deliberately, to give transitions time to come to a
gentle rest without feeling abrupt.
### Accessibility requirements
- General reduced-motion policy for transitions (see Principles above): substitute subtle fades for
intense sliding/scaling, and disable decorative effects (parallax, shape morphing) when a platform's
reduced-motion setting is on. This generalizes — and predates in priority — the component-level
reduced-motion notes already in the reference (carousel, menus).
### Source URLs
- https://m3.material.io/styles/motion/transitions/applying-transitions
- https://m3.material.io/styles/motion/transitions/transition-patterns
- https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration (mostly duplicate
of already-referenced content; see M2-vs-M3 note above for the one new fact)
+896
View File
@@ -0,0 +1,896 @@
# Material Design 3 — Styles Reference (Color, Elevation, Icons, Motion, Shape, Typography)
Ground-truth extraction from m3.material.io/styles and its sub-pages, plus androidx Compose Material3
token source files where the site only renders a value inside an unrendered interactive widget.
Anything not from the live site text is explicitly marked "(not on the site; from <source>)".
---
## Color
### Principles
- Color is used to express style and communicate meaning. With dynamic color, Material puts personal
color preferences and individual needs at the forefront of systematic color application.
- The Material color system includes: a built-in set of accessible color relationships (e.g. a dark
surface color is algorithmically paired with a light text-label color so the UI automatically meets
contrast requirements); 26+ color roles mapped to components; a built-in dark theme; a static baseline
color scheme; and dynamic color (user-generated from wallpaper, or content-based from in-app content).
- Color roles are "like the numbers in a paint-by-number canvas" — the connective tissue between UI
elements and what color goes where.
- **Color roles are mapped to Material Components.** Custom components must be mapped to this same
role set.
- **Color roles ensure accessibility.** The system is built on accessible color pairings; color pairs
provide an accessible minimum 3:1 contrast.
- **Color roles are tokenized.**
- General vocabulary used in role names:
- **Surface** — backgrounds and large, low-emphasis areas.
- **Primary / Secondary / Tertiary** — accent roles used to emphasize or de-emphasize foreground elements.
- **Container** — fill color for foreground elements like buttons; not for text or icons.
- **On** — a color for text/icons *on top of* its paired parent color (e.g. "on primary" on "primary").
- **Variant** — a lower-emphasis alternative to its non-variant pair (e.g. "outline variant" vs "outline").
- The system uses a color space called **HCT** (Hue, Chroma, Tone), not HSL/RGB. HCT lets you manipulate
hue and chroma without affecting tone.
- **Hue**: 0360, circular (perception as red/orange/yellow/…).
- **Chroma**: 0 (grey/black/white) up to roughly 120 in HCT (colorfulness); max chroma varies by hue/tone.
- **Tone**: 0 (pure black) to 100 (pure white); determines contrast. HCT tone is not interchangeable
with HSL lightness — two colors can share an HCT tone (perceived brightness) while having different
HSL lightness values.
- Dynamic color pipeline: (1) start with a source color (from wallpaper, in-app content, or hand-picked);
(2) feed it into an algorithm; (3) the algorithm generates key colors; (4) it creates a **tonal palette**
per key color (tones 0100 in steps of 10, plus 95/98/99, some palettes have more); (5) it assigns tones
to the ~26 standard color roles for both light and dark theme; (6) the new colors are applied to the UI.
### Rules (Google's wording)
- Pair and layer color roles only as intended (e.g. primary + on primary + secondary container + on
secondary container) to keep them legible as contrast level changes. **Don't** mix roles improperly
(e.g. primary + primary container + secondary container + on surface) — this can become illegible as
contrast changes.
- **Don't** use the **outline** color for dividers (different contrast requirements) — use **outline
variant** instead.
- **Don't** use **outline** for components containing multiple elements (e.g. cards) — use **outline
variant**.
- **Don't** use **outline variant** to create visual hierarchy or define a target's visual boundary —
use **outline** or another color giving 3:1 contrast with the surface.
- **Caution**: **outline variant** can border targets like chips/buttons only if elements inside them
(icons/text) already meet 4.5:1 contrast.
- Most products won't need the **add-on** color roles (fixed/fixed-dim, on-fixed/on-fixed-variant,
surface-dim/surface-bright). "If you aren't sure whether your product should use the add-on roles, it
probably shouldn't."
- **Don't** use **fixed** colors where contrast is necessary — they don't adapt to light/dark theme and
are likely to cause contrast issues (e.g. a button fill on a permissions screen). **Do** use primary/
secondary/tertiary where contrast is needed.
- All color mappings — especially surface colors — should remain the same for a given layout region
across breakpoints (e.g. body always **surface**, navigation always **surface container**, on both
mobile and tablet).
- Error is a **static** color by default (doesn't change with dynamic color schemes) but still adapts to
light/dark theme.
- Advanced customization best practices:
- Any role starting with "on-" is guaranteed sufficient contrast with its paired role; other role pairs
may not meet the 4.5:1 (small text) / 3:1 (large text) Material contrast requirements — check before
reusing a pairing.
- Always apply **color roles**, never static hex values or raw tonal-palette values, for anything that
should respond to theme, contrast level, or dynamic color.
- Test a dynamic-color component under multiple source-color themes (light/dark; red/yellow/green/blue).
- Limit a screen to two color schemes from different source types (e.g. baseline/user-generated combined
with one content-based source) to avoid visual disarray.
- Don't replace semantic colors (e.g. red error, green success) with content-based dynamic color.
- Don't harmonize colors whose appearance should stay absolutely consistent (e.g. brand colors).
- Defining custom color roles should be considered only if the existing Material roles can't achieve the
desired result.
- Choosing a scheme: use **static (baseline)** if not ready for dynamic color, migrating from M2, building
for enterprise users, or building for iOS. Use **dynamic** if the product should personalize, react to
wallpaper/content, or offer user-controlled contrast.
### Values
#### Color roles (Compose `ColorSchemeKeyTokens`, 47 tokens; tone source `ColorLightTokens.kt` / `ColorDarkTokens.kt`)
Each role resolves to a tone from one of 5 tonal palettes (Primary, Secondary, Tertiary, Neutral,
Neutral Variant) or Error. Format: role — light theme tone — dark theme tone.
| Role | Light tone | Dark tone | Purpose (site wording) |
| --- | --- | --- | --- |
| Primary | Primary40 | Primary80 | High-emphasis fills, text, icons against surface |
| On primary | Primary100 | Primary20 | Text/icons against primary |
| Primary container | Primary90 | Primary30 | Standout fill against surface, key components like FAB |
| On primary container | Primary10 | Primary90 | Text/icons against primary container |
| Secondary | Secondary40 | Secondary80 | Less prominent fills/text/icons against surface |
| On secondary | Secondary100 | Secondary20 | Text/icons against secondary |
| Secondary container | Secondary90 | Secondary30 | Recessive fill, e.g. tonal buttons |
| On secondary container | Secondary10 | Secondary90 | Text/icons against secondary container |
| Tertiary | Tertiary40 | Tertiary80 | Complementary accent, e.g. input fields |
| On tertiary | Tertiary100 | Tertiary20 | Text/icons against tertiary |
| Tertiary container | Tertiary90 | Tertiary30 | Complementary container fill |
| On tertiary container | Tertiary10 | Tertiary90 | Text/icons against tertiary container |
| Error | Error40 | Error80 | Attention-grabbing color for urgency |
| On error | Error100 | Error20 | Text/icons against error |
| Error container | Error90 | Error30 | Attention-grabbing fill |
| On error container | Error10 | Error90 | Text/icons against error container |
| Surface | Neutral98 | Neutral6 | Default background |
| On surface | Neutral10 | Neutral90 | Text/icons on surface / surface container |
| On surface variant | NeutralVariant30 | NeutralVariant80 | Lower-emphasis text/icons on surface |
| Surface variant | NeutralVariant90 | NeutralVariant30 | (legacy; superseded by surface container roles Feb 2023) |
| Background / On background | Neutral98 / Neutral10 | Neutral6 / Neutral90 | Legacy roles, same values as surface/on surface |
| Outline | NeutralVariant50 | NeutralVariant60 | Important boundaries, e.g. text-field outline |
| Outline variant | NeutralVariant80 | NeutralVariant30 | Decorative elements, e.g. dividers |
| Inverse surface | Neutral20 | Neutral90 | Background contrasting against surrounding surface |
| Inverse on surface | Neutral95 | Neutral20 | Text/icons against inverse surface |
| Inverse primary | Primary80 | Primary40 | Actionable elements (e.g. text buttons) against inverse surface |
| Scrim | Neutral0 | Neutral0 | Scrim behind modals, at 32% opacity |
| Surface tint | (=Primary) | (=Primary) | Deprecated — use elevation level tokens instead |
Surface container family (5 levels, named by emphasis — not tied to elevation, replaced "surface +1..+5"
in Feb 2023):
| Role | Light tone | Dark tone |
| --- | --- | --- |
| Surface container lowest | Neutral100 | Neutral4 |
| Surface container low | Neutral96 | Neutral10 |
| Surface container | Neutral94 | Neutral12 |
| Surface container high | Neutral92 | Neutral17 |
| Surface container highest | Neutral90 | Neutral22 |
Bright/dim add-on surface roles (keep relative brightness across both themes, unlike default surface
which inverts):
| Role | Light tone | Dark tone |
| --- | --- | --- |
| Surface dim | Neutral87 | Neutral6 |
| Surface bright | Neutral98 | Neutral24 |
Fixed accent add-on roles (same tone in light **and** dark theme; added Feb 2023):
| Role | Tone (both themes) | Purpose |
| --- | --- | --- |
| Primary fixed | Primary90 | Fill color that stays constant across themes |
| Primary fixed dim | Primary80 | Stronger/more emphasized fixed tone |
| On primary fixed | Primary10 | Text/icons on primary fixed |
| On primary fixed variant | Primary30 | Lower-emphasis text/icons on primary fixed |
| Secondary fixed | Secondary90 | ” |
| Secondary fixed dim | Secondary80 | ” |
| On secondary fixed | Secondary10 | ” |
| On secondary fixed variant | Secondary30 | ” |
| Tertiary fixed | Tertiary90 | ” |
| Tertiary fixed dim | Tertiary80 | ” |
| On tertiary fixed | Tertiary10 | ” |
| On tertiary fixed variant | Tertiary30 | ” |
#### Baseline tonal palettes (hex; from androidx `PaletteTokens.kt`, since the site renders these only in
an interactive, non-text widget)
| Tone | Primary | Secondary | Tertiary | Neutral | Neutral Variant | Error |
| --- | --- | --- | --- | --- | --- | --- |
| 0 | #000000 | #000000 | #000000 | #000000 | #000000 | #000000 |
| 4 | — | — | — | #0F0D13 | — | — |
| 6 | — | — | — | #141218 | — | — |
| 10 | #21005D | #1D192B | #31111D | #1D1B20 | #1D1A22 | #410E0B |
| 12 | — | — | — | #211F26 | — | — |
| 17 | — | — | — | #2B2930 | — | — |
| 20 | #381E72 | #332D41 | #492532 | #322F35 | #322F37 | #601410 |
| 22 | — | — | — | #36343B | — | — |
| 24 | — | — | — | #3B383E | — | — |
| 30 | #4F378B | #4A4458 | #633B48 | #48464C | #49454F | #8C1D18 |
| 40 | #6750A4 | #625B71 | #7D5260 | #605D64 | #605D66 | #B3261E |
| 50 | #7F67BE | #7A7289 | #986977 | #79767D | #79747E | #DC362E |
| 60 | #9A82DB | #958DA5 | #B58392 | #938F96 | #938F99 | #E46962 |
| 70 | #B69DF8 | #B0A7C0 | #D29DAC | #AEA9B1 | #AEA9B4 | #EC928E |
| 80 | #D0BCFF | #CCC2DC | #EFB8C8 | #CAC5CD | #CAC4D0 | #F2B8B5 |
| 87 | — | — | — | #DED8E1 | — | — |
| 90 | #EADDFF | #E8DEF8 | #FFD8E4 | #E6E0E9 | #E7E0EC | #F9DEDC |
| 92 | — | — | — | #ECE6F0 | — | — |
| 94 | — | — | — | #F3EDF7 | — | — |
| 95 | #F6EDFF | #F6EDFF | #FFECF1 | #F5EFF7 | #F5EEFA | #FCEEEE |
| 96 | — | — | — | #F7F2FA | — | — |
| 98 | — | — | — | #FEF7FF | — | — |
| 99 | #FFFBFE | #FFFBFE | #FFFBFA | #FFFBFF | #FFFBFE | #FFFBF9 |
| 100 | #FFFFFF | #FFFFFF | #FFFFFF | #FFFFFF | #FFFFFF | #FFFFFF |
(Source: androidx Compose Material3 `PaletteTokens.kt` — the purple/violet "baseline" scheme. Any product
using a different source color will generate a different palette via the HCT algorithm, but role→tone
assignments are the same.)
#### Contrast levels
| Level | Ratio (approx.) | Notes |
| --- | --- | --- |
| Standard (default) | mixed | Emphasizes hierarchy using high- and low-contrast elements together |
| Medium | 3:1 minimum | For users needing more contrast without high-contrast side effects like halation |
| High | 7:1 | Further emphasizes essential elements (e.g. card content, not the card container) |
Contrast settings apply automatically in both light and dark theme. Custom components support contrast
levels simply by using Material color roles (e.g. primary container / on primary container) — the role's
resolved value changes per contrast level automatically.
### M3 vs M3 Expressive differences
The color pages do not explicitly label any change "M3 Expressive" the way shape/motion/typography pages
do, but the "What's new" timeline on `styles/color` lists (with the May 2025 entry coinciding with the
Expressive launch):
- **May 2025 — Three levels of contrast.** Color roles now support standard/medium/high contrast, each
tokenized (see Values above).
- **Aug 2024 — More colorful text & icons.** On-primary-container, on-secondary-container,
on-tertiary-container, on-error-container were updated in light theme to be more colorful while
remaining accessible; affects badges, buttons (all variants), chips, lists, menus, navigation bar,
navigation drawer, navigation rail, switches, toolbars.
- **Feb 2023 — Tone-based surface colors.** Replaced the old "surface +1 to +5 elevation overlay" model
with the surface/surface-container role family (not tied to elevation). Also: default light-theme
surface moved from tone 99 to tone 98; neutral palette chroma increased 4→6; dark-theme surfaces
slightly darkened (alignment with Android SysUI).
- **Feb 2023 — Additional accent colors.** Added fixed / fixed-dim roles for primary, secondary, tertiary.
### Accessibility requirements
- Color pairs provide an accessible **minimum 3:1 contrast** by design (color-role pairing system).
- Text contrast targets (also stated on `foundations/designing/color-contrast`, W3C-based):
large text (≥14pt bold / ≥18pt regular) and graphics: **≥3:1** against background; small text: **≥4.5:1**.
Disabled states do not need to meet contrast requirements.
- Non-text clustered elements (e.g. a group of buttons) should meet **3:1** contrast between container
color and background; a standalone prominent element (e.g. a FAB) does not need this because its
prominence already distinguishes it.
- High contrast mode targets **7:1**.
- Default typography color is **on surface** (or **on surface variant** as a strong alternative).
- **Color-alone rule**: hyperlinked text must use primary (or tertiary, for a less prominent link) *and*
must be underlined — color alone must not be the only signal.
### Source URLs
- https://m3.material.io/styles
- https://m3.material.io/styles/color
- https://m3.material.io/styles/color/system (identical render to /styles/color)
- https://m3.material.io/styles/color/roles
- https://m3.material.io/styles/color/system/how-the-system-works
- https://m3.material.io/styles/color/choosing-a-scheme
- https://m3.material.io/styles/color/static (= /styles/color/static/baseline)
- https://m3.material.io/styles/color/dynamic
- https://m3.material.io/styles/color/advanced
- https://m3.material.io/styles/color/advanced/apply-colors
- https://m3.material.io/styles/color/advanced/define-new-colors
- https://m3.material.io/styles/color/advanced/adjust-existing-colors
- https://m3.material.io/styles/color/resources
- https://m3.material.io/foundations/designing/color-contrast (contrast-ratio table)
- https://raw.githubusercontent.com/androidx/androidx/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/ColorSchemeKeyTokens.kt
- .../tokens/ColorLightTokens.kt
- .../tokens/ColorDarkTokens.kt
- .../tokens/PaletteTokens.kt
---
## Elevation
### Principles
- Elevation is applied to all surfaces and components.
- Tokens codify the distance on the z-axis so components appear consistently relative to each other.
- Tokens carry no shadow or color themselves — each platform determines the specific shadow/value used
at each elevation level.
- Elevation can be shown as tonal surface colors **or** shadows.
- Avoid changing the default elevation of Material 3 components.
- Stick to using a small number of elevation levels.
- Elevation is measured as the distance between components along the z-axis in density-independent
pixels (dp).
- To successfully depict elevation, a surface must show: surface edges (contrast with surroundings),
overlap with other surfaces (at rest or in motion), and distance from other surfaces.
### Rules (do/don't)
- **Do** ensure floating elements (e.g. a FAB) have sufficient contrast with the surface beneath them.
**Don't** use colors with insufficient contrast — the relationship between surfaces must stay clear.
- By default, Material 3 surfaces use **tonal difference** to indicate separation; shadows and scrims
are alternative/additional methods.
- Any overlapping containment areas or components should use **different surface/surface-container color
roles** to communicate separation — these color roles are not tied to elevation.
- Shadow size and softness both express distance: small, sharp shadows = close proximity; larger, softer
shadows = more distance.
- "When it comes to applying shadows, less is more. The fewer levels in your UI, the more power they have
to direct attention and action."
- Use **visible shadows** to: (1) protect elements against a patterned/busy background (cards, chips,
buttons), or (2) encourage interaction (elements can temporarily lift on focus/selection/hover; a
raised element can lower when a higher element appears).
- Use a **scrim** to bring focus to elements over a large layered surface (e.g. modals, expanded
navigation menus).
### Values
#### Elevation levels (`ElevationTokens.kt`; matches the site's dp values)
| Level | dp |
| --- | --- |
| 0 | 0dp |
| 1 | 1dp |
| 2 | 3dp |
| 3 | 6dp |
| 4 | 8dp |
| 5 | 12dp |
An element's **resting state** is on levels 03; levels 45 are reserved for user-interacted states
(hover, dragged) — e.g. hovering a FAB increases elevation by 1 level (level 3 → 4), consistently across
all Material buttons.
#### Component resting elevation (from `styles/elevation/tokens`)
| Resting level | dp | Components |
| --- | --- | --- |
| 5 | 12dp | (not assigned as a resting level) |
| 4 | 8dp | (not assigned as a resting level) |
| 3 | 6dp | Date pickers, Dialogs (modal), Extended FAB, FAB, FAB menu (close button), Search, Time pickers |
| 2 | 3dp | App bar (scrolled), Menu, Navigation bar, Rich tooltip, Toolbar |
| 1 | 1dp | Banner, Bottom sheet (modal), Button (elevated), Card (elevated), Chips (elevated), Navigation drawer (modal), Side sheet (modal) |
| 0 | 0dp | App bar (not scrolled), Buttons (filled/tonal/outlined), Button groups, Cards (filled/outlined), Carousel, Chips, Dialog (full-screen), Extended FAB (in nav rail), FAB (in nav rail), FAB menu (list items), Icon buttons, List, Navigation rail, Segmented button, Side sheet (docked), Slider, Split button, Tabs |
#### Scrim
Scrims use the **scrim** color role at **32% opacity**.
### Differences from M2
- **Shadows**: instead of applying shadows by default at all levels, use shadows only when needed for
protection against a background or to encourage interaction.
- **Color**: new color mappings and compatibility with dynamic color.
- **Levels**: elevation is now described in terms of discrete levels (05) rather than continuous dp.
### M3 vs M3 Expressive differences
No "M3 Expressive" update is documented on the fetched elevation pages (`styles/elevation`,
`/applying-elevation`, `/tokens`) — the elevation model appears unchanged by the Expressive update.
### Accessibility requirements
- Edges (surface separation) must create sufficient contrast between surfaces — meeting or exceeding
accessible contrast ratios — to be perceived as separate. Elevation/shadow alone is treated as a visual
cue that must be reinforced with adequate color/tonal contrast, not relied on in isolation.
### Source URLs
- https://m3.material.io/styles/elevation (= /styles/elevation/overview)
- https://m3.material.io/styles/elevation/applying-elevation
- https://m3.material.io/styles/elevation/tokens
- https://raw.githubusercontent.com/androidx/androidx/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/ElevationTokens.kt
---
## Icons
### Principles
- Icons are small symbols to easily identify actions and categories.
- Material Symbols are the new default: a variable icon font set in three styles — **outlined, rounded,
sharp** — across seven weights. (Legacy "Material Icons" remain available but lack the variable-font axes.)
- Design principles (do/don't): simplify icons for clarity/legibility, don't be overly literal or complex;
use geometric, consistent, bold shapes, don't use delicate/loose organic shapes; maintain one consistent
visual style per icon set, don't mix styles within a set.
### Rules (do/don't)
- **Don't** use the lightest weight (100) for standard-size (24dp) icons — minimum weight for that size
should be **200**. Use caution with excessive weight at 24dp too.
- **Do** apply weight consistently across a group (e.g. a navigation rail); **don't** mix different weights.
- **Do** use the same size for a Material Symbol and adjacent text; **don't** mix symbol/text sizes.
- **Do** use the same optical weight for a symbol and adjacent text; **don't** mismatch optical weights.
- **Do** shift a symbol's baseline down ~11.5% of the text size when pairing with text; **don't** use the
same baseline for symbol and text.
- **Do** position icons "on pixel" within the icon grid; **don't** place on non-integer (sub-pixel)
coordinates.
- Corners: default corner radius is **2dp**. For the **outlined** style, interior corners are square (not
rounded); for shapes ≤2dp wide, stroke corners shouldn't be rounded. **Rounded** style: both exterior
and interior corners are rounded. **Sharp** style: both exterior and interior corners reduce from 2dp
to **0dp**. **Caution**: overly round corners reduce legibility. **Don't** use inconsistent corner radii.
- Stroke: recommended stroke weight is **2dp** (regular weight, 400) with consistent weights and squared
stroke terminals; **don't** use inconsistent stroke weights or rounded stroke terminals. Complex icons
may use an optically-corrected **1.5dp** stroke to fit multiple curves in the 24×24dp space.
- **Do** make icons face forward (flat); **don't** tilt, rotate, or render icons as dimensional/isometric.
- Icon content must stay inside the **live area**; it may extend into padding toward the **trim area** if
extra visual weight is needed, but **no part may extend past the trim area**.
### Values
#### Sizes
| Size | Use |
| --- | --- |
| 20dp | Desktop, dense layouts, small-scale visuals |
| 24dp | Standard (baseline) size |
| 40dp | Larger — pairs with display/headline type, larger screens |
| 48dp | Larger — pairs with display/headline type, larger screens |
#### Layout (24dp standard icon)
| Region | Size |
| --- | --- |
| Trim area (total canvas) | 24dp × 24dp |
| Live area | 20dp × 20dp |
| Padding (live→trim) | 2dp around the perimeter |
#### Grid and keyline shapes (within the 24dp grid)
| Keyline | Dimensions |
| --- | --- |
| Square | 18dp × 18dp |
| Circle | 20dp diameter |
| Vertical rectangle | 20dp height × 16dp width |
| Horizontal rectangle | 16dp height × 20dp width |
#### Corner radius by style
| Style | Exterior corner | Interior corner |
| --- | --- | --- |
| Outlined | 2dp rounded | Square (not rounded) |
| Rounded | 2dp rounded | Rounded |
| Sharp | 0dp | 0dp |
#### Material Symbols variable-font axes
| Axis | Range | Notes |
| --- | --- | --- |
| Weight | 100 (thin) 700 (bold) | Also affects overall symbol size; min 200 recommended at 24dp |
| Fill | 0 1 | 0 = unfilled/outlined, 1 = fully filled; communicates state transitions |
| Grade | negative / 0 / positive (site examples: 25, 0) | Finer-grained than weight; doesn't change width or line breaks. Default grade is 0 for a dark icon on light background, **25** for a light icon on dark background (compensates for visual bleed/halation). Positive grade for emphasis (e.g. active state). |
| Optical size | 20dp 48dp | Auto-adjusts stroke weight so the icon "looks the same" as size scales; use 20dp for dense desktop layouts, 4048dp to highlight primary actions |
#### Icon styles (3): Outlined, Rounded, Sharp — see corner-radius table above.
### M3 vs M3 Expressive differences
No "M3 Expressive" update is documented on the fetched icon pages (`styles/icons`,
`/designing-icons`, `/applying-icons`). Material Symbols (three styles + four variable axes) predate the
Expressive update; no Expressive-specific icon changes were found on the site.
### Accessibility requirements
- Icon meaning must always be unambiguous and accessible; use caution displaying icons without labels.
Label text (short, meaningful) should be used especially for navigation.
- Below **20dp**, symbols that are complex/highly-detailed, have multiple parts, or represent a key
action essential to the product should have an accompanying text label. Simple symbols (e.g. a star for
ratings) can be used alone at any size if they remain identifiable.
- **Target size**: a 24dp symbol should have a default **target size of 48dp**.
### Source URLs
- https://m3.material.io/styles/icons (= /styles/icons/overview)
- https://m3.material.io/styles/icons/designing-icons
- https://m3.material.io/styles/icons/applying-icons
---
## Motion
### Principles
- May 2025: Material introduced the **motion physics system** with M3 Expressive — a spring-based system
replacing the previous easing-and-duration system, intended to feel "more alive, fluid, and natural,"
and to be easier to implement/customize.
- Two preset **motion schemes**: **Expressive** (Material's opinionated default; overshoots final values
to add bounce; use for most situations, hero moments, key interactions) and **Standard** (minimal
bounce, eases into final values; for utilitarian products). Custom schemes can also be created.
- A **spring** is defined by three attributes: **stiffness** (hardness — higher = faster resolve),
**damping** (how fast bounce wears out — higher = faster stop; damping = 1 removes bounce entirely),
and **initial velocity** (starting speed, combines with stiffness/damping to affect total duration).
- Springs are versatile (one spring can drive transitions, button effects, gestures — consistent feel)
and feel natural/predictable, handling gesture interruption and retargeting seamlessly.
- Two spring **styles**: **Spatial** (movement — x/y position, rotation, size, rounded corners; overshoots
and bounces into place) and **Effects** (color, opacity; no overshoot).
- Three **speeds** per style: **default**, **fast**, **slow**. Most motion uses default; smaller elements
may use fast; larger elements may use slow. The exact values differ by device class (wearable/phone/
tablet) even though the *relative* speed ordering (fast < default < slow) is constant.
- Token naming: e.g. `md.sys.motion.spring.fast.spatial` — the scheme (expressive/standard) is applied at
the product level, not baked into the token name, so schemes can be swapped without re-tokenizing.
- On Jetpack Compose, **21 Material components** use the motion physics system by default (Android Views/
MDC-Android: available but not yet wired into components; Web: compatible via curve approximations).
- Legacy **easing and duration** tokens remain available as a fallback and are still used for transition
animation on platforms/paths not yet migrated to springs.
### Rules (do/don't; easing & duration choice)
- Choose the **Emphasized** easing set for most transitions (captures the M3 style); use **Standard**
only for small, utility-focused, quick transitions, and as the fallback on platforms without Emphasized
support (iOS, Web).
- Easing by transition type:
- **Begin and end on screen****Emphasized** (speeds up quickly, gentle rest, emphasizes the end).
- **Enter the screen****Emphasized decelerate** (starts at peak velocity, gentle rest).
- **Exit the screen permanently****Emphasized accelerate** (starts at rest, ends at peak velocity —
implies it can't be retrieved).
- **Exit the screen temporarily****Emphasized** (ends at rest just off-screen — implies it can be
retrieved, e.g. a drawer).
- Duration by transition size: small-area transitions get short durations; large-area transitions get
long durations (consistent sense of speed).
- Duration by direction: **exit/dismiss/collapse** → shorter durations (less attention needed); **enter/
persist** → longer durations (focus attention on what's new).
- Suggested easing+duration pairs (defaults good for most transitions):
| Easing | Duration | Transition type |
| --- | --- | --- |
| Emphasized | 500ms | Begin and end on screen |
| Emphasized decelerate | 400ms | Enter the screen |
| Emphasized accelerate | 200ms | Exit the screen |
| Standard | 300ms | Begin and end on screen |
| Standard decelerate | 250ms | Enter the screen |
| Standard accelerate | 200ms | Exit the screen |
- Motion customization levels: **Level 1** — use a default scheme (expressive/standard) as-is; **Level 2**
— create a custom `MotionScheme` (custom `AnimationSpec` per property) applied product-wide; **Level 3**
— swap the scheme per element/screen (override the `CompositionLocal`) while most of the product uses
the primary scheme.
- Transition patterns documented: container transform (full-screen and within-screen), forward/backward,
lateral, top-level, enter/exit (within screen bounds and beyond screen bounds), skeleton loaders.
### Values
#### Expressive motion scheme — spring tokens (`ExpressiveMotionTokens.kt`)
| Speed | Style | Damping | Stiffness |
| --- | --- | --- | --- |
| Fast | Spatial | 0.6 | 800.0 |
| Fast | Effects | 1.0 | 3800.0 |
| Default | Spatial | 0.8 | 380.0 |
| Default | Effects | 1.0 | 1600.0 |
| Slow | Spatial | 0.8 | 200.0 |
| Slow | Effects | 1.0 | 800.0 |
#### Standard motion scheme — spring tokens (`StandardMotionTokens.kt`)
| Speed | Style | Damping | Stiffness |
| --- | --- | --- | --- |
| Fast | Spatial | 0.9 | 1400.0 |
| Fast | Effects | 1.0 | 3800.0 |
| Default | Spatial | 0.9 | 700.0 |
| Default | Effects | 1.0 | 1600.0 |
| Slow | Spatial | 0.9 | 300.0 |
| Slow | Effects | 1.0 | 800.0 |
#### Web curve equivalents for springs (from `styles/motion/overview/specs`; springs aren't natively
supported on the web, so Web should use these approximated cubic-bezier curves with matching duration)
| Spring | Cubic-bezier | Duration |
| --- | --- | --- |
| Expressive fast spatial | 0.42, 1.67, 0.21, 0.90 | 350ms |
| Expressive default spatial | 0.38, 1.21, 0.22, 1.00 | 500ms |
| Expressive slow spatial | 0.39, 1.29, 0.35, 0.98 | 650ms |
| Expressive fast effects | 0.31, 0.94, 0.34, 1.00 | 150ms |
| Expressive default effects | 0.34, 0.80, 0.34, 1.00 | 200ms |
| Expressive slow effects | 0.34, 0.88, 0.34, 1.00 | 300ms |
| Standard fast spatial | 0.27, 1.06, 0.18, 1.00 | 350ms |
| Standard default spatial | 0.27, 1.06, 0.18, 1.00 | 500ms |
| Standard slow spatial | 0.27, 1.06, 0.18, 1.00 | 750ms |
| Standard fast effects | 0.31, 0.94, 0.34, 1.00 | 150ms |
| Standard default effects | 0.34, 0.80, 0.34, 1.00 | 200ms |
| Standard slow effects | 0.34, 0.88, 0.34, 1.00 | 300ms |
#### Legacy easing tokens (`styles/motion/easing-and-duration/tokens-specs`; curves confirmed in
androidx `MotionTokens.kt`, noted where Compose's single-cubic-bezier approximation differs from the
platform-accurate multi-segment Android `PathInterpolator`)
| Token | Android | CSS | Flutter | iOS |
| --- | --- | --- | --- | --- |
| `md.sys.motion.easing.emphasized` | PathInterpolator(M 0,0 C 0.05,0 0.133333,0.06 0.166666,0.4 C 0.208333,0.82 0.25,1 1,1) | N/A (use Standard) | `easeInOutCubicEmphasized` | N/A (use Standard) |
| `md.sys.motion.easing.emphasized.decelerate` | PathInterpolator(0.05, 0.7, 0.1, 1) | cubic-bezier(0.05,0.7,0.1,1.0) | Cubic(0.05,0.7,0.1,1.0) | ControlPoints 0.05,0.7,0.1,1.0 |
| `md.sys.motion.easing.emphasized.accelerate` | PathInterpolator(0.3, 0, 0.8, 0.15) | cubic-bezier(0.3,0.0,0.8,0.15) | Cubic(0.3,0.0,0.8,0.15) | ControlPoints 0.3,0.0,0.8,0.15 |
| `md.sys.motion.easing.standard` | PathInterpolator(0.2, 0, 0, 1) | cubic-bezier(0.2,0.0,0,1.0) | Cubic(0.2,0.0,0,1.0) | ControlPoints 0.2,0.0,0.0,1.0 |
| `md.sys.motion.easing.standard.decelerate` | PathInterpolator(0, 0, 0, 1) | cubic-bezier(0,0,0,1) | Cubic(0,0,0,1) | ControlPoints 0.0,0.0,0.0,1.0 |
| `md.sys.motion.easing.standard.accelerate` | PathInterpolator(0.3, 0, 1, 1) | cubic-bezier(0.3,0,1,1) | Cubic(0.3,0,1,1) | ControlPoints 0.3,0.0,1.0,1.0 |
Compose `MotionTokens.kt` also defines `EasingLegacyCubicBezier` = CubicBezierEasing(0.4, 0.0, 0.2, 1.0)
(M2-era "standard" curve) and `EasingLinearCubicBezier` = (0,0,1,1) — not shown on the site pages fetched
(not on the site; from androidx `MotionTokens.kt`).
#### Legacy duration tokens (site table; matches androidx `MotionTokens.kt` exactly)
| Token | Value |
| --- | --- |
| `md.sys.motion.duration.short1` | 50ms |
| `md.sys.motion.duration.short2` | 100ms |
| `md.sys.motion.duration.short3` | 150ms |
| `md.sys.motion.duration.short4` | 200ms |
| `md.sys.motion.duration.medium1` | 250ms |
| `md.sys.motion.duration.medium2` | 300ms |
| `md.sys.motion.duration.medium3` | 350ms |
| `md.sys.motion.duration.medium4` | 400ms |
| `md.sys.motion.duration.long1` | 450ms |
| `md.sys.motion.duration.long2` | 500ms |
| `md.sys.motion.duration.long3` | 550ms |
| `md.sys.motion.duration.long4` | 600ms |
| `md.sys.motion.duration.extra-long1` | 700ms |
| `md.sys.motion.duration.extra-long2` | 800ms |
| `md.sys.motion.duration.extra-long3` | 900ms |
| `md.sys.motion.duration.extra-long4` | 1000ms |
### M3 vs M3 Expressive differences
- **Physics-based motion system** (springs: stiffness/damping/initial velocity) replaces the
easing-and-duration system as the primary motion model. Legacy easing/duration tokens remain as a
fallback and still drive some transitions.
- Two schemes, **Expressive** and **Standard**, selectable/customizable at up to 3 levels of depth
(default scheme → custom scheme → per-element scheme override).
- Spring tokens split into **spatial** vs **effects**, each with **fast/default/slow** — a 2×3 matrix per
scheme (12 values total across both schemes).
- 21 Jetpack Compose components use the physics system by default; component motion is largely driven by
just two tokens in practice: **expressive fast spatial** and **expressive fast effects**.
- Shape morph (see Shape section) uses the **expressive** motion scheme by default.
### Accessibility requirements
- No general "prefers-reduced-motion" policy was found stated on `styles/motion` itself. Reduced-motion
handling is documented per component instead — e.g. `components/carousel/accessibility`: "When reduced
motion settings are turned on, the parallax effect should be removed and carousel items should no
longer expand as they come into view. All items are the same size," and hero carousels should keep the
small item only partially visible rather than animating it. (Component-level source, included here
because it is the only "reduced motion" guidance found in the crawl.)
- `components/menus/guidelines`: "In dense products, such as on desktop, menus can open instantly to
reduce motion."
### Source URLs
- https://m3.material.io/styles/motion (= /styles/motion/overview = /styles/motion/overview/how-it-works)
- https://m3.material.io/styles/motion/overview/specs
- https://m3.material.io/styles/motion/easing-and-duration
- https://m3.material.io/styles/motion/easing-and-duration/tokens-specs
- https://m3.material.io/styles/motion/transitions
- https://m3.material.io/components/carousel/accessibility (reduced motion example)
- https://m3.material.io/components/menus/guidelines (reduced motion example)
- https://raw.githubusercontent.com/androidx/androidx/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/MotionTokens.kt
- .../tokens/ExpressiveMotionTokens.kt
- .../tokens/StandardMotionTokens.kt
---
## Shape
### Principles
- The M3 shape system includes original shapes, a corner radius scale, and built-in shape morphing.
- Use abstract shapes thoughtfully to add emphasis and decorative flair; leverage Material shapes for
built-in shape morphing.
- Rectangular shapes are fully rounded in all corners by default; individual corners can be adjusted to
create asymmetrical shapes.
- Use shape and type "in harmony" — M3 shapes are designed to echo key visual attributes of M3 typography
(e.g. M3 shapes and Google Sans Flex share roundness attributes).
- Shape morph should respond to user interaction and connect function and feeling — use it to communicate
interaction states (e.g. a selected button), actions in progress (e.g. typing, loading), or environment
changes (sound, temperature, time of day).
- "Be bold and dare to embrace tension" — contrast (square + rounded, unconventional shapes) creates more
dynamic, memorable, expressive design; Material historically favored rounded shapes only.
- Shape is **versatile, not semantic** — avoid making a shape literal or assigning it one fixed meaning
(e.g. a wavy loading indicator isn't a literal progress waveform and could be reused elsewhere).
- Use abstract shapes sparingly — don't compromise clarity for visual design; be intentional and consider
how a shape fits the product narrative.
- Emphasize aesthetic moments (imagery, avatar masking) with shape — these are the most flexible,
creative uses.
- Shape (with motion) can create a 2.5D illusion of depth on flat visuals.
### Rules (do/don't)
- Components can have symmetric (all corners equal) or asymmetric ("inner corner") shapes; both use the
same 10-step scale. Asymmetric/inner corners are used in components with closely-grouped items (menus,
split buttons) — inner-corner component tokens always map to individual corner-shape tokens.
- Customization happens at **style** level (changing what a named style like "medium" resolves to —
affects every component mapped to it, unless overridden) or **component** level (remapping one
component to a different named style, e.g. remapping a button's default "full" to "small"/"medium").
- The shape style family can be customized from **rounded** to **cut** (straight line instead of curve);
add extra padding to avoid clipping content, especially in information-dense components. **Caution**:
don't apply large/full corners to information-dense components (e.g. cards).
- **Optical roundness**: when nesting rounded objects, don't reuse the same radius for both. Formula:
**outer radius padding = inner radius** (example given: 48dp 14dp = 34dp). **Do** use different,
proportional radii for nested components; **don't** use the same radius as the container.
- Use the shape library for mostly visual/decorative elements; avoid unconventional shapes on text-heavy
containers; use shapes sparingly for emphasis and delight.
### Values
#### Corner radius scale (10 styles; matches androidx `ShapeTokens.kt` exactly)
| # | Style | Value |
| --- | --- | --- |
| 1 | None | 0dp |
| 2 | Extra small | 4dp |
| 3 | Small | 8dp |
| 4 | Medium | 12dp |
| 5 | Large | 16dp |
| 6 | Large increased | 20dp |
| 7 | Extra large | 28dp |
| 8 | Extra large increased | 32dp |
| 9 | Extra extra large | 48dp |
| 10 | Full | fully rounded (Compose: `CircleShape`, not a fixed dp value) |
(M2 used a 3-level shape scale based on component-container size; M3 uses this 10-level scale based on
corner roundedness instead.)
#### Shape library
35 pre-made shapes in the Material shape library (Figma Design Kit / Jetpack Compose `MaterialShapes`
API), designed to morph seamlessly into one another. Used by the standard **button group** and
**loading indicator** components by default.
### M3 vs M3 Expressive differences (May 2025 "M3 Expressive update")
- Added **35 new shapes** and **shape morphing** to the Material Shape Library (Figma) and Jetpack
Compose (`MaterialShapes` API).
- Added new shape principles and a refreshed art direction.
- Added corner-radius tokens: **Large increased (20dp)**, **Extra large increased (32dp)**,
**Extra extra large (48dp)** — these three styles are new to the 10-step scale.
- Updated "fully rounded" corners to use the dedicated **Full** style; previously this was defined as
50% of the component's size.
- Shape morphing uses the **expressive** motion scheme by default (see Motion section); can be switched
to the standard scheme. Access is via a platform API — Android: Compose `MaterialShapes`; Web: not
currently available.
### Accessibility requirements
No shape-specific accessibility requirements (contrast ratios, color-alone rule, reduced motion) were
found on the fetched shape pages. The only adjacent accessibility-flavored guidance is the "optical
roundness" and "cut corners need extra padding" rules above, which are about legibility/clipping rather
than a formal accessibility requirement.
### Source URLs
- https://m3.material.io/styles/shape (= /styles/shape/overview-principles)
- https://m3.material.io/styles/shape/corner-radius-scale
- https://m3.material.io/styles/shape/shape-morph
- https://raw.githubusercontent.com/androidx/androidx/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/ShapeTokens.kt
---
## Typography
### Principles
- Use typography to make content readable and beautiful.
- M3 has **one type scale** containing two sets of type styles: **15 baseline** and **15 emphasized**
(added in the Expressive update) — 30 styles total, both sets following the same scale from Display
Large to Label Small.
- Five style **roles**: **Display, Headline, Title, Body, Label** — each with Large/Medium/Small (Display/
Headline/Title/Body) — see the type-scale table below for exact members.
- **Display**: largest text on screen; short, important text/numerals; best on large screens; can use
a more expressive/handwritten/script font; set optical size appropriately.
- **Headline**: short, high-emphasis text on smaller screens; can use expressive typefaces if line
height/letter spacing are adjusted to preserve readability.
- **Title**: medium-emphasis, relatively short text (e.g. dividing secondary content); use caution with
expressive/display/handwritten/script fonts here.
- **Body**: longer passages; use typefaces meant for body copy, readable at small size; avoid
expressive/decorative fonts.
- **Label**: small, utilitarian text inside components (e.g. buttons use Label Large) or captions.
- Two typeface roles: **Brand** (larger styles — Display/Headline — focused on expression) and **Plain**
(smaller styles — Body/Label — focused on readability). Roboto is the default for both.
- Typesetting: use **padding + bounding box** method for Web/iOS (line height = bounding box height, text
vertically centered — CSS "half-leading"); use the **baseline** method for Android/platform-agnostic
specs (distances measured to the invisible baseline a line of text rests on).
- **Line height**: recommend a **1.2×** ratio of type size for larger styles (title/headline/display);
recommend **~1.5×** for smaller styles (body/label) — too tight undermines flow, too loose feels
incohesive.
- Use **tabular (monospaced) numbers**, not proportional digits, wherever values change often (tables,
clocks) to prevent layout shift and aid scanning.
- Language script height: line height can auto-adapt by script category so components (with vertical
padding) don't clip. Categories: **Small/base** (~Latin, Cyrillic, Greek, Hebrew, except Vietnamese);
**Medium** (~7% taller: Arabic, Chinese, Hindi, Japanese, Korean, Vietnamese, most others); **Large**
(~30% taller: Burmese, Telugu); **Extra large** (~100% taller: Nastaliq). Default to Medium since most
languages fall there; components with a fixed height are built for Small and may not auto-adapt.
- Material's default type scale follows the **Major Second (1.125)** modular scale, anchored to 14 (the
base size for body text).
### Rules (do/don't)
- Emphasized styles are **not** used by default by Material components — swap the baseline token for the
matching emphasized token (e.g. `md.sys.typescale.display-large` → `md.sys.typescale.emphasized.
display-large`) deliberately.
- Recommended emphasized-style use: badges, buttons (primary actions), extended FAB, selected list items,
selected menu items, and generally selection/actions/headlines/other editorial-treatment moments —
not just for decoration.
- Emphasized can be combined with **weight** (apply to already-bold text) and **context** (apply
selectively to selected/unread/key-interaction states) simultaneously.
- When customizing the scale: change brand/plain typeface tokens if swapping fonts; adjust line height
and letter spacing to refine appearance; **avoid changing type size** (can break component
layout/reflow); keep both baseline and emphasized styles visually consistent (e.g. all wider than
baseline, not just some).
- Customizing the M3 type scale or individual styles may forgo future Material typography token updates.
- Hyperlinks: use **primary** (or **tertiary** for a less prominent link) and the text must be
**underlined** — never color alone.
- **Do** use the same size and same optical weight for Material Symbols paired with text; **don't** mix.
### Values
#### Type scale — baseline set (`TypeScaleTokens.kt`)
| Style | Size | Line height | Tracking | Weight | Family |
| --- | --- | --- | --- | --- | --- |
| Display Large | 57sp | 64sp | 0.2sp | Regular | Brand |
| Display Medium | 45sp | 52sp | 0sp | Regular | Brand |
| Display Small | 36sp | 44sp | 0sp | Regular | Brand |
| Headline Large | 32sp | 40sp | 0sp | Regular | Brand |
| Headline Medium | 28sp | 36sp | 0sp | Regular | Brand |
| Headline Small | 24sp | 32sp | 0sp | Regular | Brand |
| Title Large | 22sp | 28sp | 0sp | Regular | Brand |
| Title Medium | 16sp | 24sp | 0.2sp | Medium | Plain |
| Title Small | 14sp | 20sp | 0.1sp | Medium | Plain |
| Body Large | 16sp | 24sp | 0.5sp | Regular | Plain |
| Body Medium | 14sp | 20sp | 0.2sp | Regular | Plain |
| Body Small | 12sp | 16sp | 0.4sp | Regular | Plain |
| Label Large | 14sp | 20sp | 0.1sp | Medium | Plain |
| Label Medium | 12sp | 16sp | 0.5sp | Medium | Plain |
| Label Small | 11sp | 16sp | 0.5sp | Medium | Plain |
#### Type scale — emphasized set (Expressive; same size/line-height/family as baseline, heavier weight
and slightly different tracking on some styles)
| Style | Size | Line height | Tracking | Weight | Family |
| --- | --- | --- | --- | --- | --- |
| Display Large Emphasized | 57sp | 64sp | 0sp | Medium | Brand |
| Display Medium Emphasized | 45sp | 52sp | 0sp | Medium | Brand |
| Display Small Emphasized | 36sp | 44sp | 0sp | Medium | Brand |
| Headline Large Emphasized | 32sp | 40sp | 0sp | Medium | Brand |
| Headline Medium Emphasized | 28sp | 36sp | 0sp | Medium | Brand |
| Headline Small Emphasized | 24sp | 32sp | 0sp | Medium | Brand |
| Title Large Emphasized | 22sp | 28sp | 0sp | Medium | Brand |
| Title Medium Emphasized | 16sp | 24sp | 0.15sp | Bold | Plain |
| Title Small Emphasized | 14sp | 20sp | 0.1sp | Bold | Plain |
| Body Large Emphasized | 16sp | 24sp | 0.15sp | Medium | Plain |
| Body Medium Emphasized | 14sp | 20sp | 0.25sp | Medium | Plain |
| Body Small Emphasized | 12sp | 16sp | 0.4sp | Medium | Plain |
| Label Large Emphasized | 14sp | 20sp | 0.1sp | Bold | Plain |
| Label Medium Emphasized | 12sp | 16sp | 0.5sp | Bold | Plain |
| Label Small Emphasized | 11sp | 16sp | 0.5sp | Bold | Plain |
(Size, line height and font family are unchanged between a baseline style and its emphasized counterpart;
weight steps up — Regular→Medium or Medium→Bold — and a few styles gain slightly wider tracking. Source:
`TypeScaleTokens.kt`; the site's `styles/typography/type-scale-tokens` page describes these sets in prose
and confirms "30 styles: 15 baseline, 15 emphasized" and the naming convention, but renders the actual
numeric table only inside an interactive, non-text widget.)
`TypefaceTokens.kt` weight mapping used above: Regular = `FontWeight.Normal`, Medium = `FontWeight.
Medium`, Bold = `FontWeight.Bold`; Brand/Plain both default to the platform sans-serif (Compose fallback;
the actual M3 web/design spec default typeface is **Roboto** for both Brand and Plain, per
`styles/typography/fonts`).
#### Fonts
| Font | Role | Notes |
| --- | --- | --- |
| Roboto | Default (Brand + Plain) | Static; used in the M3 type scale by default; 3,300+ glyphs |
| Roboto Flex | Variable, not yet part of the type scale | Axes: Slant, Width, Weight, Grade, Optical Size, plus advanced axes (thick stroke XOPQ, thin stroke YOPQ, counter width XTRA, uppercase height YTUC, lowercase height YTLC, ascender height YTAS, descender depth YTDE, figure height YTFI); width range 25150 (example) |
| Roboto Serif | Variable | Reading-focused; wide weight/width range |
| Roboto Mono | Variable | Axes: Weight, Italic; monospaced, good for code/tabular numbers |
| Noto Sans | Fallback | Axes: Width, Weight, Italic; 150+ scripts, used when a language is unsupported |
Font fallback order (e.g. for Roboto Flex): **Roboto Flex → Roboto → Noto Sans**.
#### Units
| Platform | Font size unit | Conversion | Letter spacing unit | Conversion |
| --- | --- | --- | --- | --- |
| Android | sp | 1.0 (base) | em | tracking(px) / font size(sp) |
| Web | rem | sp / 16 = rem (16px root default) | rem | tracking(px) / font size(sp) |
Example: 16sp = 1rem; 24sp = 1.5rem; 60sp = 3.75rem. 0.2 tracking / 16sp = 0.0125 em (or rem on web).
### M3 vs M3 Expressive differences (May 2025 "M3 Expressive update")
- Added **15 emphasized type styles** alongside the existing 15 baseline styles — 30 total. Emphasized
styles have higher weight (and minor tracking adjustments) and are meant for bold/selection/emphasis
moments; baseline and emphasized are meant to be used together, not as a full replacement.
- New **emphasized token namespace**: `md.sys.typescale.emphasized.<style>` alongside
`md.sys.typescale.<style>`.
- (Aug 2026, post-Expressive) **Language script height support**: line height can automatically adapt by
language script category (small/medium/large/extra large) — see Principles above.
- Roboto Flex / variable-font axis guidance (weight, grade, width, optical size — "editorial treatments")
is presented as an Expressive-era authoring technique, though Roboto Flex itself is "not yet part of
the M3 type scale."
### Accessibility requirements
- Text contrast: **≥3:1** for large text (≥14pt bold / ≥18pt regular) and graphics; **≥4.5:1** for small
text (restated from `foundations/designing/color-contrast`).
- Default typography color is **on surface** (or **on surface variant**).
- **Color-alone rule**: hyperlinked text must be **underlined**, in addition to using primary/tertiary color.
- Avoid very light/thin weights for body text — low-resolution displays can struggle to render thin type,
especially at small sizes; reserve very light weights for larger sizes such as display type.
- Avoid excessive weight/bold at small label sizes — it can reduce readability (navigation-bar example).
- Tabular numbers should be used where digits change frequently, to avoid disorienting layout shifts.
### Source URLs
- https://m3.material.io/styles/typography (= /styles/typography/overview)
- https://m3.material.io/styles/typography/type-scale-tokens
- https://m3.material.io/styles/typography/fonts
- https://m3.material.io/styles/typography/applying-type
- https://m3.material.io/styles/typography/editorial-treatments
- https://raw.githubusercontent.com/androidx/androidx/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/TypeScaleTokens.kt
- .../tokens/TypefaceTokens.kt