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

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

598 lines
46 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Audit: containment
## Summary
The containment group is the most accurate part of the library I have measured: the card variants, the
dialog (28dp corner, 24dp padding, 560/280 width, surface-container-high, 32% scrim, headline-small), the
divider (1px outline-variant, 16dp inset), the list heights (56/72/88) and leading sizes (24/40/56), the
bottom sheet's 32×4 handle / 28dp top corner / 640px cap, and the carousel's ported Compose keylines all
match the published numbers or the androidx token files. The failures are concentrated in three places:
**accessibility of selection and disabling in lists** (colour-only selection with no `aria-selected`, a
`disabled` item whose link is still focusable and activatable), **the bottom-sheet drag handle** (a 32×4px
touch target where M3 requires 48dp, achieved by its 22dp padding), and **adaptive breakpoints** (the
list-detail pane opens at `xl`/1280 where M3 puts two panes from expanded/840). Beyond those, a handful of
measurable deviations: a cascade-layer bug that kills the state layer on segmented list rows, a basic
dialog whose headline and action row scroll away, side-sheet actions right-aligned where M3 says left,
carousel end padding of 0 where M3 says 16dp, a full-screen carousel that scrolls horizontally where M3's
scrolls vertically, and cards with no per-state elevation at all. Collapse, the error pages and the mail
theme are clean against M3's foundations and styles; the mail theme reproduces the typescale correctly in
px and the error pages use the roles and typescale properly.
## Findings
### C-01 · bottom-sheet · The drag handle is a 32×4px target where M3 requires 48dp
- Severity: must-fix
- M3 says: "drag handle has an accessible **48dp hit target**" and the specs table gives "Drag handle
padding top/bottom | 22dp" (reference-components-a.md § Bottom sheets → Specs; raw
`components_bottom-sheets_specs.md`). Confirmed in androidx: `SheetDefaults.kt` line 788,
`private val DragHandleVerticalPadding get() = 22.dp`, applied as
`modifier.padding(vertical = DragHandleVerticalPadding)` around the 32×4 handle → 4 + 22 + 22 = 48dp.
Foundations also require a ≥48×48 touch target for any interactive element.
- Library does: `resources/views/components/bottom-sheet.blade.php:60` — the `<button>` that is the handle
is `class="h-1 w-8 …"` (4×32px) with no padding of its own; the padding lives on the wrapper `<div>` at
line 59 (`py-4` = 16px, not 22px), and the wrapper is not the control. The clickable/focusable target is
therefore 32×4px, and the whole handle region is only 36px tall.
- Fix: move the padding onto the button: `class="h-1 w-8 box-content py-[22px] …"` (or wrap with
`py-[22px]` and give the button `before:absolute before:inset-x-0 before:-inset-y-[22px]`), and change
the wrapper at line 59 from `py-4` to `py-0`. Total sheet-top region becomes 48px, matching M3.
- Effort: S
- Breaks API? no
### C-02 · list-item · A `disabled` item's link stays keyboard-focusable and activatable
- Severity: must-fix
- M3 says: disabled list items carry `ItemDisabled*Opacity = 0.38` and a disabled state layer
(`ListTokens.kt:70,75`); foundations' states model treats disabled as "not interactive". A control that
looks disabled but still responds to Enter is an interaction bug.
- Library does: `resources/views/components/list-item.blade.php:50` adds only
`'pointer-events-none text-on-surface/38' => $disabled`. `pointer-events: none` blocks the pointer but
not the keyboard; the `<a href="…" data-list-open …>` at lines 73-78 is still rendered, still in the tab
order and still navigates on Enter. No `aria-disabled` is emitted, so a screen reader announces the item
as an ordinary link.
- Fix: in `list-item.blade.php`, when `$disabled` render the title as the `<p>` branch (skip the `<a>`
entirely, or add `tabindex="-1" aria-disabled="true"` and strip `href`), drop `data-list-row` when
disabled, and add `aria-disabled="true"` to the row `<div>` at line 41.
- Effort: S
- Breaks API? no
### C-03 · list-item · `selected` is colour-only and is never announced
- Severity: must-fix
- M3 says: "**Indicate selection with more than color** … don't rely on color as the only visual cue" and
"Use two visual cues to show a list item is selected, like a leading checkmark and filled color"
(raw `components_lists_accessibility.md:26-44`). Role mapping, same page: single-select and multi-select
lists on **Web** → container role **List box**, item role **Option**, state **Selected / Not-selected**.
- Library does: `list-item.blade.php:44` emits only `data-selected`; `resources/css/components/list.css:46-49`
paints `secondary-container` / `on-secondary-container`. There is no `aria-selected`, no `aria-current`,
no icon or checkmark, and the container stays `role="list"` / `role="listitem"`
(`list.blade.php:19`, `list-item.blade.php:41`), where `listitem` cannot carry a selected state at all.
- Fix: two parts. (a) Add a `selectable` (or `selection="single"|"multi"`) prop to `<x-list>` that switches
the container to `role="listbox"` and each item to `role="option" aria-selected="true|false"`; keep
`role="list"` as the default for non-selectable lists and use `aria-current="true"` there instead of
nothing. (b) Render a second cue when `selected` — e.g. a trailing `check` icon in
`on-secondary-container` — or document that callers must supply one (a `leading` checkbox/radio).
- Effort: M
- Breaks API? yes (a new prop; `role` on the container changes for selectable lists)
### C-04 · drawer · No close affordance by default, which M3 states as a requirement
- Severity: must-fix
- M3 says: "Material **requires** a close affordance (e.g. close icon button) to always be present —
without one, users can't predict the sheet's open/close flow or tell if it's transient or permanent"
(reference-components-a.md § Side sheets → Accessibility). Anatomy lists the close icon button for both
the standard and the modal side sheet.
- Library does: `resources/views/components/drawer.blade.php:33``'withCloseButton' => false`. With the
default, a side sheet renders with a headline and no close control at all; the only exits are Escape
(which `close-on-escape=false` removes) and the scrim (which `without-backdrop-close` removes). With both
of those off, the sheet is undismissable.
- Fix: flip the default to `'withCloseButton' => true` in `drawer.blade.php:33`, and make the close button
unconditional (ignore the prop) when `closeOnEscape` is false or `withoutBackdropClose` is set. Same for
`pane` mode, which has no scrim and, by default, no Escape.
- Effort: S
- Breaks API? yes (the default rendering of every existing `<x-drawer>` gains a close button)
### C-05 · carousel · Reduced motion still resizes items
- Severity: must-fix
- M3 says: "When reduced motion settings are turned on, the parallax effect should be removed and carousel
items should no longer expand as they come into view. **All items are the same size**" (quoted verbatim
in reference-styles.md § Motion → Accessibility requirements, from
`components/carousel/accessibility`; also reference-components-a.md § Carousel → Accessibility).
- Library does: `resources/js/carousel.js:966` sets `const pinned = state.reducedMotion.matches` and then
uses it only for the content pin (`const pin = pinned ? … : 0`, line 979). The mask itself
(`const inset = clamp((size - keyline.size) / 2, 0, size / 2)`, line 978) is still written to
`--material-carousel-inset` on every frame, so items keep growing and shrinking between keylines — the
exact behaviour M3 says to switch off. Only the parallax half of the rule is honoured.
- Fix: in `render()` (carousel.js ~line 966-995), when `state.reducedMotion.matches`, write `inset = 0`
(and `shift = 0`, `opacity = 1`) for every item so all items stay at `strategy.itemSize`; keep the
keyline maths for snap positions. M3's extra note for hero under reduced motion — "the small item shows
only partially" — then falls out of the scroll position rather than the mask.
- Effort: M
- Breaks API? no
### C-06 · modal · A scrolling dialog scrolls its headline and its action row away
- Severity: should-fix
- M3 says: "Scrolling: dialog content generally shouldn't scroll; if it must, **the title stays pinned at
top and buttons pinned at bottom**, and the dialog never scrolls with background content"
(reference-components-a.md § Dialogs → Behaviour and guidelines).
- Library does: `resources/views/components/modal.blade.php:66` puts `overflow-y-auto` on the outer box,
which contains the header block (lines 81-100), the body (line 102) and the actions (lines 105-112). The
body wrapper at line 80 is `min-h-0 flex-1` with no overflow of its own, so once the content exceeds
`max-h-[calc(100dvh-3rem)]` everything scrolls together. Only the `fullscreen` branch pins correctly
(`max-sm:overflow-y-auto` on the inner div, line 80).
- Fix: in `modal.blade.php`, remove `overflow-y-auto` from line 66 (keep `overflow-hidden`), give the
header block `shrink-0`, and move `overflow-y-auto` onto the `min-h-0 flex-1` wrapper at line 80 for all
cases (not just `max-sm:`). The actions already have `shrink-0`. Move `p-6` from the box to the three
regions so the pinned header/footer keep their 24dp padding.
- Effort: S
- Breaks API? no (`box-class` callers that relied on the outer scroll would change)
### C-07 · drawer · The list-detail pane opens at 1280px; M3 puts two panes from 840px
- Severity: should-fix
- M3 says: list-detail visible panes — "Compact (0599): 1 pane; Medium (600839): 1 (recommended) or 2;
**Expanded (840+): 2**; Large (12001599): 2; Extra-large (1600+): 2"
(reference-foundations-supplement.md § Canonical layout examples → List-detail).
- Library does: `drawer.blade.php:55` (`window.matchMedia('(min-width: 80rem)')`) and the `xl:` classes at
lines 64-65 and 100 — the pane appears only from 1280px. Between 840 and 1279px (the whole expanded class
and most of large) the detail still opens as a modal sheet over a scrim with the list inert, which is
M3's compact behaviour.
- Fix: change `80rem` to `52.5rem` (840px) in `drawer.blade.php:55` and swap the `xl:` prefixes for a
custom `expanded:` variant defined as `@custom-variant expanded (@media (min-width: 52.5rem))` in
`resources/css/tokens/theme.css`; update `SKILL.md:463` and the drawer header comment. If that is too
aggressive for narrow laptop layouts, make the threshold a prop (`pane-from`) with 840 as the default.
- Effort: M
- Breaks API? yes (pages laid out with `xl:flex xl:items-start xl:gap-6` around the drawer would need the
same breakpoint changed; that wrapper is documented in `SKILL.md:463`)
### C-08 · list.css · Segmented list rows lose their hover and press state layer to the cascade
- Severity: should-fix
- M3 says: list items show Hovered / Focused / Pressed / Dragged states (reference-components-a.md § Lists
→ Specs), and "Cursor: hover shows a visible cue that the item is interactive" (§ Accessibility). State
layer opacities: hover 8%, focus 10%, pressed 10% (reference-foundations, states).
- Library does: `resources/css/components/list.css:42-44` sets
`[data-list='segmented'] > [data-list-item] { background-color: var(--md-sys-color-surface-container); }`
**unlayered**, while the hover (line 22-24), focus (27-31) and press (33-35) rules live inside
`@layer components`. `resources/css/material.css` imports `list.css` without wrapping it, so the
unlayered declaration wins over every layered one regardless of the `:where()` specificity — a segmented
row gets no background state layer on hover or press. Only the corner morph (lines 93-100, unlayered)
and the focus outline (a different property) still show.
- Fix: move the `[data-list='segmented'] > [data-list-item]` background rule (lines 42-44, a duplicate of
the selector at 79-81) into `@layer components` alongside the state rules, or paint the state layer with
a `::before`/`background-image` instead of `background-color` so the two never collide.
- Effort: S
- Breaks API? no
### C-09 · bottom-sheet · Drag handle colour is on-surface-variant at 40%, not the role colour
- Severity: should-fix
- M3 says: "drag handle = On surface variant" (specs page colour roles, reference-components-a.md §
Bottom sheets). `SheetBottomTokens.kt:30``DockedDragHandleColor get() = ColorSchemeKeyTokens.OnSurfaceVariant`;
`SheetDefaults.kt:576` uses it undiluted (`color: Color = SheetBottomTokens.DockedDragHandleColor.value`),
with no opacity multiplier.
- Library does: `bottom-sheet.blade.php:60``bg-on-surface-variant/40`. The component's own header comment
(line 10) says "a 32×4px drag handle in on-surface-variant", so the code contradicts its own doc.
- Fix: `bg-on-surface-variant` in `bottom-sheet.blade.php:60`.
- Effort: S
- Breaks API? no
### C-10 · drawer · Bottom actions are right-aligned; the side-sheet spec says left
- Severity: should-fix
- M3 says: side sheet specs table (both standard and modal): "Bottom actions alignment (horizontal) |
**Left**"; "Bottom actions height 72dp; top padding 16dp; bottom padding 24dp"
(raw `components_side-sheets_specs.md:125` and `:199`).
- Library does: `drawer.blade.php:135``flex shrink-0 flex-wrap items-center justify-end gap-2 pt-6`
(right-aligned, 24px top padding, no bottom padding of its own beyond the sheet's `p-6`).
- Fix: `justify-start` in `drawer.blade.php:135`, and `pt-4 pb-0` inside a 72px-min row
(`min-h-18 pt-4`) to match 16/24/72. Note this deliberately differs from the dialog, whose actions are
trailing-aligned — M3 specifies them differently.
- Effort: S
- Breaks API? yes (visual position of every existing drawer action row)
### C-11 · carousel · The full-screen layout scrolls horizontally; M3's scrolls vertically
- Severity: should-fix
- M3 says: "The full-screen carousel layout shows one edge-to-edge large item at a time and **scrolls
vertically**"; "This layout works best with content that is taller than it is wide, and scrolls
vertically. It only works in **portrait orientation in compact and medium breakpoints**. Don't use this
layout in landscape orientation." (raw `components_carousel_guidelines.md:31,177-183`). Specs table:
full-screen padding 0dp all round, 16dp between elements, edge-to-edge with no item radius.
- Library does: `carousel.blade.php:127-131` renders every layout, full-screen included, as a horizontal
`overflow-x-auto` row; `carousel-item.blade.php:35` gives every item `rounded-corner-xl` and the 28px
clip-path. The header comment (line 22-23) describes full-screen as "one item the width of the carousel
at a time", i.e. horizontal by design.
- Fix: either (a) add a vertical mode for `layout="full-screen"` (`flex-col`, `overflow-y-auto`,
`snap-y snap-mandatory`, items `h-full w-full` with no corner radius, arrow keys Up/Down) and clamp it to
compact/medium widths, or (b) rename the layout so it does not claim to be M3's full-screen carousel and
say so in `SKILL.md:471`. Also drop the 28px corner and the 8px gap for this layout (M3: edge-to-edge,
16dp between elements).
- Effort: L
- Breaks API? yes if renamed; no for (a) if `layout="full-screen"` keeps its name
### C-12 · carousel · Leading/trailing padding defaults to 0 where M3 specifies 16dp
- Severity: should-fix
- M3 says: specs table — Multi-browse / Hero / Center-aligned hero: "Leading/trailing padding **16dp**,
Top/bottom padding **8dp**, Padding between elements 8dp"; Uncontained: "16dp (leading only)"
(reference-components-a.md § Carousel → Specs).
- Library does: `carousel.blade.php:55``'padding' => 0`; the scroller at lines 127-131 has no vertical
padding at all. Every showcase example (`resources/views/showcase/sections/carousel.blade.php`) therefore
renders with 0 end padding, so items sit flush against the container edge.
- Fix: default `'padding' => 16` in `carousel.blade.php:55` (and pass leading-only for `uncontained`, 0 for
`full-screen`); add `py-2` to the scroller class list at line 128. Update `SKILL.md:471` where it
documents "`padding` (px at the ends, 0)".
- Effort: S
- Breaks API? no (a default value changes; explicit `padding="0"` still works)
### C-13 · card · No elevation change on hover, focus, press or drag
- Severity: should-fix
- M3 says: per-state elevation from `ElevatedCardTokens.kt` / `FilledCardTokens.kt` / `OutlinedCardTokens.kt`
(reference-components-a.md § Cards → cross-check table): elevated 1dp rest → **3dp hover** → 1dp
focus/pressed → **8dp dragged**; filled 0 → **1dp hover** → 0 → **6dp dragged**; outlined 0 → **1dp
hover** → 0 → 6dp dragged. The specs page shows Hovered / Focused / Pressed / Dragged / Disabled for all
three variants (raw `components_cards_specs.md:78,128,180`).
- Library does: `card.blade.php:34-36` sets one elevation and never changes it
(`shadow-elevation-1` for elevated, none for filled/outlined). The only interactive response is in
`resources/css/components/list.css:52-72`, and that is a state layer plus a corner morph — no
`box-shadow` level change. There is no dragged state anywhere in the group.
- Fix: add hover/focus elevation to `card.blade.php` for interactive cards — e.g. in `list.css`
`[data-card][data-list-row]:hover { box-shadow: var(--md-sys-elevation-2), inset … }` for the elevated
variant and `var(--md-sys-elevation-1)` for filled/outlined. Needs a variant marker on the element
(`data-card="elevated|filled|outlined"` instead of the bare `data-card` at `card.blade.php:31`).
- Effort: M
- Breaks API? no (`data-card` gains a value; the bare attribute selector still matches)
### C-14 · list-item · Leading/trailing gap is 12px where M3 uses 16dp
- Severity: should-fix
- M3 says: `ListItem.kt` (androidx-main) lines 1269 and 1273 —
`internal val LeadingContentEndPadding = 16.dp`, `internal val TrailingContentStartPadding = 16.dp`;
`ListTokens.kt:171,338``ItemLeadingSpace = 16.dp`, `ItemTrailingSpace = 16.dp`.
- Library does: `list-item.blade.php:46``flex items-center gap-3 px-4` (12px gap). The container padding
(`px-4` = 16px) is correct; only the internal gaps are short.
- Fix: `gap-4` in `list-item.blade.php:46`.
- Effort: S
- Breaks API? no
### C-15 · list-item · Three-line items are middle-aligned; M3 top-aligns them
- Severity: should-fix
- M3 says: "Alignment: elements are middle-aligned by default; **top-aligned if the item is 88dp+ or has 3+
lines of text**" (reference-components-a.md § Lists → Specs, from the overview page). Compose confirms
the vertical padding also changes: `ListItem.kt:1259,1261`
`ListItemVerticalPadding = 8.dp`, `ListItemThreeLineVerticalPadding = 12.dp`.
- Library does: `list-item.blade.php:46``items-center` for every case; padding is `py-2` (8px) for one
line and `py-2.5` (10px) for both two- and three-line items (lines 47-49), where M3 wants 8 and 12.
- Fix: in `list-item.blade.php:45-51`, add `'items-start' => $lines === 2` (alongside `items-center` for
the others) and change `'min-h-22 py-2.5' => $lines === 2` to `'min-h-22 py-3' => $lines === 2`; leave
`py-2` for `$lines === 0` and use `py-2` for `$lines === 1` too.
- Effort: S
- Breaks API? no
### C-16 · list · Dividers between items run edge to edge; the list token insets them 16dp
- Severity: should-fix
- M3 says: `ListTokens.kt:30,36``DividerLeadingSpace = 16.dp`, `DividerTrailingSpace = 16.dp` (top/bottom
space 0). Guidelines: inset dividers "separate related content within one section (e.g. emails in a
list); indented equally from both sides by default; pair with anchoring elements like icons/avatars"
(reference-components-a.md § Divider).
- Library does: `list.blade.php:25``'divide-y divide-outline-variant' => $dividers && ! $segmented`,
which draws a full-bleed 1px rule between items. `<x-divider>` has `inset` and `middle` props, but the
list never uses them and there is no way to ask for an inset list divider.
- Fix: in `list.blade.php`, render the divide with a 16px inset — e.g. add
`[&>[data-list-item]:not(:last-child)]:after` or keep `divide-y` and add `mx-4` to the rule via a small
CSS block in `resources/css/components/list.css`: `[data-list='plain'][data-dividers] > [data-list-item]:not(:last-child) { box-shadow: inset 0 -1px 0 … }` inset by 16px. Alternatively give `<x-list>` a
`dividers="full"|"inset"|"middle"` value instead of a boolean.
- Effort: M
- Breaks API? no if `dividers` stays boolean-compatible
### C-17 · modal · Full-screen dialog header is 64px where M3 specifies 56dp
- Severity: should-fix
- M3 says: full-screen dialog specs table — "Header height | **56dp**"; "Bottom action bar height | 56dp";
"Top/left/right padding 24dp" (reference-components-a.md § Dialogs → Specs).
- Library does: `modal.blade.php:71``flex h-16 shrink-0 items-center gap-1 px-1 sm:hidden` (64px), and
the bottom action bar at line 108 is `max-sm:px-6 max-sm:py-4` around a 40px button ≈ 72px.
- Fix: `h-14` (56px) at `modal.blade.php:71`; `max-sm:min-h-14 max-sm:py-2` at line 108. Keep `px-1` on the
bar so the close icon button's 48px target still reaches the 24dp text margin (M3 aligns the header
headline to 24dp from the edge; the icon button's own padding supplies it).
- Effort: S
- Breaks API? no
### C-18 · carousel · Tab lands on the container, which M3 explicitly tells you not to do
- Severity: should-fix (two readings — see below)
- M3 says: "When navigating to a carousel using assistive technology, use **Tab** to place initial focus on
the **first carousel item**"; and the caption under the Don't image: "**Avoid focusing on the carousel
container**" (raw `components_carousel_accessibility.md:130,146`). Keyboard table: "Tab or Arrows — Moves
to the previous or next carousel item; Space or Enter — Activates the focused carousel item".
- Library does: `carousel.blade.php:123-126` gives the scroller `role="region"`,
`aria-roledescription="carousel"` and **`tabindex="0"`**; the items (`carousel-item.blade.php:23-32`) are
`role="group"` with no `tabindex`, so they are never focusable and Space/Enter cannot activate one. The
arrow keys only work while the container itself has focus (`carousel.js:1065-1068` returns early unless
`event.target === this.$refs.scroller`). The header comment (lines 37-43) says this is deliberate:
"WAI-ARIA's carousel pattern: the row is a focusable `region`".
- Both readings: the library's choice satisfies WCAG 2.1.1 for a scrollable region whose content may be
non-focusable (an `<img>`-only slide), which is the ARIA-APG "scrollable region" practice; M3's rule
assumes every item is itself an actionable target. They conflict; M3's is the stated rule here.
- Fix: give each `<x-carousel-item>` `tabindex="0"` and handle Arrow/Home/End/Space/Enter on the focused
item (roving tabindex), keeping the container out of the tab order — or, if the current pattern is kept,
record it in the "deliberate deviations" of `SKILL.md:471` with the M3 quote so a reviewer does not
re-litigate it.
- Effort: M
- Breaks API? no
### C-19 · bottom-sheet · Default height is 90dvh; M3 caps a modal sheet's initial position at 50%
- Severity: should-fix
- M3 says: "Modal: … Initial vertical position is **capped at 50% of screen height**; if content exceeds
that, it can be pulled to full screen and scrolled internally." Specs table: "Top margin 72dp; Top margin
(window width > 640dp) 56dp; Start/end margin (window width > 640dp) 56dp"
(reference-components-a.md § Bottom sheets).
- Library does: `bottom-sheet.blade.php:17``'height' => '90dvh'`, applied as
`max-h-(--sheet-max-height)` (line 55). There is one height and no preset-height cycling, so a sheet may
open at 90% of the viewport where M3 would open at 50% and let the user pull it up. The 56dp side margin
above 640px is also absent (`mx-auto … max-w-160`, line 55, with no horizontal margin).
- Fix: default `'height' => '50dvh'` with a `max-height` ceiling of `calc(100dvh - 72px)` in
`bottom-sheet.blade.php:17,55`, and add `sm:px-14` (56px) to the wrapper or `sm:max-w-[calc(100vw-7rem)]`.
A second preset height, cycled by the drag handle's click, would complete M3's "selecting the drag handle
toggles preset heights" rule; today the click only closes.
- Effort: M
- Breaks API? yes (default sheet height changes)
### C-20 · modal · No `role="alertdialog"` on a basic dialog
- Severity: should-fix (two readings)
- M3 says: "On web, basic dialogs should have the **alert dialog** role"; "Basic dialogs are known as alert
dialogs on web" (raw `components_dialogs_accessibility.md:130,134`).
- Library does: `modal.blade.php:41` uses a native `<dialog>` opened with `showModal()`
(line 51), which the browser maps to `role="dialog"` + `aria-modal="true"`. No `role` is set, and no
`aria-describedby` points at the supporting text (line 93).
- Both readings: ARIA-APG restricts `alertdialog` to dialogs that "interrupt … to communicate an important
message" and requires an `aria-describedby` message; applying it to every `<x-modal>` (including forms,
which the `fullscreen` variant is explicitly for) would over-announce. A middle path matches both.
- Fix: add an `alert` boolean prop to `modal.blade.php` that sets `role="alertdialog"` plus
`aria-describedby="{{ $id }}-body"`, and use it in the showcase's destructive-confirmation example
(`showcase/sections/containment.blade.php:73`). Add `aria-describedby` for the subtitle unconditionally.
- Effort: S
- Breaks API? no
### C-21 · list · Segmented items use `surface-container`; the token says `Surface`
- Severity: should-fix
- M3 says: `ListTokens.kt:201``ItemSegmentedContainerColor get() = ColorSchemeKeyTokens.Surface`
(`ItemContainerColor` is also `Surface`). `SegmentedGap = 2.0.dp` (line 353).
- Library does: `resources/css/components/list.css:43` and `:79-81`
`background-color: var(--md-sys-color-surface-container)`. The 2px gap (`list.blade.php:24`,
`gap-0.5`) and the 4px/16px corner morph are right; only the fill is a tone off.
- Fix: `var(--md-sys-color-surface)` in `list.css:43`. Note this only reads as "segmented" when the page
behind it is a container tone; if the library prefers the stronger tone, say so in the file comment,
because the header currently claims "each item its own surface-container tile … (ListTokens)".
- Effort: S
- Breaks API? no
### C-22 · list.css · A focused segmented row does not morph to the large corner
- Severity: nice-to-have
- M3 says: "Interaction-state expressive shapes: hovered = Medium (12dp); **focused**/pressed/dragged/
selected-any-state = Large (16dp)" (reference-components-a.md § Lists → Specs, cross-checked against
`ListTokens.kt``ItemPressedContainerExpressiveShape` = CornerLarge,
`ItemDraggedContainerExpressiveShape` = CornerLarge).
- Library does: `resources/css/components/list.css:93-100` covers `:hover` (→ md) and
`:is([data-selected], [data-list-row]:active)` (→ lg). `:focus-visible` is not in either selector, so a
keyboard-focused segmented row keeps the 4px corner.
- Fix: extend the selector at `list.css:99` to
`[data-list='segmented'] > [data-list-item]:is([data-selected], [data-list-row]:active, [data-list-row]:has([data-list-open]:focus-visible))`.
- Effort: S
- Breaks API? no
### C-23 · card · The corner morphs 12→16 on hover, which M3 cards do not do
- Severity: nice-to-have
- M3 says: cards have one shape — "Shape | 12dp corner radius" for all three variants, with no press or
hover shape listed in `ElevatedCardTokens.kt` / `FilledCardTokens.kt` / `OutlinedCardTokens.kt`
(reference-components-a.md § Cards → Specs). Shape morph is specified for buttons, FABs and list items,
not cards.
- Library does: `resources/css/components/list.css:59-62`
`[data-card][data-list-row]:hover { border-radius: var(--md-sys-shape-corner-lg); … }`. The
`card.blade.php` header (lines 10-14) documents this: "It answers with a state layer and its corner
opening a step."
- Fix: either drop the `border-radius` line at `list.css:60` (keeping the state layer, which M3 does
require for a directly-actionable card) or keep it and note in the card header that this is an Expressive
extension M3 does not specify for cards. The state layer itself is correct and should stay.
- Effort: S
- Breaks API? no
### C-24 · collapse · `interpolate-size` is set but nothing animates the height
- Severity: nice-to-have
- M3 says: n/a — collapse is not an M3 component; foundations require only that motion respect reduced
motion, which the token system already does (`resources/css/tokens/motion.css:63-75`).
- Library does: `collapse.blade.php:49` adds `[interpolate-size:allow-keywords]` and the header comment
(lines 7-8) promises "where the browser supports animating `details` content (`interpolate-size`) — a
height that eases open". There is no `transition: height` and no `::details-content` rule anywhere in
`resources/css/` (grepped: `interpolate-size` and `details-content` appear only in this file), so the
content snaps open. `interpolate-size` on its own changes nothing.
- Fix: add to a component stylesheet:
`details.group\/collapse::details-content { block-size: 0; overflow: hidden; transition: block-size var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast), content-visibility var(--md-sys-motion-spatial-fast-duration) allow-discrete; }`
and `details[open].group\/collapse::details-content { block-size: auto; }` — or delete the
`interpolate-size` utility and the sentence in the header comment.
- Effort: S
- Breaks API? no
### C-25 · carousel-item · The overlay label is `text-white`, outside the role set
- Severity: nice-to-have
- M3 says: "Layering text/icons on images is **not recommended**; if necessary, add a translucent scrim or
a bounding shape behind the text/icon to guarantee accessible contrast" (reference-components-a.md §
Cards → Behaviour; the same rule is the reason the carousel's item content is art). Colour must come from
a scheme role.
- Library does: `carousel-item.blade.php:46``type-title-md text-white` over
`bg-linear-to-t from-scrim/60` (line 44). The scrim is right; `white` is a literal, not a role, so it
does not follow a scheme or a high-contrast profile.
- Fix: `text-inverse-on-surface` (which is near-white in a light scheme and dark in a dark one — check the
intent) or add an explicit `--md-sys-color-on-scrim` style token if a fixed light-on-dark is wanted for
both schemes; a literal `white` is defensible over a 60% black scrim but should be stated as such in the
file comment.
- Effort: S
- Breaks API? no
### C-26 · list-item · Leading icon stays 24px in a segmented (expressive) list
- Severity: nice-to-have
- M3 says: `ListTokens.kt:153,156,332,335``ItemLeadingIconExpressiveSize = 20.dp` /
`ItemLeadingIconSize = 24.dp`; `ItemTrailingIconExpressiveSize = 20.dp` / `ItemTrailingIconSize = 24.dp`.
The library's segmented list is explicitly the expressive variant (`list.blade.php:4-6`).
- Library does: `list-item.blade.php:64` and `:97``size-6` (24px) in both cases, regardless of the
parent list's mode.
- Fix: the item does not know its parent, so use CSS:
`[data-list='segmented'] > [data-list-item] svg { width: 20px; height: 20px }` in
`resources/css/components/list.css`, scoped to the leading/trailing icons only.
- Effort: S
- Breaks API? no
### C-27 · mail · The card corner is 24px, a value not on M3's shape scale
- Severity: nice-to-have
- M3 says: the corner scale is 0 / 4 / 8 / 12 / 16 / 20 / 28 / 32 / 48 (reference-styles.md § Shape; the
library's own `resources/css/tokens/shape.css:11-20` reproduces it). 24 is not a step.
- Library does: `resources/views/mail/theme.blade.php` `.inner-body { border-radius: 24px; }`; the panel
uses `16px` (on-scale) and `code` uses `4px` (on-scale).
- Fix: `border-radius: 28px` (extra-large) on `.inner-body`, matching the dialog/bottom-sheet surface tone
this card stands in for. Everything else in the theme is on-scale.
- Effort: S
- Breaks API? no
### C-28 · mail · The header app name is title-large at weight 500, not 400
- Severity: nice-to-have
- M3 says: title-large = weight 400 (Regular), 22/28, tracking 0 — the library's own
`resources/css/tokens/type.css:39-41` has `--md-sys-typescale-title-lg` at regular and
`--md-sys-typescale-emphasized-title-lg` at medium.
- Library does: `resources/views/mail/theme.blade.php` `.header a { font-size: 22px; font-weight: 500; … }`,
while the section comment says "the app name in title-lg". `h2` right below it uses 22/28 at weight 400,
so the two disagree within the same file.
- Fix: either set `font-weight: 400` on `.header a`, or change the comment to say
title-large-**emphasized** — which is the more likely intent for a brand line and is a real M3 role.
- Effort: S
- Breaks API? no
## Deliberate deviations
- **`card.blade.php:16-17` — "Do not pass a `bg-*` class … it races the card's own in Tailwind's emit
order."** True and correctly explained; the CSS in `list.css` is unlayered for the same reason
(`list.css:10-11`). Holds up, and is the cause of C-08 (the same unlayered trick applied to the segmented
background, where it is not needed).
- **`card.blade.php:10-14` / `list-rows.js` — the `data-list-row` + `data-list-open` contract instead of a
stretched link or a wrapping `<a>`.** M3's accessibility page for cards says exactly this: "on a
**directly actionable card** Tab moves to the next card container; on a **non-actionable card with
actionable elements**, Tab moves through each actionable element inside before moving to the next card"
and forbids stacking an action on an already-actionable surface. The library implements the second case
with a single real control. Holds up, and is a better answer than M3 gives for the web.
- **`drawer.blade.php:9-11` — "it enters on emphasized decelerate rather than a spring — a sheet anchored
to the edge that overshot would open a gap."** M3 Expressive's motion scheme is springs for spatial
change, but `styles/motion` keeps the emphasized-decelerate curve for exactly this kind of entrance and
the reference records no rule against it. Holds up.
- **`modal.blade.php:24` — "It opens on the fast spatial spring and closes at once, as M3's do."** The
first half is fine; the second is not supported by Google's text, which says a dialog "appears via an
**enter/exit** transition" (reference-components-a.md § Dialogs → Behaviour). Native `<dialog>` makes an
exit transition awkward (`@starting-style` only covers entry), which is the real reason — worth saying so
instead. Folded into no finding of its own; it is a doc accuracy point.
- **`list.blade.php:8``role="list"` rather than `listbox`.** Correct for a plain list of links; it is
only wrong once `selected` is used, which is C-03.
- **`carousel.blade.php:37-43` — the WAI-ARIA carousel pattern rather than M3's "focus the first item".**
See C-18; a genuine standards conflict, but M3's Don't is explicit and is not acknowledged in the code.
- **`mail/theme.blade.php:9-26` — hexes only, light only, no `@media`, no elevation.** Every reason given
is correct (CssToInlineStyles' `doCleanup()` does strip `@media`; Outlook does drop alpha). Accepted by
the brief and by the constraints.
- **`collapse.blade.php:1-2` — "Not an M3 component; built on the native `<details>`."** Correct; M3 has
expandable list items and menu expansion but no standalone disclosure. Judged against foundations it is
sound: 48px summary (`min-h-12`), the 8/10% state layer, the 3px secondary focus ring, reduced motion via
the duration tokens, and the native `aria-expanded`.
## Aligned
- **Card**: filled `surface-container-highest`, elevated `surface-container-low` + elevation 1, outlined
`surface` + 1px `outline-variant` — all three match the specs page and the token files. `rounded-corner-md`
= 12dp; `p-4` = the 16dp left/right padding; `overflow-hidden` + a full-bleed `figure` slot is the media
anatomy. (Card typography is not specified by M3 at all — the specs page lists no type roles — so
`type-title-md` / `type-body-md` cannot be marked wrong.)
- **List heights and leading sizes**: `min-h-14` / `min-h-18` / `min-h-22` = 56 / 72 / 88dp exactly
(`ListTokens.kt:180,323,347`); avatar `size-10` = 40dp (`ItemLeadingAvatarSize`), image `size-14` = 56dp
(`ItemLeadingImageWidth`) with `rounded-corner-sm` = the expressive `CornerSmall`; icon `size-6` = 24dp
(baseline `ItemLeadingIconSize`); `px-4` = 16dp container padding.
- **List type roles**: title `type-body-lg` (`ItemLabelTextFont` = BodyLarge), description `type-body-md`
(`ItemSupportingTextFont` = BodyMedium), overline and trailing text `type-label-sm`
(`ItemOverlineFont` / `ItemTrailingSupportingTextFont` = LabelSmall), `line-clamp-2` on the description.
- **Selected colours**: `secondary-container` / `on-secondary-container` is right —
`ListTokens.kt:204,281,284` (`ItemSelectedContainerColor` = SecondaryContainer,
`ItemSelectedLabelTextColor` / `ItemSelectedLeadingIconColor` = OnSecondaryContainer). (The reference's
specs-page note about "Primary container" is contradicted by the token file; the library follows the
tokens.) Disabled content at 38% matches `ItemDisabled*Opacity = 0.38f`.
- **Segmented list geometry**: 2px gap = `SegmentedGap`; 4px item corner = `ItemContainerExpressiveShape`
(CornerExtraSmall); 16px at the list's ends and while pressed/selected = `ContainerShape` /
`ItemSelectedContainerExpressiveShape` (CornerLarge); 12px on hover = the specs page's Medium.
- **`data-list-row` state layer**: hover 8%, focus 10%, pressed 10% on `on-surface`, hover gated behind
`@media (hover: hover)`; the focus ring is 3px `secondary` (matching `ListTokens.kt:39`
`FocusIndicatorColor` = Secondary); the opener's own ring is suppressed so the row shows one indicator.
- **Divider**: 1px (`DividerTokens.Thickness`), `outline-variant`, `inset` = 16px start / 0 end,
`middle` = 16px both, a vertical variant, `role="separator"` with `aria-orientation`, and `decorative` to
hide it — a complete match to the specs table.
- **Dialog**: `surface-container-high` + `rounded-corner-xl` (28dp) + `shadow-elevation-3` =
`DialogTokens` exactly; `p-6` = 24dp all round; `max-w-[35rem]` / `min-w-70` = 560 / 280dp;
`backdrop:bg-scrim/32` = `ScrimTokens.ContainerOpacity = 0.32f`; `type-headline-sm` =
`DialogTokens.HeadlineFont`; `type-body-md` on-surface-variant = `SupportingTextFont`; a 24px
`text-secondary` hero icon that centres the headline (M3: "Alignment with icon: Center-aligned");
`gap-2` = the 8dp between buttons; `mt-4` = the 16dp title↔body and icon↔title gaps; `pt-6` = the 24dp
body↔actions gap; `justify-end` = trailing-edge actions, and the showcase orders Cancel before Delete
(`showcase/sections/containment.blade.php:75-76`), which is M3's "dismissive to the left of confirming".
`wire:ignore.self` + native `showModal()` gives top-layer, inert background, focus-in/focus-return and
Escape for free.
- **Bottom sheet**: `surface-container-low` (`DockedContainerColor`), `rounded-t-corner-xl` (28dp top,
`CornerExtraLargeTop`), `shadow-elevation-1` (`DockedModalContainerElevation` = Level1),
`max-w-160` = the 640dp max width, 32×4px handle geometry, 32% scrim, `x-trap.inert.noscroll`,
dismissal by scrim / Escape / downward drag, and a single-pointer alternative to the drag (the handle is
a real `<button>` with an accessible name and `role=button`, which is M3's "label only the drag handle").
- **Side sheet**: `surface-container-low`, a 16px corner on the inner edge only
(M3's "16dp corner radius for modal side sheets"), 400px default width = the specs max-width, `p-6` =
24dp start/end padding, end placement by default (M3: "usually the right"), `role="dialog"` (M3's stated
role), full height, independent vertical scroll and no horizontal scroll, `x-trap.inert.noscroll`, a
container query on the body so contents lay out by the sheet's width.
- **Carousel**: the Compose keyline maths is ported with attribution and a commit hash; small items clamp
to 40-56dp (`MIN_SMALL_ITEM_SIZE` / `MAX_SMALL_ITEM_SIZE`, carousel.js:64-65); 28px item corner
(`CarouselDefaults`); 8px between items (`gap-2` = the specs' "Padding between elements 8dp"); snap for
multi-browse / hero / full-screen and free scroll for uncontained, exactly M3's recommendation;
per-item "n of m" labels; controls placed **below** the row, never over it (M3's explicit Don't); RTL
mirroring; re-measure on resize and after a morph.
- **Collapse**: 48px summary, the shared state layer and focus ring, a chevron on the fast spatial spring,
reduced motion via zeroed duration tokens, native disclosure semantics, `wire:ignore.self` for morphs.
- **Error pages**: `bg-surface` / `text-on-surface`, `type-emphasized-display-lg` in `on-primary-container`
over a `primary-container` shape (a correct contrast pair), `type-headline-md`/`lg` for the headline,
`type-body-lg` in `on-surface-variant` for the message, a filled primary action with a text secondary
(M3's action hierarchy), 24px page gutters, and the decorative shape's rotation gated behind
`prefers-reduced-motion: no-preference`.
- **Mail theme**: the typescale is reproduced correctly in px — h1 24/32/400/0 (headline-small),
h2 22/28 (title-large), h3 16/24/500/0.15 (title-medium), p 16/24/400/0.5 (body-large),
`p.sub` and table cells 14/20/0.25 (body-medium), table head 14/20/500/0.1 (title-small),
footer 12/16/0.4 (body-small), the button 16/24/500/0.15 (title-medium, the Expressive medium button's
label) with a full corner. Roles are used properly throughout (`on-surface` for emphasis,
`on-surface-variant` for body, `outline-variant` for every rule, `surface-container-lowest` for the card
against `surface-container` for the page), and separation is by tone rather than shadow, as M3 does.
## Missing
- **Lists**: leading **video** slot (56×100dp small, 64×114dp large — `ListTokens.kt:129,132,177`); the
**expand/collapse** list-item interaction (M3: "items containing nested items can expand/collapse …
container-transform"); explicit **selection modes** (single-select / multi-select / single-action /
multi-action) with their role and keyboard mappings; **inset and middle dividers** between list items
(only a full-bleed `dividers` boolean exists); a **dragged** state (16% layer, elevation 4) for
reorderable lists.
- **Cards**: per-state **elevation** (C-13) and the **dragged** state; a first-class "directly actionable
card" that takes a `button`/`link` role — M3 says such a card gets one, and the `data-list-row` pattern
deliberately keeps the role on the inner opener instead.
- **Dialogs**: no divider pinned between a scrolling body and the header/actions (the `separator` prop
scrolls with the content); no 56dp edge-margin rule for custom-positioned dialogs on large screens; no
"discard unsaved changes" confirmation helper for the full-screen variant, which M3 requires of that
variant.
- **Bottom sheets**: **preset heights** and the handle's "cycle through heights on activation" behaviour
(M3 requires a non-drag alternative whenever more than one height exists); a peek/collapsed height for
the `standard` variant; the 56dp side margin above a 640dp window; the swap to a side sheet at expanded
widths that M3 recommends.
- **Side sheets**: a genuine **standard** (co-planar, non-modal, 0dp elevation) variant — `pane` is close
but is scoped to list-detail and starts at `xl`; the **back icon button** in the modal anatomy; a divider
above the action row; the 16dp "detached" inset M3 allows.
- **Carousel**: the **uncontained multi-aspect-ratio** layout (added November 2025 — items from 9:16 to
16:9); a **"Show all"** affordance opening a vertical list of every item, which M3 requires on
vertically-scrolling pages; a vertically-scrolling full-screen layout (C-11).
- **Divider**: the divider-with-text / subheader configuration (4dp gap to the supporting text, 8dp right
and bottom margins in the specs table).
## Breakpoint map
| Component | Library breakpoint used | M3 window size class it stands in for | Gap |
| --- | --- | --- | --- |
| `<x-modal fullscreen>` — full-screen below, basic above (`modal.blade.php:61,67,71,80,83,89,93,108`) | `max-sm` → < 640px | Compact (< 600dp): "full-screen dialogs are used only in compact breakpoints" | 40px too wide — a 600-639px window (small tablet portrait, split-screen) gets a full-screen dialog where M3 wants a basic one |
| `<x-drawer pane>` — pane vs modal sheet (`drawer.blade.php:55,64,65,100`; `xl:` = 1280px) | `xl` → ≥ 1280px | Expanded (≥ 840dp): list-detail shows **2 panes** from expanded through extra-large | 440px late — the entire expanded class (840-1199) and the bottom of large get the compact single-pane modal behaviour |
| `<x-drawer>` — fixed width and inner corner switch on (`drawer.blade.php:97-99`) | `sm` → ≥ 640px | Compact (< 600dp) is where a modal side sheet should be full-bleed; medium (600-839) onwards it has its fixed width | 40px too wide — a 600-639px window still gets a full-width sheet |
| `<x-bottom-sheet>` — 640px cap applied at every width (`bottom-sheet.blade.php:55`, `max-w-160`) | none (a `max-width`, not a breakpoint) | Specs: "Width: full width, up to max-width 640dp"; above a 640dp window also "Start/end margin 56dp" and "Top margin 56dp" | The 640 cap is right; the 56dp side/top margins above 640dp are missing entirely |
| `<x-modal>` — dialog gutters (`modal.blade.php:58`, `w-[calc(100vw-3rem)]`, `max-h-[calc(100dvh-3rem)]`) | none | Guidelines: custom-positioned dialogs on larger screens "must respect a 56dp margin from screen edges" | 24px used where 56dp is specified — only binding for custom-positioned dialogs, which the component does not offer, so informational |
| `<x-carousel controls>` (`carousel.blade.php:139`, `pointer-fine:`) | input-media query, not a width | M3 gives no breakpoint for carousel controls; it only says put them above or below the row | No gap — an input-capability query is a reasonable substitute and the placement rule is followed |
| `<x-carousel layout="full-screen">` (`carousel.blade.php:62`) | none — rendered at every width | Compact and medium only, portrait only ("Don't use this layout in landscape orientation") | Unbounded — nothing stops the layout above 840dp or in landscape |