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
46 KiB
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.ktline 788,private val DragHandleVerticalPadding get() = 22.dp, applied asmodifier.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 isclass="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 withpy-[22px]and give the buttonbefore:absolute before:inset-x-0 before:-inset-y-[22px]), and change the wrapper at line 59 frompy-4topy-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.38and 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:50adds only'pointer-events-none text-on-surface/38' => $disabled.pointer-events: noneblocks 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. Noaria-disabledis emitted, so a screen reader announces the item as an ordinary link. - Fix: in
list-item.blade.php, when$disabledrender the title as the<p>branch (skip the<a>entirely, or addtabindex="-1" aria-disabled="true"and striphref), dropdata-list-rowwhen disabled, and addaria-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:44emits onlydata-selected;resources/css/components/list.css:46-49paintssecondary-container/on-secondary-container. There is noaria-selected, noaria-current, no icon or checkmark, and the container staysrole="list"/role="listitem"(list.blade.php:19,list-item.blade.php:41), wherelistitemcannot carry a selected state at all. - Fix: two parts. (a) Add a
selectable(orselection="single"|"multi") prop to<x-list>that switches the container torole="listbox"and each item torole="option" aria-selected="true|false"; keeprole="list"as the default for non-selectable lists and usearia-current="true"there instead of nothing. (b) Render a second cue whenselected— e.g. a trailingcheckicon inon-secondary-container— or document that callers must supply one (aleadingcheckbox/radio). - Effort: M
- Breaks API? yes (a new prop;
roleon 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 (whichclose-on-escape=falseremoves) and the scrim (whichwithout-backdrop-closeremoves). With both of those off, the sheet is undismissable. - Fix: flip the default to
'withCloseButton' => trueindrawer.blade.php:33, and make the close button unconditional (ignore the prop) whencloseOnEscapeis false orwithoutBackdropCloseis set. Same forpanemode, 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:966setsconst pinned = state.reducedMotion.matchesand 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-inseton 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), whenstate.reducedMotion.matches, writeinset = 0(andshift = 0,opacity = 1) for every item so all items stay atstrategy.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:66putsoverflow-y-autoon 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 ismin-h-0 flex-1with no overflow of its own, so once the content exceedsmax-h-[calc(100dvh-3rem)]everything scrolls together. Only thefullscreenbranch pins correctly (max-sm:overflow-y-autoon the inner div, line 80). - Fix: in
modal.blade.php, removeoverflow-y-autofrom line 66 (keepoverflow-hidden), give the header blockshrink-0, and moveoverflow-y-autoonto themin-h-0 flex-1wrapper at line 80 for all cases (not justmax-sm:). The actions already haveshrink-0. Movep-6from the box to the three regions so the pinned header/footer keep their 24dp padding. - Effort: S
- Breaks API? no (
box-classcallers 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 (0–599): 1 pane; Medium (600–839): 1 (recommended) or 2; Expanded (840+): 2; Large (1200–1599): 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 thexl: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
80remto52.5rem(840px) indrawer.blade.php:55and swap thexl:prefixes for a customexpanded:variant defined as@custom-variant expanded (@media (min-width: 52.5rem))inresources/css/tokens/theme.css; updateSKILL.md:463and 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-6around the drawer would need the same breakpoint changed; that wrapper is documented inSKILL.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-44sets[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.cssimportslist.csswithout 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 componentsalongside the state rules, or paint the state layer with a::before/background-imageinstead ofbackground-colorso 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:576uses 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-variantinbottom-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:125and: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'sp-6). - Fix:
justify-startindrawer.blade.php:135, andpt-4 pb-0inside 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-131renders every layout, full-screen included, as a horizontaloverflow-x-autorow;carousel-item.blade.php:35gives every itemrounded-corner-xland 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, itemsh-full w-fullwith 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 inSKILL.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' => 16incarousel.blade.php:55(and pass leading-only foruncontained, 0 forfull-screen); addpy-2to the scroller class list at line 128. UpdateSKILL.md:471where 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 (rawcomponents_cards_specs.md:78,128,180). - Library does:
card.blade.php:34-36sets one elevation and never changes it (shadow-elevation-1for elevated, none for filled/outlined). The only interactive response is inresources/css/components/list.css:52-72, and that is a state layer plus a corner morph — nobox-shadowlevel change. There is no dragged state anywhere in the group. - Fix: add hover/focus elevation to
card.blade.phpfor interactive cards — e.g. inlist.css[data-card][data-list-row]:hover { box-shadow: var(--md-sys-elevation-2), inset … }for the elevated variant andvar(--md-sys-elevation-1)for filled/outlined. Needs a variant marker on the element (data-card="elevated|filled|outlined"instead of the baredata-cardatcard.blade.php:31). - Effort: M
- Breaks API? no (
data-cardgains 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-4inlist-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-centerfor every case; padding ispy-2(8px) for one line andpy-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(alongsideitems-centerfor the others) and change'min-h-22 py-2.5' => $lines === 2to'min-h-22 py-3' => $lines === 2; leavepy-2for$lines === 0and usepy-2for$lines === 1too. - 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>hasinsetandmiddleprops, 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)]:afteror keepdivide-yand addmx-4to the rule via a small CSS block inresources/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>adividers="full"|"inset"|"middle"value instead of a boolean. - Effort: M
- Breaks API? no if
dividersstays 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 ismax-sm:px-6 max-sm:py-4around a 40px button ≈ 72px. - Fix:
h-14(56px) atmodal.blade.php:71;max-sm:min-h-14 max-sm:py-2at line 108. Keeppx-1on 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-126gives the scrollerrole="region",aria-roledescription="carousel"andtabindex="0"; the items (carousel-item.blade.php:23-32) arerole="group"with notabindex, 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-1068returns early unlessevent.target === this.$refs.scroller). The header comment (lines 37-43) says this is deliberate: "WAI-ARIA's carousel pattern: the row is a focusableregion". - 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" ofSKILL.md:471with 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 asmax-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 amax-heightceiling ofcalc(100dvh - 72px)inbottom-sheet.blade.php:17,55, and addsm:px-14(56px) to the wrapper orsm: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:41uses a native<dialog>opened withshowModal()(line 51), which the browser maps torole="dialog"+aria-modal="true". Noroleis set, and noaria-describedbypoints at the supporting text (line 93). - Both readings: ARIA-APG restricts
alertdialogto dialogs that "interrupt … to communicate an important message" and requires anaria-describedbymessage; applying it to every<x-modal>(including forms, which thefullscreenvariant is explicitly for) would over-announce. A middle path matches both. - Fix: add an
alertboolean prop tomodal.blade.phpthat setsrole="alertdialog"plusaria-describedby="{{ $id }}-body", and use it in the showcase's destructive-confirmation example (showcase/sections/containment.blade.php:73). Addaria-describedbyfor 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(ItemContainerColoris alsoSurface).SegmentedGap = 2.0.dp(line 353). - Library does:
resources/css/components/list.css:43and: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)inlist.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-100covers:hover(→ md) and:is([data-selected], [data-list-row]:active)(→ lg).:focus-visibleis not in either selector, so a keyboard-focused segmented row keeps the 4px corner. - Fix: extend the selector at
list.css:99to[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); … }. Thecard.blade.phpheader (lines 10-14) documents this: "It answers with a state layer and its corner opening a step." - Fix: either drop the
border-radiusline atlist.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:49adds[interpolate-size:allow-keywords]and the header comment (lines 7-8) promises "where the browser supports animatingdetailscontent (interpolate-size) — a height that eases open". There is notransition: heightand no::details-contentrule anywhere inresources/css/(grepped:interpolate-sizeanddetails-contentappear only in this file), so the content snaps open.interpolate-sizeon 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; }anddetails[open].group\/collapse::details-content { block-size: auto; }— or delete theinterpolate-sizeutility 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-whiteoverbg-linear-to-t from-scrim/60(line 44). The scrim is right;whiteis 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-scrimstyle token if a fixed light-on-dark is wanted for both schemes; a literalwhiteis 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:64and: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 }inresources/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-20reproduces it). 24 is not a step. - Library does:
resources/views/mail/theme.blade.php.inner-body { border-radius: 24px; }; the panel uses16px(on-scale) andcodeuses4px(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-41has--md-sys-typescale-title-lgat regular and--md-sys-typescale-emphasized-title-lgat 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".h2right below it uses 22/28 at weight 400, so the two disagree within the same file. - Fix: either set
font-weight: 400on.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 abg-*class … it races the card's own in Tailwind's emit order." True and correctly explained; the CSS inlist.cssis 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— thedata-list-row+data-list-opencontract 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, butstyles/motionkeeps 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-styleonly 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 thanlistbox. Correct for a plain list of links; it is only wrong onceselectedis 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 nativearia-expanded.
Aligned
- Card: filled
surface-container-highest, elevatedsurface-container-low+ elevation 1, outlinedsurface+ 1pxoutline-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-bleedfigureslot is the media anatomy. (Card typography is not specified by M3 at all — the specs page lists no type roles — sotype-title-md/type-body-mdcannot 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); avatarsize-10= 40dp (ItemLeadingAvatarSize), imagesize-14= 56dp (ItemLeadingImageWidth) withrounded-corner-sm= the expressiveCornerSmall; iconsize-6= 24dp (baselineItemLeadingIconSize);px-4= 16dp container padding. - List type roles: title
type-body-lg(ItemLabelTextFont= BodyLarge), descriptiontype-body-md(ItemSupportingTextFont= BodyMedium), overline and trailing texttype-label-sm(ItemOverlineFont/ItemTrailingSupportingTextFont= LabelSmall),line-clamp-2on the description. - Selected colours:
secondary-container/on-secondary-containeris 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% matchesItemDisabled*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-rowstate layer: hover 8%, focus 10%, pressed 10% onon-surface, hover gated behind@media (hover: hover); the focus ring is 3pxsecondary(matchingListTokens.kt:39FocusIndicatorColor= 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"witharia-orientation, anddecorativeto hide it — a complete match to the specs table. - Dialog:
surface-container-high+rounded-corner-xl(28dp) +shadow-elevation-3=DialogTokensexactly;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-mdon-surface-variant =SupportingTextFont; a 24pxtext-secondaryhero 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+ nativeshowModal()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 androle=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.selffor morphs. - Error pages:
bg-surface/text-on-surface,type-emphasized-display-lginon-primary-containerover aprimary-containershape (a correct contrast pair),type-headline-md/lgfor the headline,type-body-lginon-surface-variantfor the message, a filled primary action with a text secondary (M3's action hierarchy), 24px page gutters, and the decorative shape's rotation gated behindprefers-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.suband 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-surfacefor emphasis,on-surface-variantfor body,outline-variantfor every rule,surface-container-lowestfor the card againstsurface-containerfor 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-bleeddividersboolean 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/linkrole — M3 says such a card gets one, and thedata-list-rowpattern deliberately keeps the role on the inner opener instead. - Dialogs: no divider pinned between a scrolling body and the header/actions (the
separatorprop 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
standardvariant; 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 —
paneis close but is scoped to list-detail and starts atxl; 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 |