Plan the Material 3 alignment, with the audits and Google's references
Every foundations, styles and components page of m3.material.io (238, from the sitemap) extracted into docs/reference/m3, five audit reports with 142 findings in docs/audits/m3-alignment, and the 2.0.0 plan in docs/plans/material-3-alignment.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
b1fc0c9cfa
commit
651a513d1e
@@ -0,0 +1,811 @@
|
||||
# Audit: actions and communication
|
||||
|
||||
Scope: `<x-button>`, `<x-button-group>`, `<x-group>`, `<x-split-button>`, `<x-fab>`, `<x-fab-menu>`,
|
||||
`<x-fab-menu-item>`, `<x-menu>`/`<x-menu-item>`/`<x-menu-group>`/`<x-menu-separator>`, `<x-badge>`,
|
||||
`<x-alert>`, `<x-progress>`, `<x-loading>`, `<x-toast>`, `<x-tooltip>`, `<x-rich-tooltip>`,
|
||||
`<x-stat>`, `<x-empty-state>`, plus `resources/css/components/{groups,menu}.css`,
|
||||
`resources/css/tokens/{state,motion,shape}.css`, `resources/js/{menu,snackbar,tooltip,rich-tooltip,progress,figure}.js`
|
||||
and `src/Concerns/Toasts.php`.
|
||||
|
||||
Sources are named per finding. `RC-A` = `docs/reference/m3/reference-components-a.md`, `RF` =
|
||||
`reference-foundations.md`, `RS` = `reference-styles.md`, `tokens/X.kt` = the androidx Compose
|
||||
Material 3 token file in `docs/reference/m3/tokens/`. Two numbers were fetched live from androidx-main
|
||||
during this audit (`Button.kt`, `ButtonGroup.kt`) and are marked as such.
|
||||
|
||||
## Summary
|
||||
|
||||
The button family is the strongest part of this library: all five Expressive sizes, the icon-button
|
||||
width matrix, the corner/press-morph scale, the outline widths, the per-variant toggle colours and
|
||||
the per-size type roles match the androidx tokens *exactly*, digit for digit — I could not find a
|
||||
single wrong number in `<x-button>`'s own size tables, and the icon-button widths (narrow/default/wide
|
||||
× five sizes, fifteen values) are all correct. The problems are concentrated elsewhere: the split
|
||||
button reuses the connected button group's corner variables, so its inner corners are wrong at four of
|
||||
five sizes and morph in the *opposite* direction to M3 on press; the snackbar's live region is created
|
||||
together with its message (so the announcement is unreliable) and auto-dismisses even when it carries
|
||||
an action, which M3's accessibility page forbids outright; a spinner inside a filled button is drawn
|
||||
in `text-primary` on a `bg-primary` container, i.e. invisible; `<x-group>`'s segments carry no 48 px
|
||||
touch target; and `<x-menu>` has no maximum height, so a long menu runs off the viewport with no way
|
||||
to scroll it. Colour is animated on the overshooting *spatial* spring in four components, which the
|
||||
library's own `motion.css` comment says must never happen. Nothing in this group implements a
|
||||
deprecated M3 component — segmented buttons and the small FAB are correctly absent — and the two
|
||||
non-M3 pieces (alert, stat, empty state) stay inside M3's colour roles, type scale and shape scale.
|
||||
|
||||
Counts: 5 must-fix, 17 should-fix, 14 nice-to-have (36 findings).
|
||||
|
||||
## Findings
|
||||
|
||||
### ACT-01 · button / loading · A button's spinner is invisible on a filled or tonal button
|
||||
- Severity: must-fix
|
||||
- M3 says: "when embedded in another component (e.g. a button), the active indicator should match the
|
||||
label/icon color and the **track should be removed** in that context" (RC-A, Progress indicators →
|
||||
Accessibility; same rule repeated for the loading indicator: "when embedded in another component
|
||||
(e.g. a button), the indicator needs ≥3:1 against that component").
|
||||
- Library does: `resources/views/components/button.blade.php:207` renders
|
||||
`<x-loading :class="$iconSize" :label="false" />` — only a size class. `loading.blade.php:22`
|
||||
detects a caller colour with `preg_match('/(^|\s)text-(?!…)/')`, which `size-5` does not match, so
|
||||
`loading.blade.php:31` adds `text-primary`. The SVG is `fill="currentColor"`
|
||||
(`resources/svg/loading-indicator/*.svg`), so a `spinner` on `<x-button variant="filled">`
|
||||
(`bg-primary`) paints primary on primary — contrast 1:1. Same on `tonal` (primary on
|
||||
secondary-container) and on `variant="filled" color="error"`.
|
||||
- Fix: in `button.blade.php:207` pass `:class="$iconSize.' text-current'"` — `text-current` satisfies
|
||||
`loading.blade.php`'s `$coloured` test, so no `text-primary` is added and the indicator inherits the
|
||||
button's label colour.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-02 · toast · The snackbar's live region is created together with its message
|
||||
- Severity: must-fix
|
||||
- M3 says: "Announcement: use a **live region with a 'polite'/queued** announcement (not assertive) on
|
||||
Android and web" (RC-A, Snackbar → Accessibility).
|
||||
- Library does: `resources/views/components/toast.blade.php:41` wraps the whole snackbar in
|
||||
`<template x-if="current">`, and the element carrying `role` and `aria-live="polite"` is inside it
|
||||
(`toast.blade.php:45-46`). The region therefore enters the DOM with its text already present; a
|
||||
live region must exist beforehand and have its *contents* change for assistive technology to
|
||||
announce it reliably. The outer wrapper (`toast.blade.php:33`) is permanent but carries no
|
||||
`aria-live`. Secondary issue on the same element: `x-bind:role` sets `alert` (implicitly assertive)
|
||||
while `aria-live="polite"` is hard-coded beside it; the explicit `aria-live` wins, so the intent
|
||||
("errors interrupt") is not realised anyway — and M3 asks for polite regardless.
|
||||
- Fix: move `aria-live="polite"` and `aria-atomic="true"` onto the permanent wrapper at
|
||||
`toast.blade.php:33`, drop `x-bind:role` / `aria-live` from the inner div (or keep `role="status"`
|
||||
on the wrapper). The text nodes then change inside a region that was already there.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-03 · toast / snackbar.js · A snackbar with an action still auto-dismisses
|
||||
- Severity: must-fix
|
||||
- M3 says: "**Snackbars with an action must never auto-dismiss** — let people read/act at their own
|
||||
pace; snackbars without actions may auto-dismiss (common range 4-10 seconds)" (RC-A, Snackbar →
|
||||
Accessibility). The guidelines page repeats it as a design requirement: "make the snackbar
|
||||
actionable so it doesn't auto-dismiss".
|
||||
- Library does: `resources/js/snackbar.js:60` sets `timeout: sticky || toast.timeout === 0 || … ? 0 :
|
||||
(toast.timeout ?? DEFAULT_TIMEOUT_MS)` — the presence of `action` is not considered, so
|
||||
`materialToast('Share deleted', { action: { label: 'Undo', … } })` (exactly the showcase example,
|
||||
`showcase/sections/communication.blade.php:22`) disappears after 4 s. Hover/focus pauses the timer
|
||||
(`snackbar.js:97-117`), which helps a mouse user but not a screen-reader or keyboard user who has
|
||||
not yet reached it.
|
||||
- Fix: in `snackbar.js:55-63`, treat an entry with `action` as untimed: `timeout: sticky ||
|
||||
toast.action || toast.timeout === 0 … ? 0 : …`. The close button already appears for an actioned
|
||||
toast (`toast.blade.php:76`), so dismissal stays possible. Let an explicit `timeout` still override
|
||||
it if the caller insists.
|
||||
- Effort: S
|
||||
- Breaks API? no (behaviour change only; `timeout` keeps working)
|
||||
|
||||
### ACT-04 · menu · A long menu has no maximum height and cannot be scrolled
|
||||
- Severity: must-fix
|
||||
- M3 says: "Scrolling: a menu that can't fit all items **scrolls and shows a persistent scrollbar**"
|
||||
(RC-A, Menus → Behaviour and guidelines).
|
||||
- Library does: `resources/views/components/menu.blade.php:70` sets
|
||||
`min-w-28 max-w-70 overflow-visible … [inset:auto]` — there is no `max-block-size` and overflow is
|
||||
explicitly *visible*. A popover positioned with `position-area` and `inset: auto` is sized
|
||||
`fit-content`; it is not clipped to the position area, so a menu with (say) 20 items extends past
|
||||
the viewport and the items beyond the edge cannot be reached — the top layer does not scroll with
|
||||
the page. The library's own dropdown CSS already does this correctly:
|
||||
`resources/css/components/menu.css:24-25` (`max-block-size: 18rem; overflow-y: auto`) and
|
||||
`menu.css:99` for `::picker(select)`.
|
||||
- Fix: on `menu.blade.php:70` replace `overflow-visible` with
|
||||
`max-h-72 overflow-y-auto` (18rem, matching `.field-menu`), or
|
||||
`max-h-[min(18rem,calc(100vh-2rem))]`. Verify in Chrome that `position-try-fallbacks` still flips
|
||||
before the cap bites.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-05 · group / button-group · Connected segments miss M3's 48 dp target and 48 dp minimum width
|
||||
- Severity: must-fix
|
||||
- M3 says: "Each button needs a minimum **48x48dp** target; XS/S groups get extra inner padding
|
||||
specifically to preserve this — **don't reduce it**" and "Minimum widths: XS and S connected button
|
||||
groups have a 48dp target area and a **48dp minimum width**" (RC-A, Button groups → Accessibility
|
||||
and → Specs). RF:78 repeats the 48 × 48 rule, RF:82 gives "at least 48 × 48 **CSS pixels**" for web.
|
||||
- Library does: `<x-button>` solves this for itself with the pseudo-element at
|
||||
`button.blade.php:172` (`after:min-h-12 after:min-w-12` on `xs`/`sm`). `<x-group>` does **not**: its
|
||||
segments are plain `<label>`s (`group.blade.php:75-83`) sized `h-8` (xs) or `h-10` (sm)
|
||||
(`group.blade.php:48-49`) with no target expansion, so the whole `<x-group size="sm">` — the
|
||||
default, and the only form in the showcase — offers a 40 px-tall target. Separately, nothing
|
||||
enforces the 48 dp *minimum width*: a connected `<x-button-group connected>` of `sm` icon buttons
|
||||
is 40 px wide per segment (`button.blade.php:141`), and `groups.css` has no `min-width`.
|
||||
- Fix: (a) add the same pseudo-element to `group.blade.php:75-83` for `xs`/`sm`
|
||||
(`after:absolute after:top-1/2 after:left-1/2 after:size-full after:min-h-12 after:min-w-12
|
||||
after:-translate-x-1/2 after:-translate-y-1/2`); (b) in `groups.css`, add
|
||||
`[data-button-group='connected'][data-size='xs'] > *, [data-button-group='connected'][data-size='sm'] > * { min-inline-size: 3rem; }`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-06 · split-button · Inner corners are wrong at four of five sizes and morph the wrong way
|
||||
- Severity: should-fix
|
||||
- M3 says (RC-A, Split button → Specs, from `SplitButton{Size}Tokens.kt`, cross-checked against the
|
||||
site's own "inner corner radius" figure):
|
||||
|
||||
| Size | Inner corner (rest) | Inner corner (hover/press) |
|
||||
| --- | --- | --- |
|
||||
| XS | 4dp | 8dp |
|
||||
| S | 4dp | 12dp |
|
||||
| M | 4dp | 12dp |
|
||||
| L | 8dp | 20dp |
|
||||
| XL | 12dp | 20dp |
|
||||
|
||||
Verified directly: `tokens/SplitButtonSmallTokens.kt` — `InnerCornerCornerSize =
|
||||
CornerValueExtraSmall`, `InnerHoveredCornerCornerSize = InnerPressedCornerCornerSize =
|
||||
CornerValueMedium`; `tokens/SplitButtonMediumTokens.kt` — the same pair. The inner corner **grows**
|
||||
under the finger.
|
||||
- Library does: `resources/views/components/split-button.blade.php:39` emits
|
||||
`data-button-group="split"`, and `resources/css/components/groups.css:54-66` gives `[data-split]`
|
||||
the *connected group's* variables — `--group-inner` (rest) and `--group-inner-pressed` (pressed).
|
||||
Those are 8 px/4 px at `sm` and `md`, 16 px/12 px at `lg`, 20 px/16 px at `xl`
|
||||
(`groups.css:32-36`). So rest is 8/8/16/20 px where M3 wants 4/4/8/12, and pressed *shrinks* to
|
||||
4/4/12/16 px where M3 wants it to *grow* to 12/12/20/20. Only `xs` is right at rest (4 px), and its
|
||||
pressed value (2 px, `groups.css:32`) should be 8 px.
|
||||
- Fix: give `[data-split]` its own variables in `groups.css` rather than reusing `--group-inner*`,
|
||||
e.g. `[data-button-group='split'][data-size='sm'] { --split-inner: var(--md-sys-shape-corner-xs);
|
||||
--split-inner-pressed: var(--md-sys-shape-corner-md); }` per size, and point the `[data-split]` /
|
||||
`[data-split]:active` rules (`groups.css:54-66`) at them.
|
||||
- Effort: M
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-07 · split-button · The trailing menu icon is the wrong size at every size step
|
||||
- Severity: should-fix
|
||||
- M3 says: trailing icon 22dp (XS), 22dp (S), 26dp (M), 38dp (L), 50dp (XL) (RC-A, Split button →
|
||||
Specs; `tokens/SplitButtonSmallTokens.kt` `TrailingIconSize = 22.0.dp`,
|
||||
`SplitButtonMediumTokens.kt` `= 26.0.dp`).
|
||||
- Library does: `split-button.blade.php:56-66` renders the trailing half as a plain icon-only
|
||||
`<x-button>`, which picks its icon size from the icon-button table
|
||||
(`button.blade.php:155`): 20 / 24 / 24 / 32 / 40 px. Every step is undersized (S is 24 vs 22 —
|
||||
oversized — and M/L/XL are 2/6/10 px small). The trailing *button widths* are all correct
|
||||
(48/48/56/96/136 px, `split-button.blade.php:36` plus the icon-button defaults), so only the glyph
|
||||
is off.
|
||||
- Fix: pass an explicit icon size from `split-button.blade.php`, e.g. add
|
||||
`['xs' => 'size-5.5', 'sm' => 'size-5.5', 'md' => 'size-6.5', 'lg' => 'size-9.5', 'xl' => 'size-12.5'][$size]`
|
||||
to the trailing button's `:class` (Tailwind's 4 px scale gives 22 px as `size-5.5`).
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-08 · fab · The extended FAB's icon↔label gap is too small at md and lg
|
||||
- Severity: should-fix
|
||||
- M3 says: icon↔label gap 8dp (small), **16dp** (medium), **20dp** (large) — RC-A, Extended FAB →
|
||||
Specs; verified in `tokens/ExtendedFabMediumTokens.kt` (`IconLabelSpace = 16.0.dp`) and
|
||||
`tokens/ExtendedFabLargeTokens.kt` (`IconLabelSpace = 20.0.dp`).
|
||||
- Library does: `resources/views/components/fab.blade.php:39` — `gap-2` (8 px) small ✓,
|
||||
`gap-3` (12 px) medium ✗, `gap-4` (16 px) large ✗.
|
||||
- Fix: `gap-4` for `md` and `gap-5` for `lg` in `fab.blade.php:39`. (Heights, corners, icon sizes and
|
||||
the 26 px/28 px paddings on the same line are all correct.)
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-09 · button · The xs button's horizontal padding is 12 px, not 16 dp
|
||||
- Severity: should-fix
|
||||
- M3 says: XS leading/trailing padding = **16dp** (RC-A, Buttons → Specs;
|
||||
`tokens/ButtonXSmallTokens.kt` `LeadingSpace = 16.0.dp`, `TrailingSpace = 16.0.dp`). The variants
|
||||
table on the same page makes the point twice: 24 dp is "Not recommended. Use 16dp", and 16 dp is
|
||||
the Expressive value.
|
||||
- Library does: `button.blade.php:147` — `'xs' => 'h-8 gap-2 px-3 …'` (12 px). The same 12 px is
|
||||
written into the group spacing (`groups.css:32`, `--group-pad: 0.75rem`) and into `<x-group>`'s
|
||||
segments (`group.blade.php:48`), so all three are consistently 4 px short.
|
||||
- Fix: `px-4` in `button.blade.php:147` and `group.blade.php:48`; `--group-pad: 1rem` in
|
||||
`groups.css:32`.
|
||||
- Effort: S
|
||||
- Breaks API? no (visual width of xs buttons changes by 8 px)
|
||||
|
||||
### ACT-10 · button-group · A standard group is allowed to wrap onto a second line
|
||||
- Severity: should-fix
|
||||
- M3 says: "Adaptive/resizing: groups move through layouts as **a single line and never wrap to a
|
||||
second line**; multiple groups can stack vertically but don't interact with each other" (RC-A,
|
||||
Button groups → Behaviour and guidelines).
|
||||
- Library does: `button-group.blade.php:35` adds `flex-wrap` to every non-connected group. On a
|
||||
narrow window a three-button group breaks into two rows, and the press-expansion rules in
|
||||
`groups.css:38-48` (which only look at `+` siblings) then compress a neighbour on a different line.
|
||||
- Fix: drop `'flex-wrap' => ! $connected` from `button-group.blade.php:35`. If overflow is a concern,
|
||||
M3's answer is a smaller size or fewer buttons, not wrapping.
|
||||
- Effort: S
|
||||
- Breaks API? no (a class hook disappears from the rendered output)
|
||||
|
||||
### ACT-11 · menu-item · Leading and trailing space is 12 px where the Expressive menu wants 16 dp
|
||||
- Severity: should-fix
|
||||
- M3 says: `tokens/SegmentedMenuTokens.kt` — `ItemLeadingSpace = 16.0dp`, `ItemTrailingSpace = 16.0dp`,
|
||||
`ItemBetweenSpace = 12.0dp`. (The site publishes no numbers for the Expressive vertical menu — RC-A,
|
||||
Menus → Specs says the measurements section is diagram-only — so the token file is the only source,
|
||||
and it is the one the component's own header comment cites.)
|
||||
- Library does: `menu-item.blade.php:46` — `px-3` (12 px) with `gap-3` (12 px). The gap is right; the
|
||||
side padding is the *baseline* menu's 12 dp (RC-A, Menus → Specs, baseline table), not the
|
||||
Expressive one. `menu-group.blade.php:6` and `menu-separator.blade.php:3` inherit the same 12 px.
|
||||
- Fix: `px-4` on `menu-item.blade.php:46`, and `px-4`/`mx-4` on the group label and the separator so
|
||||
the list stays aligned.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-12 · menu-separator · Divider padding is 4 px, half of M3's 8 dp
|
||||
- Severity: should-fix
|
||||
- M3 says: "Divider top/bottom padding | **8dp**; Divider height | 1dp" (RC-A, Menus → Specs).
|
||||
- Library does: `menu-separator.blade.php:3` — `my-1` (4 px) with `h-px` ✓ and
|
||||
`bg-outline-variant` ✓.
|
||||
- Fix: `my-2` in `menu-separator.blade.php:3`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-13 · menu.js · Disabled menu items cannot be focused
|
||||
- Severity: should-fix
|
||||
- M3 says: "Interactability: **disabled items can still receive focus** (just aren't selectable);
|
||||
dividers and gaps can never receive focus" (RC-A, Menus → Accessibility). The guidelines page adds
|
||||
"Disable a menu item that's conditionally unavailable rather than removing it" — the point of
|
||||
keeping it is that people can find out it exists.
|
||||
- Library does: `resources/js/menu.js:183` filters them out —
|
||||
`.filter((item) => item.getAttribute('aria-disabled') !== 'true')` — so arrow keys, Home/End and
|
||||
typeahead skip them entirely, and `menu-item.blade.php:53` also sets `pointer-events-none`. A
|
||||
disabled item is announced only if the user happens to read the menu with a virtual cursor.
|
||||
- Fix: keep disabled items in `items()` and instead refuse activation (the check already exists in
|
||||
`menu.js:237`). Two lists are cleanest: `items()` for focus movement, `items().filter(enabled)` for
|
||||
activation. `menu-item.blade.php:53` can keep `pointer-events-none` for the pointer.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-14 · fab · The FAB's icon is drawn outlined
|
||||
- Severity: should-fix
|
||||
- M3 says: "Icon (should be **filled**, not outlined…)" (RC-A, FAB → Anatomy) and "icon should be
|
||||
filled (not outlined) and unambiguous" (→ Behaviour and guidelines). The same rule is why
|
||||
`<x-button>` fills a *selected* toggle's icon.
|
||||
- Library does: `fab.blade.php:68` renders `<x-icon :name="$icon" :class="$iconSize" />` with no
|
||||
`filled`, so the Material Symbol's outlined face is used. `fab-menu.blade.php:53` has the same
|
||||
issue for the FAB-menu trigger, and `fab-menu-item.blade.php:43` for its items.
|
||||
- Fix: add `filled` to `fab.blade.php:68` and `fab-menu.blade.php:53`. (Leave the FAB-menu *items*
|
||||
outlined only if you decide they read as list items rather than FABs; M3's FAB-menu anatomy shows
|
||||
filled icons there too.)
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-15 · button / icon button · A default (non-toggle) icon button uses an outlined icon
|
||||
- Severity: should-fix
|
||||
- M3 says: "**Default icon buttons should use filled icons**; toggle buttons use outlined icon
|
||||
unselected → filled icon selected" (RC-A, Icon buttons → Behaviour and guidelines).
|
||||
- Library does: `button.blade.php:213` — `:filled="$selected === true"`. For an icon button that is
|
||||
not a toggle (`$selected === null`) that evaluates to `false`, so the outlined face is drawn where
|
||||
M3 asks for the filled one. The toggle half of the rule is implemented correctly.
|
||||
- Fix: `:filled="$selected === true || ($iconOnly && $selected === null)"` in `button.blade.php:213`.
|
||||
This is a visible, opinionated change across every icon button in an application — if the library
|
||||
prefers outlined icon buttons, say so in the header comment as a deliberate deviation rather than
|
||||
leaving it implicit.
|
||||
- Effort: S
|
||||
- Breaks API? no (visual change)
|
||||
|
||||
### ACT-16 · fab · `disabled` exists on a component M3 says must never be disabled
|
||||
- Severity: should-fix
|
||||
- M3 says: "**Never disable a FAB** — if its action is unavailable, remove the FAB entirely instead"
|
||||
(RC-A, FAB → Accessibility). RF:389 repeats it from the states side: "'if the action… is
|
||||
unavailable, the FAB shouldn't appear' (don't just disable it) — a Don't example specifically about
|
||||
FABs". The same page's inheritance table lists FABs under "Disabled … NOT inherited by" (RF:381).
|
||||
- Library does: `fab.blade.php:23` declares a `disabled` prop and `fab.blade.php:52` paints the full
|
||||
disabled treatment (`disabled:bg-on-surface/10 disabled:text-on-surface/38 disabled:shadow-none`).
|
||||
Nothing in the header comment or in SKILL.md (`### <x-fab>`, line 277) warns against it — SKILL.md
|
||||
simply lists `disabled` among the props.
|
||||
- Fix: cheapest honest fix is documentation — say in `fab.blade.php`'s header and in SKILL.md that M3
|
||||
forbids a disabled FAB and that the prop exists only for a form-submit FAB that must block a double
|
||||
submit. Removing the prop is the M3-pure option and would break callers.
|
||||
- Effort: S
|
||||
- Breaks API? no (if documented), yes (if removed)
|
||||
|
||||
### ACT-17 · toast / button · The snackbar covers the `fab` button on a phone
|
||||
- Severity: should-fix
|
||||
- M3 says: "Placement: bottom of the UI, in front of main content; **nudge upward to avoid
|
||||
overlapping FABs**/docked toolbars… Snackbars should appear **above** FABs — never in front of or
|
||||
behind one" (RC-A, Snackbar → Behaviour and guidelines).
|
||||
- Library does: both sit at the same offset. `toast.blade.php:36` —
|
||||
`fixed inset-x-4 z-50 bottom-[calc(var(--material-bottom-bar,0px)+1rem)]`; `button.blade.php:177`
|
||||
(the `fab` prop, below `sm`) — `max-sm:fixed max-sm:end-4
|
||||
max-sm:bottom-[calc(var(--material-bottom-bar,0px)+1rem)] max-sm:z-30`. The snackbar is full width
|
||||
below `sm` (`sm:w-auto` only applies from 640 px up, `toast.blade.php:51`), so it lands *on top of*
|
||||
the FAB, which is exactly the "never in front of" case. `<x-fab>` placed by the application has the
|
||||
same problem, since the toast knows nothing about it.
|
||||
- Fix: mirror the existing `--material-bottom-bar` mechanism — have `<x-button fab>` (and the
|
||||
documented wrapper for `<x-fab>`) publish `--material-fab: 4.5rem`, and make `toast.blade.php:36`
|
||||
read `bottom-[calc(var(--material-bottom-bar,0px)+var(--material-fab,0px)+1rem)]`. Document the
|
||||
variable next to `--material-bottom-extra` in SKILL.md's "Safe areas".
|
||||
- Effort: M
|
||||
- Breaks API? no (a new opt-in CSS variable)
|
||||
|
||||
### ACT-18 · toast / alert · 40 px controls inside the snackbar and the alert miss the 48 px target
|
||||
- Severity: should-fix
|
||||
- M3 says: web interaction targets are "at least 48 × 48 **CSS pixels**" (RF:82), and "Interaction
|
||||
targets must stay ≥48×48dp even when the visible element is scaled down… a 36dp-tall button can
|
||||
keep a 48dp target" (RF:561-563).
|
||||
- Library does: `toast.blade.php:73` (the action, `h-10` = 40 px), `toast.blade.php:77` (the close
|
||||
button, `size-10` = 40 px) and `alert.blade.php:61` (the dismiss button, `size-10`). None of them
|
||||
has the pseudo-element trick `<x-button>` uses at `button.blade.php:172`. The snackbar container is
|
||||
`min-h-12` (48 px) so there is no room to grow vertically without the pseudo-element.
|
||||
- Fix: add `relative after:absolute after:top-1/2 after:left-1/2 after:size-full after:min-h-12
|
||||
after:min-w-12 after:-translate-x-1/2 after:-translate-y-1/2` to those three buttons (they already
|
||||
carry `state-layer`, which sets `position: relative`).
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-19 · button / group / menu-item / fab-menu · Colour is animated on the overshooting spatial spring
|
||||
- Severity: should-fix
|
||||
- M3 says: "Two spring **styles**: **Spatial** (movement — x/y position, rotation, size, rounded
|
||||
corners; overshoots and bounces into place) and **Effects** (color, opacity; **no overshoot**)"
|
||||
(RS:472-473). `resources/css/tokens/motion.css:11-13` states the same rule in the library's own
|
||||
words: "effects — colour and opacity. Critically damped; **a colour must never overshoot**."
|
||||
- Library does: four components put colour on the spatial spring, whose fast variant peaks at 1.094
|
||||
(`motion.css:28-30`):
|
||||
- `button.blade.php:163` — `transition-[border-radius,background-color,color,box-shadow,padding,margin] … ease-spatial-fast`
|
||||
- `group.blade.php:77` — `transition-[border-radius,background-color,color] … ease-spatial-fast`
|
||||
- `menu-item.blade.php:48` — `transition-[border-radius,background-color] … ease-spatial-fast`
|
||||
- `fab-menu.blade.php:49` — `transition-[border-radius,background-color,color,box-shadow] … ease-spatial-default`
|
||||
- Fix: split each into two transitions, e.g. on `button.blade.php:163`
|
||||
`transition-[border-radius,padding,margin] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast`
|
||||
plus a second declaration for `background-color,color,box-shadow` with
|
||||
`duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast`. Tailwind cannot emit two
|
||||
`transition` shorthands on one element, so this wants a small utility in
|
||||
`resources/css/components/` (e.g. `.state-transition`) rather than utility classes.
|
||||
- Effort: M
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-20 · toast · The snackbar draws a status icon, which M3 tells you not to
|
||||
- Severity: should-fix
|
||||
- M3 says: "**Avoid icons in a snackbar** (use a dialog instead if an icon feels necessary); avoid
|
||||
stylized text/inline links" (RC-A, Snackbar → Behaviour and guidelines). The specs page does list an
|
||||
optional icon slot, but it is the *close* affordance ("2. Icon (optional close affordance) — …the
|
||||
guidelines page anatomy instead lists 'Close button (optional)' in the same slot", RC-A, Snackbar →
|
||||
Anatomy).
|
||||
- Library does: `toast.blade.php:53-65` draws a `check_circle` / `error` / `warning` / `info` glyph
|
||||
for every typed toast, and `Toasts.php:19-37` makes typing the *only* API — `$this->success(…)`
|
||||
always sets `type: 'success'`. The glyphs are painted in the library's own
|
||||
`inverse-success/error/warning/info` roles (`scheme.css:76-79`), which is a sound extension of
|
||||
`inverse-primary`, so the colour is not the problem; the icon's presence is.
|
||||
- Fix: either drop the leading icon and let the type only choose the announcement role, or document
|
||||
the deviation in `toast.blade.php`'s header (it currently presents the icon as M3-sanctioned: "A
|
||||
type draws its state icon in the inverse state colour"). If the icon stays, keep it out of the
|
||||
accessible name — it is already `aria-hidden` via `<x-icon>`.
|
||||
- Effort: S
|
||||
- Breaks API? no (if documented), yes for the visual contract (if removed)
|
||||
|
||||
### ACT-21 · fab-menu · The trigger has no accessible name unless `label` is passed
|
||||
- Severity: should-fix
|
||||
- M3 says: "Labeling (Web): the FAB menu is a FAB + Menu combo — follow FAB accessibility guidance for
|
||||
the trigger… **the FAB's own accessibility label should describe what menu it opens**"; and for the
|
||||
close button "Label 'Toggle menu', Role Button, State Expanded/Collapsed" (RC-A, FAB menu →
|
||||
Accessibility).
|
||||
- Library does: `fab-menu.blade.php:46` writes `aria-label` only `@if ($label)`. `label` has no
|
||||
default (`fab-menu.blade.php:21`), and the only content of the button is two `<x-icon>`s, which are
|
||||
`aria-hidden="true"` by default (`icon.blade.php:27`). A `<x-fab-menu>` without `label` is therefore
|
||||
an unnamed button. `<x-menu>` has the same shape but delegates naming to the trigger the caller
|
||||
supplies, so the gap is specific to the FAB menu, which builds its own button.
|
||||
- Fix: make `label` required, or default it to `__('Toggle menu')` in `fab-menu.blade.php:21` and
|
||||
always emit `aria-label`. Note the expanded/collapsed state is already handled — `menu.js:140`
|
||||
writes `aria-haspopup`/`aria-controls`/`aria-expanded` onto the button it finds in the trigger.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-22 · rich-tooltip · The bubble is never associated with its trigger
|
||||
- Severity: should-fix
|
||||
- M3 says: "Assistive tech must be able to **receive the tooltip's message** and activate it via
|
||||
keyboard/switch input" (RC-A, Tooltips → Accessibility); "Role: **Tooltip** (or an equivalent role)
|
||||
on the container".
|
||||
- Library does: `rich-tooltip.blade.php:41-56` gives the bubble `role="tooltip"` (or `dialog` when
|
||||
`persistent`) and an `aria-label` of the title, but nothing points at it: the trigger gets no
|
||||
`aria-describedby`, and for `persistent` no `aria-haspopup="dialog"`/`aria-expanded` either. A
|
||||
screen-reader user focusing the trigger hears only the trigger's own label; the explanation — the
|
||||
whole point of a rich tooltip — is never announced. (`<x-tooltip>` is deliberately `aria-hidden`
|
||||
and documents why, which is defensible for an icon button whose `aria-label` *is* the tooltip text;
|
||||
it is not defensible for the standalone form, e.g.
|
||||
`showcase/sections/communication.blade.php:30-32`, where the tip says something the trigger does
|
||||
not.)
|
||||
- Fix: in `rich-tooltip.blade.php`, put `aria-describedby="material-rich-tooltip-{{ $key }}"` on the
|
||||
wrapper's focusable child (or document that the caller must), and for `persistent` add
|
||||
`aria-haspopup="dialog"` + an `aria-expanded` written by `rich-tooltip.js`. For the standalone
|
||||
`<x-tooltip>`, either drop `aria-hidden` and describe the trigger, or document in SKILL.md that the
|
||||
trigger must carry the same words itself.
|
||||
- Effort: M
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-23 · badge · The floating badge sits 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.5` for the dot (2 px in on both
|
||||
axes, where M3 wants 0) and `absolute -top-1 start-[calc(100%-0.75rem)]` for the count (the `start`
|
||||
is exactly right; the top is −4 px where M3 wants −2 px).
|
||||
- Fix: `top-0 end-0` for the dot and `-top-0.5` for the count in `badge.blade.php:79-80`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-24 · badge · The `outline` status badge draws its edge in `outline-variant`
|
||||
- Severity: nice-to-have
|
||||
- M3 says: a badge "must be ≥**3:1**, same rule whether default or custom color" (RC-A, Badges →
|
||||
Accessibility). `outline-variant` is M3's *decorative* boundary role (dividers); `outline` is the
|
||||
role for a boundary that must be seen.
|
||||
- Library does: `badge.blade.php:68` — `'border-outline-variant text-on-surface-variant'`. On
|
||||
`surface` in the library's own scheme `outline-variant` is around 1.5:1, so the badge's shape is
|
||||
barely visible. (`<x-button variant="outlined">` uses the same role, but there the M3 table
|
||||
explicitly names it: "Outlined container … **Outline variant (outline)**" — RC-A, Buttons → colour
|
||||
table — so the button is inside spec and the badge, which has no M3 table, is not.)
|
||||
- Fix: `border-outline` in `badge.blade.php:68`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-25 · tooltip.js · A tooltip disappears the instant the pointer leaves
|
||||
- Severity: nice-to-have
|
||||
- M3 says: "tooltips are **transient by default** — both plain and rich tooltips disappear **1.5
|
||||
seconds** after the cursor/focus leaves the target region; opening a new tooltip immediately closes
|
||||
any other open one (**only one tooltip visible at a time**)" (RC-A, Tooltips → Specs).
|
||||
- Library does: `resources/js/tooltip.js:40-43` hides on `pointerleave`, `pointerdown` and
|
||||
`focusout` with no delay; `rich-tooltip.js:43` uses a 200 ms grace instead of 1500 ms. Nothing
|
||||
closes other open tooltips — because the bubble is `popover="manual"`
|
||||
(`tooltip.blade.php:35`), a tooltip held open by keyboard focus stays up while a second one opens
|
||||
on hover.
|
||||
- Fix: `hide(1500)` in `tooltip.js` (add the same `delay` parameter `rich-tooltip.js:37` already
|
||||
has), and keep a module-level `let open = null` that `show()` hides before showing its own.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-26 · menu · The open/close transition is a plain fade
|
||||
- Severity: nice-to-have
|
||||
- M3 says: "Motion: menus use an **enter/exit transition tying them visually to their trigger**; the
|
||||
trigger shows a pressed state while the menu is open" (RC-A, Menus → Behaviour and guidelines).
|
||||
- Library does: `menu.blade.php:72` transitions `opacity` only — `translate` is in the
|
||||
`transition-[…]` list but no translate or scale value is ever set, so the menu simply fades in
|
||||
place. The library's own `::picker(select)` does it properly (`menu.css:116-128`: opacity + scale
|
||||
0.95 from `transform-origin: top`, on the spatial spring).
|
||||
- Fix: mirror `menu.css:116-128` on `menu.blade.php:72` — add
|
||||
`origin-top scale-95 open:scale-100 starting:open:scale-95` and put the scale on the spatial spring
|
||||
while opacity stays on effects.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-27 · menu-item · A selected item is told by colour and shape only
|
||||
- Severity: nice-to-have
|
||||
- M3 says: "by default, selected items change **both shape and color**; default contrast between
|
||||
selected/unselected items is 3:1; it's **recommended to add yet another cue** on top (e.g. a
|
||||
checkmark) so selection isn't conveyed by color/shape alone" (RC-A, Menus → Accessibility).
|
||||
- Library does: `menu-item.blade.php:51` changes container colour and corner, and
|
||||
`menu-item.blade.php:87` fills the leading icon; `aria-checked` is set
|
||||
(`menu-item.blade.php:57`), so assistive tech is fine. No visible tick. Again the library's own
|
||||
select menu does it — `menu.css:179-188` draws a `::checkmark` at the row's end.
|
||||
- Fix: when `$selected === true` and no `iconRight` is given, render a trailing `check` icon in
|
||||
`menu-item.blade.php:105-107`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-28 · menu-item · 44 px rows, where the rest of the library's menus use 48 px
|
||||
- Severity: nice-to-have
|
||||
- M3 says: `tokens/SegmentedMenuTokens.kt` `Item = 44.0.dp` (what the component's header cites, and it
|
||||
is correct), while the baseline menu's published spec is "List item height | **48dp**" (RC-A, Menus
|
||||
→ Specs) and the same page's accessibility section asks for "≥**48×48dp** targets" inside menu item
|
||||
slots.
|
||||
- Library does: `menu-item.blade.php:46` — `min-h-11` (44 px). `resources/css/components/menu.css:55`
|
||||
(`.field-option`) and `menu.css:134` (native `option`) both use `min-block-size: 3rem` (48 px), so
|
||||
a `<x-menu>` and a `<x-select>` dropdown in the same form do not match.
|
||||
- Fix: either `min-h-12` on `menu-item.blade.php:46` (consistent, and clears the 48 px target), or
|
||||
keep 44 px and note the inconsistency deliberately. Both readings are defensible; the token is the
|
||||
more specific source, the accessibility page the more binding one.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-29 · split-button · The trailing chevron is not nudged off centre
|
||||
- Severity: nice-to-have
|
||||
- M3 says: "Menu icon offset from center when unselected (site, specs page): XS/S = **−1dp**, M =
|
||||
−2dp, L = −3dp, XL = −6dp" (RC-A, Split button → Specs) — the chevron sits slightly towards the
|
||||
leading button so the pair reads as one control.
|
||||
- Library does: `split-button.blade.php:56-66` centres the icon (`justify-center` from
|
||||
`button.blade.php:162`).
|
||||
- Fix: add a per-size negative `ms-px`/`-me-*` or `translate-x` to the trailing button's `:class` in
|
||||
`split-button.blade.php:36`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-30 · split-button · The chevron rotates on the expressive spring
|
||||
- Severity: nice-to-have
|
||||
- M3 says: "The trailing (menu) button uses the **standard motion scheme, not expressive**, when
|
||||
rotating; it rotates 180° inward on open/close" (RC-A, Split button → Behaviour and guidelines).
|
||||
- Library does: `groups.css:85-87` — `transition: rotate var(--md-sys-motion-spatial-fast-duration)
|
||||
var(--md-sys-motion-spatial-fast)`, i.e. the expressive spring, which overshoots past 180° and
|
||||
comes back. The 180° rotation itself (`groups.css:89-90`) is right.
|
||||
- Fix: the library has no standard-scheme spring token; `--md-sys-motion-easing-standard` with
|
||||
`--md-sys-motion-duration-short` (`motion.css:52,58`) is the closest published equivalent.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-31 · fab · The small extended FAB's minimum width is 56 px, not 80 dp
|
||||
- Severity: nice-to-have
|
||||
- M3 says: baseline extended FAB "Container width | Dynamic, **80dp min**" (RC-A, Extended FAB →
|
||||
Specs). `ExtendedFabSmallTokens.kt` publishes no minimum, and the small extended FAB is the
|
||||
baseline's replacement, so 80 dp is the only number on record.
|
||||
- Library does: `fab.blade.php:39` — `min-w-14` (56 px) for `sm`, `min-w-20`/`min-w-24` for md/lg
|
||||
(which match those sizes' heights rather than any published minimum).
|
||||
- Fix: `min-w-20` (80 px) for the small extended FAB in `fab.blade.php:39`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-32 · fab-menu · Margins do not grow in large windows, and items cannot scroll
|
||||
- Severity: nice-to-have
|
||||
- M3 says: "Menu/FAB margin from screen edge | 16dp (compact/medium windows), **24dp** (large/extra-large
|
||||
windows)"; "Extra bottom margin when opened from medium FAB | 40dp… large FAB | 56dp"; "On short
|
||||
viewports (e.g. landscape phone), items can **scroll — behind the close button**, which stays
|
||||
fixed" (RC-A, FAB menu → Specs and → Behaviour).
|
||||
- Library does: `<x-fab-menu>` does not place itself (by design), and both the header comment
|
||||
(`fab-menu.blade.php:3`) and SKILL.md line 283 show a fixed `end-4 bottom-4` (16 px) with no
|
||||
large-window variant. The list (`fab-menu.blade.php:69-75`) has no `max-height`/`overflow`, so on a
|
||||
landscape phone six 56 px items plus gaps (~360 px) push the menu off screen.
|
||||
- Fix: document `sm:end-6 sm:bottom-6`-style margins in the header and SKILL.md (Tailwind has no
|
||||
1200 px breakpoint by default — see the breakpoint map), and add
|
||||
`max-h-[calc(100vh-8rem)] overflow-y-auto` to `fab-menu.blade.php:70`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-33 · fab-menu / menu · Items enter but never exit
|
||||
- Severity: nice-to-have
|
||||
- M3 says: "Motion: FAB transforms into the close button; items animate in/out via an **enter-exit
|
||||
transition**, originating from one of the FAB's trailing corners" (RC-A, FAB menu → Behaviour).
|
||||
- Library does: `fab-menu-item.blade.php:27` has `starting:translate-y-2 starting:opacity-0` for the
|
||||
entry, but the popover is hidden with `display: none` on close and the items carry no
|
||||
`transition-discrete`/`@starting-style` exit pair, so they vanish instantly. `<x-menu>` fades out
|
||||
correctly (`menu.blade.php:72` includes `display,overlay` and `transition-discrete`).
|
||||
- Fix: add `transition-[translate,opacity,display,overlay] transition-discrete` to
|
||||
`fab-menu-item.blade.php:27` and drive the closed state from the popover's `:not(:popover-open)`.
|
||||
- Effort: M
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-34 · toast · Escape does not dismiss a focused snackbar
|
||||
- Severity: nice-to-have
|
||||
- M3 says: "Keyboard: Tab moves between interactive elements; **Esc dismisses the focused snackbar**";
|
||||
and "on web, provide a documented keyboard shortcut (e.g. Alt+G) to jump focus to a snackbar with an
|
||||
action" (RC-A, Snackbar → Accessibility).
|
||||
- Library does: `snackbar.js` binds no key handler; `toast.blade.php:41-82` binds only pointer and
|
||||
focus events. Escape does nothing, and there is no way to reach an actioned snackbar from the
|
||||
keyboard except by tabbing past the whole page (the host is the last element in `<body>`).
|
||||
- Fix: add a `keydown` listener in `snackbar.js:38` that calls `dismiss()` on Escape while the
|
||||
snackbar contains `document.activeElement`; optionally a documented shortcut that focuses
|
||||
`[data-toast-action]`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-35 · toast · The description line is dimmed with an opacity instead of a role
|
||||
- Severity: nice-to-have
|
||||
- M3 says: the snackbar's roles are "container = **Inverse surface**; supporting text/icon = **Inverse
|
||||
on surface**; action label = **Inverse primary**" (RC-A, Snackbar → Specs) — there is no fourth
|
||||
role, and supporting text is one block of up to two lines.
|
||||
- Library does: `toast.blade.php:69` — `type-body-md opacity-80` on the description, i.e.
|
||||
inverse-on-surface at 80 %, a colour outside the roles that also drops the contrast of the longer
|
||||
half of the message.
|
||||
- Fix: drop `opacity-80` and let both lines be `inverse-on-surface`; M3 distinguishes them by position,
|
||||
not tone.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-36 · alert · `role="alert"` on content that is present at page load
|
||||
- Severity: nice-to-have
|
||||
- M3 says: for the nearest equivalent (the snackbar) "use a **live region with a 'polite'/queued**
|
||||
announcement (**not assertive**)" (RC-A, Snackbar → Accessibility). M3 publishes no alert/banner
|
||||
component at all — the current site has no banner page; the only survivals are legacy mentions in
|
||||
the states inheritance list (RF:384) and the elevation table (RS:320).
|
||||
- Library does: `alert.blade.php:38` — `role="{{ in_array($color, ['error','warning']) ? 'alert' :
|
||||
'status' }}"`. `role="alert"` is an assertive live region; when the alert is part of the rendered
|
||||
page (the normal case — the showcase renders four of them statically,
|
||||
`showcase/sections/communication.blade.php:47-53`) some screen readers announce it over the page
|
||||
title on load, and a Livewire morph that re-renders the region can re-announce it.
|
||||
- Fix: default both to `role="status"` and add an opt-in prop (`assertive`) for the case where the
|
||||
alert really is injected in response to an action.
|
||||
- Effort: S
|
||||
- Breaks API? yes (the rendered role changes; tests asserting `role="alert"` would need updating)
|
||||
|
||||
## Deliberate deviations
|
||||
|
||||
- **The text button's label is `primary`, not `on-surface-variant`** (`button.blade.php:20-22`,
|
||||
`:100-127`). Holds up. The M3 specs table reads "Text icon & label | **Primary** | -- | --" (RC-A,
|
||||
Buttons → colour table), and the M2→M3 notes say "neutral text button no longer recommended". The
|
||||
library is right, and it is also right that an *icon-only* text button (M3's "standard icon button")
|
||||
takes `on-surface-variant` — "Standard icon | On surface variant | On surface variant | Primary"
|
||||
(RC-A, Icon buttons → colour table). Both readings are implemented in the same `match` at
|
||||
`button.blade.php:123-126`, which is exactly correct.
|
||||
- **A selected round toggle button turns square** (`button.blade.php:132-136`). Ambiguous, and the
|
||||
library picked the site's reading. The guidelines page says "By default, toggle buttons change from
|
||||
**round to square when selected**" (RC-A, Buttons → Behaviour), but `tokens/ButtonSmallTokens.kt`
|
||||
and `ButtonMediumTokens.kt` both set `SelectedContainerShapeRound = CornerFull`, i.e. a selected
|
||||
round *label* button stays round in Compose. For *icon* buttons the tokens agree with the site
|
||||
(`SmallIconButtonTokens.SelectedContainerShapeRound = CornerMedium`,
|
||||
`…ShapeSquare = CornerFull`; `LargeIconButtonTokens`/`XLargeIconButtonTokens` are the same pattern
|
||||
with ExtraLarge), and the library implements that swap exactly. Worth a line in the header comment
|
||||
saying which source won for label buttons.
|
||||
- **A selected text button takes the tonal container** (`button.blade.php:18`, `:123-126`). Holds up as
|
||||
an extension: M3 gives text buttons no toggle states at all, so anything is an invention, and
|
||||
borrowing the tonal selected colours keeps it inside the roles and satisfies the two-indicator rule.
|
||||
- **Press expansion is a fixed step per size, not 15 % of width** (`groups.css:9`, `:32-48`). Holds up.
|
||||
`ButtonGroupDefaults.ExpandedRatio = 0.15f` and the layout applies `expandedRatio * width / 2` per
|
||||
side, clamped to the neighbour's padding (fetched live from androidx-main `ButtonGroup.kt:186`,
|
||||
`:482`, `:500-511`). CSS cannot express a percentage of an intrinsic width, and every size's
|
||||
`--group-grow` (4/6/8/16/20 px) is safely below its `--group-pad`, so the neighbour never reaches
|
||||
negative padding. The animation uses `ease-spatial-fast`, matching Compose's
|
||||
`MotionSchemeKeyTokens.FastSpatial` (`ButtonGroup.kt`, defaultAnimationSpec).
|
||||
- **`--group-full` is half the height rather than `corner-full`** (`groups.css:19-23`, `:32-36`).
|
||||
Holds up, and the values are right: 16/20/28/48/68 px are exactly half of 32/40/56/96/136 dp, which
|
||||
is what `ConnectedButtonGroupSmallTokens.SelectedInnerCornerCornerSizePercent = 50%` means.
|
||||
- **The `fab` prop on `<x-button>`** (`button.blade.php:177`). Inside spec as far as the *component*
|
||||
goes: an extended FAB at 56 px, corner 16 dp, title-medium, primary-container, elevation 3 and a
|
||||
16 px margin, which is the small extended FAB exactly (`ExtendedFabSmallTokens.kt`). Two caveats:
|
||||
M3 says an extended FAB needs "**No tooltip** — the visible label already serves that role
|
||||
(explicit Don't)" while `button.blade.php:225` will happily attach one; and the elevation never
|
||||
reaches Level 4 on hover because `max-sm:shadow-elevation-3` outranks `hover:shadow-elevation-1`
|
||||
in Tailwind's variant order.
|
||||
- **`<x-group>` binds native radios/checkboxes, so the arrow keys move the choice**
|
||||
(`group.blade.php:9-10`, `:84-91`). Ambiguous. M3's button-group accessibility says "Keyboard: **Tab**
|
||||
navigates to the next button; Space or Enter activates/selects the focused button" (RC-A, Button
|
||||
groups → Accessibility), which is roving-tabindex-free but also not a radio group. The library's
|
||||
choice matches WAI-ARIA's radiogroup pattern and gives `wire:model` for free; it is a defensible
|
||||
reading of "single-select, selection-required", and the `multiple` form (checkboxes) does behave
|
||||
exactly as M3 describes.
|
||||
- **The alert exists at all** (`alert.blade.php:1-10`). Holds up, with a caveat. M3 publishes no
|
||||
banner and no alert; its own guidance routes in-page notices to a snackbar (transient, low priority)
|
||||
or a dialog (blocking, high priority) — and an "your storage is almost full" notice is neither. The
|
||||
component stays inside the foundations: container roles only, `rounded-corner-md`, title-small +
|
||||
body-medium, text buttons in the actions slot. The header comment should add *when not to* use it
|
||||
(a confirmation belongs in `<x-modal>`, a result of an action in `<x-toast>`), since the M3 site
|
||||
will not tell a reader that.
|
||||
- **`<x-stat>` and `<x-empty-state>`** (`stat.blade.php:3-8`, `empty-state.blade.php:8-11`). Hold up.
|
||||
Both draw only from M3's roles (`surface-container`, `secondary-container`/`on-secondary-container`),
|
||||
the shape scale (`rounded-corner-lg`), the Expressive emphasized type roles
|
||||
(`type-emphasized-headline-md`, which `type.css` does define) and the Expressive shape library
|
||||
(`cookie-9`). The counting figure honours reduced motion by reading
|
||||
`--md-sys-motion-spatial-slow-duration`, which `motion.css:63-74` zeroes (`figure.js:59-64`, `:75`).
|
||||
Nothing here draws a colour outside the roles.
|
||||
- **`<x-progress>`'s non-primary track colours** (`progress.blade.php:64-68`). Holds up. M3 fixes the
|
||||
track at `secondary-container` for the primary indicator (RC-A, Progress indicators → Specs), which
|
||||
the library does for `primary` and `secondary`; the extra colours take their own container, which is
|
||||
the only consistent extension.
|
||||
- **`thick` grows the container beyond M3's `WaveHeight`** (`progress.blade.php:11-13`,
|
||||
`:154-162`). Holds up: M3 publishes 10 dp only for the 4 dp stroke, "Track thickness | Configurable"
|
||||
is an Expressive configuration with no published table, and adding the extra stroke to the container
|
||||
keeps the 3 dp amplitude intact (`progress.js:1333`, `waveHeight = height - stroke`).
|
||||
|
||||
## Aligned
|
||||
|
||||
Verified correct — no need to re-check:
|
||||
|
||||
- **Button sizes**: 32/40/56/96/136 px heights, 8/8/8/12/16 px gaps, 20/20/24/32/40 px icons,
|
||||
16/24/48/64 px padding at sm/md/lg/xl, 1/1/1/2/3 px outlines (`button.blade.php:147-158` against
|
||||
RC-A Buttons → Specs and `tokens/Button*.kt`).
|
||||
- **Button corners**: square 12/12/16/28/28 px and pressed 8/8/12/16/16 px, and the pressed value is
|
||||
applied to round buttons too, as M3 requires ("round and square buttons should have the same pressed
|
||||
shape") — `button.blade.php:129-136`, `:166`.
|
||||
- **Button type roles per size**: label-large / label-large / title-medium / headline-small /
|
||||
headline-large. Confirmed live against androidx-main `Button.kt:1645-1648`
|
||||
(`buttonHeight < mediumHeight -> labelLarge; < largeHeight -> titleMedium; < xLargeHeight ->
|
||||
headlineSmall; else -> headlineLarge`).
|
||||
- **Every toggle colour pair**, all four variants × three states, matches the specs table exactly
|
||||
(`button.blade.php:118-127`): filled unselected = surface-container/on-surface-variant, tonal
|
||||
selected = secondary/on-secondary, outlined selected = inverse-surface/inverse-on-surface, elevated
|
||||
selected = primary/on-primary, standard icon button selected = primary.
|
||||
- **Icon-button widths**: all fifteen values (narrow/default/wide × 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`) 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, 112–280 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 4–10 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 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 |
|
||||
Reference in New Issue
Block a user