Files
livewire-material/docs/audits/m3-alignment/containment.md
T
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

46 KiB
Raw Blame History

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
  • 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)
  • 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:30DockedDragHandleColor get() = ColorSchemeKeyTokens.OnSurfaceVariant; SheetDefaults.kt:576 uses it undiluted (color: Color = SheetBottomTokens.DockedDragHandleColor.value), with no opacity multiplier.
  • Library does: bottom-sheet.blade.php:60bg-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:135flex 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)
  • 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
  • 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,338ItemLeadingSpace = 16.dp, ItemTrailingSpace = 16.dp.
  • Library does: list-item.blade.php:46flex 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,1261ListItemVerticalPadding = 8.dp, ListItemThreeLineVerticalPadding = 12.dp.
  • Library does: list-item.blade.php:46items-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,36DividerLeadingSpace = 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:71flex 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
  • 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:201ItemSegmentedContainerColor get() = ColorSchemeKeyTokens.Surface (ItemContainerColor is also Surface). SegmentedGap = 2.0.dp (line 353).
  • Library does: resources/css/components/list.css:43 and :79-81background-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.ktItemPressedContainerExpressiveShape = 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
  • 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:46type-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,335ItemLeadingIconExpressiveSize = 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 :97size-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:8role="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