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

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

58 KiB
Raw Permalink Blame History

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.ktInnerCornerCornerSize = 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:39gap-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.ktItemLeadingSpace = 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:46px-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:3my-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:36fixed 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:163transition-[border-radius,background-color,color,box-shadow,padding,margin] … ease-spatial-fast
    • group.blade.php:77transition-[border-radius,background-color,color] … ease-spatial-fast
    • menu-item.blade.php:48transition-[border-radius,background-color] … ease-spatial-fast
    • fab-menu.blade.php:49transition-[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-80absolute 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:46min-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-87transition: 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:39min-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:69type-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:38role="{{ 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