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
58 KiB
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:207renders<x-loading :class="$iconSize" :label="false" />— only a size class.loading.blade.php:22detects a caller colour withpreg_match('/(^|\s)text-(?!…)/'), whichsize-5does not match, soloading.blade.php:31addstext-primary. The SVG isfill="currentColor"(resources/svg/loading-indicator/*.svg), so aspinneron<x-button variant="filled">(bg-primary) paints primary on primary — contrast 1:1. Same ontonal(primary on secondary-container) and onvariant="filled" color="error". - Fix: in
button.blade.php:207pass:class="$iconSize.' text-current'"—text-currentsatisfiesloading.blade.php's$colouredtest, so notext-primaryis 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:41wraps the whole snackbar in<template x-if="current">, and the element carryingroleandaria-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 noaria-live. Secondary issue on the same element:x-bind:rolesetsalert(implicitly assertive) whilearia-live="polite"is hard-coded beside it; the explicitaria-livewins, so the intent ("errors interrupt") is not realised anyway — and M3 asks for polite regardless. - Fix: move
aria-live="polite"andaria-atomic="true"onto the permanent wrapper attoast.blade.php:33, dropx-bind:role/aria-livefrom the inner div (or keeprole="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:60setstimeout: sticky || toast.timeout === 0 || … ? 0 : (toast.timeout ?? DEFAULT_TIMEOUT_MS)— the presence ofactionis not considered, somaterialToast('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 withactionas 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 explicittimeoutstill override it if the caller insists. - Effort: S
- Breaks API? no (behaviour change only;
timeoutkeeps 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:70setsmin-w-28 max-w-70 overflow-visible … [inset:auto]— there is nomax-block-sizeand overflow is explicitly visible. A popover positioned withposition-areaandinset: autois sizedfit-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) andmenu.css:99for::picker(select). - Fix: on
menu.blade.php:70replaceoverflow-visiblewithmax-h-72 overflow-y-auto(18rem, matching.field-menu), ormax-h-[min(18rem,calc(100vh-2rem))]. Verify in Chrome thatposition-try-fallbacksstill 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 atbutton.blade.php:172(after:min-h-12 after:min-w-12onxs/sm).<x-group>does not: its segments are plain<label>s (group.blade.php:75-83) sizedh-8(xs) orh-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>ofsmicon buttons is 40 px wide per segment (button.blade.php:141), andgroups.csshas nomin-width. - Fix: (a) add the same pseudo-element to
group.blade.php:75-83forxs/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) ingroups.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:39emitsdata-button-group="split", andresources/css/components/groups.css:54-66gives[data-split]the connected group's variables —--group-inner(rest) and--group-inner-pressed(pressed). Those are 8 px/4 px atsmandmd, 16 px/12 px atlg, 20 px/16 px atxl(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. Onlyxsis 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 ingroups.cssrather 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]:activerules (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.ktTrailingIconSize = 22.0.dp,SplitButtonMediumTokens.kt= 26.0.dp). - Library does:
split-button.blade.php:56-66renders 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:36plus 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 assize-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) andtokens/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-4formdandgap-5forlginfab.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.ktLeadingSpace = 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-4inbutton.blade.php:147andgroup.blade.php:48;--group-pad: 1remingroups.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:35addsflex-wrapto every non-connected group. On a narrow window a three-button group breaks into two rows, and the press-expansion rules ingroups.css:38-48(which only look at+siblings) then compress a neighbour on a different line. - Fix: drop
'flex-wrap' => ! $connectedfrombutton-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) withgap-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:6andmenu-separator.blade.php:3inherit the same 12 px. - Fix:
px-4onmenu-item.blade.php:46, andpx-4/mx-4on 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) withh-px✓ andbg-outline-variant✓. - Fix:
my-2inmenu-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:183filters them out —.filter((item) => item.getAttribute('aria-disabled') !== 'true')— so arrow keys, Home/End and typeahead skip them entirely, andmenu-item.blade.php:53also setspointer-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 inmenu.js:237). Two lists are cleanest:items()for focus movement,items().filter(enabled)for activation.menu-item.blade.php:53can keeppointer-events-nonefor 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:68renders<x-icon :name="$icon" :class="$iconSize" />with nofilled, so the Material Symbol's outlined face is used.fab-menu.blade.php:53has the same issue for the FAB-menu trigger, andfab-menu-item.blade.php:43for its items. - Fix: add
filledtofab.blade.php:68andfab-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 tofalse, 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)"inbutton.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:23declares adisabledprop andfab.blade.php:52paints 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 listsdisabledamong 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(thefabprop, belowsm) —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 belowsm(sm:w-autoonly 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-barmechanism — have<x-button fab>(and the documented wrapper for<x-fab>) publish--material-fab: 4.5rem, and maketoast.blade.php:36readbottom-[calc(var(--material-bottom-bar,0px)+var(--material-fab,0px)+1rem)]. Document the variable next to--material-bottom-extrain 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) andalert.blade.php:61(the dismiss button,size-10). None of them has the pseudo-element trick<x-button>uses atbutton.blade.php:172. The snackbar container ismin-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/2to those three buttons (they already carrystate-layer, which setsposition: 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-13states 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-fastgroup.blade.php:77—transition-[border-radius,background-color,color] … ease-spatial-fastmenu-item.blade.php:48—transition-[border-radius,background-color] … ease-spatial-fastfab-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:163transition-[border-radius,padding,margin] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fastplus a second declaration forbackground-color,color,box-shadowwithduration-(--md-sys-motion-effects-fast-duration) ease-effects-fast. Tailwind cannot emit twotransitionshorthands on one element, so this wants a small utility inresources/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-65draws acheck_circle/error/warning/infoglyph for every typed toast, andToasts.php:19-37makes typing the only API —$this->success(…)always setstype: 'success'. The glyphs are painted in the library's owninverse-success/error/warning/inforoles (scheme.css:76-79), which is a sound extension ofinverse-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 alreadyaria-hiddenvia<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:46writesaria-labelonly@if ($label).labelhas no default (fab-menu.blade.php:21), and the only content of the button is two<x-icon>s, which arearia-hidden="true"by default (icon.blade.php:27). A<x-fab-menu>withoutlabelis 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
labelrequired, or default it to__('Toggle menu')infab-menu.blade.php:21and always emitaria-label. Note the expanded/collapsed state is already handled —menu.js:140writesaria-haspopup/aria-controls/aria-expandedonto 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-56gives the bubblerole="tooltip"(ordialogwhenpersistent) and anaria-labelof the title, but nothing points at it: the trigger gets noaria-describedby, and forpersistentnoaria-haspopup="dialog"/aria-expandedeither. 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 deliberatelyaria-hiddenand documents why, which is defensible for an icon button whosearia-labelis 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, putaria-describedby="material-rich-tooltip-{{ $key }}"on the wrapper's focusable child (or document that the caller must), and forpersistentaddaria-haspopup="dialog"+ anaria-expandedwritten byrich-tooltip.js. For the standalone<x-tooltip>, either droparia-hiddenand 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 2–4 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.5for the dot (2 px in on both axes, where M3 wants 0) andabsolute -top-1 start-[calc(100%-0.75rem)]for the count (thestartis exactly right; the top is −4 px where M3 wants −2 px). - Fix:
top-0 end-0for the dot and-top-0.5for the count inbadge.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-variantis M3's decorative boundary role (dividers);outlineis the role for a boundary that must be seen. - Library does:
badge.blade.php:68—'border-outline-variant text-on-surface-variant'. Onsurfacein the library's own schemeoutline-variantis 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-outlineinbadge.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-43hides onpointerleave,pointerdownandfocusoutwith no delay;rich-tooltip.js:43uses a 200 ms grace instead of 1500 ms. Nothing closes other open tooltips — because the bubble ispopover="manual"(tooltip.blade.php:35), a tooltip held open by keyboard focus stays up while a second one opens on hover. - Fix:
hide(1500)intooltip.js(add the samedelayparameterrich-tooltip.js:37already has), and keep a module-levellet open = nullthatshow()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:72transitionsopacityonly —translateis in thetransition-[…]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 fromtransform-origin: top, on the spatial spring). - Fix: mirror
menu.css:116-128onmenu.blade.php:72— addorigin-top scale-95 open:scale-100 starting:open:scale-95and 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:51changes container colour and corner, andmenu-item.blade.php:87fills the leading icon;aria-checkedis 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-188draws a::checkmarkat the row's end. - Fix: when
$selected === trueand noiconRightis given, render a trailingcheckicon inmenu-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.ktItem = 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) andmenu.css:134(nativeoption) both usemin-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-12onmenu-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-66centres the icon (justify-centerfrombutton.blade.php:162). - Fix: add a per-size negative
ms-px/-me-*ortranslate-xto the trailing button's:classinsplit-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-standardwith--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.ktpublishes 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) forsm,min-w-20/min-w-24for md/lg (which match those sizes' heights rather than any published minimum). - Fix:
min-w-20(80 px) for the small extended FAB infab.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 fixedend-4 bottom-4(16 px) with no large-window variant. The list (fab-menu.blade.php:69-75) has nomax-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 addmax-h-[calc(100vh-8rem)] overflow-y-autotofab-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:27hasstarting:translate-y-2 starting:opacity-0for the entry, but the popover is hidden withdisplay: noneon close and the items carry notransition-discrete/@starting-styleexit pair, so they vanish instantly.<x-menu>fades out correctly (menu.blade.php:72includesdisplay,overlayandtransition-discrete). - Fix: add
transition-[translate,opacity,display,overlay] transition-discretetofab-menu-item.blade.php:27and 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.jsbinds no key handler;toast.blade.php:41-82binds 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
keydownlistener insnackbar.js:38that callsdismiss()on Escape while the snackbar containsdocument.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-80on 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-80and let both lines beinverse-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, noton-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") takeson-surface-variant— "Standard icon | On surface variant | On surface variant | Primary" (RC-A, Icon buttons → colour table). Both readings are implemented in the samematchatbutton.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), buttokens/ButtonSmallTokens.ktandButtonMediumTokens.ktboth setSelectedContainerShapeRound = 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/XLargeIconButtonTokensare 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.15fand the layout appliesexpandedRatio * width / 2per side, clamped to the neighbour's padding (fetched live from androidx-mainButtonGroup.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 usesease-spatial-fast, matching Compose'sMotionSchemeKeyTokens.FastSpatial(ButtonGroup.kt, defaultAnimationSpec). --group-fullis half the height rather thancorner-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 whatConnectedButtonGroupSmallTokens.SelectedInnerCornerCornerSizePercent = 50%means.- The
fabprop 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)" whilebutton.blade.php:225will happily attach one; and the elevation never reaches Level 4 on hover becausemax-sm:shadow-elevation-3outrankshover:shadow-elevation-1in 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 giveswire:modelfor free; it is a defensible reading of "single-select, selection-required", and themultipleform (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, whichtype.cssdoes define) and the Expressive shape library (cookie-9). The counting figure honours reduced motion by reading--md-sys-motion-spatial-slow-duration, whichmotion.css:63-74zeroes (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 atsecondary-containerfor the primary indicator (RC-A, Progress indicators → Specs), which the library does forprimaryandsecondary; the extra colours take their own container, which is the only consistent extension.thickgrows the container beyond M3'sWaveHeight(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-158against RC-A Buttons → Specs andtokens/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 × XS–XL) 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) matchesSmall/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) matchesFilledButtonTokens/TonalButtonTokens/ElevatedButtonTokens.DisabledContainerOpacity = 0.1fandDisabledLabelTextOpacity = 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 usescurrentColor). - Focus ring: 3 px
secondary, 2 px offset, keyboard-only (state.css:46-53), andSegmentedMenuTokens.ItemFocusIndicatorColor = Secondaryconfirms 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°,
textvariant excluded, menu 4 px away,aria-expandedon 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 matchestokens/FabMenuBaselineTokens.kt. - Menu container: surface-container-low / tertiary-container for
vibrant, 16 px corner, elevation 2, 112–280 px width (menu.blade.php:70-79) — matchesStandardMenuTokens.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. currentusessecondary-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-hiddenunless labelled,maxoverflow to "999+" within M3's four-character limit (badge.blade.php:74-85) — matchestokens/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"witharia-valuenowonly 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) — matchesLoadingIndicatorTokensand 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 4–10 s range, hover/focus pauses,
never steals focus (
toast.blade.php:51-73,snackbar.js:13,:84-117) — matchestokens/SnackbarTokens.ktvalue 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-219has 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 thatItemShape = 4dpcorners 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 noshapeprop; the corner variables ingroups.cssassume a round group, so a group ofshape="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'saria-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 frommin-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 600–639 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 840–1023 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 |