436 Commits
Author SHA1 Message Date
Andreas Reinhold / reiniandClaude Opus 5 252c649c45 Keep a closing docked search above the page until its view has gone
tests / lint (push) Successful in 1m17s
tests / feature (8.4) (push) Failing after 1m56s
tests / feature (8.5) (push) Failing after 1m46s
tests / browser (chrome, chromium) (push) Successful in 7m14s
tests / browser (firefox, firefox) (push) Failing after 11m33s
tests / browser (safari, webkit) (push) Failing after 12m9s
The docked search raised itself to z-index 50 only while open, so the
moment it closed, positioned content later on the page could cover the
view while it faded out. The root now keeps the stacking for as long as
its view carries the `md-transition` class Alpine's x-transition sets
for the length of the exit. The browser test samples the view part-way
through its fade and counts it only while the root is still at 50; it
fails without the change and passes in Chrome, Firefox and Safari.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:02:48 +02:00
Andreas Reinhold / reiniandClaude Opus 5 1ccf0b639e Draw a standard side sheet's first state without motion
Giving a closing standard sheet its room back made the root's inline
size a transition, with an `@starting-style` for the entry, so a sheet
that starts open grew in on every page load: its open state arrives
through Alpine, after the first paint of the closed root. Nothing should
move while a page loads.

The view now marks the root `data-md-drawer-settled` two frames after
the first `settle()` — once the state Alpine starts with has been
painted — and drawer.css gives the standard sheet's root and sheet no
transitions until then. The browser test loads a page with slowed motion
tokens and a sheet that starts open, and finds it standing at its full
width, shown, with nothing animating; closing it afterwards still
animates. It fails without the change in Chrome and Firefox and passes
in all three engines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:02:48 +02:00
Andreas Reinhold / reiniandClaude Opus 5 99c18e01d8 Give a closing standard side sheet's room back as it leaves
From `expanded` a standard side sheet sits in the layout beside the
content, and the view took it out of the layout the moment it closed:
`data-md-drawer-collapsed` (`display: none` on the root) followed `! open`
at once, so its fade was never seen and the content beside it took the
sheet's width, and the gap, in one jump — in every engine. M3's side
sheets: opening a standard sheet shrinks the body beside it, and closing
gives the room back.

drawer.css: the standard root is a clipping flex box whose `inline-size`
springs between none and the sheet's width, with a negative margin as
wide as its flex parent's gap (`--md-drawer-gap`), on the sheet's own
exit and entry timings; the sheet keeps its width at the root's far edge,
so it is uncovered from its inner edge while it fades. drawer.blade.php:
`settle()`, from `x-effect`, measures the gap on each change and holds
the collapse (`closing`) for the root's and the sheet's closing
durations, read a frame on; reopening lets a pending end go by. The
binding reads `collapsed`, which only `settle()` and the window's width
write: a binding reading `open` ran before `settle()` in the same flush
and, already queued, did not run again once `closing` changed.

ContainmentTest closes the showcase's sheet at 1000px and samples, in the
page, for the root part-way to none while the column beside it has grown
part of the way, then checks the collapse, the column at the row's full
width, and a reopen part-way through the exit ending fully open; it fails
on the previous code in Chrome, Firefox and Safari. OverlayTest pins the
root's declarations and the view's bindings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:00:23 +02:00
Andreas Reinhold / reiniandClaude Opus 5 db6023bf80 Close a full-screen search back into its bar or icon, bar and view together
`fullScreen` followed `open`, so the moment a full-screen search closed,
`data-md-full-screen` went with it: the fixed header bar went back to its
resting pill — or to `display: none`, behind the search icon — and the
view, still fading out under Alpine's hold, dropped to the docked layout
for its exit. In every engine the bar vanished on the first frame while
the view went on fading somewhere else.

search.js: a close from full screen now `hold()`s the layout — `leaving`
keeps `fullScreen`, and with it `data-md-full-screen`, the back arrow and
the fixed bar, for the view's own closing duration, read from its
computed style a frame on (zero under reduced motion); reopening lets a
pending end go by. search.css fades the header bar out with the view on
the view's spring and keeps the root above the page while it leaves,
and the icon trigger's bar stays displayed until the layout settles. The
focus trap now binds to `open && fullScreen`, so it still lets go at the
close and its return of focus lands inside RETURN_GUARD_MS rather than
reopening the view at the end of the exit.

PickingTest samples both exits in the page — the icon trigger, and the
bar trigger on a compact window — for a moment where the root is still
full screen with a fixed bar and both bar and view part-way through their
fade, then checks the settled layout and focus back on the icon or the
field; both fail on the previous code in Chrome, Firefox and Safari. The
tests wait for the entry's own transitions first: Firefox reads a
transition's end value until its next refresh tick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:00:23 +02:00
Andreas Reinhold / reiniandClaude Opus 5 9f46630352 Ease a collapse open and shut in Firefox and Safari too
collapse.css animated `<details>`' `::details-content` from `block-size: 0`
to `auto`, which needs `interpolate-size: allow-keywords` (Chrome 129 and
nothing else) and a `content-visibility` held through the close with
`allow-discrete` (not Firefox), so in Firefox and Safari the section
snapped open and shut. No stylesheet can do it there.

resources/js/collapse.js animates the `<details>` itself, the same way
in every engine: a Web Animation of its `block-size` from the height it
is drawn at to the height it is going to, on the fast spatial spring's
tokens, with `overflow: clip` and a `min-block-size` that keeps the
summary whole through the spring's overshoot, all inside the keyframes,
so nothing is left in `style`. Content below moves with it. Opening sets
`open` at once and grows the height; closing keeps `open` while the
height goes, marks `data-md-collapse-closing` so the chevron turns back
at the start, and drops `open` (and fires `toggle`) when the height has.
A press mid-way turns it round from the height it has reached. The CSS
animation is gone, so Chrome takes the same path and never animates
twice.

Routes: a press on the summary (pointer, Enter, Space) is taken over;
the Alpine and Livewire bindings call `materialCollapse()` from the
view's `x-effect` (its first call, as Alpine starts, sets the state at
once); a `name` group's open member closes on the same spring, its
`name` lifted for the close so the browser's exclusivity does not shut
it first and put back once closed. Anything else that sets `open`
(find-in-page, an application's own script) stays instant, as the
browser draws it, and is followed. Under reduced motion every route is
the browser's own.

Browser tests in Chrome, Firefox and Safari catch the height part-way
open and shut (and what is under it moving) from a press and from the
Alpine binding, the chevron turning at the start of a close, the clip
while it moves, Enter on the summary, a reversal mid-way, a name group,
and reduced motion. The four motion tests fail on main in Firefox and
Safari.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:00:23 +02:00
Andreas Reinhold / reiniandClaude Opus 5 15e4fa7ed1 Slide the sheets and close the search view out in Firefox too
The side sheet, the bottom sheet's panel and the docked search's scrim
and view kept `display` alive through their exit with
`transition-behavior: allow-discrete`. Firefox does not transition
`display` (Chrome 117 and Safari 18 do), and `x-show` sets
`display: none` in the frame the exit starts, so in Firefox the sheets
vanished instead of sliding out and the search view and scrim vanished
instead of fading. The docked search had a second problem in every
engine: neither the view nor the scrim had a closed state to transition
to, so where `display` was held (Chrome, Safari) the view stood at full
opacity for its duration and then disappeared.

Each element now carries `x-transition:enter`/`:leave="md-transition"`,
the approach the two sheet scrims already took (renamed from
`md-scrim-transition` to one name for all of them). The class only
switches Alpine to CSS-transition mode, so `x-show` holds `display` for
the element's computed transition-duration before hiding it, in every
engine, and a reopen during the exit cancels the pending hide; nothing
styles it. `display` and `allow-discrete` leave the transitions so
Chrome and Safari do not hold a second time. Alpine reads the first
`transition-duration` listed, which is the closing slide or fade in each
list (the preset panel lists translate before height). The search view
now closes back into the bar (opacity 0, `scale: 1 0.9`, the reverse of
its `@starting-style` entry) and its scrim fades out on close and when
the search turns full screen. Under reduced motion the durations are
zero and every one of them closes at once.

Four browser tests sample each exit mid-way in the page, in the same
round trip as the close: the sheets part of the way to their closed
offset, the search scrim and view part of the way faded, each still
displayed, then `display: none`. All four fail on main in Firefox (the
two search tests in Chrome too) and pass in Chrome, Firefox and Safari.
OverlayTest pins the drawer's new transition and the view's markup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:26:45 +02:00
Andreas Reinhold / reiniandClaude Opus 5 92e560bef6 Slide the navigation rail and fade its scrim out in Firefox too
A rail that was open over the page left it on `transition: … display …
allow-discrete`: the compact adaptive rail's slide-out, the slide-out of
a rail that hides when collapsed, and every rail scrim's fade. Firefox
does not transition `display`, even with `allow-discrete` (Chrome 117
and Safari 18 do), so there the panel and the scrim vanished on the
first frame. Unlike the sheets' scrims these are drawn by attribute
rules on `data-md-open`, not `x-show`, so Alpine had nothing to hold.

navigation.js now marks a rail that closes `data-md-closing` — `sheet`
when the panel leaves the window, `scrim` when it stands in the layout
again — and drops it once the panel's `translate` and the scrim's
`opacity` transitions have finished (at once under reduced motion, or
when the rail opens again). navigation-rail.css keeps what is leaving
displayed, and a sliding panel in its open geometry, while the
attribute is set, and no longer transitions `display` anywhere, so
Chrome and Safari do not hold a second time. The view sets it from
`x-effect`, beside the `x-bind` that drops `data-md-open`, so both
attributes change in one flush: a `$watch` a microtask later let a
style read in between settle the scrim as already hidden. The collapsed
branches and `--md-navigation-rail-value` are untouched.

NavigationTest samples each exit part-way in the page (the compact
slide, the hide-when-collapsed slide, a modal rail's scrim) and checks
it ends hidden; all three fail on main in Firefox and pass in Chrome,
Firefox and Safari. They wait for the entry to finish first: Firefox
creates a transition on its next refresh tick, so straight after a
change its computed style already reads the end value. NavigationRailTest
pins the holds and that no rule transitions `display`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:24:43 +02:00
Andreas Reinhold / reiniandClaude Opus 5 7db522826b Fade and shrink popovers out in every engine, Firefox included
Menus, submenus, tooltips, rich tooltips and the FAB menu held their
exit with `transition-behavior: allow-discrete` on `display` and
`overlay`. Firefox transitions neither (MDN browser-compat-data,
`display.is_transitionable`: Chrome 117, Safari 18, Firefox none), so
every one of them vanished on its first frame there. A script cannot
hold a popover open instead: `beforetoggle` is not cancellable on the
way out, and the browser's own light dismiss (Escape, a press outside)
never asks.

resources/js/popover-exit.js: a popover marked `data-md-popover-exit`
closes for real at once — focus, aria-expanded and toggle stay the
browser's — and a copy taken in `beforetoggle`, while it is still drawn,
stands in for the exit. The copy is decoration: a manual popover in the
top layer (closing no other popover), inert, aria-hidden, without ids or
nested popovers, `x-ignore`d so Alpine starts nothing in it, pinned to
the popover's box with its resolved colours. It is shown with its
transitions off, so `@starting-style` does not replay the entry, then
marked `data-md-popover-closing`, which each stylesheet turns into its
closed values (`:popover-open:not([data-md-popover-closing])`, and the
FAB menu's items' sink), so it moves on the component's own tokens. It
is removed once the longest of them has run, and opening the popover
again takes it away. Under reduced motion every duration is zero and no
copy is made. `display`, `overlay` and `allow-discrete` leave the
transitions, so Chrome and Safari take the same path.

Browser tests in Chrome, Firefox and Safari slow the motion tokens so a
round trip still finds the exit on screen: a menu after Escape and after
a press outside (the real menu closed and focus back on its button, the
copy inert, fading, with no Alpine state, and gone after), a reopen
part-way through, reduced motion, a submenu while its menu stays open, a
tooltip, the FAB menu's items part-way down their sink, and a persistent
rich tooltip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:24:23 +02:00
Andreas Reinhold / reiniandClaude Opus 5 684d60efc8 Close a persistent rich tooltip on a second press of its trigger
The bubble is a popover="auto" and the trigger is outside it, so the press
on the trigger light-dismissed the open bubble and the click that followed
opened it again: a second press never closed it, in any engine. Found by
the new exit test. A close the browser made within 250ms is now taken for
that press, as menu.js's REOPEN_GUARD_MS does, timed from beforetoggle
because toggle is queued past the click; the morph test waits past the
guard before pressing again, as the menu tests do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:24:13 +02:00
Andreas Reinhold / reiniandClaude Opus 5 6a133c2c67 Fade the side and bottom sheet scrims out in Firefox too
Both scrims are shown and hidden by Alpine's `x-show`, and faded by CSS:
closing set `display: none` inline in the same frame as the opacity
change, and `transition-behavior: allow-discrete` on `display` held the
element for the fade. Firefox does not transition `display`, so no
transition started there and the scrim vanished at once — what
ContainmentTest's two fade-out tests caught.

Alpine now holds `display` itself: `x-transition` with a class string
waits for the element's computed transition-duration (the effects token,
zero under reduced motion) before hiding it, the same in every engine,
and registering `enter` too means reopening during the fade cancels the
pending hide. `display` leaves the scrims' CSS transitions, so Chrome and
Safari do not hold it a second time after Alpine's hold.

The Alpine transition also holds `$nextTick` until the sheet is shown,
which menu.js's openSheet() relies on ("Alpine holds $nextTick until
then") and which had been lost when the bottom sheet moved off
x-transition. Without it `focusItem()` could run while the sheet was
still `display: none`, so ActionsTest's sheet-at-compact menu and its
filter field did not take the focus in Firefox and Safari; both pass
there again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:24:19 +02:00
Andreas Reinhold / reiniandClaude Opus 5 921c8cef04 Give a text-only dialog's scrolling body the first focus in every engine
With nothing focusable inside, showModal() focuses the <dialog> itself.
Chrome's scroll containers are keyboard-focusable, so there the scrolling
body became the focus delegate and modal.css drew its inset ring; Firefox
and WebKit focused the dialog, where the arrow keys scroll nothing, and
WebKit never lets Tab reach a scroll container, so a keyboard could not
read a long text-only dialog there at all.

<x-modal> now opens through materialShowModal() (resources/js/dialog.js):
showModal(), and when the dialog took the focus itself and its body
overflows, the body gets tabindex="0" and the focus, as in Chrome. The
tabindex goes when the dialog closes. The body is wire:ignore.self, like
the dialog: a Livewire render would otherwise morph the tabindex away and
WebKit dropped the focus out of the dialog. ContainmentTest now also
checks the focus stays on the body through a render.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:23:15 +02:00
Andreas Reinhold / reiniandClaude Opus 5 b4a82a4dbe Focus what x-show reveals on the frame it shows
A sheet-at-compact menu focused its first item, or its filter field, in
$nextTick, and the icon entry point of <x-search> handed focus back to
its trigger the same way. Both targets are x-show'n, and Alpine shows an
element on the animation frame after its condition turns true, not in the
tick: the focus reached an element still display: none, which Firefox and
WebKit refuse. The sheet's focus trap then put the focus on the drag
handle, and a closed search left it on the page body; in Chrome the
frame or the trap's own return happened to cover for it. The comment in
openSheet() assumed Alpine holds $nextTick until the transition begins,
which it only does with x-transition, and the sheet uses CSS transitions.

Both now wait for that frame after the tick, running after Alpine's own
show in it, as search.js's expand() already did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:23:15 +02:00
Andreas Reinhold / reiniandClaude Opus 5 d2245ad122 Measure the error shape's laid-out width, not its turning bounding rect
The error page's shape turns once a minute (material-error-turn), and
getBoundingClientRect() returns the axis-aligned box of what is drawn,
which is wider than the 240px art box as soon as the turn has begun. The
browser test compared that rect with 240: Chrome sampled it before the
first frame of the animation (currentTime 0) and passed, while Firefox
(33ms, 240.83px) and Safari (4ms, 240.20px) had already turned a fraction
of a degree and failed. The layout itself was right in all three: the
SVG's computed width is 240px.

The test now reads the SVG's computed width, which the rotation does not
touch. It still guards the regression it was written for: with the
inlined layout rules removed, the SVG computes to 1280px in every engine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:16:35 +02:00
Andreas Reinhold / reiniandClaude Opus 5 59a24f6c11 Open the M3 select picker only where the browser has base-select
The browser test asserted CSS.supports('appearance', 'base-select') as a
precondition, so it failed in Firefox, which has no customizable select.
menu.css styles the picker only inside @supports (appearance: base-select),
and the native picker is the intended fallback there; the test now checks
that fallback where the property is missing and the M3 menu where it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:13:32 +02:00
Andreas Reinhold / reiniandClaude Opus 5 f2a3c4d056 Size the loading indicator and publish the rail's value
Two gaps found rewriting the Boost skill for 2.0's plain CSS, each
closed the way M3 describes it.

`<x-loading size="96">`: M3 gives the loading indicator a responsive
size, 48dp by default and flexible from 24 to 240dp ("never exceed that
range"), with the container and the active shape in a fixed ratio. The
size had no prop, so an application wrote a width and height of its own;
`size` now takes a whole number of px in that range, written as
`--md-loading-size`, and the SVG keeps the 48:38 ratio as it scales. A
value outside the range is ignored, as `<x-icon>` and `<x-shape>` ignore
theirs, and an application's own width and height still win.

`--md-navigation-rail-value`: M3 Expressive's rail has two values,
collapsed and expanded (Compose's WideNavigationRailValue), and content
in a rail follows it. Without a hook an application copied the rail's
seven conditions — mode, `data-rail`, `data-rail-auto`, open, and the
window band — out of navigation-rail.css. The rail now publishes the
answer from the same branches that narrow it: `expanded` by default,
`collapsed` wherever it is drawn collapsed, so a style query in the
application's CSS switches at the first paint and at the same moment
as the rail's own items. A rail open over a scrim reads `expanded`, and
outside a rail the property is unset. A JS attribute would have missed
the first paint; a width container query would have lagged the collapse
animation.

Style queries on a custom property need Firefox 151, so the browser
floor moves from Firefox 147 to 151 (README, the CI note, UPGRADE's new
2.1.0 section); Chrome 125 and Safari 18.4 are unchanged.

Browser tests pin both in Chrome, Firefox and Safari: the indicator's
drawn box at 96 and 32px, and the rail's value — with a style query
acting on it — at the first paint for fixed modes, across the window
classes and the menu button for an adaptive rail, and open and closed
for a modal one. NavigationRailTest pins the value in each of the five
collapsed branches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:06:16 +02:00
Andreas Reinhold / reiniandClaude Opus 5 24eb811f34 Teach 2.0's plain CSS in the Boost guidelines and skills
2.0.0 took Tailwind out of the stack, but the texts Boost copies into
every application still taught its utilities: the core guideline said
"built on Tailwind CSS", the always-on material-3 guideline wrote every
rule as `bg-primary`, `type-*`, `rounded-corner-*`, `state-layer` and
`medium:`, and both skills' tables and examples did the same. None of
those classes exists in 2.0's stylesheets, so an agent following the
guideline wrote markup that compiled to nothing. Found moving ReStride
onto 2.0: its CLAUDE.md, and SealShare's, carry these texts.

The guidelines and skills now teach what 2.0 has: component and layout
component props (`gap="space200"`, `hide-from="medium"`), the `md-type-*`,
`md-ink-*` and interaction classes, and `--md-sys-*` tokens in the
application's own CSS, with breakpoints as range media queries.
UPGRADE.md §1, §2 and §4 describe the finished move instead of the
in-between state, and header comments that pointed at the removed
tokens/utilities.css and tailwind.css, or called a component "still
Tailwind", say what is true now.

BoostVocabularyTest runs DesignGuard over the code in every shipped
guideline and skill (fenced Blade and CSS, and each inline class list),
so a text that teaches a class the stylesheets do not define fails the
suite; it finds 268 violations in the texts as 2.0.0 shipped them. The
guard's own table of what it reports is exempt. BoostResourcesTest now
asks the design skill for each breakpoint's prop value and media query
instead of the removed `medium:` variants.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 11:07:54 +02:00
Andreas Reinhold / reiniandClaude Opus 5 bae1df09da Give a segmented list item a fill that reads against the page
tests / lint (push) Successful in 1m6s
tests / feature (8.4) (push) Failing after 1m41s
tests / feature (8.5) (push) Failing after 1m46s
tests / browser (chrome, chromium) (push) Failing after 6m21s
tests / browser (firefox, firefox) (push) Failing after 4m16s
tests / browser (safari, webkit) (push) Failing after 11m12s
`ItemSegmentedContainerColor get() = ColorSchemeKeyTokens.Surface`
(ListTokens.kt, C-21) was taken literally, but foundation/base.css
paints the page `surface` too — so a segmented list was invisible on any
default page. The token reads against a Compose scaffold, not against
this package's own page.

The tile is `surface-container` now, the step M3 names for a container
that has to read against the page, and what 1.x drew. Found in
SealShare: the files chosen for an upload list under the drop zone with
nothing to set them apart from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 22:23:16 +02:00
Andreas Reinhold / reiniandClaude Opus 5 5800ab1fe1 Leave a toolbar's filled button its own colours
M3's colour list for a standard toolbar ends "Standard button
(Primary)" and the vibrant list ends "Standard button (On primary
container)" (N-13) — both name the *standard* button. The two rules
painted every icon button in the toolbar instead, so a button that
brings its own container got a primary label on a primary fill and its
icon disappeared: SealShare's floating navigation marks the current page
with a filled button, and that button came out a blank circle.

A button that fills its own container is now left alone — filled, tonal,
or either of them selected — and keeps the label colour that goes with
the fill. A disabled button still falls back to button.css's disabled
ink in a toolbar of either colour, as before. AppBarTest pins the
exclusion on both rules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 22:23:16 +02:00
Andreas Reinhold / reiniandClaude Opus 5 5014d3e576 Let a select, a password and a file field span its container
<x-input>, <x-textarea> and <x-datepicker> took `full` to drop the 40rem
bound field.css puts on a text field from `medium`; <x-select>,
<x-password> and <x-file> did not, so a field inside a container that
already bounds it stopped short of that container's edge with no way to
say so. The three now take the prop and hand it to <x-field> like the
others. <x-choices> and <x-timepicker> still do not.

Found in SealShare: its share options, its admin settings and its user
settings are cards, and a card is exactly the "other container" M3 names
as the thing that should bound a field on a medium or expanded screen
instead of the 40rem ceiling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 22:23:05 +02:00
Andreas Reinhold / reiniandClaude Opus 5 5b05e52e1c Wrap a fixed tab's long label to a second line
Plan step 46, M3 § Tabs: "Labels: single row by default; may wrap to a
max second line if needed with truncation, or use scrollable tabs to
give longer titles more room." In fixed tabs (<x-tabs>, <x-section-nav>
up to four sections) the label's text, now its own data-md-tab-text in
both views, wraps and truncates at two lines. The tab's 48px (64px
stacked) is a minimum, the bar stretches its tabs to one height, and the
content stretches with them, so the indicator stays on the divider.
Scrollable tabs keep one row. SealShare's "Two-Factor Auth" and
"Appearance" showed an ellipsis at 600px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 16:10:13 +02:00
Andreas Reinhold / reiniandClaude Opus 5 0a028b158f Skip a font face named in a comment when trimming the fallback
Found while doing plan step 46: withoutFontFace() searched the raw
bundle, so the `@font-face` that foundation/base.css's and
error-page.css's header comments mention read as the rule. Each cut from
inside its comment to the end of the next block and left the comment
open, so the no-build fallback lost base.css's html rule and icon.css's
rules. The search and the brace count now read Stylesheets::mask(), and
only tokens/font.css's real block leaves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 16:10:13 +02:00
Andreas Reinhold / reiniandClaude Opus 5 70c983b7bb Inline the error layout's own rules beside the app's build
Plan step 46, from SealShare's breakpoint walk: an application imports
the stylesheets its own views render, and none renders the error layout,
so under a real build its 403/404/500 pages drew unstyled. With a build
the page now keeps the app's Vite tags (foundation, tokens, scheme, font)
and inlines Stylesheets::bundle() of components/error-page.css and its
imports beside them (ErrorPage::layoutStyles(), cached). Without a build
the fallback is unchanged. An application imports nothing for its error
pages; one that imports all.css gets the same layered rules twice, which
is harmless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 16:10:01 +02:00
Andreas Reinhold / reiniandClaude Opus 5 05f115ab36 Report the stylesheets an entry imports that no view needs any more
missingStylesheets() names an import the views need, but nothing named
one they had stopped needing: when SealShare's last card left its views,
card.css stayed in every page until a reviewer noticed by hand.
unusedStylesheets($cssEntry) reads the entry's direct package imports
and reports each one no scanned view needs, counting what a needed
stylesheet imports itself and foundation.css always; an entry that
imports all.css is left alone. Opt-in, beside missingStylesheets()
(plan step 46).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 14:31:01 +02:00
Andreas Reinhold / reiniandClaude Opus 5 ff5349759b Let a card's title take the heading level the page needs
<x-card> always titled itself with an <h3>, so a card straight under a
page's <h1> skipped a level, which SealShare's download page did with
no way round it from the application. `heading` takes h2 to h6, or div
or p for a title that is not a heading, as <x-pane> and <x-app-bar>
already do; h3 stays the default. The skill also says a card holds one
subject, as M3 does (plan step 46).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 14:09:06 +02:00
Andreas Reinhold / reiniandClaude Opus 5 d557dd9b49 Accept a list of token values in the guard, and quote what was written
A transition-duration or -timing-function listing one token per property
(a spatial spring for scale, an effects spring for opacity) was reported
as literal: blanking each var() left a bare comma, which is not one of
the safe values. Every item of a list is now checked on its own. A
message also quoted the blanked shell, `var(      ), 200ms`; it now
quotes the declaration as written. SealShare's drop zone found it
(plan step 46).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 13:48:59 +02:00
Andreas Reinhold / reiniandClaude Opus 5 f307079a45 Name the monospace faces once, as a typeface token and md-mono
Both applications show values a person reads or types character by
character: SealShare's share tokens and recovery codes, ReStride's file
and folder names and its keyboard shortcuts. The package wrote the
platform's monospace stack twice (the reset's <code>, <kbd>, <samp> and
the mono field) and gave everything else nothing, so an app copied the
stack by hand. --md-ref-typeface-mono now holds it, read by the reset,
the field and a new md-mono text class; the guard points font-mono at
<code> or md-mono. Not an M3 token: M3 names a brand and a plain
typeface and nothing for code. Decided with the user (plan step 46).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 12:56:55 +02:00
Andreas Reinhold / reiniandClaude Opus 5 92d3d761f3 Keep a button at its label's width in a stack and a form
<x-stack> stretches its children across by default and <x-form> is a
one-track grid, so a button written directly in either drew as a
full-width pill. M3 keeps a button's width "dynamic to fit label" and
says not to "stretch buttons into long flat shapes on large windows".
A button there now sits at the start edge at its own width; a stack
aligned start, centre or end is untouched, and an application's own
width still wins (a cap on the button itself was rejected for exactly
that: it would have limited an app's inline-size, which CascadeTest
guards). Chromium test in both directions, failing without the rules.
Decided with the user (plan step 46).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 12:56:45 +02:00
Andreas Reinhold / reiniandClaude Opus 5 f5f040516e Smooth the page's text in the foundation, not per surface
The showcase and the error pages set grayscale font smoothing on
themselves, but the foundation did not, so an application that dropped
Tailwind's antialiased class rendered heavier text on macOS than the
package's own pages, silently. base.css now sets it on the page; the
showcase's and error page's copies go, and the guard tells a leftover
antialiased class to go too. Decided with the user (plan step 46).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 12:41:20 +02:00
Andreas Reinhold / reiniandClaude Opus 5 6614b32fd3 Document the icon's size and mirror-rtl props in the skill
The skill still sized an icon with Tailwind's size-* classes and paired
optical="20" with size-5 by hand. 2.0.0's <x-icon> takes size in px and
picks the 20 cut from it; SealShare's settings rewrite found the prop in
the view, not the skill (plan step 46).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 11:51:51 +02:00
Andreas Reinhold / reiniandClaude Opus 5 a5ad6af698 Name what a component tag takes in place of each Blade directive
The guard said a directive does not compile inside a component tag but
not what to write instead, and the skill's note said only "use {{ }}".
SealShare's two-factor challenge had @js in an x-data on <x-stack>, and
its implementer had to work out {{ Js::from() }} from Laravel's source.
Each message now names the form: Js::from for @js, json_encode for
@json, Arr::toCssClasses and toCssStyles for @class and @style,
$wire.entangle for @entangle, a bound attribute for @disabled and its
kin, and a prop, an echo or a plain element for control structures
(plan step 46).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 11:13:49 +02:00
Andreas Reinhold / reiniandClaude Opus 5 d0bed0da69 Lift the snackbar above a toolbar placed at the bottom
SealShare's only navigation is a floating toolbar at place="bottom". In
1.x it set --material-bottom-bar to lift the snackbar over it; in 2.0.0
the toolbar reads that variable to place itself, so an application can
no longer set it, and the snackbar landed on the toolbar. M3 nudges a
snackbar "upward to avoid overlapping FABs/docked toolbars" and never
puts one in front of navigation.

A bottom-placed toolbar now publishes --material-bottom-toolbar, the
distance from the window's bottom edge to its top: floating 64px (as
tall as an md or lg FAB beside it), docked 64px plus the safe area it
pads, rounded docked floating from 840px. It is declared on :root and
again on the scaffold's root, the only place its formula sees the bar.
The snackbar clears whichever of the bar and the toolbar reaches higher,
and a page pads its end with it. Two Chromium tests, both failing on the
old formula: a docked toolbar on a scaffold's navigation bar, and a
floating toolbar without a scaffold (plan step 46).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 10:47:50 +02:00
Andreas Reinhold / reiniandClaude Opus 5 579af9e7a9 Say in UPGRADE.md that every component hook became data-md-*
The note named only the scaffold's and the theme toggle's hook changes.
202 of 1.2.0's 237 hooks gained the prefix and nothing else; the table
lists the rest, whose inner parts now carry their component's name. A
negative assertion on a 1.x hook passes vacuously against 2.0.0, which
SealShare's toolbar test did (plan step 46).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 10:24:00 +02:00
Andreas Reinhold / reiniandClaude Opus 5 5ea0bff6d3 Keep vendor and node_modules out of a path repository's copy
Composer mirrors a path repository with "symlink": false through the
source's .gitattributes export-ignore rules and nothing else, so an
application wired to this working tree (SealShare, plan step 43) would
copy the package's own 267 MB of vendor/ and node_modules/ into its
vendor/. Neither is ever in a git archive, so tagged installs are
unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 10:14:42 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 1d23f24500 Browser-test the five central cascade claims
One probe page per claim: an application's unlayered .app-wide rule beats
button.css's layered inline-size and padding; [hidden] beats a component's
display on both <x-row> and <x-button>; the layer order holds when
Stylesheets::bundle() serves button.css before foundation.css, so the
reset's padding: 0 still loses to the button's own; a data-md-variant
attribute updates correctly through the same re-render/morph a plain
wire:click="$refresh" drives, toggled both ways; <x-icon mirror-rtl> mirrors
under dir="rtl" and does not under dir="ltr". Chromium: 6 assertions, all
green, alongside ShowcaseTest.php (5 tests, 18 assertions).

Plan step 42 (Phase F), Part A.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 09:43:17 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 34b77cfd31 Fold the four groups' import check into one dataset over every component view
Action/Input/Containment/NavigationStylesheetsTest.php each carried the same
"imports the stylesheet of every component its view renders" check on their
own group's dataset. StylesheetsTest.php now runs it once, over every
resources/views/components/*.blade.php backed by a stylesheet of its own
(icon and shape included, no longer needing a group to run in at all;
tabs/tab's shared stylesheet, N-16, still handled) — a new component gets
the check by existing, not by being added to the right group's array too.
Each group file keeps its own dataset for the checks that stay group-shaped
(shape, tokens, breakpoints, the block it imports from).

Plan step 42 (Phase F), Part A.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 09:38:25 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 4cf0997712 Keep all.css under a gzip size budget, no Node required
Measured the way the showcase and the error page's fallback actually serve
it — Stylesheets::bundle() in PHP, then PHP's own gzencode() — so CI holds
the line with no Node build. Currently 106,838 bytes gzipped; the budget is
120,000, about 10% of headroom for organic growth before the failure
message points a maintainer at what grew.

Plan step 42 (Phase F), Part A.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 09:34:27 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 6efb0b5a30 Give every component view a stylesheet, or a documented exception
Audits step 42's "every component view has a stylesheet" against the whole
of resources/views/components/: everything already has one, apart from two
that need none, now documented in the test itself — theme-script renders an
inline <script> only, and tab is drawn by tabs.css, its parent <x-tabs>'s
own stylesheet.

Plan step 42 (Phase F), Part A.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 09:33:33 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 6897306e18 Prove no Tailwind utility class remains in resources/ or tests/
StylesheetsTest.php's new test runs DesignGuard's own family table (check
(i)) over resources/views, resources/js, src, tests/Browser and
tests/Feature — the guard turned on the package's own source, so the two
never drift apart. tests/Feature/DesignGuardTest.php and
tests/Fixtures/design-guard/ carry Tailwind on purpose and are left out.

The scan found Tailwind-shaped class strings used as caller classes in 21
test files (a component test proving a caller's class lands on the root or
a caller's size wins, mostly), left over from before Tailwind cleared the
whole stack: replaced with neutral application-style names (`app-hero-icon`)
or, where one already carries the right meaning, an `md-*` class
(`md-text-end`, `md-ink-warning`). BreakpointsTest.php's own heredoc probe —
which needs a genuine Tailwind-shaped breakpoint prefix to prove its
detection works — now builds that one string from a placeholder at runtime,
so its own source stays clean for this same scan.

Plan step 42 (Phase F), Part A.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 09:32:36 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 3d9e494e22 Allow legitimate linear easing and a role-coloured box-shadow ring
Check (iii) flagged two shapes the package's own stylesheets (and their
stylesheet tests) already treat as fine: `linear`, M3's own easing for
continuous motion (an indeterminate progress indicator's rotation), and a
box-shadow that is only an inset or outline-style ring — `[inset] 0 0 0
<n>px` in a `var(--md-sys-color-*)` role, or one `color-mix()`-dimmed for a
disabled ring — the shape a focused field's edge or a day's "current"
outline draws. A blurred shadow or one with a literal colour is still
reported.

Plan step 42 (Phase F), Part B.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 09:31:47 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 b243222e26 Match easing and duration Tailwind classes only in a class list
outsideTheScale() read ease-in/ease-out/ease-in-out/duration-* line by line
across every file, the way it still reads its other scale steps — but a
plain CSS keyword read out of an API into a JS string (matchMedia(…) ?
'linear' : 'ease-out') names a real value, not a Tailwind class, and the
same line scan read the words back out of a stylesheet test's own regex
literals (`ease-in`, `ease-out`) as if they were Tailwind too. Tested both
ways per the plan's instruction: Blade/PHP class lists have the identical
false-positive shape, so the two families move to FAMILIES, matched only
through a class list (literalClasses()/tailwindFamilyHint()) as the newer
families already are, in Blade and PHP as much as in JavaScript.

Plan step 42 (Phase F), Part B.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 09:31:01 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 9cfb24f419 Sort the design guard's findings by path, line and message
Different checks land violations for the same file in their own pass order
(colours before shadows before media queries, an application's CSS always
appended after its views) rather than the order a maintainer would read the
file in. DesignGuard::violations() now sorts everything it returns
deterministically before handing it back, and a fixture proves it crosses
both dimensions: a later-processed application-CSS file whose path sorts
first, and two different checks landing on the same line in the opposite
order from how they run.

Plan step 42 (Phase F), Part B.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 09:30:03 +02:00
Andreas Reinhold / reiniandClaude Opus 5 87004a9035 Document the design guard as it now stands
Plan step 41 review: README and the development skill describe the API
that exists after the review, briefly — what scan() reads, the family
table with the M3 spacing step and 1.x utilities, missingStylesheets()
reading imports only, forbidColours() on the 2.0.0 vocabulary — and
UPGRADE.md lists the removed checks and methods and the retargeted
forbidColours() (the user, 2026-09-15).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 08:51:32 +02:00
Andreas Reinhold / reiniandClaude Opus 5 bbd0192790 Name the replacement for a Tailwind palette colour in the design guard
Plan step 41 review: "colour the theme does not declare `text-red-500`"
was the one family (i) finding without a 2.0.0 hint; it now says the
utility compiles to nothing and points at md-ink-* or var(--md-sys-color-*).
The foundation-only fixture view also carries an <x-dynamic-component> and
an <x-slot:title>, which the stylesheet check must neither report nor trip on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 08:50:38 +02:00
Andreas Reinhold / reiniandClaude Opus 5 2d75091b82 Run the design guard's smoke test with missingStylesheets(), as Blade resolves
Plan step 41 review: the committed smoke test scanned the package's views
without the stylesheet check, so the claim that the package, showcase and
Workbench pass it through all.css was untested; it now runs it, and
checks the showcase's plain tags do need imports from a foundation-only
entry, so the pass is not vacuous. Doing so exposed that a component class
one test evals leaked into every later check in the process: the shadow
check now guesses the class the way Blade does (Blade::component()
aliases, component namespaces, the application's own root namespace), and
the test gives its class a namespace of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 08:50:13 +02:00
Andreas Reinhold / reiniandClaude Opus 5 2073480da4 Tighten the design guard's application-CSS check on real stylesheets
Plan step 41 review, check (iii): calc() hid every literal inside it and
now only one built on a var() does (min(), max() and clamp() too), so
calc(8px + 4px) and clamp(1rem, 2vw, 2rem) are reported. Declarations are
read whole, so a last one without a semicolon is seen and a custom
property named --card-border-radius is not mistaken for border-radius;
the font shorthand is checked; a 0s duration is fine. Media queries read
only width features: a height or prefers-* condition is not a breakpoint,
and (600px <= width < 840px) passes. The type hints no longer suggest
font-size: var(--md-sys-typescale-*), which is a font shorthand value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 08:48:25 +02:00
Andreas Reinhold / reiniandClaude Opus 5 c0c71ef16c Keep missingStylesheets() to imports, and never crash on a foreign one
Plan step 41 review: an application's CSS entry may import what a Vite
build resolves but Stylesheets::bundle() refuses (tailwindcss mid-
migration, a font URL, a layer() import); resolvedFiles() threw on each,
failing the application's whole design test. It now follows relative
imports only and skips the rest. Each missing stylesheet, shadowed tag
and ->links() is reported once, at its first use, instead of at every
occurrence; a CSS entry that does not exist says so; the @import line goes
through vendor/ when Composer symlinks the package.

missingStylesheets() also switched on the literal-value check for the
entry's whole import graph, vendor stylesheets included, with no way to
check imports alone. Check (iii) now reads the .css files scan() is given,
like every other check; the entry's imports still feed the class
exemptions, now escaped selectors (.hover\:underline) included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 08:46:33 +02:00
Andreas Reinhold / reiniandClaude Opus 5 9595722f35 Close the design guard's family-table gaps on real application markup
Plan step 41 review: run over SealShare's and ReStride's views, family (i)
missed about a quarter of the class tokens, most of them 1.x's own
utilities (type-body-md, text-meta, rounded-corner-lg, focus-ring, link)
and common Tailwind families (relative, inset-0, z-10, overflow-hidden,
border, opacity-50, shrink-0, -mx-4, cursor-pointer, font-mono). Each now
has a hint, and the ones with a layout component name its prop: gap-4 is
gap="space200" (16px), a step between two of M3's names both, items-center
align="center", grid-cols-3 :columns="3"; variants say which selector or
media query replaces them. Shapes are tightened to real Tailwind values,
so an application class such as content-header or p-card is not matched.

Class lists are also read from wire:*.class, x-transition:*, unquoted
:class keys, double-quoted PHP strings, 'class' => '…' and plain PHP
files; a string compared in a condition (view === 'grid') is skipped,
and each token carries its own line in a multi-line list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 08:43:29 +02:00
Andreas Reinhold / reiniandClaude Opus 5 d5972b8ddb Retarget forbidColours() at where 2.0.0 writes a role
Plan step 41 review (the user, 2026-09-15): an application without Tailwind
names a role through its --md-sys-color-* custom property (CSS, an inline
style, a script), an md-ink-* class, or a component's color/tone prop, not
a bg-tertiary utility. forbidColours() keeps its name and signature and
reports each of those, the role's on- and container roles included;
a Tailwind leftover stays family (i)'s single report. ReStride's
forbidColours(['tertiary', 'primary-container']) keeps its meaning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 08:35:06 +02:00
Andreas Reinhold / reiniandClaude Opus 5 6dc996afdf Retire forbidAbsolutes() and forbidOpacityInk() from the design guard
Plan step 41 review (the user, 2026-09-15): without Tailwind, bg-white and
text-on-surface/60 compile to nothing like any other utility, so family (i)
now reports every colour utility on an M3 role, a 1.x ink name
(text-meta, border-divider), white, black or current, with or without an
opacity, each with its 2.0.0 replacement (an md-ink-* class, <x-divider>,
<x-surface level>, md-state-layer or the role's var()). Check (iii) reads
named colours anywhere in a declaration's value (border: 1px solid white),
hex codes only in values (an id like #add no longer matches), and masks
strings and url() contents first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 08:33:20 +02:00
Andreas Reinhold / reiniandClaude Opus 5 c047fefd8b Remove the design guard's maryUI and daisyUI checks
Plan step 41 review (the user, 2026-09-15): SealShare and ReStride have
both left maryUI and daisyUI, and no application of 2.0.0 carries either,
so the <x-mary-*> tag check, the daisyUI class and colour lists and the
Tailwind look-alike exclusions they needed go, with their fixture lines
and the mentions in the header, README and development skill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 08:28:35 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 6147221b89 Document the design guard's 2.0.0 API in the README and development skill
Plan step 41: short updates only (the full docs rewrite is Phase H) --
missingStylesheets(), the family-table replacements now named (a layout
component and prop, an md-* class, or a var(--md-sys-*) token instead of a
dead Tailwind utility), and a note that forbidAbsolutes()/forbidOpacityInk()
matter mainly to an application still migrating off Tailwind.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 08:18:41 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 09f4edd31d Test check (iii) and fix two bugs it exposed in the application-CSS scan
Plan step 41(iii): fixtures/tests for every literal kind (colour, radius,
shadow, font size/weight, line height, letter spacing, easing, duration) and
an off-scale media query, plus material-scheme.css excluded by name and by
its generated header wherever it sits among the scanned paths. Also adds a
fixture proving Tailwind-shaped bare words in running prose are never read
as classes.

Two fixes found by writing the fixtures rather than only the implementation:
a directly-scanned .css file was also running through the Blade/PHP/JS checks
(the same file's `ease-in-out` in a `transition` value tripped the old scale
check a second time), so a CSS file is now check (iii)'s alone; and
`withoutTokenFunctions()` leaves a `var()`/`calc()` call's own name and
parentheses standing once its arguments are blanked, which
`isSafeLiteralValue()` did not yet recognise as the same "nothing to see
here" as an empty value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 08:16:15 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 b998683762 Add fixtures and tests for DesignGuard::missingStylesheets()
Plan step 41(ii): exercises the tag-to-stylesheet mapping (plain, prefixed and
namespaced spellings), a package stylesheet's own imports counting toward its
dependents (split-button.css satisfies button.css and menu.css too),
->links() needing pagination.css, the always-required foundation.css, and a
package tag shadowed by the application's own anonymous or class component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 08:12:01 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 08f2ae9d76 Rewrite the design guard's family table for a Tailwind-free application
Plan step 41(i): every Tailwind utility or variant in a Tailwind-free
application's views now gets a 2.0.0 hint instead of the old Tailwind-utility
replacement (which no longer compiles either) -- a layout component and prop,
an md-* class, or a var(--md-sys-*) token. New families cover flex/grid/gap,
spacing, sizing, display, text-layout and the M3 role utilities
(text-on-surface-variant, border-outline-variant, bg-surface-*, bg-primary);
the breakpoint and scale checks fold in with their hints rewritten the same
way. A class the application's own stylesheets declare is exempt.

literalClasses() now returns each class token unstripped of its variant
prefix (`sm:`, `hover:`) so the new checks can see it; the daisyUI check
strips it itself via the new withoutVariantPrefix() helper, as it always did
internally before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 08:08:08 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 7ec6960731 Add Stylesheets::resolvedFiles() for the design guard to reuse
Plan step 41 (ii): the design guard needs the set of files an application's
CSS entry pulls in, transitively, to check whether a package tag's
stylesheet is among them — reusing bundle()'s own import resolution and
cache rather than re-implementing @import parsing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 07:51:07 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 e1fe573182 Bundle the error page's fallback instead of hand-writing it
Plan step 40. ErrorPage::fallbackStyles() now inlines
Stylesheets::bundle() of the foundation and error-page.css (which
imports button.css and shape.css) rather than a hand-copied
stylesheet, so the fallback can never drift from the built version.
Every @font-face block is dropped structurally (withoutFontFace(),
brace-balanced, not a text search) since there is no build to serve
the font file; --md-ref-typeface-brand already lists ui-sans-serif,
system-ui and sans-serif after the brand name, so the page still gets
a sensible system stack. The scheme half comes from
Scheme::forStylesheet() drawn through SchemeStylesheet::levels(), in
material-scheme.css's own selector shape: standard, the medium and
high contrast levels, and a block per colour profile keyed on
[data-scheme] (the theme script has already resolved and written the
active one to <html> before this stylesheet is read, so nothing here
picks one in PHP). Both halves are cached per worker, the scheme half
by the scheme file's path and mtime.

Tests: both render paths, the inlined CSS's shape (no @import, no
relative url(), no @font-face, the button/shape/error-page rules, the
scheme's roles, [data-contrast='high'] and a profile block), and a new
browser test that forces the fallback and checks the button, the
shape and a dark-mode repaint.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 07:30:05 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 3b9a889e7c Extract the scheme stylesheet shape for ErrorPage to reuse
SchemeCommand::levels()/selectors()/blocks() move to a new
SchemeStylesheet, and Scheme gains forStylesheet(), which resolves a
scheme (and every profile) at all three contrast levels together. Plan
step 40's fallback needs to draw the same colours material:scheme
writes, without duplicating its selector logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 07:29:50 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 513e344912 Write the installation docs Tailwind-free
Plan step 39 (part 6). README's Requirements, Installation and
Stylesheet-and-script sections drop Tailwind CSS 4, the second Vite
entry, @source and the Tailwind-replaces-utilities framing; the plain
CSS entry keeps the layer statement (matching every package
stylesheet), and the Layout example's <body> loses classes
foundation/base.css already applies globally (surface, on-surface,
the brand typeface) since Tailwind is no longer there to write them.
UPGRADE.md §6 gets the same Tailwind-free setup in place of the
interim two-entry note step 37 left. The development skill's install
snippet was already Tailwind-free; its one stale paragraph describing
a second Tailwind entry is deleted (fixed with the build in the
"Delete Tailwind's half" commit). DesignGuard's own Tailwind-detection
docs stay: applications can still carry leftover Tailwind classes for
the guard to catch, which is unrelated to the build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 06:57:02 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 7e95b4a91b Retire BreakpointsTest's docblock reference to the deleted theme.css
Plan step 39 (part 5). Tailwind no longer clears its own breakpoint
scale anywhere in the stack, so the guard's rationale changes from "a
leftover sm: compiles to nothing" to "a browser drops the invalid
class or wrong-width rule just as silently" — the scans themselves
already worked over plain files and needed no change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 06:54:52 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 58ef33dc95 Select on [dir='rtl'] now that no build rewrites :dir()
Plan step 39 (part 4). With Tailwind gone the Workbench's Vite build
still turns every `:dir(rtl)` into a long `:lang()` list (its own CSS
minifier, not Tailwind's doing — confirmed by rebuilding and grepping
the output). Every RTL mirror rule across the eleven stylesheets that
had one now selects `:is([dir='rtl'], [dir='rtl'] *)` instead, matching
`:dir(rtl)`'s own specificity (one pseudo-class) and its inherited
"this element or a descendant of one carrying the attribute" reach; the
built CSS now keeps the selector as written (no `:lang(` or `:dir(` left).

RTL browser tests drop the `lang="ar"` workaround the old rewrite
needed (ContainmentTest's side-sheet probe, LayoutTest's shared
layoutPage() helper) and set only dir="rtl"; CarouselTest's probe
already did. Feature tests asserting the selector's literal text
(Progress, Overlay, Menu, ListDetail, Icon, Carousel) updated to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 06:54:16 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 f21943746e Delete Tailwind's half and build the Workbench without it
Plan step 39 (parts 1-3, folded into one commit: PHP tests read the
deleted files directly, so they cannot land apart from it). Tailwind
leaves the whole stack:

- Delete resources/css/tailwind.css, tokens/theme.css and
  tokens/utilities.css. Neither token file declared an --md-sys-*
  custom property of its own (both only referenced tokens declared
  elsewhere), so nothing loses a value; the md-* interaction and text
  classes already mirror utilities.css's declarations exactly
  (foundation/interaction.css, text.css).
- npm uninstall tailwindcss @tailwindcss/vite; vite.config.js drops the
  plugin and its import; composer.json drops the tailwindcss keyword
  (no lock change — keywords are outside Composer's content hash).
- The Workbench now builds one CSS entry, workbench/resources/css/app.css
  (all.css, showcase.css and the scheme; package.css is folded in and
  removed) instead of two, used by ErrorPage::assets() and every
  browser-test probe page's raw @vite() call; the showcase's own pages
  still take their CSS from the bundle route.
- TokensTest and StylesheetsTest: the two facts theme.css and
  utilities.css carried (every scheme role becomes a colour, resolved
  on the element; md-type-* matches the type-* utilities) are asserted
  directly against the scheme and text.css now that there is no second
  copy to cross-check; StylesheetsTest gained a full-tree scan (every
  .css file under resources/css/ is reached from all.css or
  showcase.css, no exclusions left for Tailwind); the Workbench-entry
  test and every "moved out of tailwind.css" assertion updated for the
  single entry and its removal.
- DesignGuard.php's comments and the development skill's setup section
  no longer name the deleted files or a second Tailwind entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 06:49:03 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 cfc57a9867 Land a caller's class and style on the scaffold and tooltip roots
Plan step 39 (part 0): the step 38 review found <x-scaffold> and
<x-tooltip> never referencing $attributes, so a caller's class and
style were silently dropped. The scaffold's root is its single
data-md-scaffold div; the tooltip's root, documented in its header, is
the standalone wrapper it draws around a trigger (data-md-tooltip-anchor,
merged with its own anchor-name) — passed an anchor instead, it renders
only a popover fragment beside another component's root and takes
nothing of the caller's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 06:37:20 +02:00
Andreas Reinhold / reiniandClaude Opus 5 783f7902cc Name each canonical layout page in its window title
Plan step 38 review: ShowcaseController::layout() passes a title
("List-detail · Layout"), but the frame overwrote it with the section's
own, so all three pages and the Layout overview shared one title. The
frame keeps a title it was given; the layout page test asserts each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 06:28:31 +02:00
Andreas Reinhold / reiniandClaude Opus 5 1945f8233a Prove again that the carousel scrolls instantly under reduced motion
Plan step 38 review: de-flaking the reduced-motion test moved the click
out of the retried assertScript, which was right, but also dropped the
arrival read in the click's own task, so a smooth scroll finishing
within assertScript's retries would now pass a test named "scrolls
instantly". The click script records at(1) at once and the assertion
requires it. It is read against the slot size measured before the
click: the row re-measures a frame later (222 against 217.7px at
1280px), which is why a later read in the same evaluation fails.
Chromium: CarouselTest 10 of 10, the test itself three runs green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 06:27:59 +02:00
Andreas Reinhold / reiniandClaude Opus 5 13b975358f Teach the ink classes and lines on the Colour page again
Plan step 38 review: the Colour section dropped its "Ink and lines"
group with the retired Tailwind ink names and put nothing in its
place, so no page showed the md-ink-* classes that replaced them or
how to draw a line. A new "Ink and lines" example writes md-ink,
md-ink-variant and md-ink-quiet with their roles, the state inks, and
a line as <x-divider> and an edge as <x-surface outlined>.

Doc comments teach too: the Icons intro showed optical="20" alone for
a 20px icon, where <x-icon size="20"> picks the cut itself; icon's
header still spoke of a caller's size-5 class, the navigation rail's
usage wrapped it in flex/min-w-0 utilities, and the menu item's
icon-class example named a Tailwind-style text- class.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 06:22:26 +02:00
Andreas Reinhold / reiniandClaude Opus 5 ebdcdb82b5 Write the showcase examples without showcase-only hooks
Plan step 38 review: the component sections' code samples, which an
application copies, carried 60 data-md-showcase-* hooks that only
showcase.css draws (a sized loading indicator, the plain badge's own
colours, a bordered bar frame, the navigation demos' frames, a table's
scroll wrapper...), so a copied sample did nothing in the application.
They now write what an application has: <x-surface outlined corner>
for a frame and a line, <x-row> for the FAB menu's corner, and an
inline style from the tokens for a size, a scroll box or a colour,
as the Containment batch already did. The side sheet demo takes
align="stretch", which holds in both of <x-row stack-below>'s modes,
instead of a hook that undid align="start" once stacked. The menu
icon colours go through icon-class with two unlayered application-like
classes, which the sample's comment names, and the fields' and date
picker's widths are inline styles now that style reaches their roots.
The radio hint said "from sm"; it is medium.

showcase.css keeps only the frame and the foundation specimens; its
header describes the file instead of the batches that wrote it. The
tests reject a showcase hook or a class outside md-* and the named
application classes in any example, a showcase.css selector outside
its own hooks, and a hook no showcase view renders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 06:21:18 +02:00
Andreas Reinhold / reiniandClaude Opus 5 cd47aa4b0f Land a caller's style with its class on field and picker roots
Plan step 38 review: the showcase kept two unlayered width classes
because <x-input> and <x-datepicker> "don't forward style usefully".
They did not: the Phase F rule is that a caller's class and style land
on the component root, and the field family sent class to the field's
root but style to the inner control, where a width sizes nothing.
Input, password, textarea, select and file now pass style to the field
root with class; datepicker and timepicker put it on their root;
<x-group> dropped style entirely and <x-split-button> gave it to the
leading button; <x-search> rendered it twice, on the root and the
input. A textarea given a style also lost its autogrow rows, since
its own style attribute came second and the browser ignores it.
A dataset test renders each component with both and requires them on
the root, once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 06:14:51 +02:00
Andreas Reinhold / reiniandClaude Opus 5 3fb0a404db Write the showcase examples' layout tags as an application does
Plan step 38 review: nine sections' examples wrapped their components
in <x-livewire-material::stack>, ::row and ::grid (148 tags) beside an
unprefixed <x-button>, while bars, containment and the layout pages
wrote <x-stack>. The code sample is what an application copies, and
the example component rewrites only unprefixed tags to a configured
prefix, so the namespaced ones taught a second form and escaped it.
Every example now writes <x-stack>, <x-row> and <x-grid>; a test
rejects a namespaced tag inside an example heredoc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 06:11:08 +02:00
Andreas Reinhold / reiniandClaude Opus 5 5fb8fc6b8f Fail when the showcase search skips an example a page renders
Plan step 38 review: the index reads example titles out of each
section's source with a regex, which once skipped all six Progress
examples without a sound; the existing test only checked that indexed
anchors exist. A new test renders every section and canonical layout
page and requires the index's examples for it to equal the titled
examples the page draws, both ways, so a format the regex misses (or
an anchor it invents) fails by page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 06:10:35 +02:00
Andreas Reinhold / reiniandClaude Opus 5 79ca9a28b4 Stop drawing each section's title twice on its page
Plan step 38 review: the frame's structural rule that kept a section's
own <h2> for screen readers (Tailwind's [&>section>h2]:sr-only before,
a showcase.css rule during the rewrite) was removed as dead weight
once every section had a real heading, so each page drew its title
twice, the <h1> and an identical headline under it. The <h2>s are
md-visually-hidden again, the frame's comment says why, and the
section page test asserts it. The Layout section's description said
"window size classes"; M3's term is breakpoints.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 06:10:01 +02:00
Andreas Reinhold / reiniandClaude Opus 5 3027f85cef 404 a null byte or a directory on the showcase's asset route
Plan step 38 review: a %00 in the asset path reached realpath(), which
throws on a null byte, so the route answered 500 instead of 404; a
directory whose name ended in a served extension would have reached
response()->file(). Both 404 now. The tests add what the review
probed: encoded dot segments and slashes, backslashes, absolute paths,
a directory, a very long path and a symbolic link pointing out of a
served folder, all 404.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 06:08:39 +02:00
Andreas Reinhold / reiniandClaude Opus 5 2f649983fa Keep the application's stylesheets off the scaffold page too
Plan step 38 review: /material/shell still passed the whole configured
Vite list, so it loaded Tailwind and all.css a second time beside the
showcase bundle. Both frames now take ShowcaseAssetController::scripts():
every entry but a stylesheet, by the same extensions Laravel's Vite
treats as CSS (not only .css), and skip @vite() when nothing is left,
since a list of stylesheets alone would read a build manifest for
nothing. A test renders the overview, a section, a layout page and the
shell against a hot file. The frame's header said the rail collapses
from `lg`; it is `expanded` (840px). The config comment is shorter and
true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 06:07:44 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 7abcc7c5f5 Pin the carousel showcase's viewport and de-flake one assertScript
Found running the full browser suite for plan step 38's last batch,
unrelated to its content changes (confirmed by reverting every
touched file and reproducing both failures against the pre-batch
commit): CarouselTest never pinned a window size, so the multi-browse
row's keyline math only lined "scroll by 2 slots" up with the open
keyline at some widths — the host's own default (here, wider than the
carousel was ever exercised at) left a remainder large enough to fail
four assertions. carouselShowcase() now resizes to 1280x900, matching
the width other browser tests already treat as "large desktop".

One test still failed after that: it put a `.click()` inside the
script assertScript asserts, and assertScript retries its whole
expression on a false result (this file's own comment says so) — a
retry re-fires the click, drifting the carousel one item further each
time and never recovering, unlike every other test here, which clicks
through a separate call first. Split the click out the same way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 05:50:55 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 97d7d9b5c0 Prove the heredoc breakpoint scan on a fixture, not production debt
Plan step 38's last batch cleared the one real medium: this premise
test relied on (containment.blade.php's Lists example, now
<x-grid :columns="['compact' => 1, 'medium' => 2]">) — its own goal,
not a regression, but it leaves nothing left over to find. The test
now builds a real two-heredocs-deep fixture (an @php block nesting a
<<<'BLADE' example, exactly how a showcase section is shaped) and
reuses the first test's own pattern and helper against it, so the
scan is proven on demand instead of by whatever Tailwind happens to
still be lying around.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 05:37:10 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 d15312023b Move browser-test probes off Tailwind utility classes
Plan step 38 (last batch), plan step 42's target for tests/: every
Tailwind utility class in a tests/Browser/*.php probe's Blade string
or Livewire component render() (~60 class attributes across 14 files)
becomes an inline style built from --md-sys-color-*/--md-sys-shape-*/
--md-sys-measurement-* tokens or a literal px value for an arbitrary
demo size — PickingTest's clip box, every probe's <body
class="bg-surface">, the grid/stack/row wrapper divs, the carousel
item's coloured filler. No test's assertions, selectors or expected
text change; these are layout containers around the components under
test, not anything a test reads.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 05:34:20 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 067626c52e Pare down the showcase's unlayered sizing classes
Plan step 38 (last batch): only two of the previous batch's three
call sites actually need a plain unlayered class for their demo width
— <x-input> forwards `style` to the raw <input>, not the field wrapper
that is sized, and <x-datepicker> forwards neither, only `class`,
`wire:key` and `x-model` — so .showcase-w-sm and .showcase-w-xs stay.
<x-slider> does forward `style` to its root, so its vertical demo's
shared height is one now instead of the .showcase-slider-vertical
class. .showcase-w-narrow stays a class on purpose rather than an
inline style: fields.blade.php's "Narrower" input demonstrates an
application's own unlayered rule beating the package's layered
default, and an inline style would win regardless of layers, proving
nothing. The header explains all three.

StylesheetsTest gains showcase.css's own shape check — the same rule
every package stylesheet passes (header, layer statement, plain
imports, no Tailwind directive, breakpoints only at M3's four) — with
its three documented unlayered exceptions as the one allowed gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 05:34:06 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 42009f3b0c Stop loading Tailwind on the showcase page
Plan step 38 (last batch): every showcase view is now rewritten
without Tailwind, so the showcase's layout keeps only the JavaScript
entries of the application's configured Vite list (Livewire, Alpine)
before its own bundle link, instead of the whole array — its own CSS
already comes solely from ShowcaseAssetController's bundle
(Stylesheets::bundle() of all.css, showcase.css and the scheme), and
linking the application's build too meant the package's own CSS
(all.css) loaded twice on every showcase page (once through the
Workbench's package.css entry, once through the showcase's own
bundle), besides pulling in Tailwind's CSS for nothing. ErrorPage::
assets() keeps passing the whole configured list: the error pages have
no bundle of their own to fall back on, and step 40 is where their
fallback is redesigned. The config's own comment says so.

The frame's temporary visually-hidden <h2> rule goes too — it existed
only because the first batch could not reach into the still-Tailwind
sections/*.blade.php partials it @include's; every section now
carries its own real `class="md-type-headline-md"` heading, so the
structural rule that quietly hid all of them is dead weight.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 05:33:33 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 ab2fcdb0d3 Rewrite the showcase's Pages section without Tailwind
Plan step 38 (last batch): the section wraps in <x-stack as="section">
and the error-page button row in <x-row>, text on the md-* classes;
needs no showcase.css hook of its own. The error-page and mail links,
every route and every visible label stay as they were.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 05:33:04 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 a62800e8ff Rewrite the showcase's Navigation section without Tailwind
Plan step 38 (last batch): the navigation-bar and navigation-rail
demos' bounded frames become data-md-showcase-nav-demo (`bar`,
`bar-wide` inside its own data-md-showcase-nav-scroll wrapper, `rail`,
`rail-wide`), the "the page" filler is data-md-showcase-nav-page and
the content pane beside a rail data-md-showcase-nav-content; a group
of demos moves onto <x-row>/<x-stack> with inline max-width styles for
the two arbitrary demo widths (400px, 800px) Tailwind's arbitrary
values gave them. The "Open the scaffold" link's md-link/md-ink-primary
classes match bars.blade.php's.

Every #navigation id, aria-label and visible label the Chromium
NavigationTest (against its own probes and /material/shell) relies on
is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 05:32:58 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 7b7e7279f5 Rewrite the showcase's App bars and tabs section without Tailwind
Plan step 38 (last batch): the app-bar/toolbar row becomes <x-stack>/
<x-row>, each bordered demo box a data-md-showcase-bar-frame hook
(`visible` for the search bar, whose docked suggestions must not clip;
`rounded` for the large-screen docked toolbar, which only gains its
edge and padding from `expanded`, 840px), and the search bar's own
avatar reuses the existing data-md-showcase-avatar hook. The
scrollable-tabs demo's forced-narrow box is
data-md-showcase-tabs-narrow, and the "Open the scaffold" link uses
the shared md-link/md-ink-primary classes in place of the old
Tailwind-era link/text-primary utilities.

Every #bars id and visible label/tooltip the Chromium BarsTest
(against its own probes) and the search index rely on is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 05:32:31 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 295673554e Rewrite the showcase's Carousel section without Tailwind
Plan step 38 (last batch): every item's colour role pair moves from a
Tailwind bg-*/text-*/bg-linear-to-br utility string to a plain
`background-color`/`color`/`background: linear-gradient(...)` inline
style built from --md-sys-color-* tokens (the same "caller's own
style" mechanism as the foundation pages' colour swatch), and each
<x-shape> takes its px size as a prop instead of a size-* class.
data-md-showcase-carousel-swatch only sizes and centres the shape
inside the item.

Every #carousel id and data-md-carousel-item/multi-aspect selector the
Chromium CarouselTest reads is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 05:32:15 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 c53c94174d Rewrite the showcase's Containment section without Tailwind
Plan step 38 (last batch): every example moves onto the layout
components, the md-* text classes and tokens. Wrapper divs that only
sized or grouped a demo (grid/list widths, the divider-and-collapse
stack, the dragged-card scope) become <x-grid>/<x-stack>/<x-row> with
an inline style where a specific px width still matters; a video
thumbnail's Tailwind gradient utility becomes an inline
background/color pair since each list-item's own colours differ. The
standard side sheet demo keeps M3's row-then-column breakpoint through
<x-row stack-below="expanded">, with a data-md-showcase-side-sheet-demo
hook that restores the row's own stretch alignment once it collapses
to a column (an explicit align="start", needed for the row layout,
would otherwise leave it shrink-wrapped).

Every id, data-md-* selector and visible string the Chromium
ContainmentTest reads (#containment, its dialog/sheet/card queries) is
unchanged; no demo content removed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 05:32:02 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 9e661836d6 Rewrite the showcase's Data section without Tailwind
Plan step 38: text.css already has the three classes this section
needed (md-text-end, md-tabular, md-visually-hidden), so only the
table's own scroll wrapper and the pagination list move — the wrapper
to a data-md-showcase-table-scroll hook, the two paginators onto
<x-stack>.

Every #data id and data-md-list-row/aria-selected selector the
Chromium DataTest reads is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 04:57:09 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 8bef785f51 Rewrite the showcase's Sliders section without Tailwind
Plan step 38: single-column groups move onto <x-stack>, per-breakpoint
ones onto <x-grid>, and the "Vertical" row's shared height (M3's
second orientation needs one on the wrapper, which the label and hint
share) moves to a showcase-slider-vertical class — <x-slider> forwards
only class, style and wire:key to that wrapper, so a data attribute
would have been dropped silently.

Every #sliders id and data-bound span the Chromium SliderTest reads is
unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 04:56:21 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 58a5dca8d5 Rewrite the showcase's Time pickers section without Tailwind
Plan step 38: both examples move onto <x-grid> with <x-stack>
columns, x-data landing on the bound example's own stack.

TimepickerTest's Chromium selectors run against an isolated timeProbe
fixture, not this page, so nothing there is at risk.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 04:55:21 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 6f7dccc5de Rewrite the showcase's Date pickers section without Tailwind
Plan step 38: every two-column layout moves onto <x-grid> with
<x-stack> columns, x-data landing on the grid itself; the small demo
picker reuses the showcase-w-sm class the Text fields section already
defines.

DatepickerTest's Chromium selectors run against isolated probe
fixtures, not this page, so nothing there is at risk.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 04:54:04 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 0eb0343008 Rewrite the showcase's Text fields and selection section without Tailwind
Plan step 38: every two-column layout moves onto <x-grid> with
<x-stack> columns; the checkbox group's card, its legend and the two
search demos' bespoke geometry (an avatar chip, the compact
search-icon-button bar) move to data-md-showcase-* hooks. The two
small demo controls and the "Narrower" field's override move to
.showcase-w-sm/-xs/-narrow — plain, unlayered classes, since
<x-input> and <x-datepicker> forward only class and style to the
element that needs sizing, never a data attribute. "Narrower"'s hint
is reworded from "A max-w-* class" to "An unlayered rule from the
call site wins", since that is now the mechanism being shown.

FieldsTest's Chromium selectors run against an isolated fieldProbe
fixture, not this page, so nothing there is at risk; #fields and every
id (showcase-error, showcase-error-filled, showcase-audience,
showcase-radio-theme) is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 04:53:58 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 dfb5997474 Rewrite the showcase's Chips section without Tailwind
Plan step 38: the Alpine-scope wrapper divs become <x-row>/<x-stack>
carrying x-data directly, giving Filter and Input chips M3's
full-width stretch in place of w-full/space-y-6. The narrow demo that
forces its chip set to scroll moves to a data-md-showcase-chip-scroll
hook (384px, Tailwind's old max-w-sm).

Every id, value and derived aria-label the Chromium ChipsTest
selectors read (#chips, the filter checkboxes' values, "Remove
<email>") is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 04:50:29 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 e1cdc52b48 Rewrite the showcase's Progress section without Tailwind
Plan step 38: the "bind" example's wrapper div becomes <x-stack>/
<x-row>, and the one enlarged circular indicator moves to a
data-md-showcase-progress-lg hook. Along the way, $examples changes
from a ['Title' => [code, stack]] tuple back to the plain
'Title' => <<<'BLADE' every other section uses, with the row
exceptions listed separately: the tuple's leading `[` broke
Sections::index()'s regex, so every one of this section's examples
was silently missing from the showcase search index. Fixing the shape
recovers them — the Feature ShowcaseTest's search-index assertion
count moves from 259 to 266.

Every aria-label the Chromium ProgressTest reads off #progress
(Linear, Circular, Circular wavy, Bound, the indeterminate variants)
is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 04:49:30 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 c87fc8ca41 Rewrite the showcase's Communication section without Tailwind
Plan step 38: the badge/icon wrapper spans move to
style="position: relative; display: inline-flex", the exact pattern
<x-badge>'s own header documents; Alerts, Stats and the two empty
states move onto <x-stack>/<x-grid> in place of w-full and a
Tailwind grid, so a caller no longer sizes each child by hand. The
"plain" badge (no colour of its own, by design) and the illustration's
two decorative fills move to data-md-showcase-* hooks, as does the
tooltip demo span's focus-ring corner; the shortcut kbds now use the
data-md-showcase-kbd hook the frame batch already drew.

No demo content removed; #communication, the toast/snackbar hooks and
the rich-tooltip triggers (Press for details, About expiry) the
Chromium CommunicationTest selectors read are unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 04:47:59 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 4d1481c832 Rewrite the showcase's Menus section without Tailwind
Plan step 38: text moves onto the md-* classes; the "Icons in their
own colour" example's two roles outside the fixed md-ink-* set
(tertiary, secondary) move to a data-md-showcase-icon-color hook in
showcase.css targeting the item's [data-md-icon] child, since
icon-class only reaches roles the fixed set already names. Swimming's
info icon keeps icon-class, now icon-class="md-ink-info".

Every id, aria-label and role selector the Chromium ActionsTest
selectors read against /material/menus (#menus, the More/Sort/Share/
Assign to/Photo triggers, the submenu and sheet-at-compact behaviour)
is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 04:46:00 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 9aab3fbe67 Rewrite the showcase's Buttons section without Tailwind
Plan step 38: every example moves onto the layout components and the
md-* text classes. Wrapper divs that existed only to give an Alpine
scope to a button group or a connected <x-group> ("A connected group
that owns its selection", "Square groups", "A choice as a connected
group") become <x-stack>/<x-grid> carrying the x-data directly, which
also gives them M3's full-width stretch in place of w-full. The
loading indicator's two sized, tertiary-coloured variants and the FAB
menu's bounded demo box move to data-md-showcase-* hooks in
showcase.css; hint-class="text-warning" becomes hint-class="md-ink-warning",
the exact example the <x-group> header itself documents.

No demo content removed; every id, aria-label and input[name] the
Chromium ActionsTest selectors read (#buttons, showcase-theme,
showcase-days, the "Tonal" tooltip, the progressbar, collapse-on-scroll,
data-md-selection) is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 04:45:38 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 6f0e430804 Recognise a semicolon-terminated example heredoc as documented usage
InternalComponentsTest strips a showcase example's <<<'BLADE' heredoc
before checking that a package view only ever refers to its own
components through the livewire-material:: namespace, since an
example heredoc documents an application's own (unprefixed) usage.
The check only recognised an array's BLADE, terminator; the Layout
section's three dedicated pages (plan step 38) assign their one
example to $code with BLADE; instead, which the check now strips too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 04:24:24 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 487176d345 Give each canonical layout a page of its own in the Layout section
Plan step 38: the Layout section's Overview keeps its breakpoint
table and live "This window" readout (now a shared
<x-showcase::breakpoint-readout> partial, and the five breakpoint
cards a real range-queried data-md-showcase-breakpoint-card instead
of Tailwind's max-medium:/medium:max-expanded: variants), but the
three canonical layouts move off it onto a page of their own:
/material/layout/{list-detail,supporting-pane,feed}
(ShowcaseController::layout(), routes/showcase.php), each built on
the real component, sharing one <x-section-nav> Sections::
layoutPages() feeds. The search index gains an entry per page with
its own URL, and the component-homes scan now also reads the three
pages' own source so <x-list-detail>, <x-supporting-pane> and
<x-feed> keep a home.

The whole section moves onto the layout components and md-* classes:
no bespoke Tailwind grid or table styling remains.

LayoutTest's browser test now visits the three dedicated pages
instead of one shared /material/layout; a new Feature test asserts
each page renders and is linked from the Layout section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 04:22:38 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 44a29a8ea2 Rewrite the showcase's Icons section without Tailwind
Plan step 38: the filter row is now real components — <x-input
icon="search"> and two <x-checkbox>s — instead of a bespoke bordered
label and accent-coloured native inputs; the result grid is
<x-grid min-item="120px"> of <x-surface as="li">/<x-stack> tiles.
The fetched symbol's mask stays the view's own x-bind:style (its
shorthand depends on the fetched name), but the size it toggles
between 24 and 20px is now the data-md-showcase-icon-size attribute
a script sets, never a class, drawn by data-md-showcase-icon-mask in
showcase.css.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 04:18:37 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 3669415d57 Rewrite the showcase's Motion section without Tailwind
Plan step 38: the two demo buttons are now real <x-button>s (filled
for Move, text for Change the figure) instead of a hand-rolled state-
layer/focus-ring button; each spring's row and its travelling dot
move onto <x-surface>/<x-stack> plus data-md-showcase-motion-row/
-track/-dot in showcase.css — only the dot's size and colour are
fixed there, since the transition under test stays the spring token
in the view's own inline style.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 04:17:45 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 a4b368386d Rewrite the showcase's Elevation section without Tailwind
Plan step 38: the shadow-level grid moves onto <x-surface> and
<x-grid>; each tile is a data-md-showcase-elevation-tile keyed on
data-md-elevation, drawing var(--md-sys-elevation-1..5) — the same
tokens the label now names instead of the retired shadow-elevation-*
utility.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 04:17:02 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 3cc0025b33 Rewrite the showcase's Shape section without Tailwind
Plan step 38: the corner grid and the shape gallery move onto
<x-grid> and <x-stack>; each corner swatch is a data-md-showcase-
shape-swatch keyed on data-md-corner, one showcase.css rule per
corner token; <x-shape :size="80"> replaces the size-20 utility, its
fill the caller's style="color: var(--md-sys-color-secondary-
container)" since a shape's colour is inherited text colour, not a
class.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 04:16:49 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 9ad9d3ec13 Rewrite the showcase's Type section without Tailwind
Plan step 38: the typescale listing moves onto <x-surface> for its
container and a bespoke data-md-showcase-type-row (a fixed label
column beside two flexible samples from 840px, one column below it,
a rule between rows) since no layout component draws an asymmetric
grid track; the sample text itself is styled by the very md-type-*
class names the row's own label prints, so the code sample and the
rendered specimen are the same string.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 04:16:33 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 365df791c1 Rewrite the showcase's Colour section without Tailwind
Plan step 38: swatches move from Tailwind's bg-* role utilities to
data-md-showcase-swatch spans whose fill is the caller's own inline
style="background: var(--md-sys-color-{role})" (a swatch's colour
depends on the role it shows, so that one declaration cannot be a
class), sized and edged in showcase.css; the two grids and every
per-theme panel move onto <x-grid>, <x-surface> and <x-stack>; text
onto the md-type-*/md-ink-* classes. The legacy "Ink and lines" group
(bg-body, bg-meta, bg-quiet, bg-structure, bg-chrome, bg-divider) is
dropped — those Tailwind ink/line names end with Phase F and their
roles already show under Surface.

ThemeTest and ColourProfilesTest read the swatch's computed colour
through the new data-md-showcase-swatch="primary" hook instead of the
retired .bg-primary class.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 04:14:22 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 9c8b39600b Rewrite the showcase frame without Tailwind
Plan step 38 (first batch): layout.blade.php, index.blade.php,
section.blade.php, shell.blade.php and the example component now
lay out through the layout components (pane, stack, row, grid,
surface) and their props, text through the md-* classes, and
everything else through showcase.css's data-md-showcase-* hooks — no
Tailwind utility remains in any of them. The head links the new
stylesheet route after @vite(...), not before: that entry opens the
document's first @layer statement, and linking the showcase's own
bundle ahead of it re-anchors the material layer before Tailwind's
own and lets its preflight's `padding: 0` beat the search view's
padding-top (found by a failing browser test). @vite(...) stays, its
Tailwind CSS entry included, only for the section views (still
Tailwind until the next batches) and the JS runtime; the config
comment says so.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 03:55:25 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 62b09c45b5 Add the showcase's own chrome stylesheet
Plan step 38: resources/css/showcase.css, in material.components
beside components/error-page.css for the same reason (a whole page's
chrome, not a reusable component, still outranked by an application's
unlayered CSS by design). Keyed on data-md-showcase-* hooks; batch 1
covers only what the frame (layout, index, section, shell, the example
component) needs beyond the layout components and the text classes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 03:55:11 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 1c8a4d412a Serve the showcase's own stylesheet without the application's build
Plan step 38 (first batch): a route bundles all.css, showcase.css and
the application's generated scheme (Stylesheets::bundle(), found next
to its configured JSON by swapping the extension, or a Scheme::load()
fallback in tokens/scheme.css's own shape) into one long-cached,
content-hashed CSS response; a stale hash redirects to the current
one. A second route serves the fonts and SVGs its relative url()s
point at, from the package's fonts/ and svg/ folders only, MIME-typed
by extension and 404ing on ".." or an unlisted extension. Both stay
unregistered with the showcase off.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 03:55:05 +02:00
Andreas Reinhold / reiniandClaude Opus 5 bce55b26a2 Require range syntax for every stylesheet's media queries
Plan step 37 review. Folding the layout-only checks into the all.css-wide
ones dropped the layout test's requirement that a width query be written
as a range (`width >= 840px`), leaving only its unit and breakpoint
checked. The all.css-wide media query test now rejects min-/max- width,
height and aspect-ratio features in every stylesheet, which the package
already never writes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 03:15:24 +02:00
Andreas Reinhold / reiniandClaude Opus 5 3365730610 Import the components in the documented installation
Plan step 37 review. Removing material.css left README.md, the
development skill, UPGRADE.md and the tailwind.css and foundation.css
headers telling an application to import foundation.css, tailwindcss and
tailwind.css in its place. material.css was also what brought layout.css
and components.css, so that install rendered every component unstyled,
and without a layer statement Tailwind's preflight would have outranked
the package's layers.

The skill's snippet is now the Tailwind-free one 2.0.0 ends with: the
foundation, then the stylesheet of each component the views render, or
all.css. README.md, whose layout and prose still assume Tailwind, shows
all.css in its own entry and Tailwind in a second, both opening with the
layer statement, the shape the Workbench now builds; the skill and
UPGRADE.md describe that interim in a sentence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 03:15:05 +02:00
Andreas Reinhold / reiniandClaude Opus 5 5e7c8b2b58 Pin Vite's deduplication for an application-shaped entry
Plan step 37 review. The Vite test counted each of four files' plain
root rule, which the minifier folds into one even when a build repeats
the whole stylesheet: it passed against the Workbench's Tailwind entry,
which repeated 231 rules. It now asserts that no innermost rule repeats
under the same at-rules, which that build fails and a deduplicating one
passes, and it builds a second entry shaped like an application's —
outside the package, the foundation, then four component stylesheets
that each import button.css, and button.css again — where button.css
must also keep its first position. The fixture config refuses to run
without DEDUP_OUT_DIR, so it never writes into the package tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 03:12:54 +02:00
Andreas Reinhold / reiniandClaude Opus 5 3854f4321c Build the Workbench's package CSS apart from Tailwind
Plan step 37 review. @tailwindcss/vite inlines the imports of an entry
that uses Tailwind without Vite's skipDuplicates, so with all.css beside
@import 'tailwindcss' every shared component stylesheet repeated later in
the cascade (231 repeated rules; button.css's hover and disabled rules
thirteen times), and every browser test ran against an order no
application gets. workbench/resources/css/package.css now holds all.css
and the scheme in an entry Tailwind never touches, and app.css keeps
Tailwind for the showcase's classes. The built package CSS repeats no
rule, and the Workbench's CSS shrinks from 559 to 406 KB.

Both entries open with the same layer statement, properties first,
because Tailwind hoists that layer to the top of its output; the order
is the one the single entry had, whichever the page links first. The
showcase's Vite config, vite.config.js and the error page test's probe
manifest name the new entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 03:12:44 +02:00
Andreas Reinhold / reiniandClaude Opus 5 eda16ba727 Make the rail header FAB's collapsed shape win by specificity
Plan step 37 review. The collapsed rail FAB's gap and minimum width tied
with fab.css's extended-size and collapse-on-scroll rules at three
attributes and won only by coming after them. A deduplicating bundle
keeps that order (navigation-rail.css imports fab.css), but a build that
repeats a shared stylesheet need not: all.css imports fab.css again after
the scaffold has reached the rail, and the Workbench's Tailwind entry
inlines every occurrence. Doubling [data-md-extended] settles it by
specificity, as the flat shadow already was, so the order no longer
matters.

A scan of every component and layout stylesheet pair for same-layer,
same-property ties on a shared hook found no other override that relies
on order: the remaining ties are on mutually exclusive elements or sit
behind an import edge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 03:08:39 +02:00
Andreas Reinhold / reiniandClaude Opus 5 3beeb2fc22 Reject imports Stylesheets::bundle() cannot inline faithfully
Plan step 37 review. bundle() left an @import it did not recognise
exactly as written, in the middle of the output, where a browser ignores
it: a layer(), supports() or media condition was silently dropped, an
unquoted url(./x.css) import was left in place with its path mangled by
the url() rewrite, and a bare specifier or absolute URL vanished the same
way. An @import after a rule or inside a @layer block was inlined anyway,
nesting a whole file's layers inside another. Each now throws, naming the
file; unquoted url() imports and @IMPORT inline like the other forms.

The cache keyed only the top-level files' mtimes, so a changed
button.css left a cached all.css bundle standing in a long-lived worker;
a cached bundle is now served only while every file it inlined keeps its
mtime. A null and an empty $base no longer share a cache key, a leading
@charset or byte-order mark is dropped from each inlined file, and a
test pins the import graph free of cycles, the one case where a
depth-first bundle would place a stylesheet before a file it imports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 03:07:26 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 792ec44d5e Add Stylesheets::bundle(), a build-free CSS import resolver
Plan step 37: the showcase and the error page's fallback serve CSS
without the application's Vite build, so nothing deduplicates their
@imports for them the way Vite's bundled postcss-import does — a
browser's native @import fetches every occurrence, it does not skip a
file it already loaded. Stylesheets::bundle(array $files, ?string
$base = null): string does in PHP what that build step does: it
inlines every @import depth-first, each file once, first occurrence
kept; leaves a bare specifier or an absolute URL untouched; rewrites a
relative url() against $base (or, without one, against the directory
of $files[0]); breaks a cycle instead of looping; throws naming the
importer when an import is missing; and caches per resolved file list
and mtime, with resetCache() for tests.

tests/Feature/StylesheetsBundleTest.php covers bundle()'s own
behaviour (dedup, url() rewriting, cycles, the missing-import
exception, the cache) and, at the end, pins the Vite deduplication
bundle() is modelled on against a real build — of all.css alone
(tests/Fixtures/dedup.vite.config.mjs), whose docblock explains why:
the Workbench's own entry still shares one file with @tailwindcss/vite
until plan step 39 removes it, and that plugin bundles its whole
reachable module graph itself, without the same dedup guarantee.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 02:49:59 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 a967cfcc2b Remove material.css, the 1.x single-import shortcut
Plan step 37: an application on 1.x-style imports now imports
foundation.css before @import 'tailwindcss' and tailwind.css after it,
the way foundation.css's own header has described since Phase F
started — material.css only ever saved writing those two lines.
Updates every place that named it: tailwind.css's and
foundation/hidden.css's and tokens/utilities.css's header comments,
the development skill's install snippet and Tokens section, README.md's
install snippet, and a new line in UPGRADE.md's migration notes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 02:49:43 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 925d9a4439 Fold layout.css and components.css into all.css
Plan step 37: all.css replaces the two interim import lists with one
entry for an application that wants everything — the foundation, every
layout stylesheet and every component stylesheet, grouped under the
same block comments components.css used, plus a Layout block. It also
directly imports the three files nothing imported by name before
(layout/spacing.css, layout/visibility.css, components/selection.css),
so every file under components/ and layout/ is now one @import away.

Every test that read a block of components.css or layout.css now reads
the matching block of all.css through one shared helper (allCssBlock(),
in tests/Pest.php so it loads for any test run) instead of repeating
the same substr() search in each file. StylesheetsTest.php's shape,
Tailwind-free and breakpoint checks, previously run twice (once for
the foundation, once for the layout tree), now run once over the whole
tree all.css reaches, since every component and layout stylesheet is
plain CSS after step 36; a new test asserts all.css imports everything
under components/ and layout/ exactly once. The Workbench imports
all.css in place of layout.css and components.css.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 02:49:29 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 04271bf227 Fix ActionsTest's colour-class probe to use a real class
hint-class="text-error" and icon-class="text-error" named a Tailwind
utility Tailwind never generates: its content scanner does not read
tests/Browser/*.php, so a class used only in a route fixture there
never makes it into the built CSS, and the assertion compared two
unstyled defaults that happen to differ rather than two error-coloured
elements. md-ink-error is a real, always-present class (text.css) for
exactly this "an application's own colour" case group.blade.php's own
comment already recommends (plan step 36, closing the full run).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 02:01:24 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 3f41bffbdc Rename <x-choices>'s data-active hook to data-md-active
Review leftover (plan step 36): the searchable list's highlighted-row
hook was still the plain pre-Phase-F name, in both the view's binding
and its scroll-into-view lookup and the shared rule in menu.css that
paints it. No test named it directly, so none needed a change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 01:57:39 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 f53bee9ef3 Check icon.css, shape.css and selection.css like every other stylesheet
Review leftover (plan step 36): none of the three foundation/shared
stylesheets was in a group dataset, so their shape, token and
breakpoint rules went unchecked. icon and shape have views of their
own and sit in components.css's first block, so they join
ActionStylesheetsTest's dataset outright, with that file's block check
narrowed to look for them there instead of the actions block.
selection.css has no view (checkbox, radio and toggle each import it)
and components.css never imports it directly, so it follows
navigation-item.css's pattern in InputStylesheetsTest: a second,
stylesheet-only dataset alongside the view-based one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 01:54:55 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 66847d5104 Cover a disabled icon button's colour inside a toolbar
The reviewer asked this run to look closely at it; no test exercised
it. toolbar.css's primary recolour already excludes :disabled and
[aria-disabled] so button.css's own disabled colour wins there — this
pins that a disabled button in a standard toolbar resolves to the
disabled formula, not primary, while an enabled sibling resolves to
primary (plan step 36, navigation group).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 01:52:09 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 f86f3270cd Cover the rail header FAB's collapsed-to-extended morph
The reviewer asked this run to look closely at it; no test exercised
it. Checks the label span's clipped opacity/max-width and the FAB's
square shape while the rail is collapsed, then its read label and
wider-than-tall shape once the rail's menu button expands it, waiting
past the shared spatial-default spring both morph on (plan step 36,
navigation group).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 01:52:09 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 bc6a4d68c6 Wait past LayoutTest's FAB spring before measuring, fix its safe-area math
Three FAB position tests chained ->assertScript() off a bare
$page->script(), which returns the script's value, not the page.
Fixing that surfaced a real settling issue: layoutPage() opens wider
than the 599/600px these tests probe before resize() narrows it, so
the FAB's inset-block-end (a spatial spring, scaffold.css) is still
animating in from the wider default margin when the page first
reports ready. Any baseline captured then is mid-flight, not the
resting value, so both tests now wait past the spring before taking
one. The safe-area test's inline-end (right) assertion also assumed
its margin and the safe area add together; the CSS is
max(margin, safe-right), an alternative to the margin, not additive
with it, unlike the bottom edge (plan step 36, navigation group).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 01:48:26 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 98f861c124 Fix BarsTest's overflow selector and reopen timing, add reviewer coverage
The overflow menu's trigger selector matched its own popover's menu
items too (they sit inside the same wrapper), so a click resolved to
5 elements instead of 1 — scoped to [data-md-menu-trigger]. Escape's
close is the browser's own light dismiss (menu.js), so a click right
after it needs to be past the 250ms reopen guard, as the same pattern
already is elsewhere (ActionsTest.php); the reopen test now waits.

Also adds the reviewer's requested coverage: the section nav's list
items share the row's width equally regardless of label length, and a
plain wire:model tab switches its panel locally without reaching the
server per click while wire:model.live does, one request that also
flushes the deferred property along with it (plan step 36).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 01:37:12 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 bd2bb07106 Close a menu whose trigger a breakpoint hides instead of leaving it open
An app bar's overflow menu is two separate <x-menu> instances trading
places as the width crosses 600px, each display: none where the other
shows. Resizing away from a menu's trigger left its popover in an open
state forever, anchored to a wrapper nobody could see or reach anymore.
A ResizeObserver on the trigger now closes the menu once it has no box
left to render (plan step 36, navigation group).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 01:37:03 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 c214745132 Fix a chained assertScript() and add adaptive hide-when-collapsed coverage
navigationExtrasProbe's pinned-snackbar test chained ->assertScript() off
$page->script(), which returns the script's value (null here), not the
page. Also adds the reviewer's requested browser coverage for an adaptive
rail with hide-when-collapsed across 839/840/1199/1200px: collapsed (not
away) at medium, away at expanded with data-rail-auto, standing again by
default from large (plan step 36).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 01:20:53 +02:00
Andreas Reinhold / reiniandClaude Opus 5 26c338be25 Say how the rail's FAB overrides win, and that rail-collapsed: is gone
Plan step 36, navigation review. navigation-rail.css claimed each FAB
override beats fab.css by specificity alone; the collapsed FAB's gap
and minimum width tie with fab.css's extended-size rules and win by
order, which the file's own import of fab.css guarantees. The rail
view pointed at one file for the collapsed conditions that four files
write out. UPGRADE.md now says applications lose the `rail-collapsed:`
variant the development skill used to teach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 01:14:42 +02:00
Andreas Reinhold / reiniandClaude Opus 5 27028bfb7a Import the button stylesheet from list-detail.css for its back button
Plan step 36, navigation review sweep. <x-list-detail> renders an
<x-button> as the single-pane back action, but only pane.css picked up
button.css when the carry-over landed; an application importing
layout/list-detail.css alone got an unstyled back button. A new check
reads every layout view's component tags against its stylesheet's
imports, as the group stylesheet tests do for components.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 01:14:07 +02:00
Andreas Reinhold / reiniandClaude Opus 5 cbf6c36191 Match only the scaffold's own main, and correct its header
Plan step 36, navigation review. `[data-md-scaffold] main` carried only
step 35's `--md-layout-margin`; the rewrite added the region's flex,
padding and overflow clip to it, which would also land on a <main> an
application nests in the page. It is `[data-md-scaffold-content] > main`
now. The header said panes read `--material-margin` (only the region
does), that the bar wrapper carries `--material-bottom-bar` (the root
does), and that the old utility and the hidden-bar override both sit in
material.components (the scaffold's is material.layout).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 01:13:29 +02:00
Andreas Reinhold / reiniandClaude Opus 5 f31acf82d7 Pin every written-out copy of the rail's collapsed conditions
Plan step 36, navigation review. The Tailwind-era rail-collapsed:
variant is now plain selectors in 26 rules, each repeated in five
window bands, across navigation-rail.css, navigation-rail-item.css,
navigation-rail-section.css and layout/scaffold.css; the tests pinned
a few of them. A new test walks every rule whose :where() names the
rail and fails when a copy drifts from the seven conditions for its
band, or when a rule is drawn collapsed in some bands but not all five.
ComponentStylesheet gains rules() for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 01:12:27 +02:00
Andreas Reinhold / reiniandClaude Opus 5 cbd058f948 Check navigation-item.css's shape like every navigation stylesheet
Plan step 36, navigation review. The shared indicator and state-layer
stylesheet has no view of its own, so it was in no group dataset, and
since 9c134b2d the layout stylesheet tests skip the component files
scaffold.css reaches it through. Its layer, Tailwind, token, scoping
and components.css checks now run with the navigation components'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 01:11:12 +02:00
Andreas Reinhold / reiniandClaude Opus 5 52beb676bc Leave a disabled icon button's colours alone inside a toolbar
Plan step 36, navigation review. The toolbar's recolouring rules
(primary in a standard toolbar, on-primary-container and the pressed
fill in a vibrant one) outrank button.css's disabled rule by
specificity, so a disabled icon button in a toolbar drew in full
primary. They now skip `:disabled` and `aria-disabled`, and the header
no longer claims each override only meets a button's base selector.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 01:10:29 +02:00
Andreas Reinhold / reiniandClaude Opus 5 ed92e916b9 Lay out the section nav's list items as flex rows again
Plan step 36, navigation review. The rewrite kept `min-w-0` from the
view's `<li class="flex min-w-0">` but dropped `flex`, so tabs.css's
`li > [data-md-tab] { flex: 1 1 0% }` no longer applied inside the
section nav. The header also called body-large "the tab bar's own"
type, which is title-small.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 01:09:55 +02:00
Andreas Reinhold / reiniandClaude Opus 5 348803484a Keep a deferred wire:model on the tabs deferred
Plan step 36, navigation review. The tabs rewrite appended `.live` to
@entangle, so every tab set bound with a plain wire:model sent a request
on each click. @entangle already adds `.live` for wire:model.live, which
the view's own header promises is the caller's choice. Pinned for both
spellings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 01:09:13 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 c3225d5353 Keep navigation-bar's own example in step with the scaffold's rewrite
Its header comment quoted <x-scaffold>'s old Tailwind wrapper class
list verbatim; the scaffold now draws that wrapper on data-md-scaffold-bar,
so the example follows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 00:53:31 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 bc284b2b33 Draw the scheme picker without Tailwind
Plan step 36 (navigation group, third batch, last component):
<x-scheme-picker>'s class lists move into resources/css/components/
scheme-picker.css, keyed on data-md-scheme-picker (its legend and
options grid, 2 columns below medium and 4 from it) and
data-md-scheme-picker-option, whose radio inside is read with :has()
for the chosen and the focus states — the input, not the label, is
the real control and carries the ring group.css's own segments keep
the same refinement for. `dark:bg-(--swatch-dark)` becomes
[data-theme='dark'] on the swatch (theme.css's own dark custom
variant reduces to a plain descendant selector here, since the
swatch never carries data-theme itself); the profile's name keeps its
label-large type as a text class on the view, since it is the
caller's own prose.

Hooks renamed: data-scheme-picker to data-md-scheme-picker,
data-scheme-option to data-md-scheme-picker-option, updated in
SchemePickerTest.php and tests/Browser/{ThemeTest,ColourProfilesTest}.php.

Browser tests added (docs/plans/material-3-browser-tests.md): the
contrast switch repaints at once and survives a wire:navigate, and
prefers-contrast: more picks high (Playwright's contrast context
option, the same shape as the existing reducedMotion tests).

This is the last navigation-group component off Tailwind. Every
package component is now drawn without Tailwind; tailwind.css keeps
only tokens/theme.css and tokens/utilities.css, which the showcase
still needs until step 38.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 00:52:12 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 74cbcd2f44 Draw the theme toggle without Tailwind
Plan step 36 (navigation group, third batch): <x-theme-toggle>'s icon
button (mode="toggle"|"cycle") moves into resources/css/components/
theme-toggle.css, keyed on data-md-theme-toggle="toggle"|"cycle" and
still carrying data-md-icon-button, the hook toolbar.css matches
generically so the toggle takes a toolbar's own icon colour when
placed in one (N-13). The button renders the shared md-state-layer/
md-touch-target/md-focus-ring classes (N-01's 48px target); its
corner morphs from full to sm while pressed on the fast spatial
spring, the same motion <x-button>'s own icon buttons take.

mode="picker"|"contrast" already drew a connected group over native
radios (N-04, N-17's fix), so only its wrapper's hook needed renaming.

Hook renamed: data-theme-toggle to data-md-theme-toggle, updated in
AppBarTest.php, which carries theme-toggle's render tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 00:45:09 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 f5c90c3874 Draw the account menu without Tailwind
Plan step 36 (navigation group, third batch): <x-account-menu>'s class
lists move into resources/css/components/account-menu.css, keyed on
data-md-account-menu (the avatar trigger), data-md-account-menu-avatar
and data-md-account-menu-header. The trigger already carried the
target-extension and state-layer fixes from an earlier pass (N-01,
N-15); this rewrite only moves them onto the shared md-state-layer/
md-touch-target/md-focus-ring classes and their z-index-ordering
comment into the stylesheet. The header's name and email keep their
type and ink as the fixed text classes on the view
(md-type-title-sm/md-ink, md-type-body-sm/md-ink-variant), since they
are the caller's own prose.

Hooks renamed: data-account-menu to data-md-account-menu,
data-account-theme to data-md-account-menu-theme, updated in
AppBarTest.php, which carries account-menu's render tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 00:42:28 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 f1cd6e4eb4 Draw the section navigation without Tailwind
Plan step 36 (navigation group, third batch): <x-section-nav>'s class
lists move into resources/css/components/section-nav.css, keyed on
data-md-section-nav (data-md-section-nav-picker, its own trigger and
label) and <x-tabs>'s own hooks, reused whole rather than copied
(N-16's fix keeps the real scrollable tab bar it landed): the bar's
outline-variant divider, active indicator and aria-current colouring
are tabs.css's rules, not this file's. The picker's trigger takes the
shared md-focus-ring class and the fixed md-type-body-lg/md-ink/
md-text-start text classes for its own body-large type, since it is
prose on a plain button rather than a component's own type; icons
take a size prop instead of a size class.

Hooks renamed: data-section-nav to data-md-section-nav,
data-section-picker to data-md-section-nav-picker, updated in
TabsTest.php (which carries section-nav's render tests) and
tests/Browser/BarsTest.php.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 00:40:39 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 ed93222d22 Draw the scaffold without Tailwind
Plan step 36 (navigation group, third batch): <x-scaffold>'s own
styling moves into resources/css/layout/scaffold.css (the content
region, the bar and rail row, the banner, the actions row and its
rail-collapsed column layout, --material-bottom-bar and
--material-margin publishing, the skip link) alongside step 35's FAB
and content-margin rules already there. Every data-app-shell* hook
becomes data-md-scaffold-* (data-app-shell-bar, -actions, -banner);
the skip link is data-md-skip-link; data-app-shell itself is dropped,
data-md-scaffold already named the root.

The actions row's rail-collapsed:flex-col is written out branch for
branch as the navigation rail's own rewrite did for its internal
parts: the three width-independent conditions in one :where() group,
the four width-gated ones each in their own @media block. With that
gone, resources/css/tailwind.css's rail-collapsed custom-variant
shim (its last use) is removed; tailwind.css now carries only
tokens/theme.css and tokens/utilities.css, which the showcase still
needs until step 38.

navigation-bar.css's hide-on-scroll rule reading --material-bottom-bar
stayed unlayered only because <x-scaffold> published that variable
through a Tailwind utility, which no layered rule could outrank; now
scaffold.css sets it itself in material.layout, a layer
navigation-bar.css's own material.components always beats, so the
rule moves into the layer and the file fits one
@layer material.components block like every other navigation
stylesheet. navigation-bar rejoins NavigationStylesheetsTest.php's
dataset and NavigationBarTest.php's own duplicate shape test is
retired in favour of it.

Browser tests added (docs/plans/material-3-browser-tests.md): the
scaffold's FAB dropping the bar's own height once hide-bar-on-scroll
slides it away, at the trailing edge in a right-to-left document, and
clearing a safe area an application sets on its inline-end and bottom
edges.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 00:37:22 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 20b062846d Keep rail-collapsed: alive in Tailwind for scaffold.blade.php's one use
Plan step 36 follow-up. Deleting navigation.css also deleted the only
definition of the rail-collapsed: custom variant, which
scaffold.blade.php's data-app-shell-actions row still uses
(rail-collapsed:flex-col) — scaffold's own rewrite is a later batch, so
it is still Tailwind. Left alone, that class would silently stop
matching anything. The definition moves into tailwind.css, updated to
the data-md-* hooks navigation-rail.css now renders (data-rail and
data-rail-auto are unchanged, so those branches needed nothing);
otherwise it reproduces the same seven branches navigation-rail.css's
own header documents, so the two cannot drift silently in what
"collapsed" means. It goes with the scaffold's own rewrite.

theme-script.blade.php's doc comment, which named the old variant, now
points at navigation-rail.css directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 00:12:01 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 cbd7d01c62 Sweep up navigation's last data-navigation-* references
Plan step 36 (navigation group, second batch, cleanup). Every leftover
data-navigation-*, data-tall, data-hide-on-scroll, data-hidden,
data-width, data-align, data-divider, data-fill and component-level
data-open reference outside the rewritten stylesheets and views moves
to data-md-*, in the files the two component commits left alone:
tests/Feature/Components/ScaffoldTest.php (the scaffold's own rewrite
is a later batch, but it renders the bar and the rail today and asserts
their hooks); tests/Browser/NavigationTest.php, ShowcaseTest.php and
ColourProfilesTest.php (selectors only — document-level attributes,
data-rail, data-rail-auto and data-app-shell*, keep their names).
NavigationTest.php also gets the browser tests the plan owed this
group (docs/plans/material-3-browser-tests.md § Navigation): a tall
bar's vertical layout at a width where the short bar would go
horizontal; hide-on-scroll never firing while a pinned snackbar is on
screen; a narrow rail's 80px width and centred destinations; a rail
that hides when collapsed leaving the layout and coming back only
through an application's own menu button.

resources/css/components/navigation.css and its import in tailwind.css
are deleted now that nothing imports the file any more (the previous
commit's message said this already happened there; it did not — this
is where it actually lands).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 00:09:44 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 25549374ec Draw the navigation rail without Tailwind
Plan step 36 (navigation group, second batch): <x-navigation-rail>'s,
<x-navigation-rail-item>'s and <x-navigation-rail-section>'s class
lists move into navigation-rail.css, navigation-rail-item.css and
navigation-rail-section.css, keyed on data-md-navigation-rail (the
mode, data-md-width, data-md-align, data-md-hide-when-collapsed,
data-md-divider, data-md-fill, data-md-open), data-md-navigation-rail-
item (data-md-active) and data-md-navigation-rail-section. Every
Tailwind wrapper class in the view — the menu row's centring padding,
the FAB row, the two swapped menu glyphs, the brand's visibility —
becomes a hook the stylesheet draws instead; the menu button itself
renders the shared md-state-layer/md-focus-ring/md-touch-target
classes (N-01's pattern) since it draws its own layer on itself, not a
child. `<x-icon>` and `<x-badge>` take size and floating props instead
of size/position classes; a small data-md-navigation-icon hook (the
shared navigation-item.css) replaces the ad hoc "relative inline-flex"
wrapper a floating badge anchors to.

`rail-collapsed` (a Tailwind @custom-variant, forbidden in Phase F) is
reproduced as plain selectors, branch for branch: the three width-
independent conditions (a fixed collapsed mode; a collapsible rail the
visitor collapsed and not open; a modal rail not open) merge into one
:where() group, provably the same match set as three separate rules
since :where(A, B, C) on an element is true exactly when :where(A) or
:where(B) or :where(C) is; the four width-gated conditions stay
separate media blocks, since CSS cannot merge different `@media`
queries. Every rem length becomes px, since these are dp-based M3
tokens, not a text measure (unlike <x-pane>'s rem widths). The FAB
overrides for a rail's header — elevation 0 (N-03), morphing into an
extended FAB instead of swapping two by display (N-23) — move from
unlayered into this file's own material.components, like toolbar.css's
FAB override: fab.css's `[data-md-fab]` is one attribute, so a doubled
selector here always outranks it without needing to sit outside the
layer.

navigation.js: the arriving-indicator stylesheet and every code comment
follow the new hooks; resources/css/components/navigation.css is
deleted (nothing imports it any more) and its line in tailwind.css with
it. Behaviour is unchanged except one thing Tailwind's `rail-collapsed:`
variant could do that plain CSS in this shape cannot: it is gone for
consuming applications too, since the definition lived only in the file
this commit removes. The development skill's guidance for it is
rewritten to point at navigation-rail.css's own selectors instead of
teaching a Tailwind variant that no longer exists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 00:09:06 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 63d421ccb6 Draw the navigation bar and bar item without Tailwind
Plan step 36 (navigation group, second batch): <x-navigation-bar>'s and
<x-navigation-bar-item>'s class lists move into
resources/css/components/navigation-bar.css and navigation-bar-item.css,
keyed on data-md-navigation-bar (data-md-tall, data-md-hide-on-scroll/
-hidden) and data-md-navigation-bar-item (data-md-active), both in
material.components. The indicator's growing fill and its state layer,
shared with the rail's item, move into a new navigation-item.css both
files import — the day's own layer in datepicker.css is the precedent
for keeping it out of the shared foundation classes: the element
focused and pressed is the whole item, the layer drawn only on the
smaller indicator or pill inside it, which md-state-layer cannot do and
:focus-visible never matches. The wash colour moves from a dead
on-surface base overridden by both items to a shared on-secondary-
container declaration, and its opacities from literal 0.08/0.1 to
state.css's own tokens — no visible change, since the numbers matched.

Every size is px (16dp Tailwind quirks aside, this file had none); the
bar's own item-count and container-query layout, N-08's label-medium
fix and N-19's on-secondary-container wash already matched the audit,
so only hooks, units and layer needed to change.

The scaffold's hide-on-scroll offset rule (`--material-bottom-bar`,
navigation-bar.css) stays unlayered: <x-scaffold> still publishes that
variable with a Tailwind utility until its own rewrite, and a rule in
any layer loses to it regardless of specificity — unlike the FAB
overrides the rail commit moves into material.components, which only
have to beat another material.components rule.

navigation.css is not deleted yet (still Tailwind's for the rail); its
"Navigation" comment in tailwind.css narrows as each stream leaves.
navigation-bar.css is not in NavigationStylesheetsTest.php's dataset:
its one unlayered rule breaks that test's "every block is
material.components" assumption, so the same checks are in
NavigationBarTest.php instead, the same reason ErrorPagesTest.php
carries error-page.css's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 00:08:34 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 1f9384efdf Draw the tabs without Tailwind
Plan step 36 (navigation group, third and last stream): <x-tabs>'s
and <x-tab>'s class lists move into resources/css/components/tabs.css,
one commit for both since they share it. Root data-md-tabs; the bar
data-md-tabs-bar with data-md-variant/-stacked/-scrollable, holding
data-md-tab buttons (data-md-tab-content, the new data-md-tab-label
wrapping the label and badge, data-md-tab-indicator); the panel
data-md-tab-panel. The server-rendered hidden panel (N-05), the 52dp
scrollable offset (N-10), the indicator's 2dp inset (N-18), the ring
moved 2px out (N-20) and aria-current sharing the active colour
(N-21) already matched the audit; only hooks, units and layer
changed. Sizes (48/64px tab height, 90px minimum width) stay px;
spacing goes through the measurement tokens where the value matches
one (the icon-label and label-badge gaps, the panel's top padding).
Imports icon.css and badge.css for what the view renders.

Hooks renamed: data-tabs-bar, data-tab, data-tab-content,
data-tab-indicator, data-scrollable, data-stacked and the bar's
data-variant to data-md-*, updated in tabs.js (dataset.tab ->
dataset.mdTab), section-nav.blade.php (which reuses the tab bar's
hooks under its own, still-Tailwind, markup),
tests/Feature/Components/TabsTest.php and tests/Browser/BarsTest.php.

Added the owed browser test: a tab panel never flashes before Alpine
boots, read from an inline script that runs while the page is still
parsing, well before Alpine's own script (docs/plans/material-3-browser-tests.md).

This closes plan step 36: every navigation-group component is now
data-md-* and Tailwind-free; tailwind.css's Navigation block keeps
only navigation.css (the bar and rail, out of this batch's scope).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 23:31:27 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 9c134b2dc7 Draw the toolbar without Tailwind
Plan step 36 (navigation group, second stream): <x-toolbar>'s class
lists move into resources/css/components/toolbar.css, keyed on
data-md-toolbar (data-md-variant/-vertical/-vibrant/-rounded/
-toolbar-place) and data-md-toolbar-group/-toolbar-fab. The docked
form with a FAB (N-02's --material-bottom-bar clearance), the vertical
24dp margin (N-12), the standard toolbar's primary icon buttons
(N-13), and the rounded large-screen form with dividers from 840px
were all already correct; only their hooks, units and layer needed to
change. The file no longer sits outside every layer: button.css and
fab.css now draw their own ink through data-md-* attributes rather
than a Tailwind utility, so a toolbar's more specific selector always
beats their single base color/background-color/box-shadow declaration
without needing to escape the cascade layers.

Sizes stay px (64px row, 40px divider); spacing goes through the
measurement tokens where one matches the value (4px leading/trailing
gap, 16/32px docked spread, 8px floating ends, 16/24px placed
margins).

Hooks renamed: every unprefixed data-toolbar* attribute to data-md-*,
updated in tests/Feature/Components/{AppBarTest,ToolbarTest}.php and
tests/Browser/BarsTest.php. toolbar.js needed no change: it reads the
bar through $root and role="toolbar", not by hook name.

Carry-over from the app-bar step: layout/pane.css now imports
app-bar.css and button.css for the pane's own top app bar and back
button, now that both are rewritten. That import crosses into
material.components for the first time from a layout file, so
tests/Feature/StylesheetsTest.php's two layout-shape checks now skip
non-`layout/` files reached through it — each already covered by its
own group's stylesheet test.

Added the owed browser tests (docs/plans/material-3-browser-tests.md):
a docked toolbar clear of the navigation bar below medium, and the
rounded form with its divider height from 840px, clear of the
window's edges.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 23:27:26 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 b7641a35c2 Draw the app bar without Tailwind
Plan step 36 (navigation group, first stream): <x-app-bar>'s class
lists move into resources/css/components/app-bar.css, keyed on
data-md-app-bar (with data-md-variant/-sticky/-subtitled/-scrolled/
-collapsed) and its parts data-md-app-bar-row/-leading/-headline/
-title/-subtitle/-search/-trailing/-action/-overflow/-expanded. The
overflow menu (two icon buttons below 600px, four from it, N-09's
312px search cap and N-11's three-column centred row) already matched
the audit; only its hooks, units and layer needed to change. Every
length is px, spacing through the measurement tokens where one
matches (M3's own "4dp leading/trailing space" and "16dp" headline
inset had no exact token, so those stay literal). Imports button.css,
menu.css and menu-item.css for the overflow menu the view renders.

Hooks renamed: every unprefixed data-app-bar-* attribute and
data-scrolled/data-collapsed to data-md-*, updated in
tests/Feature/Components/{AppBarTest,PaneTest}.php and
tests/Browser/{BarsTest,NavigationTest}.php. app-bar.js needed no
change: it reads the bar through $root, not by hook name.

tests/Feature/Components/NavigationStylesheetsTest.php is the
navigation twin of ContainmentStylesheetsTest.php, on the same
tests/Support/ViewClasses.php rule; its dataset starts at 'app-bar'
and a 'tabs' entry (once that commit lands) will read both
tabs.blade.php and tab.blade.php, since they share one stylesheet.

Added the owed browser tests (docs/plans/material-3-browser-tests.md):
the overflow at 599/600px, keyboard in its menu, a menu closing once
its width no longer shows it, and a wire:click action from both the
icon button and the menu item form.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 23:21:54 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 b7f120af98 Cover a selected table row keeping its fill under hover and focus
Plan step 36 browser-test run, reviewer checklist. Only a plain,
opening/closing sanity check existed for a selected row's background;
this proves the state layer mixes over the secondary-container fill
rather than in place of it, on the showcase's design-review.mp4 row —
the same guarantee list.css already has, from a real hover and focus
in the browser rather than only the Feature-level CSS declarations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 23:00:42 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 17a575c753 Fix and extend the containment browser tests
Plan step 36 browser-test run. Five test bugs: a nested-dialog test
matched the wrong dialog by substring (the outer's textContent also
contains the inner's), a bottom-sheet test and a side-sheet test passed
Playwright-only `:has-text()`/a JS expression into APIs expecting a
CSS selector or ran page JS with `:has-text()`, a scroll-divider test
never actually scrolled (its content fit without it), and a full-screen
dialog test narrowed the viewport before an unrelated open side sheet
correctly became modal below it, leaving nothing else clickable.

The reviewer's review-checklist items without a covering test get one:
a selected segmented row keeps its fill under a real hover and focus
(pressed already has a Feature-level check); the side sheet mirrors
into a right-to-left page; a basic dialog nested inside a full-screen
one keeps its own title and padding at 400px; a dragged showcase card
shows the 16% state layer; the side and bottom sheet scrims are proven
to fade rather than vanish, sampled every few ms in the page itself
rather than after a fixed PHP-side wait (unreliable next to a 200ms
transition once round trips are counted); and the reduced-motion
dialog test now also proves a real, non-zero transition exists without
reduced motion, not only that none does with it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 22:57:56 +02:00
Andreas Reinhold / reiniandClaude Opus 5 54d88ebc3b Correct three containment headers
Plan step 36 review. The list's header sent dividers to list.css, which
list-item.css draws since 9ff5a7a8; the carousel item's label overlay
shows the `label` prop, not the "n of m" name; the dialog's 560/280px
are its maximum and minimum width, in that order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 22:08:05 +02:00
Andreas Reinhold / reiniandClaude Opus 5 0326065348 Drop the old data-dragged hook from md-state-layer
Plan step 36 review. interaction.css read `data-dragged` beside
`data-md-dragged` while <x-card> still set the old name; the card now
sets the new one and nothing in the package renders the old one on an
element with the class, which 2.0.0 introduces. The Tailwind
`state-layer` utility keeps `data-dragged` until step 39 deletes it.
UPGRADE.md's card line named the old `data-card` hook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 22:07:33 +02:00
Andreas Reinhold / reiniandClaude Opus 5 a764fddf27 Style the body only on the error page
Plan step 36 review of a8c2d88f. error-page.css drew a bare `body`, which
components.css already bundles into every Workbench page and step 37's
all.css would put in every application page: each would take the error
page's background, margin and system font. The rule now matches
body:has(> [data-md-error-page]); the fallback stylesheet, inlined into
the error page alone, keeps its own. A test in the containment harness
and in ErrorPagesTest rejects an unscoped html, body, dialog or :root.

The body's system font stack is gone: the old `font-sans` was the brand
typeface (tokens/theme.css), which the page inherits from the
foundation's html rule, and the header's claim otherwise was wrong. The
shape's slow turn, which moved from the view's inline style into the
stylesheet, is back in the fallback so the no-build page still turns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 22:06:56 +02:00
Andreas Reinhold / reiniandClaude Opus 5 cab8d804a5 Reveal a cut-off carousel item under reduced motion
Plan step 36 review of a149970d. Reduced motion writes a zero inset for
every item, so isMasked() never called one partly shown and a press on
an item the row cut off did nothing. It now reads the item's box against
the scrollport there, as it already did for multi-aspect, and the
browser test the rewrite added to document the gap asserts the reveal
instead (plan step 32's owed test).

A focused item's inset ring is 3px in again: Tailwind's
-outline-offset-3 is 3px, not the 12px the rewrite wrote.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 22:05:40 +02:00
Andreas Reinhold / reiniandClaude Opus 5 0093671bf3 Fade a bottom sheet's scrim out, and keep a nested sheet's state
Plan step 36 review of 287340b0. The scrim had no closed opacity, so
allow-discrete held it at full strength for the length of the fade and
then dropped it, where x-transition.opacity had faded it out. The open
state now matches the root's own scrim and panel: a sheet nested in an
open one (a menu's sheet at compact inside a sheet) matched the outer
open rule, and closed in place after half a second instead of sliding.
The handle's ring stands 4px off its bar again, as the old handle's
outline-offset-4 drew it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 22:04:50 +02:00
Andreas Reinhold / reiniandClaude Opus 5 b6259d45f3 Hide a standard side sheet's scrim from 840px again
Plan step 36 review of 9dd527f3. The old view hid a standard sheet's
scrim with `expanded:hidden`; the stylesheet lost that rule (and the
render test lost its assertion), so an open standard sheet at 840px and
up laid a full-window scrim over the page it sits beside, one click from
closing. The rule is back, pinned by a test.

The same review: the scrim now fades out as well as in (with no closed
opacity, allow-discrete only held it at 32% for 200ms and then dropped
it); the modal sheet slides without the fade the old enter and leave
classes never had; it slides from its own edge in a right-to-left
document, which translate-x-full never did; the state rules match the
root's own sheet and scrim, so a sheet nested in an open one keeps its
state; the script's breakpoint is 840px, not 52.5rem, which disagreed
with the stylesheet's px query once the reader's text size grew; and
`width` and the standard sheet's cap are M3's 400px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 22:04:13 +02:00
Andreas Reinhold / reiniandClaude Opus 5 d86392fd47 Keep a nested dialog's head out of the full-screen phone rules
Plan step 36 review of 3c235ecc. The rules that hide a full-screen
dialog's head and title below 600px, zero its subtitle's margin and
shorten its action bar matched descendants of the full-screen dialog, so
a basic confirm opened from inside its body lost its title and its
padding on a phone. They now go through > [data-md-modal-box] >, as the
divider marks already did (old dialog.css: "the child combinators tie a
mark to its own dialog's rows"), and the icon and subtitle checks look
only at the head's own children.

The computed data-md-modal-divider flag stays. It is correct (the old
view's data-dialog-head placement, one for one), and though a :has()
chain on the box could say when the bar owns the rule, the flag keeps
bar, head and actions on one hook the view already has the facts for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 22:02:28 +02:00
Andreas Reinhold / reiniandClaude Opus 5 21f8ed826c Tie a collapse's padding and chevron to its own parts
Plan step 36 review of 76abdd62. The old view put the filled variant's
16px padding on its own summary and body; the stylesheet matched them as
descendants of any filled collapse, so a plain collapse nested inside
one took the padding too (and an open one turned every nested chevron).
Child combinators now tie each rule to its own <details>. The 12px gap is
a literal like alert.css's, not an average of two spacing tokens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 22:01:22 +02:00
Andreas Reinhold / reiniandClaude Opus 5 c298b19e75 Draw the dragged layer on a card that is not a row
Plan step 36 review of f04e31b3. The card rendered md-state-layer only
on a row, so a card the application marks data-md-dragged without being
a row, as the showcase's "A card being dragged" is, lost the 16% dragged
layer its old inset shadow drew and its header still promises. Every
card now renders the class, since the attribute arrives from script
after render, and card.css keeps the layer off a card that is neither a
row nor dragged. The divider under the header is matched as its child.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 22:00:49 +02:00
Andreas Reinhold / reiniandClaude Opus 5 7d3abdd506 Keep a list row's fill under its state layer
Plan step 36 review of 9ff5a7a8. The rewrite dropped the unlayered
selected rule into material.components beside the segmented fill and the
row tint, so on equal or greater weight a selected item in a segmented
list drew surface instead of secondary-container, and hovering, pressing
or focusing any selected row (or a table's selected row) swapped its
fill for a translucent tint. Each fill now names itself
--md-list-row-fill, and the row's layer is its ink mixed over that fill
at the state tokens, which is also C-08's second suggested fix.

A disabled item inks the whole row at the disabled-content token, as the
old root colour did, so the check, the leading slot and the end slot dim
with the text; the three-line padding is a literal 12px like alert.css;
list-rows.js named list.css.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 22:00:03 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 a8c2d88fbb Rewrite the error pages without Tailwind
Plan step 36 (containment group, error pages): errors::minimal's class
lists move into resources/css/components/error-page.css, keyed on
data-md-error-page/-art/-shape/-code/-headline/-message/-actions;
body carries no hook of its own and is styled by a bare `body`
selector, matching how ErrorPage::fallbackStyles() already styles it
(no other rewritten view is a full HTML document). The section
contract (title, code, headline, message, shape, actions) and the
framework's 401/402 compatibility are untouched, since neither depends
on a class or a hook. The shape's <x-shape> (a component outside this
batch, and one with no "fill the parent" prop) is coloured and sized
to its container by a plain `svg` descendant selector rather than a
class or a new prop. The page's font stays a literal system stack, not
var(--md-ref-typeface-brand): Tailwind's own `font-sans` utility this
replaces was never the brand font either
(resources/css/tokens/theme.css's --font-sans is the only utility
that is), and an error page must read before any webfont has loaded.

ErrorPage::fallbackStyles() draws onto the same hooks, renamed the
same way (data-error-* -> data-md-error-*, data-error-fallback ->
data-md-error-fallback) so the no-build path keeps working; how it is
built (a hand-written heredoc, not Stylesheets::bundle()) is
unchanged, per step 40. Updated in the same commit:
tests/Feature/ErrorPagesTest.php and tests/Browser/ErrorPagesTest.php.

The error pages' views live outside resources/views/components/, so
they are not in ContainmentStylesheetsTest's dataset (its "imports
what its view renders" check reads a fixed
resources/views/components/<name>.blade.php path); adapted
tests/Feature/ErrorPagesTest.php instead, with the same stylesheet-
shape, import, ViewClasses, token/px-breakpoint and containment-block
checks the dataset gives every other component.

Imported from the Containment block of components.css.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 21:44:55 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 a149970dd0 Rewrite the carousel without Tailwind
Plan step 36 (containment group): <x-carousel>'s and <x-carousel-item>'s
class lists move into resources/css/components/carousel.css and
carousel-item.css, keyed on data-md-carousel (its value is the layout)
and the parts' data-md-carousel-* hooks (-probe, -scroller, -controls
with "auto"/"always", -previous/-next, -item, -surface, -content,
-label/-label-text). Every selector uses a `>` combinator rather than a
bare descendant one, because a carousel item can itself hold a nested
carousel whose own root would otherwise match its parent's layout
rules too (list.css already solves the same problem for segmented
list rows).

Behaviour is unchanged: resources/js/carousel.js (the keyline maths,
C-05's reduced-motion fix, C-11's vertical full-screen layout, C-12's
padding, C-18's item-as-tab-stop, the multi-aspect layout) is touched
only where it reads or writes the renamed hooks and dataset
properties; every inline custom property it writes
(--material-carousel-*) is untouched. The item renders the shared
md-focus-ring class (foundation/interaction.css) instead of a
hand-rolled ring, refined to an inset offset since an outward one
would draw under the neighbouring item. The previous/next buttons
mirror whole in RTL from carousel.css rather than through
<x-icon mirror-rtl>, which <x-button icon> has no prop to reach (a
component outside this batch); the technique matches how the
Tailwind-era markup already mirrored the whole button. The overlay
label's literal white ink over the scrim (C-25) is kept, with the
same reasoning as before.

Hooks renamed data-material-carousel(-item/-surface/-content/-label)
-> data-md-carousel(-item/-surface/-content/-label), data-padding(-end)
-> data-md-padding(-end), data-centered -> data-md-centered, updated in
the same commit: resources/js/carousel.js, tests/Feature/Components/
CarouselTest.php (rewritten on data-md-* and ComponentStylesheet) and
tests/Browser/CarouselTest.php. Browser tests owed by
docs/plans/material-3-browser-tests.md, added but not run: the
multi-aspect carousel's previous/next, arrow keys, Home and End
(scoped by data-md-carousel="multi-aspect" rather than a position in
the showcase, so reordering its examples cannot silently mis-target
the wrong carousel); a reduced-motion click on an item cut off only by
the row's own edge, which documents rather than fixes a real gap —
isMasked()'s inset check is always false once C-05 zeroes every
item's inset, so the click-to-reveal affordance does not fire there
(found by the Chromium baseline, step 32; fixing it is outside a hook
rename).

Imported from the Containment block of components.css.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 21:44:38 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 287340b066 Rewrite the bottom sheet without Tailwind
Plan step 36 (containment group): <x-bottom-sheet>'s class lists move
into resources/css/components/bottom-sheet.css, keyed on
data-md-bottom-sheet (data-md-open, data-md-standard) and
data-md-bottom-sheet-scrim/-probe/-panel (data-md-preset while
heights/snap gives it stops)/-handle/-grip/-announce/-body/-title/
-actions. x-transition's enter/leave classes are replaced the same way
as drawer.css: @starting-style + transition-behavior: allow-discrete
on the x-show-driven panel, the asymmetric open (spatial-default,
emphasized-decelerate) vs close (effects-default, emphasized-
accelerate) timing coming from the more specific open-state selector's
own transition-duration/-timing-function, which a CSS transition
always takes from the after-change style. data-md-preset adds the
height-between-stops transition (the spatial spring) alongside it,
independent of the open/close slide.

The drag handle (C-01, already fixed in the Tailwind-era markup this
replaces) renders the shared md-focus-ring and md-touch-target classes
instead of a hand-rolled ring: unlike the datepicker's day, its own
visible bar is not a smaller indicator drawn inside a bigger box those
classes cannot reach, so nothing here refines them further. Its 22px
vertical padding (SheetDefaults.kt's DragHandleVerticalPadding) is a
literal, matching alert.css's 12px for the same reason: 22 is not on
the measurement scale.

Hooks renamed data-drag-handle -> data-md-bottom-sheet-handle,
updated in resources/js/bottom-sheet.js and
tests/Browser/ContainmentTest.php; data-sheet -> data-md-drawer in
that test's comment. <x-menu sheet-at-compact> renders this component
unchanged; menu.css now imports bottom-sheet.css, and
tests/Feature/Components/MenuTest.php's stale Tailwind-class assertion
for the embedded sheet is replaced with a hook check. The two
sheet-at-compact browser tests in ActionsTest.php stay on
assertVisible(): the sheet is still x-show-driven (a deliberate choice
for this batch, keeping the existing modal/side-sheet/bottom-sheet
architecture and converting only their styling), so open still shows
as visibility rather than :popover-open; their comments no longer say
"not rewritten in this batch".

Imported from the Containment block of components.css. Browser tests
owed by docs/plans/material-3-browser-tests.md, added to
ContainmentTest.php but not run: dialog divider marks on scroll
(including that a nested dialog keeps its own marks), the full-screen
phone bar, separator always, no fade under reduced motion, the
scrolling body's inset focus ring; bottom sheet preset-height cycling
with its announcement and a drag settling on the nearest stop; the
standard side sheet from 840px without a scrim or a focus trap, modal
below it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 21:16:22 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 9dd527f303 Rewrite the drawer (side sheet) without Tailwind, remove pane
Plan step 36 (containment group): <x-drawer>'s class lists move into
resources/css/components/drawer.css, keyed on data-md-drawer (with
data-md-open, data-md-standard) and data-md-drawer-scrim/-sheet
(data-md-side)/-head/-head-row/-heading/-title/-subtitle/-close/-body/
-actions. The modal sheet slides in on the emphasized-decelerate
easing and out on emphasized-accelerate (an asymmetric CSS transition
expressed by giving the open-state selector its own
transition-duration/-timing-function, which the "after-change style"
rule picks up); the sheet stays x-show-driven, with @starting-style +
transition-behavior: allow-discrete taking the place of x-transition's
enter/leave classes, matching resources/css/components/search.css's
already-rewritten scrim/view. A standard sheet drops the scrim and
trap from 840px and only fades in place (an in-flow box does not
travel); data-md-drawer-collapsed (from the view's `wide` Alpine
state) takes it out of the layout while closed there.

<x-drawer pane> is removed: step 35's <x-list-detail> is M3's form of
that job (the plan's decision, docs/plans/material-3-alignment.md
"Decisions"). Removed the prop, its CSS classes and its tests
(OverlayTest's two pane-only tests deleted, the others' pane cases
dropped); pointed each mention at <x-list-detail> in the material-3
guideline, the material-3-design and livewire-material-development
skills, and added a line to UPGRADE.md's 2.0.0 notes. No other living
file named `<x-drawer pane>` (grepped views, tests, README, boost,
workbench excluding build output); docs/plans and docs/audits keep
their history unchanged.

Imported from the Containment block of components.css, importing
button.css and divider.css for what the view renders.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 21:07:04 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 3c235ecc1e Rewrite the modal (dialog) without Tailwind
Plan step 36 (containment group): <x-modal>'s class lists move into
resources/css/components/modal.css, keyed on data-md-modal (with
data-md-fullscreen) and data-md-modal-box/-bar/-head/-title/-subtitle/
-body/-content/-actions. Sizes stay px (560/280/48px matching
DialogTokens.kt/FullScreenDialogTokens.kt and the 56px full-screen
header, C-17), spacing the measurement tokens, breakpoints a literal
600px range query.

The phone-only header bar and the pinned head/actions keep their
existing responsive behaviour (a subtitle or an icon keeps the head on
screen below 600px; only the bar's own divider-eligibility needs a
computed flag, since it alone can sit beside a head that is also on
screen) — reproduced with :has() against data-md-icon/
data-md-modal-subtitle wherever that is self-contained, and with an
explicit data-md-modal-divider attribute where it is not. The
scroll-driven dividers rename data-dialog-head/-actions ->
data-md-modal-head/-actions, data-overflow-top/-bottom ->
data-md-overflow-top/-bottom (resources/js/dialog.js); the
dialog-dividers Alpine directive keeps its name, since it isn't a
data-md-* hook.

resources/css/components/dialog.css (Tailwind-era) is replaced by
modal.css, imported from the Containment block of components.css;
its import leaves resources/css/tailwind.css. No other component
renders <x-modal> or dialog.css's rules (checked: datepicker,
timepicker and search draw their own dialog-shaped surfaces
independently).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 21:01:05 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 9ff5a7a809 Rewrite list and list-item without Tailwind
Plan step 36 (containment group, last of its four components): moves
into resources/css/components/list.css (the container: display, the
2px segmented gap) and list-item.css (everything else — anatomy,
states, the segmented item's own background/corner, and the row
interaction pattern list.css used to carry). One commit for both:
list-item.css keys heavily on list's own data-md-list="segmented"
attribute and the two files are designed together.

Kept, now on data-md-* hooks: selectable/selection listbox semantics
(role="listbox"/"option", aria-selected, a trailing check as the
second cue, C-03), a disabled item's link dropped entirely rather than
left focusable (C-02), the segmented row's own state layer no longer
racing its background through an unlayered escape hatch (C-08 doesn't
recur — nothing here is unlayered), leading video/icon/avatar slots,
three-line top alignment and 12px vertical padding (C-15), 16px
leading/trailing gap (C-14), dividers inset 16px (C-16), the segmented
list's surface fill (C-21) and its hover/focus/pressed/selected corner
morph (C-22). The segmented icon size (20px) is now the `size` prop
`<x-icon>` already takes from the parent's @aware(['segmented']),
replacing the CSS override C-26's fix suggested.

data-md-lines="1|2|3" replaces the old line-count classes; data-md-list-item-*
hooks replace every other class list.

Hooks renamed data-list -> data-md-list, data-list-item -> data-md-list-item,
data-dividers -> data-md-dividers, data-selected -> data-md-selected, the
generic data-list-row/-open row pattern's last consumer (list-item)
moved, so list-rows.js drops the transitional dual-hook fallback the
card commit added. table.css now @imports list-item.css, completing
what its own comment already claimed (a row answers a pointer as a
list row does) under the new per-component-import architecture.

Imported from the Containment block of components.css; leaves
tailwind.css, which now carries only dialog.css for this group.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 20:37:38 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 5cb11cd127 Make list-rows.js answer both the old and new row hooks
Missed in the card rewrite: <x-card> now renders data-md-list-row,
data-md-list-open and data-md-list-actionable, but list-rows.js still
queried only the unprefixed data-list-row/-open/-actionable, so a
card's row press and its actionable Enter/Space stopped working.
<x-list-item>, still unrewritten until the next commit, keeps
rendering the old names, so the script answers both for now (the same
transition interaction.css already makes for data-md-dragged), and the
fallback comes out once list-item moves too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 20:31:21 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 f04e31b3aa Rewrite the card without Tailwind
Plan step 36 (containment group): <x-card>'s class lists move into
resources/css/components/card.css, keyed on data-md-card (per variant),
data-md-card-figure/-body/-header/-heading/-title/-subtitle/-menu/
-content/-actions. A row (data-md-list-row) renders the shared
md-state-layer and md-focus-ring classes for its tint and ring
(foundation/interaction.css) instead of copying their rules; card.css
adds only the per-state elevation the shared class has no opinion on
(ElevatedCardTokens.kt/FilledCardTokens.kt/OutlinedCardTokens.kt), the
press/hover exclusion for a card's own nested buttons, and the ring for
a non-actionable row whose focus lands on its opener rather than the
card. data-md-dragged is read automatically by the shared class's own
16% tint; card.css only adds its elevation levels 3/4.

Hooks renamed data-list-row -> data-md-list-row, data-list-open ->
data-md-list-open, data-list-actionable -> data-md-list-actionable,
data-dragged -> data-md-dragged, updated in the same commit:
resources/js/search.js and list-rows.js's consumers, table.css's
comment, the showcase (index, data and containment sections),
tests/Browser/ContainmentTest.php, and the development skill's card
and table examples.

Imported from the Containment block of components.css.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 20:30:14 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 76abdd6242 Rewrite the collapse without Tailwind
Plan step 36 (containment group): <x-collapse>'s Tailwind-era
resources/css/components/collapse.css (the height animation, C-24)
becomes the plain stylesheet, keyed on data-md-collapse and
data-md-variant; the summary renders the shared md-state-layer and
md-focus-ring classes instead of copying their rules. Leaves
tailwind.css for the Containment block of components.css.

Icon-to-title and title-to-chevron gaps stay 12px (not an M3 number;
collapse isn't an M3 component), written as the average of the two
adjacent spacing steps since the scale has no exact token for it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 20:26:52 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 c887bcd054 Rewrite the divider without Tailwind
Plan step 36 (containment group): <x-divider> moves its class lists
into resources/css/components/divider.css, keyed on data-md-divider,
data-md-orientation, data-md-inset/middle and data-md-divider-heading/
-text for the subheader form (DividerTokens.kt; docs/reference/m3
§ Divider). Imported from the Containment block of components.css.

Hooks renamed: none of divider's own (it had no data-* hooks before),
but its rendered attribute order moved data-md-* ahead of role/aria-*,
which ToolbarTest.php's regex for a toolbar's vertical divider depended
on; fixed in the same commit. form.css picks up the @import './divider.css'
its TODO(step 36) marker was waiting on, since form.blade.php renders
<x-divider> and divider.css now carries the material.components layer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 20:25:31 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 72a4f477d5 Add the containment group's stylesheet test harness
tests/Feature/Components/ContainmentStylesheetsTest.php is the
containment twin of InputStylesheetsTest.php and ActionStylesheetsTest.php:
same shape, import and ViewClasses checks, keyed on the "Containment"
block of components.css. Its dataset starts empty and grows by one name
per component commit in this stream (plan step 36).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 20:25:18 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 b4c4542f5d Cover the timepicker's dial holding its size at a 20px root font size
Browser-test coverage the reviewer asked for on the step 36 batch: the
256px dial and the hour/minute boxes keep their pixel size at a 20px
root font size, while their (rem-sized) text grows and nothing spills
out of the dialog surface.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 20:04:40 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 0b7dd7d46a Cover the datepicker's shared classes, hover layer and 20px root font
Browser-test coverage the reviewer asked for on the step 36 batch: the
year, menu-button and list-option state layer/focus ring (with the
option's ring drawn inset in its row), a selected day's hover layer
over its primary fill versus none on a disabled or blank day, and the
40px day indicator holding its size at a 20px root font size while
only its (rem-sized) text grows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 20:04:40 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 c72908458b Wait for the timepicker dialog to actually open before focusing it
The period-selector arrow-key test called .focus() on the AM/PM radio
right after clicking the field. show() moves focus into the dialog
from Alpine's $nextTick, which Livewire's bundled Alpine defers with a
real setTimeout(0), not a microtask, so the dialog was still closed
when .focus() ran and it silently did nothing, leaving the arrow key
with no effect (step 36 batch: browser tests for the datepicker/
timepicker rewrite).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 19:50:35 +02:00
Andreas Reinhold / reiniandClaude Opus 5 4cc5a7363f Fix two datepicker browser assertions that could never pass
Plan step 36 review of 72d9dbfc's new full-screen range test, written
but not run. It chained click() off script(), whose return value is the
evaluated script's result rather than the page (the chaining bug
4bf1ca13 fixed elsewhere), and asserted the close and Save buttons with
a bare querySelector, which assertScript compares strictly with true.
The chain is split, and the buttons are asserted as drawn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 19:38:25 +02:00
Andreas Reinhold / reiniandClaude Opus 5 459678c341 Size the timepicker in px, as its landscape queries already are
Plan step 36 review of d46b07c1. The landscape queries moved from rem
to px (560/364/346, equal at the default 16px) while every size they
make room for stayed in rem, so with a larger browser text size the
dial grew past a threshold that no longer moved with it. The sizes are
px now, as M3 gives them in dp, spacing the measurement tokens, the
selected period corner corner-md, and disabled the 38% and 12% state
tokens; the supporting text's two-line room stays in rem, being text.
The header's arithmetic is in px, and its note on targets says why the
period halves render no md-touch-target instead of claiming nothing
here reaches 48px.

The landscape browser test expected the display and the dial to share a
top edge, but the display is centred in the dial's 256px row, 61px
lower; it now checks the display sits inside that row with the dial
36px after it. Feature tests pin the classes and the three queries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 19:37:05 +02:00
Andreas Reinhold / reiniandClaude Opus 5 22c141d380 Draw the datepicker's controls from the shared classes, in px
Plan step 36 review of 72d9dbfc. The year, the three menu buttons and
the docked lists' options hand-rolled md-state-layer and md-focus-ring
on themselves, in their own ink, on the claim that each was a bigger box
than an indicator inside it; only the day is. They now render the
classes, with the option's inset ring as the one refinement. The day
keeps its own layer on the 40px indicator, now at M3's state-opacity
tokens, gone from a disabled or blank day (a focused disabled day still
showed it), and in its own stacking context so it lies over a chosen
day's primary fill instead of under it.

The month announcement is md-visually-hidden again, not a hook with a
copy of its clip. The stylesheet kept every length in rem although the
commit said px: sizes are now px, spacing the measurement tokens, and
disabled ink the 38% state token, as search and slider were drawn. The
view header's "below sm" is 600px, medium.

Six browser assertions still read the old unprefixed hooks
(dataset.presentation, data-selected, data-between, data-start,
data-end); one of them would have passed vacuously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 19:34:51 +02:00
Andreas Reinhold / reiniandClaude Opus 5 e3fcbf39ed Hide field, search and chip text with md-visually-hidden again
Plan step 36 review. 52fd65a3 replaced the md-visually-hidden class on
field's spoken counter, search's result count and a bound filter chip's
native checkbox with hooks carrying copies of its clip, on the reading
that a package view writes no class but the interaction classes. The
brief and step 34 let a view write the text classes too, and one shared
source beats three copies: the class is back, the copies and the
data-md-field-live and data-md-chip-native hooks are gone
(data-md-search-status predates them and stays). chip.css keeps its
tooltip.css import.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 19:31:39 +02:00
Andreas Reinhold / reiniandClaude Opus 5 a3eef657cc Share one view class rule between the stylesheet tests
Plan step 36 review. The brief and step 34 let a package view write the
md-* text classes beside the three interaction classes, but both
stylesheet tests allowed only the latter, and each kept its own copy of
the check. tests/Support/ViewClasses.php is now the one rule: the
interaction classes, the text classes read from text.css, and a caller's
class handed on whole. It also closes the holes the copies left: a
single-quoted :class, an echo mixed into a class list, a hint-, icon- or
box-class literal, a 'class' => entry that is not a plain literal, and
->class('…') as a string.

InputStylesheetsTest's exemptions narrow too: the autofill transition is
skipped in field.css alone; an orientation query may name any px height
but never a width off a breakpoint; a box-shadow must be none, an
elevation level, or a 0 0 0 ring in a colour role.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 19:31:02 +02:00
Andreas Reinhold / reiniandClaude Opus 5 d46b07c13c Draw the timepicker without Tailwind
Plan step 36 (inputs group, last of the "not started" pair): <x-timepicker>
renders data-md-timepicker-* throughout (data-md-mode, data-md-cycle,
data-md-view, data-md-inner, data-md-dragging, data-md-disabled,
data-md-error and data-md-value replace their unprefixed hooks), and
timepicker.css moves into material.components, importing field.css,
icon.css and button.css. The dialog's Tailwind utility class (position,
the scrim, the spatial-fast pop and its @starting-style) becomes plain
declarations under data-md-timepicker-dialog, matching the datepicker's
modal dialog. The landscape rule stays keyed on orientation and viewport
height, in px (560/364/346), never a breakpoint, as M3 asks.

The hour and minute boxes and the period buttons swap their Tailwind
`state-layer focus-ring` utilities for `md-state-layer md-focus-ring`:
same declarations, so nothing else in the stylesheet changes for them.
The dial swaps in `md-focus-ring` for its own hand-rolled, byte-identical
outline-on-:focus-visible rule, which timepicker.css no longer carries.
The period selector was already the radio-in-a-list M3's labelling table
asks for (role="radio" in a role="radiogroup", moved by the arrow keys),
and Shift+M/Y already reached the date picker's dropdowns — both
predate this rewrite.

field.css's data-md-field-button note is tidied now that every component
that selects it (input, password, datepicker, timepicker) is rewritten.

Browser tests written but not run (the inputs group's Chromium run
follows this stream): the period radios moving with the arrow keys, and
the dial lying on its side below the landscape height threshold.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 19:19:34 +02:00
Andreas Reinhold / reiniandClaude Opus 5 72d9dbfc89 Draw the datepicker without Tailwind
Plan step 36 (inputs group): <x-datepicker> renders data-md-datepicker
with every part data-md-datepicker-* (data-md-presentation replaces
data-presentation; data-md-range, data-md-docked, data-md-concealed and
data-md-value replace their unprefixed hooks), and datepicker.css moves
into material.components on the same px, spacing and colour tokens,
importing field.css, icon.css and button.css for what the view renders.
The month's live-announced text drops its `sr-only` class for a
data-md-datepicker-month-live hook with the same visually-hidden clip
(a package view writes no class but the interaction classes), matching
field's and search's counter/status live regions. The day, year, menu
button and list option keep their own hand-rolled state layer and focus
ring rather than the foundation's classes, as data-md-field-button
already does: each is a bigger interactive box than the round indicator
drawn inside it, which the foundation's fixed-geometry classes can't
draw, and `:focus-visible` only ever matches the actually-focused
element. Cancel, OK and the icon buttons are <x-button>, which already
draws from the classes. datepicker.js follows the renamed hooks and
dataset properties.

Shift+M/Y (reaching the month/year dropdowns) and the full-screen range
picker at compact were already implemented; this commit adds the browser
tests the plan owed for them plus the month list's keyboard-driven growth
in both directions and Save/close from the full-screen app bar
(docs/plans/material-3-browser-tests.md), written but not run — the
inputs group's Chromium run follows this stream.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 19:12:58 +02:00
Andreas Reinhold / reiniandClaude Opus 5 52fd65a390 Add InputStylesheetsTest, the inputs twin of the actions stylesheet test
Plan step 36, step 0 of the inputs stream: mirrors ActionStylesheetsTest's
five checks over the seventeen inputs components already rewritten, plus
pagination (its four views live under resources/views/pagination/**, not
components/, so it gets its own small versions of the two view-reading
checks). The "no class list" check also recognises the wrapper fields'
`:class="$attributes->get('class')"` (Blade's own prop-forwarding syntax,
the only way a component with no root element of its own hands the caller's
class to its field) and a `'class' => '…'` attribute-bag entry, and exempts
field.css's week-long autofill transition from the motion-token check.

The new test caught three already-rewritten components writing a bare
`md-visually-hidden` class, which a package view no longer does (only the
three interaction classes, or the caller's own class, reach a view): field's
character-count live region, search's result-count live region, and chip's
native checkbox under a bound filter chip. Fixed by giving each its own
data-md-* hook with the same visually-hidden declarations in its stylesheet,
and chip.css was also missing its `tooltip.css` import for the tooltip
chip.blade.php renders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 19:00:48 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 a29a147f1a Cover six actions/communication behaviours the browser suite missed
Plan step 36's handoff listed six behaviours no browser test exercised
yet:
- FieldsTest: <x-select>'s open list uses the customizable picker
  (appearance: base-select) where the browser supports it, drawn as
  M3's 48px-row menu.
- CommunicationTest: a two-line snackbar's action stays on the
  title's row from 600px, only wrapping under it below that
  (toast.css's [data-md-toast-wrap]).
- ActionsTest: a disabled menu item reached by the keyboard withholds
  its state layer (interaction.css's [aria-disabled="true"] rule).
- DataTest: a paginator's page step shows md-focus-ring's outline on
  keyboard focus, alongside its already-tested 48px target.
- CommunicationTest: a transient rich tooltip stays open 1.5s after
  focus leaves rather than closing at once (rich-tooltip.js's
  LEAVE_GRACE_MS).
- ActionsTest: an xs or sm group segment takes a press at its 48px
  edge, past its drawn size, now that the target comes from the
  shared md-touch-target class rather than a rule of its own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 18:40:43 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 4bf1ca13a3 Fix two CommunicationTest chains that dropped the page after script()
Both chained keys()/assertScript() straight off script()'s return
value, which is the evaluated script's result, not the page — so a
snackbar whose dismiss button had just been focused, or whose page
focus had just been reset to <body>, called keys() on null. Splits
each into its own statement, as the FAB menu scroll test in
ActionsTest.php was fixed in the same way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 18:28:02 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 636a0fe71d Fix five never-run ActionsTest selectors and a script-chaining bug
The new menu browser tests never ran before this batch, and five had
selector bugs of their own once they did:
- "Delete" and a bare menuitem query matched every menu's copy on the
  showcase page, not just the open one's — scoped to the visible item
  or the filtered menu's own labelled list.
- the filter test's id lookups ran :has-text(), a Playwright locator
  extension, inside a real document.querySelector, which throws —
  rewritten as a textContent search.
- the sheet-at-compact assertions expected the bottom sheet to be a
  popover (:popover-open); <x-bottom-sheet> is still the x-show-based
  component from before this batch (containment, not yet rewritten),
  so openness is checked with assertVisible()/assertMissing() instead.
- the FAB menu scroll test chained assertScript() off script()'s
  return value (the evaluated script's result, not the page).

Plan step 36, actions group.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 18:26:59 +02:00
Andreas Reinhold / reiniandClaude Sonnet 5 2b3d3d507c Scope the submenu reopen guard to the browser's own light dismiss
A submenu's close() marked every close it caused as recent, so the
250ms guard meant to stop a trigger's own click reopening a popover
the same press just light-dismissed also blocked a deliberate
keyboard reopen (Enter/Space right after ArrowLeft) that happened to
land within that window. Only a close the browser made on its own
now counts toward the guard; close() marks its own hidePopover() call
explicit so it never seeds it. Fixes the "opens a submenu with Right,
Enter or Space" browser test (plan step 36, actions group).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 18:26:51 +02:00
Andreas Reinhold / reiniandClaude Opus 5 884f585e4d Match the app bar's overflow menu item past its new class
Follow-up to menu-item's move to the interaction classes (this batch): the
app bar's own test anchored a menu item's link right after <a, which the
item's new class="md-state-layer md-focus-ring" now precedes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 18:15:25 +02:00
Andreas Reinhold / reiniandClaude Opus 5 d5a3df535c Keep the state layer off a disabled field trailing button
Plan step 36 audit. field.css's trailing button (clear, copy, reveal)
scales its state layer with the field's own icon size and doubles it as
the touch target, which the foundation's fixed-size interaction classes
cannot draw, and the hook stays data-md-field-button rather than a class
because datepicker and timepicker, not yet rewritten, still select it —
so it keeps its own declarations. It never hid the layer on a disabled
button, unlike every rewritten component's copy; it now does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 18:13:55 +02:00
Andreas Reinhold / reiniandClaude Opus 5 627709b67d Draw the paginators' live steps from the interaction classes
Plan step 36 ("Interaction is the shared classes", the user, 2026-09-14).
A prior pass on this same step had hand-rolled the page step's state
layer, focus ring and touch target to keep the views free of a class
list; the user's decision reverses that: a live step (a link or button,
never the current-page or disabled span) now renders md-state-layer,
md-focus-ring and md-touch-target, and pagination.css keeps only the
step's own size, colour and shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 18:12:05 +02:00
Andreas Reinhold / reiniandClaude Opus 5 7ff6928d4a Draw the alert's dismiss button from the interaction classes
Plan step 36 ("Interaction is the shared classes", the user, 2026-09-14).
The dismiss button hand-rolled md-state-layer, md-focus-ring and
md-touch-target, and alert.css's own header said so; it now renders the
classes and keeps only its own size, offset and colour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 18:10:26 +02:00
Andreas Reinhold / reiniandClaude Opus 5 480387a64e Draw the snackbar's action and close buttons from the classes
Plan step 36 ("Interaction is the shared classes", the user, 2026-09-14).
The action and the close button hand-rolled md-state-layer, md-focus-ring
and md-touch-target, and toast.css's own header said so; both now render
the classes and keep only their own size and colour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 18:10:16 +02:00
Andreas Reinhold / reiniandClaude Opus 5 5a9211acd4 Draw the FAB menu's trigger and items from the interaction classes
Plan step 36 ("Interaction is the shared classes", the user, 2026-09-14).
The trigger button (fab-menu.css) and each item (fab-menu-item.css)
hand-rolled md-state-layer and md-focus-ring; both now render the classes
and keep only their own colour, shape and motion. Neither needs
md-touch-target: the trigger and every item draw at 56px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 18:08:47 +02:00
Andreas Reinhold / reiniandClaude Opus 5 6c30f1e3ae Draw the menu item's state layer and focus ring from the classes
Plan step 36 ("Interaction is the shared classes", the user, 2026-09-14).
menu-item.css hand-rolled md-state-layer and md-focus-ring, including a
separate aria-disabled rule the class already carries; the row now renders
the classes and keeps only the inward -3px ring offset a row edge to edge
in the list needs. No md-touch-target: the row is already 48px tall.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 18:07:12 +02:00
Andreas Reinhold / reiniandClaude Opus 5 7803298fe7 Draw the group segment's state layer from the foundation class
Plan step 36 ("Interaction is the shared classes", the user, 2026-09-14).
A segment's hover and press opacities were group.css's own copy of
md-state-layer's declarations; the label now renders md-state-layer
(and, below medium's two smallest sizes, md-touch-target) and the
stylesheet keeps only what the class cannot draw: focus and disabled read
off the real radio or checkbox inside with :has(), not the label itself,
so the focus ring's layer tint and the disabled hide are kept as a
refinement (the latter a real gap the old rule left open). button-group.css,
which only shapes corners and spacing, needed no change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 18:06:01 +02:00
Andreas Reinhold / reiniandClaude Opus 5 ea12f9d75b Draw the FAB's state layer and focus ring from the classes
Plan step 36 ("Interaction is the shared classes", the user, 2026-09-14).
fab.css hand-rolled the same declarations as foundation/interaction.css's
md-state-layer and md-focus-ring; it now renders those classes and keeps
only its own hover elevation, colour and motion. Every FAB size draws at
56px or more, so it needs no md-touch-target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 18:03:29 +02:00
Andreas Reinhold / reiniandClaude Opus 5 bfd368e463 Draw the button's state layer, focus ring and target from the classes
Plan step 36 ("Interaction is the shared classes", the user, 2026-09-14).
button.css hand-rolled the same declarations as foundation/interaction.css's
md-state-layer, md-focus-ring and md-touch-target; it now renders those
classes (the touch target only at xs and sm, where it draws under 48px) and
keeps only its own geometry, colour and motion. The stylesheet test's "writes
no class list" check now allows the three interaction classes, in any subset,
and $attributes->class() as the way a root merges them with a caller's own
class; split-button and button-group, which nest a button, are updated to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 18:01:18 +02:00
Andreas Reinhold / reiniandClaude Opus 5 aa3e789081 Give the menu's headers the cap it now draws
Plan step 36. The rewrite drew the popover's 18rem cap as 288px, and its
stylesheet opened with "two lists" before naming three; the headers now
say what the rules do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 17:10:36 +02:00
Andreas Reinhold / reiniandClaude Opus 5 682029b813 Name the snackbar's renamed hooks in the development skill
Plan step 36. The toast rewrite renamed data-toast and data-toast-action
to data-md-toast-snackbar and data-md-toast-action, added
data-md-toast on the host and data-md-toast-dismiss on the close button;
the skill still told applications to target the old names in tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 17:10:11 +02:00
Andreas Reinhold / reiniandClaude Opus 5 3b47ebf028 Say why the FAB menu's exit rule wins inside the layer
Plan step 36. fab-menu.css claimed the item sets no translate or opacity
of its own once it has entered; fab-menu-item.css sets both at rest, and
the closing rule wins on specificity, which the header now says.
fab-menu-item.css named the rule by the root's hook instead of the
popover's, and a render test named for the list's scroll asserted the
exit; it is named for what it checks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 17:09:58 +02:00
Andreas Reinhold / reiniandClaude Opus 5 97ba035b68 Let a rich tooltip linger after the focus leaves too
Plan step 36. The rewrite moved the rich tooltip's pointer-leave grace
from 200ms to M3's 1.5s — kept: the reference gives plain and rich
tooltips the same 1.5s, and ACT-25 names this file's 200ms. Its header
claimed the same for the focus, which still hid the bubble at once; the
focus now leaves on the same 1.5s the plain tooltip uses, and the skill
says so. The stylesheet header no longer cites chip.css and fab-menu.css
for a pulled-back actions row neither has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 17:09:33 +02:00
Andreas Reinhold / reiniandClaude Opus 5 32313e0fd9 Draw the page steps' state layer from the paginator stylesheet
Plan step 36. The paginators put md-state-layer, md-focus-ring and
md-touch-target on their live steps, where the rest of the actions and
communication group (button, FAB, menu item, snackbar, alert) writes no
class list and carries those declarations keyed on its own hooks, as
the plan's data-md-* rule and the brief's "text classes, nothing else"
ask. pagination.css now draws them on a link or button step, the layer
hidden while Livewire disables previous and next; the views render no
class at all, which the render tests pin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 17:08:54 +02:00
Andreas Reinhold / reiniandClaude Opus 5 f53c559c40 Keep the state layer off a disabled menu item
Plan step 36. The old `state-layer` utility hid its layer on
`[aria-disabled='true']`; the menu item's hand-written layer dropped
that, so a disabled item, which stays focusable, drew the 10% focus
layer. Its 38% ink now reads --md-sys-state-disabled-content-opacity,
as button.css does, instead of a literal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 17:07:10 +02:00
Andreas Reinhold / reiniandClaude Opus 5 4893eaf398 Grow the alert's text from a zero basis again
Plan step 36. The rewrite drew Tailwind's `flex-1` (flex: 1 1 0%) as
`flex: 1 1 auto`; it is `flex: 1` again. The header's reasons were not
true either: no chip or FAB menu stylesheet pulls a text-button row back
(the rich tooltip's does), and the dismiss button's negative margin
does not stop a touch target's ::after. It now says the button carries
the shared classes' declarations because the view writes no class list,
as button.css does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 17:06:50 +02:00
Andreas Reinhold / reiniandClaude Opus 5 204c2d2b24 Grow the snackbar's text from a zero basis again
Plan step 36. The rewrite drew Tailwind's `flex-1` (flex: 1 1 0%) as
`flex: 1 1 auto`. In a wrapping row that measures the text at its full
line length, so a long title pushed the action onto a line of its own
from 600px, where the two used to share one. The header also gave a
reason for drawing the buttons' state layer by hand that does not hold
(the layer is currentColor either way); it now says why: the view writes
no class list, as button.css.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 17:06:28 +02:00
Andreas Reinhold / reiniandClaude Opus 5 d706eef01b Opt the select into base-select from its own stylesheet
Plan step 36. menu.css left Tailwind's unlayered half for
material.components, where its `appearance: base-select` on the select
ties select.css's `appearance: none` in layer and specificity and loses
on order: select.css's block always follows the menu.css it imports. The
customizable select would have fallen back to the native list. The
select's own opt-in now sits in select.css's @supports block; menu.css
keeps the picker's. A test pins where each is written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 17:05:56 +02:00
Andreas Reinhold / reiniandClaude Opus 5 99e73fdc58 Draw the empty state without Tailwind
Plan step 36 (actions, last of the batch). <x-empty-state> renders
data-md-empty-state with data-md-empty-state-illustration/-shape/
-icon/-title/-description/-actions; no class list. empty-state.css
draws the secondary-container shape behind the on-secondary-container
icon (the icon needs its own stacking context to paint above the
shape's absolute position), the title-large title and the 448px
body-medium description. The docblock's illustration example moves
from a caller class to a caller style, since the shape rule scans
the whole file. EmptyStateTest is rewritten on the hooks and
ComponentStylesheet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 16:52:09 +02:00
Andreas Reinhold / reiniandClaude Opus 5 4cc905b144 Draw the stat card without Tailwind
Plan step 36 (actions). <x-stat> renders data-md-stat with
data-md-stat-header/-value/-description/-extra; no class list.
stat.css draws the surface-container panel, the label-large header,
the value as an emphasized-headline-md hero with tabular figures, and
the body-small description. StatTest is rewritten on the hooks and
ComponentStylesheet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 16:49:47 +02:00
Andreas Reinhold / reiniandClaude Opus 5 05c3b968ba Draw the alert without Tailwind
Plan step 36 (actions). <x-alert> renders data-md-alert with
data-md-color and data-md-alert-content/-title/-description/-actions/
-dismiss; no class list. alert.css draws the state's tinted container
colour across all eight colours, the medium corner, and the dismiss
button reaching M3's 48px target from its 40px (ACT-18) with its own
state layer, focus ring and touch target. AlertTest is rewritten on
the hooks and ComponentStylesheet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 16:48:37 +02:00
Andreas Reinhold / reiniandClaude Opus 5 15cd306aba Draw the progress indicator without Tailwind
Plan step 36 (actions). <x-progress> renders data-md-progress with
data-md-color and data-md-circular/-wavy/-thick, and
data-md-value/-max in place of data-value/data-max; no class list.
progress.css draws LinearProgressIndicatorTokens and
CircularProgressIndicatorTokens' stroke, sizes and colours, and the
linear indicator's unconditional right-to-left mirror. Its sizing is
a default only, same as loading.css: a caller's own class, unlayered
or in Tailwind's utilities layer, still outranks it, so the width/
size detection regex the view carried is gone with it.

resources/js/progress.js's WATCHED array and its reads follow the
renamed hooks. ProgressTest (Feature and Browser) is rewritten on the
hooks and ComponentStylesheet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 16:46:52 +02:00
Andreas Reinhold / reiniandClaude Opus 5 ce8ac4d316 Draw the snackbar host without Tailwind
Plan step 36 (actions). <x-toast> renders data-md-toast with
data-md-position, its snackbar data-md-toast-snackbar with a bound
data-md-two-line, data-md-toast-content with a bound
data-md-toast-wrap, and data-md-toast-action/-dismiss; no class list.
toast.css draws SnackbarTokens' inverse surface, 48/68px container,
the two 40px controls reaching 48px targets with their own state
layer and touch target (ACT-18), the enter transition and the
position-start margin that grows from medium.

data-toast becomes data-md-toast-snackbar and data-toast-action
becomes data-md-toast-action (snackbar.js, navigation.js's
hide-on-scroll guard, NavigationBarTest, CommunicationTest); a new
data-md-toast-dismiss hook names the close button. ToastTest is
rewritten on the hooks and ComponentStylesheet. CommunicationTest
gains the owed tests: an actioned snackbar past the default timeout,
Escape on a focused one, the live region before any message, and the
two-line height with Alt+G reaching the action from elsewhere on the
page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 16:42:05 +02:00
Andreas Reinhold / reiniandClaude Opus 5 8cb8727f75 Draw the rich tooltip without Tailwind
Plan step 36 (actions). <x-rich-tooltip> renders data-md-rich-tooltip
with the bubble's data-md-rich-tooltip-bubble and data-md-side, and
data-md-rich-tooltip-title/-text/-actions on its plain text; no class
list. rich-tooltip.css draws RichTooltipTokens' surface-container,
medium corner, elevation 2, 312px bubble, its corner-to-corner anchor
positioning and its fade, the same shape as tooltip.css.

rich-tooltip.js's leave grace goes from 200ms to M3's 1.5s (ACT-25),
matching tooltip.js. RichTooltipTest is rewritten on the hooks and
ComponentStylesheet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 16:35:36 +02:00
Andreas Reinhold / reiniandClaude Opus 5 3680808ae8 Write the actions group's owed browser tests
From docs/plans/material-3-browser-tests.md § Actions: submenu
keyboard (Right/Enter/Space open, Left/Escape close and return
focus) and hover-open on a fine pointer; a filtering menu's typing,
arrows, Enter and the clear on reopen; a sheet-at-compact menu's
focus on its first item below 600px, its trigger switching between
menu and dialog across 600px, a submenu opening in place inside it,
its own filter, and a Livewire render keeping it open with the
field's focus; a long menu scrolling while the keyboard keeps the
reached item inside view; the FAB menu's list scrolling on a short
window behind its close button, which stays put (plan step 36,
actions). Written on the data-md-* hooks the group's five commits
render; not run — the group's Chromium pass happens once every
stream in the group has landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 16:25:31 +02:00
Andreas Reinhold / reiniandClaude Opus 5 c065d940af Draw the FAB menu without Tailwind
<x-fab-menu> renders data-md-fab-menu with its trigger
data-md-fab-menu-trigger, data-md-color and data-md-position; a
close icon swaps in on aria-expanded instead of Tailwind's group
variant. fab-menu.css draws FabMenuBaselineTokens' 56px trigger,
its round-to-full morph on the default springs (ACT-19), the 8px
list padding and its calc(100dvh-128px) scroll cap behind the fixed
close button (ACT-32), and the [data-md-fab-menu-popover]:not(:popover-open)
rule that drives fab-menu-item's exit (ACT-33) — actions.css's last
two rules, moved here, empty it, so it and its tailwind.css import
are deleted; fab.js's stale reference to it is corrected to fab.css
(plan step 36, actions — the group is now fully off Tailwind).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 16:20:45 +02:00
Andreas Reinhold / reiniandClaude Opus 5 330b835043 Draw the FAB menu item without Tailwind
<x-fab-menu-item> renders data-md-fab-menu-item and data-md-color
with no class list; fab-menu-item.css draws FabMenuBaselineTokens'
56px pill, 24px sides, 24px icon, full corner and elevation 3, and
the entry/exit rise-and-sink transition (ACT-33) that fab-menu.css's
[data-md-fab-menu]:not(:popover-open) rule will drive once the FAB
menu itself is rewritten. Its tests move out of FabTest.php into a
new FabMenuTest.php (plan step 36, actions).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 16:18:18 +02:00
Andreas Reinhold / reiniandClaude Opus 5 5cdeec26e8 Draw the menu without Tailwind
<x-menu> renders data-md-menu with data-md-sheet-at-compact, and its
popover data-md-menu-popover with data-md-position and data-md-vibrant;
menu.css caps a long menu at 288px so it scrolls instead of running
off the top layer (ACT-04) and ties the open/close transition to the
trigger's corner (ACT-26), restyles a submenu to open in place inside
a sheet-at-compact sheet, and still carries the dropdown a select's
picker and a searchable choices field draw, so select.css and
choices.css finish their step-36 TODO import. actions.css sheds the
state-transition-fast and popover-transition utilities menu-item and
menu no longer use; state-transition-default and [data-fab-menu]
stay for fab-menu's own rewrite.

data-menu, data-menu-filter, data-menu-sheet, data-menu-empty,
data-sheet-at-compact, data-vibrant and data-active are renamed to
data-md-*, also in menu.js, split-button.css (which renders a
<x-menu> and now imports its stylesheet) and AppBarTest's overflow
menu (plan step 36, actions).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 16:16:49 +02:00
Andreas Reinhold / reiniandClaude Opus 5 c040925cd4 Draw the menu item without Tailwind
<x-menu-item> renders data-md-menu-item, data-md-description and its
aria-* state, with data-md-keep-open renamed from data-keep-open;
menu-item.css draws SegmentedMenuTokens' 48px row, 16px sides, 12px
gaps, 4/12px corners, the selected/current colours and the shared
--md-menu-item-ink icon-class outranks except when disabled
(ACT-11/19/27/28); a submenu's popover shares its container colour
with the menu it opens from and menu.js follows the rename (plan
step 36, actions).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 16:11:56 +02:00
Andreas Reinhold / reiniandClaude Opus 5 2f8fb2183f Draw the menu group without Tailwind
<x-menu-group> renders data-md-menu-group with data-md-gap and no
class list; menu-group.css draws the plain cluster's 4px padding and
the gapped Expressive layout's 2px item gap and 8px cluster margin
from SegmentedMenuTokens (plan step 36, actions).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 16:08:25 +02:00
Andreas Reinhold / reiniandClaude Opus 5 2acd9ab492 Draw the paginators without Tailwind
Laravel's and Livewire's paginator views render data-md-pagination
hooks; pagination.css draws the row, the 40px steps with their 48px
targets, the selected page and the swap to "Page n of m" below 600px
(plan step 36, inputs). It imports button.css for the simple
paginators, now that the button is rewritten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 15:47:07 +02:00
Andreas Reinhold / reiniandClaude Opus 5 6df034f81a Draw the menu separator without Tailwind
The separator renders data-md-menu-separator and takes its 1px line,
8px block margin and 16px inset from menu-separator.css in
material.components (plan step 36, actions).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 15:47:07 +02:00
Andreas Reinhold / reini b573cfbcc2 Merge branch 'worktree-agent-a9b4c522a39c6ce62'
# Conflicts:
#	tests/Feature/StylesheetsTest.php
#	workbench/resources/css/app.css
2026-09-14 15:41:37 +02:00
Andreas Reinhold / reini 82ed683df4 Merge branch 'worktree-agent-a41c756369d016a29' 2026-09-14 15:40:58 +02:00
Andreas Reinhold / reiniandClaude Opus 5 c68a1fdbef Draw the sort header without Tailwind
<x-sort-header> renders data-md-sort-header (data-md-active on the
sorted column) with its button and arrow as data-md-* attributes; the
button keeps md-focus-ring and md-touch-target, the arrow takes size 16,
and the hover and hint-arrow states move into components/sort-header.css
(plan step 36). A browser test presses the button at the edge of its
48px target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 15:07:37 +02:00
Andreas Reinhold / reiniandClaude Opus 5 25e1bb4788 Draw the data table without Tailwind
<x-table> renders data-md-table with data-md-size and data-md-dense,
and table.css moves into material.components on px and spacing tokens,
its cell selectors still inside :where() so a caller's cell rule wins
(plan step 36). The showcase's layout table follows the hook. A browser
test pins 52px rows and 36px dense ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 15:06:51 +02:00
Andreas Reinhold / reiniandClaude Opus 5 1cf4f16c95 Draw the FAB without Tailwind
Plan step 36. <x-fab> renders data-md-fab with data-md-size,
data-md-color, data-md-variant, data-md-extended and, collapsing on
scroll, data-md-collapse-on-scroll with a bound data-md-collapsed; it
passes its glyph's size to <x-icon>. fab.css draws FabBaseline/Medium/
LargeTokens and ExtendedFab*Tokens per size, the container and filled
colours, elevation 3 and 4 on hover, the state layer and focus ring,
and the collapse morph that leaves actions.css.

data-fab becomes data-md-fab, also in navigation.css (the rail flattens
a nested FAB) and toolbar.css (a docked toolbar's FAB), with their tests.
The actions browser test gains the owed collapse-on-scroll test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 15:06:45 +02:00
Andreas Reinhold / reiniandClaude Opus 5 4141ebe7b0 Draw the search without Tailwind
<x-search> renders data-md-search with data-md-trigger and binds
data-md-open and data-md-full-screen; its parts become data-md-search-*
and the empty row data-md-search-empty. search.css moves into
material.components on px, spacing and state tokens (plan step 36).
search.js and the showcase layout follow the renamed hooks.

Browser tests cover the polite result count as it changes, the icon
entry point expanding full screen and returning focus, and suggestions
swapping to results on the first key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 15:04:50 +02:00
Andreas Reinhold / reiniandClaude Opus 5 2a4dcead3d Draw the split button without Tailwind
Plan step 36. <x-split-button> renders data-md-split-button and
data-md-size on the pair and data-md-split on its halves. split-button.css
draws SplitButton*Tokens' shape — full outer corners, inner corners that
grow under the finger, the trailing half round while its menu is open —
the narrower leading padding and 48px trailing half at xs and sm, and the
chevron's own size, nudge and turn on the standard scheme. groups.css is
empty and leaves tailwind.css.

The actions browser test follows data-md-split and data-md-button-group,
and gains the owed tests for a connected segment's 48px edge and a
button group's single-required and multi selection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 15:03:02 +02:00
Andreas Reinhold / reiniandClaude Opus 5 281c21b0a4 Draw the slider without Tailwind
<x-slider> renders data-md-slider with its size, colour, value label,
orientation and centring as data-md-* attributes and its parts as
data-md-slider-*; every size, colour role, disabled state, the vertical
quarter turn, RTL mirroring and the no-script fallback move into
components/slider.css as custom properties per size and colour (plan
step 36). slider.js reads the props from the root and marks the
renamed handle, tick and icon hooks.

Browser tests pin the handle centred on the track at each size, Space
held with an arrow, and the vertical slider's drag along Y, keys and
value label beside the handle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 15:01:19 +02:00
Andreas Reinhold / reiniandClaude Opus 5 8dd0a7ef9d Draw the button group without Tailwind
Plan step 36. <x-button-group> renders data-md-button-group (standard or
connected), data-md-size, data-md-shape and, with a selection,
data-md-selection and data-md-selection-required. button-group.css now
also draws the standard group: its gaps per size, the square corner scale
it hands its buttons, and the press expansion that widens a pressed label
button while its neighbours give way. groups.css keeps only the split
button until that is rewritten too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:59:18 +02:00
Andreas Reinhold / reiniandClaude Opus 5 b8c6d411db Draw the connected choice group without Tailwind
Plan step 36. <x-group> renders data-md-group with its size, variant,
shape, multiple and inline, a data-md-button-group row and
data-md-group-segment labels over its radios or checkboxes. group.css
draws the segments as M3's buttons with the toggle button's colours
from :has(:checked), the disabled treatment from :has(:disabled), the
focus ring from :has(:focus-visible) and the 48px target at xs and sm.

The connected shapes move into button-group.css (the corner scale,
square ends, the pressed and selected inner corners), which group.css
imports; <x-button-group> adopts them in its own rewrite. The group's
render tests move to GroupTest; AppBarTest (navigation group) follows
the new legend and row hooks of theme-toggle's rows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:56:59 +02:00
Andreas Reinhold / reiniandClaude Opus 5 1bb5adab0e Draw the choices without Tailwind
<x-choices> renders data-md-choices (data-md-searchable) and its
"Nothing matches" row as data-md-choices-empty, drawn by
components/choices.css, which brings the field, chip set and chip it
renders (plan step 36).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:55:29 +02:00
Andreas Reinhold / reiniandClaude Opus 5 55cf8ceed7 Draw the chip set without Tailwind
<x-chip-set> renders data-md-chip-set (data-md-scroll), its row, label,
hint, errors and scroll buttons as data-md-* attributes, and its row,
fade mask, scroll padding and pointer-fine buttons move into
components/chip-set.css on tokens (plan step 36). chips.js marks the
row data-md-scroll-start and data-md-scroll-end. Browser tests cover the
scroll buttons in LTR and RTL and the arrow keys with a roving tab stop
that scrolls the focused chip clear of the button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:54:41 +02:00
Andreas Reinhold / reiniandClaude Opus 5 d65e25b208 Show each canonical layout on the showcase's Layout page
Plan step 35: the Layout section gains a working example of list-detail,
supporting pane and feed, and one of surfaces, stacks, rows and grids,
written with the layout components and md-* text classes only; the panes
table names <x-list-detail> and <x-supporting-pane>. The showcase's own
Tailwind stays for step 38.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:52:01 +02:00
Andreas Reinhold / reiniandClaude Opus 5 0013e0154d Rename the app shell to the scaffold and give it a FAB slot
Plan step 35: <x-app-shell> becomes <x-scaffold>, M3's and Compose's name
for the structure of bars, rails and panes, with no alias; every package
view, the showcase, the tests, the skills, README and UPGRADE follow. Its
markup and Tailwind classes stay for the navigation rewrite. The new fab
slot places the FAB as Compose's Scaffold does - bottom-end, 16px from the
edges below medium and 24px from it, clear of the navigation bar and of a
snackbar on screen (resources/css/layout/scaffold.css) - and the content
region tells a pane inside that the margin is already drawn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:51:48 +02:00
Andreas Reinhold / reiniandClaude Opus 5 acd6ba21ae Add the feed canonical layout
Plan step 35: <x-feed min-item gap>, following the reference's feed rules
- one column below medium, then as many equal columns of at least the
minimum item width as the feed has room for, 24px apart from medium,
items in their source order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:51:30 +02:00
Andreas Reinhold / reiniandClaude Opus 5 9eb06323aa Add the supporting-pane canonical layout
Plan step 35: <x-supporting-pane>, following the reference's placement
table - below the focus pane below expanded, beside it from 840px at a
fixed 360px (412px from large) or the two-thirds split. Below expanded
compact="sheet" docks the supporting pane as a bottom sheet whose drag
handle opens it; always on the trailing side, so focus order matches the
panes on screen, and mirrored in RTL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:51:22 +02:00
Andreas Reinhold / reiniandClaude Opus 5 b5c5be1fd7 Add the list-detail canonical layout
Plan step 35: <x-list-detail>, following the reference's visible-panes
table row by row - one pane below expanded, the detail replacing the list
with a back button once something is selected; from 840px the list a
fixed 360px (412px from 1200px) beside the detail, 24px apart. The
selection binds with wire:model or x-model; below expanded focus moves to
the detail and back() returns it to the item (resources/js/layout.js).
Grid columns mirror in RTL, and the back arrow turns with them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:51:13 +02:00
Andreas Reinhold / reiniandClaude Opus 5 b4f1e005de Add the pane layout component
Plan step 35: <x-pane>, a content region with M3's margins (16px below
medium, 24px from it) drawn once however panes and layouts nest, a width
cap, and an optional pane app bar - <x-app-bar> as a direct child of the
pane with title, subtitle, actions, a back link or action, a leading
slot, and the section navigation under it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:51:04 +02:00
Andreas Reinhold / reiniandClaude Opus 5 4ad257034e Draw the button without Tailwind
Plan step 36. <x-button> renders data-md-button with its variant, colour,
size, shape, icon-button width, selected state and compact FAB as
data-md-* attributes, and passes the glyph's size to <x-icon>.
button.css draws Button*Tokens' geometry per size, the colour roles
through custom properties that the disabled treatment replaces, the
state layer, focus ring and 48px target, and the compact FAB below
600px. Its corner rules carry no specificity (:where), so a group's
stylesheet reshapes the buttons inside it with any selector; the
split button no longer passes an empty corners prop, which is gone.

data-icon-button becomes data-md-icon-button, also in groups.css,
toolbar.css and theme-toggle's own button (navigation group), and the
AppBarTest assertions follow the new hooks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:50:58 +02:00
Andreas Reinhold / reiniandClaude Opus 5 e99c5993d8 Add the surface layout component
Plan step 35: <x-surface level padding corner outlined>, a tonal region
in surface or a surface-container step, padded with a spacing token,
rounded with a corner token and optionally edged in outline-variant. A
pane on a surface keeps its margin again, since the surface is a new edge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:50:55 +02:00
Andreas Reinhold / reiniandClaude Opus 5 da7a05a619 Add the grid layout component
Plan step 35: <x-grid :columns gap min-item>. The column count per
breakpoint is written as five inline custom properties, each filled from
the nearest smaller breakpoint, so a nested grid never inherits its
parent's; min-item fills a row by the room the grid has, and with columns
the counts become a ceiling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:50:47 +02:00
Andreas Reinhold / reiniandClaude Opus 5 8de053f15c Add the row layout component
Plan step 35: <x-row gap align justify wrap stack-below>, children side
by side in a pane and stacked under one another below an M3 breakpoint,
in the inline direction so a right-to-left document mirrors it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:50:39 +02:00
Andreas Reinhold / reiniandClaude Opus 5 d8072d878a Draw the chips without Tailwind
<x-chip> renders data-md-chip with its type and its states and parts as
data-md-* attributes (elevated, disabled, selected, actionable, the
icon, check slot, avatar, action and remove button), and every colour,
size, target and transition moves into components/chip.css on tokens
(plan step 36). Icons take size 18; the filter checkbox is
md-visually-hidden. chips.js follows the renamed chip hooks.

A disabled filter chip drawn as a label no longer shows a hover state
layer, which M3 never gives a disabled control. Browser tests cover the
32px chip and the remove button's 48px target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:50:29 +02:00
Andreas Reinhold / reiniandClaude Opus 5 b677e60876 Add the stack layout component and the layout foundation
Plan step 35: <x-stack gap align>, the first of M3's layout components,
with what they all share - src/Support/Layout.php reading the props into
data-md-* attributes, the spacing-token gap and padding rules, the
hide-below/hide-from rules in material.visibility, the stylesheet checks
for resources/css/layout, the browser test file and the skill's Layout
group. The Workbench puts the material layers above Tailwind's preflight,
which would otherwise zero every layout padding and margin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:50:27 +02:00
Andreas Reinhold / reiniandClaude Opus 5 b42069503d Draw the switch without Tailwind
<x-toggle> renders data-md-toggle, its row (data-md-right), track,
handle and icons as data-md-* attributes; the handle's icons take size
16 and a label-less track md-touch-target (plan step 36). toggle.css
gains the root; a browser test pins the handle's 16px/24px sizes and
16px/36px centres off and on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:43:17 +02:00
Andreas Reinhold / reiniandClaude Opus 5 8747163fce Draw the radio group without Tailwind
<x-radio> renders data-md-radio (data-md-inline), its legend, options,
rows and support as data-md-* attributes, and its layout moves into
components/radio.css: the 16px stack, the inline row from 600px, the
legend and the hint on the type and spacing tokens (plan step 36). A
label-less ring wears md-touch-target; the caller's style now lands on
the fieldset with its class.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:41:38 +02:00
Andreas Reinhold / reiniandClaude Opus 5 7bdd3ef61c Draw the badge without Tailwind
Plan step 36. <x-badge> renders data-md-badge with data-md-dot,
data-md-variant (solid, tonal or outline, resolved in that order),
data-md-color and data-md-floating; badge.css draws BadgeTokens' dot and
count, the status label, each colour's pair of roles through custom
properties, and M3's anchor geometry when floating. A plain badge sets
no colour, so the caller's CSS paints it.

NavigationBarTest (navigation group) asserts the dot by data-md-dot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:34:24 +02:00
Andreas Reinhold / reiniandClaude Opus 5 d4b71c519c Keep the tooltip bubble's border style from the reset
Plan step 36. border-width: 0 rather than border: 0, so the bubble's
computed border matches what Tailwind's border-0 drew.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:34:24 +02:00
Andreas Reinhold / reiniandClaude Opus 5 f86afc0542 Draw the checkbox and the selection controls' row without Tailwind
<x-checkbox> renders data-md-checkbox, its box, tick and dash, and its
row, text and errors as data-md-* attributes; the 18px tick takes a size
prop and a label-less box md-touch-target (plan step 36). selection.css
moves into material.components as the row, text and state layer the
three controls share, and each control's drawing goes to its own file:
checkbox.css, radio.css, toggle.css, on the state tokens. The radio and
the switch take the renamed shared hooks now and lose their layout
classes in their own commits. field.js follows data-md-indeterminate.

Renaming the switch's data-handle also stops selection.css matching the
slider's handles, which it drew 24px above the track.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:34:00 +02:00
Andreas Reinhold / reiniandClaude Opus 5 3083fe28b2 Move the file picker's rules out of the field into file.css
<x-file> marks its field root data-md-file, and the tonal
::file-selector-button with its hover and disabled states moves from
field.css to components/file.css on the state tokens (plan step 36). A
browser test pins the field's 56px box and 16px padding and the pill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:12:40 +02:00
Andreas Reinhold / reiniandClaude Opus 5 23f2bdabf9 Move the select's rules out of the field into select.css
<x-select> marks its field root data-md-select, and what a <select>
changes in the field — covering the whole box, the customizable
select's closed button, the focused look and turned arrow while open —
moves from field.css to components/select.css (plan step 36). The open
list stays in menu.css until the menu leaves Tailwind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:11:40 +02:00
Andreas Reinhold / reiniandClaude Opus 5 147dbcf041 Draw the plain tooltip without Tailwind
Plan step 36. <x-tooltip> renders data-md-tooltip and data-md-side on
the bubble and data-md-tooltip-anchor on a standalone trigger's wrapper;
tooltip.css draws PlainTooltipTokens' inverse-surface bubble, its anchor
placement and flips per side, and the fade on the fast effects spring
with @starting-style.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:11:37 +02:00
Andreas Reinhold / reiniandClaude Opus 5 c2fe64e00d Move the textarea's rules out of the field into textarea.css
<x-textarea> marks its field root data-md-textarea, and what a
<textarea> changes in the field — the grip in the corner, the label on
the first line, autogrow between rows and max-rows, the filled offsets —
moves from field.css to components/textarea.css (plan step 36).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:11:22 +02:00
Andreas Reinhold / reiniandClaude Opus 5 6c7414d60e Draw the loading indicator without Tailwind
Plan step 36. <x-loading> renders data-md-loading and data-md-contained,
and loading.css in material.components draws the 48px indicator in
primary, the contained circle and the still shape under reduced motion.
The view no longer reads the caller's classes to decide its size and
colour: a caller's utility outranks the package's layer on its own.

ActionStylesheetsTest checks the group's stylesheets for the package's
shape (layer statement, plain imports, material.components, tokens, px
breakpoints, imports of rendered components) and its views for class
lists; tests/Support/ComponentStylesheet reads a rule's declarations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:10:16 +02:00
Andreas Reinhold / reiniandClaude Opus 5 cfdb4619ad Give the password field its own stylesheet entry
<x-password> marks its field root data-md-password, and
components/password.css brings the field and its icons (plan step 36).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:10:14 +02:00
Andreas Reinhold / reiniandClaude Opus 5 172629face Give the one-line text field its own stylesheet entry
<x-input> marks its field root data-md-input, and components/input.css
brings what it renders, the field and its icons, so an application
imports one file per component (plan step 36).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:09:58 +02:00
Andreas Reinhold / reiniandClaude Opus 5 0341f9d4ca Draw the text field's chrome without Tailwind
<x-field> renders data-md-field with its props and parts as data-md-*
attributes (box, control, label, outline, support, counter, trailing
buttons), and field.css moves into material.components on tokens: px
geometry, spacing and state tokens, the 600px breakpoint (plan step 36).
Its icons take a size prop, 24/20/16 by the field's size.

Every control the field wraps now marks itself data-md-field-control,
so the inputs, the pickers, choices, field.js, menu.css's select and
listbox rules and timepicker.css follow the renamed hooks. Browser tests
cover the error icon, the disabled field's hover, the counter and the
width bound from 600px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:08:22 +02:00
Andreas Reinhold / reiniandClaude Opus 5 a6e5a593d5 Draw the form without Tailwind
<x-form> renders data-md-form and data-md-form-actions, and its column
and actions row move to components/form.css in material.components, on
the spacing tokens (plan step 36). The actions slot's own attributes
still land on the row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 13:59:32 +02:00
Andreas Reinhold / reiniandClaude Opus 5 d7653aa6d0 Put Tailwind's preflight below the material layers in the Workbench
Plan step 36. A component rewritten without Tailwind keeps its padding,
margins and borders in material.components, and Tailwind's preflight
(* { padding: 0; margin: 0; border: 0 solid }) in its base layer, named
after material, undid every one of them. The Workbench now names theme and
base before importing the foundation, so the preflight — the same reset
foundation/reset.css carries — sits below the package, while Tailwind's
components and utilities stay above it and a caller's utility still wins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 13:59:30 +02:00
Andreas Reinhold / reiniandClaude Opus 5 50431181a7 Rank the material layers above Tailwind's preflight in the Workbench
A rewritten component's padding, margin and border sit in
material.components, which the Workbench declared below all of
Tailwind's layers, so preflight's universal reset beat them. The entry's
layer statement now puts material between base and components (plan
step 36): the material reset already carries preflight, and every
utility still outranks the package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 13:56:44 +02:00
Andreas Reinhold / reiniandClaude Opus 5 6be9f35c98 Group the stylesheet import lists by component stream
tailwind.css and components.css now hold one block per audit group, and the new
layout.css gathers the layout components, so the parallel rewrites of plan steps
35 and 36 each edit their own lines. .browser-lock is the lock that keeps those
streams to one browser at a time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 13:50:27 +02:00
Andreas Reinhold / reiniandClaude Opus 5 bdbb76a0bd Size icons and shapes with a prop instead of a Tailwind class
<x-icon size> writes --md-icon-size (24px by default) and takes the optical-size-20
cut for 20px or less, as M3 asks; mirror-rtl flips a directional symbol. <x-shape
size> writes --md-shape-size. Both draw from plain-CSS stylesheets in
material.components, gathered in components.css until all.css replaces it; a
caller's size class still wins while the other components are rewritten.
Plan steps 35 and 36, the prop every component rewrite depends on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 13:49:24 +02:00
Andreas Reinhold / reini 94f9404319 Merge branch 'worktree-agent-ad8d9c4488c648d01' 2026-09-14 13:44:15 +02:00
Andreas Reinhold / reiniandClaude Opus 5 7fb7fb7bf2 Name the foundation and the text classes in the development skill
Plan steps 33 and 34. The Tokens section gains a paragraph naming
foundation.css as the one required import, its layer order, and the
md-* text classes as 2.0.0's vocabulary; the full rewrite of the docs
is step 47.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 13:43:33 +02:00
Andreas Reinhold / reiniandClaude Opus 5 66e219ae63 Check the foundation's stylesheets
Plan steps 33, 34 and 42. StylesheetsTest follows foundation.css through
its imports and checks each file: a header, the exact layer statement,
plain imports only before its first block, every rule in its own
material layer but the two hiding rules, no Tailwind directive, media
queries only at 600/840/1200/1600px. It also pins the spacing tokens to
the reference table, the md-type classes to the type utilities'
declarations, the whole text class set, the interaction classes against
their utilities, one [x-cloak], material.css never reaching the reset,
and the Workbench importing the foundation before Tailwind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 13:43:33 +02:00
Andreas Reinhold / reiniandClaude Opus 5 1075305a9c Import the foundation before Tailwind in the Workbench
Plan step 33. The Workbench imports foundation.css ahead of
@import 'tailwindcss', so the material layers are declared first and
sit below Tailwind's, then tailwind.css: the theme, the token utilities
and the component stylesheets still written for Tailwind, with no
tokens, so nothing is declared twice. material.css keeps working for
1.x-style imports after Tailwind by bringing the tokens, the hiding
rules and tailwind.css, and never the reset, which would outrank
Tailwind's utilities from there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 13:43:23 +02:00
Andreas Reinhold / reiniandClaude Opus 5 0ab3bd861d Add the md text classes
Plan step 34. text.css (material.text, imported by the foundation) is
the fixed set for text on plain elements: md-type-* and
md-type-emphasized-* with the type utilities' declarations, the role
inks md-ink to md-ink-inverse, md-text-start|center|end, md-truncate,
md-line-clamp-2|3, md-nowrap, md-tabular and md-visually-hidden. Its
header says when to use each, from the material-3 guideline and
docs/reference/m3/styles.md § Typography.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 13:43:11 +02:00
Andreas Reinhold / reiniandClaude Opus 5 c545fb69ef Add the foundation stylesheet, written without Tailwind
Plan step 33. foundation.css is the one required import: the full
material layer statement, then plain imports of its parts, each opening
with the same statement.

- foundation/reset.css (material.reset): Tailwind 4's preflight rule for
  rule, with M3's on-surface-variant placeholder and no typeface.
- foundation/hidden.css: [hidden] and [x-cloak], outside every layer.
- foundation/tokens.css (material.tokens): the token files.
- foundation/base.css (material.base): the page's surface, ink and
  brand typeface, and the font face.
- foundation/interaction.css (material.base): md-state-layer,
  md-focus-ring, md-touch-target and md-link, the plain-CSS forms of the
  utilities; md-state-layer also takes data-md-dragged, and
  md-touch-target is 48px (space600) where the utility reads 3rem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 13:43:04 +02:00
Andreas Reinhold / reiniandClaude Opus 5 d36f50bc3c Add M3's spacing tokens
Plan step 33. tokens/spacing.css declares --md-sys-measurement-space25
to space900 (2 to 72px) in material.tokens, from
docs/reference/m3/styles-supplement.md § Spacing, in px because a dp
is a CSS pixel and spacing does not scale with text. The design skill
names the new token family, as its drift test requires.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 13:42:49 +02:00
Andreas Reinhold / reiniandClaude Opus 5 5f899c7287 Layer the token files and keep their utilities apart
Plan step 33. Each token file now opens with the material layer
statement and wraps its declarations in material.tokens (the font face
in material.base), so an application's unlayered scheme outranks the
package default whatever the selectors, and the foundation can import
the files with no layer() on the import.

The type-* and state utilities move to tokens/utilities.css, still
imported by material.css, so no file the foundation imports carries a
Tailwind directive. [x-cloak] moves beside the [hidden] rule in
foundation/hidden.css, outside every layer; material.css imports it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 13:42:43 +02:00
Andreas Reinhold / reiniandClaude Opus 5 09954cc474 Find the scrolling chip row inside its scroll-button wrapper
Plan step 32. The chip set's scroll buttons (plan step 24) moved
`x-data="materialChipSet"` from the row to a wrapper around the row and
its buttons, so `[data-chip-set][x-data]` found the first wrapping set on
the page, which never overflows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 13:39:17 +02:00
Andreas Reinhold / reiniandClaude Opus 5 fe1cea4ac6 Follow the search's combobox wrapper and scrim in browser tests
Plan step 32. inputs.md IN-01 moved `aria-expanded` from the raw input to
the `role="combobox"` wrapper around it, and IN-09 put a scrim under the
docked view, so a press outside lands on the scrim and Playwright could
never press the text beneath it. The tests read the wrapper and press
the scrim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 13:39:17 +02:00
Andreas Reinhold / reiniandClaude Opus 5 661eabbc57 Widen the same page to check a collapsed rail holds at extra-large
Plan step 32. The Phase C rewrite checked the stored collapse at 1600px
with a new visit(), but every visit opens a fresh browser context with
empty storage, where the rail rightly starts expanded. The test now
resizes the page that holds the choice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 13:39:17 +02:00
Andreas Reinhold / reiniandClaude Opus 5 d5ec5b8638 Read the modal date picker's headline from its own header
Plan step 32. The full-screen range picker (plan step 24) added a second
`[data-datepicker-headline]` in its app bar, hidden unless the picker is
full screen, so Playwright's strict locator found two headlines in every
modal picker. The tests read the modal header's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 13:39:17 +02:00
Andreas Reinhold / reiniandClaude Opus 5 2206c8e09b Point the containment browser tests at their own sheet and card
Plan step 32. Phase E put a standard side sheet (containment.md, step 23)
above the modal one on the showcase page, so the first `[data-sheet]` scrim
the test pressed belonged to it; the test now presses the modal sheet's
own scrim. The preset-heights bottom sheet's button made "Bottom sheet"
match two buttons under Playwright's strict locators; it is matched by its
exact label. The actionable card test chained an assertion on script(),
which returns the script's value rather than the page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 13:39:17 +02:00
Andreas Reinhold / reiniandClaude Opus 5 fadbd2ba97 Expect the snackbar host to stay after an actioned toast closes
Plan step 32. Since actions.md ACT-02 the host is the permanent polite live
region, in the page before any message and after the last, so the test
that waited for it to disappear once Undo ran could never pass. It now
waits for the toast to go and checks the host stays.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 13:39:17 +02:00
Andreas Reinhold / reiniandClaude Opus 5 56e985c40f Leave a carousel item at the padded focal size where it is
Plan step 32, found by the Browser suite. Content padding (16dp by default
since containment.md C-12) shrinks every keyline near the ends, so large
items resting at the start or the end carry a small inset. Bring-into-view
took any inset over half a pixel for "not fully open", so focusing or
pressing a large item at rest scrolled the row to it. The focal keyline's
own inset at the current scroll is the threshold now. The press test also
checks that focusing and pressing an open item moves nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 13:36:26 +02:00
Andreas Reinhold / reiniandClaude Opus 5 0d6b0035ed Bring a pressed carousel item into focus, though it is a tab stop
Plan step 32, found by the Browser suite. Since containment.md C-18 put
the tab stop on each item, the item itself matches the selector for
interactive controls, so every press on an item read as a press on a
control inside it and never scrolled a partly hidden item into focus. A
mouse press still worked through the focus it gives the item; an activation
that clicks without focusing, as a test or assistive technology does, did
nothing. Only a control other than the item counts now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 13:36:12 +02:00
Andreas Reinhold / reiniandClaude Opus 5 3434426e23 Measure carousel items against the padded focal size in browser tests
Plan step 32. Since containment.md C-12 the carousel defaults to M3's 16dp
of content padding, and Compose's createShiftedKeylineListForContentPadding
shrinks every keyline near the ends by a share of it, so a large item at
rest at the start or the end is masked by 1.33px a side in the showcase.
The tests asked for a zero inset there and failed; `open(i)` asks whether
an item is at the largest size any item has at that position. The helper's
list of showcase carousels gains the multi-aspect one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 13:35:58 +02:00
Andreas Reinhold / reiniandClaude Opus 5 9942e98fdd List the menu's compact bottom sheet among 2.0.0's additions
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 11:31:40 +02:00
Andreas Reinhold / reini 40defeaaf5 Merge branch 'worktree-agent-a9c6ef32512bc556d' 2026-09-14 11:31:19 +02:00
Andreas Reinhold / reiniandClaude Opus 5 deb1442dfb Open a menu in a bottom sheet on a compact window
Plan step 22, item 8 (actions.md § Missing, "Adaptive menu → bottom sheet
at compact"): M3 says "at compact breakpoints, consider swapping a menu for
a bottom sheet", and nothing connected <x-menu> to <x-bottom-sheet>.

<x-menu sheet-at-compact> draws its slot twice, in the popover and in a
modal <x-bottom-sheet> teleported to <body>, and below `medium`
(upTo('medium')) the trigger opens the sheet. The trigger says
aria-haspopup="dialog" there and "menu" from medium, aria-expanded in
both. Items keep their roles and the APG keyboard in the sheet; choosing
one, Escape or Tab close it and return focus to the trigger; a submenu
opens in place under its item; `filter` works in both; a resize across
600px closes whichever is open. The sheet has a fixed id that menu.js
makes unique and keeps as its wire:key, and the lists are keyed, so a
Livewire render patches an open sheet instead of swapping it.

bottom-sheet.blade.php is used as is. The filter now finds its field and
empty row per list instead of through x-refs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 11:30:55 +02:00
Andreas Reinhold / reiniandClaude Opus 5 0d9ca4b506 Add Phase E's changed defaults and new configurations to the upgrade note
Text fields bounded from medium, dialog scroll dividers and the new meaning of
separator, the two-line snackbar, bottom sheet stops, chip set scroll buttons,
and a list of the configurations 2.0.0 adds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 11:13:10 +02:00
Andreas Reinhold / reiniandClaude Opus 5 ff16b12b3b Draw M3's focus ring on a dialog's scrolling body
Chrome makes a scroll container with nothing focusable inside a keyboard stop, and
showModal() gives it the dialog's first focus, which showed the browser's own ring.
The body now takes M3's 3px secondary indicator, inset so the dialog's rounded,
overflow-hidden box does not clip it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 11:04:40 +02:00
Andreas Reinhold / reini 8a2d8c0327 Merge branch 'worktree-agent-ae17d09efb642a925' 2026-09-14 11:03:41 +02:00
Andreas Reinhold / reiniandClaude Opus 5 31ccd5440c Quote Google on the docked toolbar's corners
The condensed reference said a docked toolbar is never rounded and left 'it'
ambiguous for the large-screen form; the guidelines page says straight corners,
except that on web and large screens the docked toolbar can be rounded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 11:03:41 +02:00
Andreas Reinhold / reiniandClaude Opus 5 50b78b4c23 Show and document a dialog's scroll dividers
Plan step 23 (containment). The containment showcase gets a dialog with a
long body, whose rules follow its scroll, and one with `separator`, whose
rules always show. The <x-modal> entry in SKILL.md says what `separator`
means now and that every dialog divides a scrolling body by itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 11:03:05 +02:00
Andreas Reinhold / reiniandClaude Opus 5 cbc0fa76d2 Divide a dialog's scrolling body from its pinned header and actions
Plan step 23 (containment), containment.md § Missing: "no divider pinned
between a scrolling body and the header/actions". Every dialog now draws a
1px outline-variant rule under the header once the body is scrolled away
from its top, and over the actions while more of it is below; a body that
fits shows neither. The rules are pseudo-elements in the rows' own padding
(dialog.css), so showing one moves nothing, and x-dialog-dividers
(dialog.js) marks the wire:ignore.self <dialog>, which a morph leaves
alone, watching scroll and a ResizeObserver on the body and a wrapper
around the slot. The M3 gaps are split around the rules (8/8 under the
header, 8/16 over the actions). A full-screen dialog's rule sits under its
phone bar, and its action bar's always-on border follows the scroll too.
`separator` now means "draw both rules always" instead of rendering two
<x-divider> elements. The fade uses the effects-fast token, zero under
reduced motion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 11:03:05 +02:00
Andreas Reinhold / reiniandClaude Opus 5 f33fb8be64 Give a docked toolbar M3's rounded large-screen form
Plan step 25, item 8 (navigation Missing: "Toolbar, large-screen
treatments"). M3's toolbar guidelines (Adaptive design, Resizing) say it
of the docked toolbar: "On web and large screens, the docked toolbar can
be rounded. Dividers can be used to organize large amounts of items",
and it can be placed in different parts of the page. `rounded` does that
from expanded (840px): fully rounded, spanning its container, lifted
16px off the window's edges at place="bottom"; below expanded it stays
the square full-width bar. An <x-divider vertical /> between groups of
controls stands as tall as the icon buttons inside any toolbar. The
floating toolbar is already fully rounded and gets no new form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 11:02:43 +02:00
Andreas Reinhold / reiniandClaude Opus 5 092100c6ae Overflow an app bar's trailing actions below medium
Plan step 25, item 7 (navigation Missing: "App bar, trailing-action
overflow at small widths"). `<x-app-bar :actions="[...]">` takes the
trailing actions as a list whose entries take <x-menu-item>'s props plus
any action attribute. M3 allows up to two trailing icon buttons, and up
to four on larger screens, and lets trailing actions collapse into an
overflow menu at smaller breakpoints: below medium the bar keeps at most
two icon buttons, from medium four, the "More options" button counted
among them, the rest in its menu. Both forms render and media queries
pick one, so there is no script and no flash; each width has its own
menu so the arrow keys never reach a hidden row. The actions slot still
takes markup and never overflows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 10:57:50 +02:00
Andreas Reinhold / reini 12cdeaaf67 Merge branch 'worktree-agent-aed4618fbd0b6aeac' 2026-09-14 10:38:57 +02:00
Andreas Reinhold / reini cca87e58d7 Merge branch 'worktree-agent-a3082a7e6ef1bee94' 2026-09-14 10:38:57 +02:00
Andreas Reinhold / reini 0805ad8a48 Merge branch 'worktree-agent-a0d44b9ad5ca7814d' 2026-09-14 10:38:56 +02:00
Andreas Reinhold / reiniandClaude Opus 5 c975c5c593 Let a docked toolbar carry a FAB
Plan step 25 (navigation Missing): M3's toolbar configuration table lists
"With FAB" for both toolbars, but the `fab` slot was gated to the floating one.
A docked toolbar now takes it at its end, inside the toolbar where the arrow
keys reach it; its controls gather at the start, and the FAB rests flat on the
bar — elevation 0 for a FAB nested in another component, as Compose's
bottomAppBarFabElevation is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 07:16:26 +02:00
Andreas Reinhold / reiniandClaude Opus 5 7e4e7b44c6 Collapse an extended FAB to a FAB while the page scrolls
Plan step 22, actions.md § Missing (FAB to extended FAB scroll collapse):
M3 has an extended FAB collapse to a FAB on scroll-down and re-extend on
scroll-up, and `<x-button fab>` only swapped on window width.

`<x-fab collapse-on-scroll>` (extended, with an icon) takes a
`materialFab` flag from the new resources/js/fab.js, which reads the
window's scroll once a frame, ignores moves under 8px and extends again
near the top. The morph is CSS in actions.css: the label sits in a grid
track that closes to zero, the gap and minimum width follow on the
default spatial spring, and the label fades on the effects one; reduced
motion swaps outright through the motion tokens. The label is clipped, not
removed, so the collapsed FAB keeps its accessible name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 07:15:56 +02:00
Andreas Reinhold / reiniandClaude Opus 5 a54f79a085 Head a group with M3's divider-with-subheader
Plan step 23, containment.md § Missing ("Divider: the divider-with-text /
subheader configuration"). `<x-divider text="…">` draws the label at the
start and the rule running on from it, with the divider specs table's
geometry: 4dp between the words and the rule, 8dp right margin, 8dp
bottom margin. The label is M3's subhead — title-small in
on-surface-variant, the only subhead type M3 publishes (the rich
tooltip's specs), since the divider page names none. The words stay text
naming the group; only the rule is the separator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 07:15:33 +02:00
Andreas Reinhold / reiniandClaude Opus 5 9c047a8fb1 Let a rail hide when collapsed instead of narrowing
Plan step 25 (navigation Missing): M3's "Expanded behavior | Hide when
collapsed" was only implicit below `medium` in the adaptive rail.
`<x-navigation-rail hide-when-collapsed>` (collapsible and adaptive rails) takes
the rail out of the layout when it is collapsed; `$store.rail.show()` from an
app-bar menu button brings it back expanded over a scrim, sliding in, and its
own menu button docks it again. It does not reach the bands where the window,
not the visitor, collapses a rail (below `medium` for collapsible, `medium` for
adaptive), since M3's collapsed rail may never hide. Every `rail-collapsed`
branch now stops while the rail is open. `<x-app-shell hide-rail-when-collapsed>`
exposes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 07:15:20 +02:00
Andreas Reinhold / reiniandClaude Opus 5 bb9473b0b2 Add M3's uncontained multi-aspect-ratio carousel
Plan step 23, containment.md § Missing ("Carousel: the uncontained
multi-aspect-ratio layout"). `<x-carousel layout="multi-aspect">` is the
layout M3 added in November 2025: each `<x-carousel-item aspect="…">`
keeps its own ratio at the row's fixed height, held inside M3's 9:16 to
16:9 range (a square by default), with 16dp leading padding, 8dp gaps,
the extra-large corner and uncontained (default) scrolling.

The keyline machinery does not fit it: an Arrangement counts items of
one size each, and every snap position and mask follows from that size.
So the layout is a plain flex row, unmasked, and carousel.js only
measures each item's resting position off the DOM, which keeps the
previous/next buttons, the arrow keys, Home/End and bring-into-view
working. The header and SKILL.md say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 07:14:29 +02:00
Andreas Reinhold / reiniandClaude Opus 5 44ae9ecea4 Pin the two-line snackbar, and reach it with Alt+G
Plan step 22, actions.md § Missing (two-line snackbar height, snackbar
keyboard shortcut): the container grew organically from `min-h-12` rather
than sitting at SnackbarTokens.TwoLinesContainerHeight, and there was no
way for a keyboard to reach an actioned snackbar at all.

A `description` is M3's second line, so the snackbar is pinned to 68px
whenever one is there, and below `medium` a two-line snackbar with an
action wraps the action under the text — M3's "two lines with longer
action" configuration. Alt+G, the shortcut M3 suggests for the web, moves
the focus to a snackbar that carries an action from wherever the page had
it; `event.code`, because Alt rewrites `event.key` on some layouts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:45:06 +02:00
Andreas Reinhold / reini c5f764a78c Merge branch 'worktree-agent-a4fc1072037346c95' 2026-09-14 06:44:46 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 c8357cfd4d Give a scrolling chip set a scroll button at each edge
M3 § Chips, Accessibility asks that a row which overflows horizontally carry a
visible affordance; `<x-chip-set scroll>` faded its edges and left it there
(plan step 24, audit docs/audits/m3-alignment/inputs.md § Missing). Where the
pointer is fine, and so there is no swipe to reach for, a button now sits over
each fading edge and scrolls the row by most of its width. They are pointer
affordances only — no tab stops, since the arrow keys already walk every chip —
and the row's scroll padding grows on a fine pointer so a chip the keyboard
reaches clears the buttons as well as the fade. The scroller became a ref,
because the buttons stand outside it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:43:38 +02:00
Andreas Reinhold / reiniandClaude Opus 5 6af501c966 Let a button group own its selection
Plan step 22, actions.md § Missing (Selection-required / multi-select
semantics): M3 lists single-select, multi-select and selection-required as
button-group configurations, and `<x-button-group connected>` left
selection entirely to the caller's own `aria-pressed`.

`selection="single|multi"`, with `required`, takes `aria-pressed` over:
pressing a button writes its `value` to `wire:model` or `x-model`,
deselects the others in `single`, and refuses the press that would leave
nothing selected. With no model it reads the buttons' own `aria-pressed`
once and goes on from there.

`<x-group>` and this do not absorb one another, and the header and
SKILL.md say why: `<x-group>` is for a choice whose options are data — it
renders real inputs, posts in a plain form and paints its own segments —
and stays the first thing to reach for; a selection group governs buttons
the caller writes, manages state and shape, and leaves each button to draw
its own colours from its own `:selected`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:43:34 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 944431668d Give a bottom sheet M3's preset heights
Plan step 23, containment.md § Missing ("Bottom sheets: preset heights
and the handle's cycle-through-heights behaviour"). `heights` (or `snap`,
which is 25/50/90dvh) lists the stops a sheet moves between; it then
takes its stop's height and opens at the stop that equals `height`, or at
the first. Fewer than two stops is no stops, since M3 only requires the
non-drag alternative "if multiple preset heights exist".

That alternative is the drag handle, which M3 names a button: activating
it moves to the next stop and announces it in a live region, and from the
last stop it closes the sheet — M3's "selecting the drag handle toggles
preset heights or closes the sheet", and the same thing a handle with no
stops has always done. A drag now runs the sheet's height with the
pointer and settles on the nearest stop, or closes below the smallest one
or on a downward flick.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:42:37 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 549d96b0dc Give search M3's icon entry point and its suggestions
M3 § Search names three entry points and only the bar existed; suggestions
before the first keystroke were missing too (plan step 24, audit
docs/audits/m3-alignment/inputs.md § Missing). `trigger="icon"` is the search
icon button — search as a secondary action, one 48px button that expands into
the full-screen view at any width, since an icon button has nothing to dock
under, and takes its focus back on close. The `suggestions` slot stands where
the results do until something is typed; the live region counts whichever of
the two is on screen and names suggestions as suggestions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:40:48 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 a5acbe7f6b Let the rail centre its destinations
Plan step 25 (navigation Missing): M3's "alignment: top or center; on tablets
prefer center alignment for reach" had no implementation — the rail was
top-aligned only. `<x-navigation-rail align="center">` centres the destinations
between the header and the footer, which stay at their ends because M3 keeps the
menu icon and the FAB top-aligned. `safe center` gives the top back the moment
there are more destinations than fit, where centring would push the first one
out of reach above the scroller.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:40:36 +02:00
Andreas Reinhold / reiniandClaude Opus 5 5d0e9bc12f Square a button group and the choice drawn as one
Plan step 22, actions.md § Missing (Square-by-default button groups): M3
lists "Default shape | Round, square" as a button-group configuration and
neither `<x-button-group>` nor `<x-group>` had a `shape` prop, so a group
of square buttons still drew round outer corners.

`shape="square"` passes the square corner scale to every child through
`data-shape` and two new variables in groups.css: `--group-outer` is what
the two ends of a group round to (half the height when round, the corner
its inner edges take when square — M3's square table, 4/8/8/16/20dp), and
`--group-square`/`--group-square-pressed` give a standard group's buttons
the scale `<x-button shape="square">` draws. The selected rules keep
`--group-full`, so a selected button in a square group rounds: M3 has a
toggle inside a group swap shape on selection either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:40:14 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 cef350d739 Add the rail's narrow collapsed width
Plan step 25 (navigation Missing): NavigationRailCollapsedTokens
.NarrowContainerWidth, 80dp, had no way in — only the default 96.
`<x-navigation-rail width="narrow">` takes it; the collapsed width is now a
variable, so the four rules that each have their own reason to name it follow
without a specificity fight. No label fits under a 56px indicator at 80px, so a
narrow rail's items are their icons alone, with the label taken out of the
drawing and left in the accessibility tree, where it names the destination.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:39:31 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 07759cd664 Offer M3's standard side sheet beside the content
Plan step 23, containment.md § Missing ("Side sheets: a genuine standard
variant"). `<x-drawer standard>` is M3's other side-sheet variant: from
`expanded` it is co-planar with the content — no scrim, no focus trap,
nothing inert, 0dp elevation, `surface` rather than surface-container-low,
no corner, and an outline-variant rule down its inner edge in place of the
scrim — sitting in the page flow at the window's full height. Below
`expanded` it is the modal sheet, since M3 caps a side sheet at 400dp and
a medium window has too little left beside one. Capped at that 400dp
whatever `width` says, and it always draws the close affordance M3
requires, because nothing else closes it there.

`pane` is untouched and the header and SKILL.md say how the two differ:
pane is the list-detail companion, standard is supplementary content.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:38:16 +02:00
Andreas Reinhold / reiniandClaude Opus 5 88c46001fb Filter a menu from a field at the top of it
Plan step 22, actions.md § Missing (Menus as a filtering surface): M3's
menus page describes a menu that embeds a text field and filters its
options as you type, and nothing in the library did that.

`<x-menu filter>` renders the field, sticky above the list, and hides the
rows the query leaves out — client-side over the items already rendered,
so nothing is fetched and a `wire:click` stays where it was. The field
keeps the focus and the arrow keys move a highlight it names through
`aria-activedescendant`, the APG combobox keyboard `<x-choices
searchable>` already uses; Enter chooses the highlighted row, and a query
that leaves nothing says so. The list becomes a `role="menu"` inside the
popover, because a text field is not something a menu may contain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:38:02 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 ce1cd6eec0 Let the navigation bar hide on a scroll down
Plan step 25 (navigation Missing): M3's "hides on scroll-down, reappears on
scroll-up" had no implementation. `<x-navigation-bar hide-on-scroll>` slides the
bar out on the default spatial spring — an instant swap under reduced motion,
which zeroes the duration token — and never while a snackbar, a bottom sheet or
a drawer is anchored to its edge; focus reaching the bar brings it back.
`<x-app-shell hide-bar-on-scroll>` picks it, and --material-bottom-bar follows
the bar down and up so a `fab` button and the snackbar keep their offsets.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:36:57 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 2662040c24 Open a range picker full screen on a compact window
M3 § Date Pickers, Anatomy describes a 14-element full-screen range picker for
compact breakpoints; `<x-datepicker range>` always used the 360px modal dialog,
which on a phone is cramped for a two-month scroll (plan step 24, audit
docs/audits/m3-alignment/inputs.md § Missing). A third presentation, "full",
joins docked and modal: the same dialog grown to the screen, an app bar with a
close button and Save, the supporting text and the range as the headline over a
divider, the weekday labels held at the top, and the months in one vertically
scrolling list, each under its own label. The grid now draws from one `rows`
getter, which is this month's weeks everywhere else, so the docked and
single-date pickers render exactly as before. The list is a window of months
grown by the scroll and by the keyboard, since nothing here is lazy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:36:49 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 b1f64771f3 Draw M3's dragged state on a card
Plan step 23, containment.md § Missing ("Cards: the dragged state").
`data-dragged` on `<x-card>` takes the top of M3's card elevation scale
— Level4 (8dp) elevated, Level3 (6dp) filled and outlined — under the
16% dragged state layer from tokens/state.css. The row's hover and press
rules now exclude a dragged card, so the card a pointer is holding stays
at its dragged level. The application owns the attribute: nothing in the
browser tells a card it is being carried.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:34:35 +02:00
Andreas Reinhold / reiniandClaude Opus 5 3f651c5c08 Cluster menu items by a gap, as Expressive does
Plan step 22, actions.md § Missing (Grouped menu layout by gap): M3
Expressive's "Grouped" layout separates clusters with a gap rather than a
divider, and `<x-menu-group>` only had the labelled form.

`<x-menu-group gap>` holds its items in a box 2px apart
(SegmentedMenuTokens.SegmentedGap) whose ends round like a list's, and
stands 8px off its neighbours — the same 8px the separator keeps around
its line. `label` is now optional, so a cluster can be a gap alone. The
header says when to reach for which: the divider first, and always in a
menu long enough to scroll, where M3 says gaps are unsupported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:34:19 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 4d00d87cd2 Add M3's tall navigation bar
Plan step 25 (navigation Missing): NavigationBarTokens.TallContainerHeight,
80dp, had no prop. `<x-navigation-bar tall>` picks it, and the tall container
keeps the vertical item layout at every width — the 600px horizontal layout is
the short bar's alone, so every rule behind that container query now names
`:not([data-tall])`. `<x-app-shell tall-bar>` passes it through and grows
`--material-bottom-bar` to 5rem with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:33:24 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 927e439064 Lead a list item with M3's video slot
Plan step 23, containment.md § Missing ("Lists: leading video slot").
`<x-list-item video="…">` (or `<x-slot:video>`) draws M3's leading media
in its landscape sizes: 100×56px in a two-line item, 114×64px in a
three-line one (ListTokens' LeadingVideoSmall 56×100dp and
LeadingVideoLarge 64×114dp), with the small corner the leading image
takes. The tallest element sets an item's height, so a video lifts the
item to at least the 72px two-line row — 56 + 2×8 is 72 exactly, and
64 + 2×12 is 88.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:32:15 +02:00
Andreas Reinhold / reiniandClaude Opus 5 25091ebf21 Open a submenu from a menu item
Plan step 22, actions.md § Missing (Submenus): M3's Expressive vertical
menu specifies submenus and the Left/Right keys that open and close them,
and the library had neither.

`<x-menu-item submenu>` puts its slot in a second popover on the item's
end, flipping to the start where there is no room. `materialSubmenu`
reuses the menu button pattern one level in: the item is its own trigger,
so the anchor move and the button lookup are overridden away; Right,
Enter and Space open it, Left and Escape close it and return the focus,
and a fine pointer resting on the item opens it after a moment. `items()`
now stops at the popover it belongs to, so the arrows never walk between
a menu and an open submenu. The menu publishes its container as custom
properties so a vibrant menu's submenus are vibrant too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:31:56 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 790ef93c6a Stand a slider up with orientation="vertical"
M3 Expressive's configuration table lists a vertical orientation the library
had no prop for (plan step 24, audit docs/audits/m3-alignment/inputs.md
§ Missing). The drawing is the horizontal one, laid out as long as the slider
is tall inside a size container and turned a quarter anticlockwise, so the
geometry, the sizes and the tokens are untouched; the value label and the inset
icon are turned back so they read across. The keyboard stays the native range's
— Up and Right raise, Down and Left lower — and the inputs say
aria-orientation. A vertical slider takes its length from its wrapper, so the
header, the SKILL.md entry and the showcase all say to give it a height. M3
keeps range sliders horizontal, so `range` wins over `orientation`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:31:07 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 cc91e99a5d Say how M3 wants a checkbox group laid out at expanded
M3 § Checkbox, Behaviour asks that from expanded (840px) related checkboxes be
gathered into a contained region rather than left as one long column. That is a
rule about the page around the control, not about the control, so it is written
down rather than built: the component's header and its SKILL.md entry now say
it, and the showcase lays a group out in an `expanded:grid-cols-2` card (plan
step 24, audit docs/audits/m3-alignment/inputs.md § Missing).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:24:02 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 b9b7840b8d Point the development skill at the material-3-design skill for the rules
Plan step 27: the names stay here, the rules and tables live in the design skill.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:23:49 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 53d362622d Say when to use each token on the showcase's foundation pages, and name both skills in the README
The paragraphs restate the material-3 guideline's rules beside the tokens they
govern (plan step 28); the README's AI agents section names the second guideline
and skill (step 30).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:23:31 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 72a3e22fbc Bound a text field's width from medium
M3 § Text Fields, Behaviour: compact may let a field span full width, but
medium and expanded must bound it — "never let it span the full width of a
large screen". Nothing in the package capped a field (plan step 24, audit
docs/audits/m3-alignment/inputs.md § Missing). From `medium` a field now stops
at 40rem — the site names no number, so the header says where this one comes
from — which a `max-w-*` class beats and `full` takes off. The search bar
already carries M3's own 720px.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:23:08 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 f40138095b Draft the 2.0.0 upgrade note
Breakpoints, cleared scales, the regenerated scheme with contrast levels, inks as
roles, and every changed default and hook the component streams reported.
Plan step 31, to finish after Phase E.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:22:39 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 c8c2d2e13e Count a text field's characters against its maximum
M3 lists "supporting text + character counter" as a configuration of both
text-field variants; nothing in the package drew one (plan step 24, audit
docs/audits/m3-alignment/inputs.md § Missing). `counter` on `<x-input>` and
`<x-textarea>` now puts `n/max` at the end of the supporting-text row, counted
from the control on every input, in the error colour past the maximum, and said
as M3's own "Character count, 5/20" from a polite region once typing settles.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:21:22 +02:00
Andreas Reinhold / reini ce04101f89 Merge branch 'worktree-agent-ab53265ffbd837742' 2026-09-14 06:21:01 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 0d7489a995 Keep the carousel label white over its scrim
The scrim is --md-sys-color-scrim, black in every scheme, theme and contrast level,
so the ink over it is the absolute the theme keeps rather than inverse-on-surface,
which is dark in a dark scheme (containment C-25, revisited).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:20:26 +02:00
Andreas Reinhold / reini 24520402c1 Merge branch 'worktree-agent-a6769a5cd2c4b070e' 2026-09-14 06:19:47 +02:00
Andreas Reinhold / reiniandClaude Opus 5 2482a0b6d4 Square the rail's collapsed FAB whatever size it is
The actions stream has since given the small extended FAB an 80px minimum width,
which the morph would have kept once the label closed. Dropping that minimum and
squaring the box against the height its size already sets gives back the 56, 80
or 96px FAB without this file naming any of those numbers.
Plan: docs/plans/material-3-alignment.md, step 21 (navigation N-23).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:19:18 +02:00
Andreas Reinhold / reini b921be855d Revert "Mark a FAB's root with data-fab"
This reverts commit 4d4862189a.
2026-09-14 06:18:19 +02:00
Andreas Reinhold / reiniandClaude Opus 5 5e58dd5449 Scroll the full-screen carousel down the page, as M3's does
Plan step 19, containment.md C-11. The full-screen layout was another
horizontal row with a 28px corner and a mask, where M3's "shows one
edge-to-edge large item at a time and scrolls vertically". It is now a
vertical scroll-snap column: items fill the row with no corner and no
mask, 16dp apart, no end padding, the previous/next buttons point up and
down and the arrow keys are Up and Down. The row is capped at the 840px
medium window, which with the portrait rule in the header comment is as
far as CSS can hold M3's "compact and medium, portrait only". The ported
FullScreenCarouselStrategy keylines go with it: nothing measures them
any more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:17:31 +02:00
Andreas Reinhold / reini d74fa250ee Merge branch 'worktree-agent-a6b15d4e17dad5970' 2026-09-14 06:17:26 +02:00
Andreas Reinhold / reiniandClaude Opus 5 7d14f2bd03 Morph the rail's FAB, and offer a divider, no fill and a width floor
M3 asks the rail to animate its FAB into an extended FAB as it expands; the
documented pattern was two FABs swapped by `display`, so the collapsed one
appeared at once while the rail's width was still springing. The header now takes
one <x-fab label icon> whose label springs open and shut on the rail's own
spring — and names it at both widths, where the collapsed FAB had needed a
tooltip to have a name at all.

M3 also lists two container treatments as optional that the library had neither
of: `divider` draws the vertical divider on the page's edge, which is its own
answer to a page scrolling under a fixed rail, and `fill="false"` drops the
container colour. Neither reaches a rail open over a scrim. And a `collapsible`
rail, whose width is the visitor's choice rather than the window's, is held to
the collapsed 96px below `medium`, where M3 asks for a navigation bar instead.
Plan: docs/plans/material-3-alignment.md, step 21 (navigation N-22, N-23, N-24).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:16:37 +02:00
Andreas ReinholdandClaude Opus 5 9aa02df69f Cut the badge's example glyph at 20 too
A 12px icon beside a status label came from the 24px cut in every place the
library shows one off. Plan step 18, the optical-size pass behind actions.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:16:22 +02:00
Andreas ReinholdandClaude Opus 5 611cd7061d Point the snackbar browser test at the region that moved
The live region is the toast host itself now, not a descendant of it, so the
selector that looked for `[aria-live]` inside the host found nothing. Plan step
11, actions.md ACT-02. (The Browser suite was not run from this worktree.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:15:37 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 71442831ff Name the error icon in the field's anatomy
Plan step 20, finding IN-18.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:15:10 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 a13f5e44a6 Wait for the search view's ref before watching it
Alpine registers x-ref as it walks the children, so $refs.view is not
there yet when the component's init() runs.

Plan step 13, finding IN-01.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:14:40 +02:00
Andreas Reinhold / reiniandClaude Opus 5 9875ba156e Put the carousel's tab stop on its items, as M3 asks
Plan step 19, containment.md C-18. The row was the focusable `region`
and the items were not focusable at all, which is the thing M3's
accessibility page draws a Don't for: "use Tab to place initial focus on
the first carousel item", "avoid focusing on the carousel container".
Each item is now `tabindex="0"` with the focus ring drawn inside it, the
row is out of the tab order, and from a focused item the arrows move one
item (moving focus with them), Home and End go to the ends, and Space or
Enter opens one that is not fully in view.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:14:34 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 0ffb2b06db Hold the docked search view's 240px floor to a view with results
A search whose slot renders nothing would otherwise open as an empty
240px box; M3's minimum is about a container with something in it.

Plan step 20, finding IN-20.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:13:45 +02:00
Andreas ReinholdandClaude Opus 5 05236ecb3a Let a tooltip linger, and tell the screen reader what it says
A plain tooltip vanished the instant the pointer left, where M3 keeps one for
1.5s and shows only one at a time — these are popover="manual", so nothing was
closing the last one. A rich tooltip's bubble was never associated with its
trigger at all, so the explanation it exists to give was never announced; the
script now writes aria-describedby (and aria-haspopup/aria-expanded when it is
persistent) onto the focusable control in the trigger slot, and again after a
morph. Plan step 18, actions.md ACT-22, ACT-25.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:13:14 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 0074f9bd5c Finish the keyboards, and say a state once
Shift+M and Shift+Y reach the month and year dropdowns, which is M3's
date picker keyboard table; Home and End go to the ends of an open
combobox list, and stay the field's own caret keys when it is closed. The
password reveal drops aria-pressed and keeps only its flipping label, so
a screen reader hears the state once rather than twice and inverted. The
radio's `inline` now says that M3 cautions against a row and wants an
option chosen on load.

Plan step 20, findings IN-22, IN-23, IN-28 and IN-29.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:13:00 +02:00
Andreas Reinhold / reiniandClaude Opus 5 1218371568 Give the account-menu avatar a state layer
The trigger had the focus ring but no hover and no pressed feedback at all,
where every comparable trigger in the library carries `state-layer` and M3 asks
for two visual indicators per state. An avatar image sits behind the layer, so
the wash shows over a photograph too.
Plan: docs/plans/material-3-alignment.md, step 21 (navigation N-15).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:12:44 +02:00
Andreas Reinhold / reiniandClaude Opus 5 ad37862a9f Give the carousel M3's end padding and a role for its label
Plan step 19, containment.md C-12 and C-25. `padding` defaulted to 0
where M3's specs table gives every layout 16dp of leading and trailing
padding — leading only for `uncontained`, none for the edge-to-edge
`full-screen` — so the default is 16 and each layout now says which
ends it applies to (`data-padding`, `data-padding-end`). The row gains
the 8dp above and below that goes with it, without shortening the
items. The overlay label is `inverse-on-surface` instead of a literal
`white`, which no scheme or contrast profile follows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:12:24 +02:00
Andreas ReinholdandClaude Opus 5 56d82b71f3 Put the badge on M3's anchor geometry and a visible edge
A floating dot sat 2px inside the icon's top-trailing corner where M3 puts it
flush, and a floating count 4px above it where M3 says 2px. An `outline` status
label drew its edge in outline-variant, the decorative role dividers use, which
is about 1.5:1 on surface — M3 asks a badge for 3:1, so it takes `outline`.
Plan step 18, actions.md ACT-23, ACT-24.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:11:52 +02:00
Andreas Reinhold / reiniandClaude Opus 5 5205aa8842 Offer the app shell a window-wide banner above the rail
M3's scaffold is bars, then rails, then panes, and the shell had no slot that
rendered above the rail: an application-wide bar could only be a pane bar beside
it. `banner` is that slot — the shell is a column now, with the rail and the page
as one row inside it — and the rail sticks under a pinned banner through
--material-banner rather than behind it. `top` still means the page's own bar.
Plan: docs/plans/material-3-alignment.md, step 21 (navigation N-14).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:11:49 +02:00
Andreas ReinholdandClaude Opus 5 c33d90c653 Stop the alert interrupting, unless it is asked to
An error or warning alert was role="alert", an assertive live region — but the
usual alert is part of the page as it renders, which some screen readers then
announce over the page title, and a Livewire morph re-announces. It is now
role="status" whatever its colour, with an `assertive` prop for a notice put on
screen in answer to something the person just did. Its 40px dismiss button takes
the shared touch-target. Plan step 18, actions.md ACT-18, ACT-36.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:11:09 +02:00
Andreas Reinhold / reiniandClaude Opus 5 657fa526fa Put the mail card on M3's corner scale and its header at title-lg
Plan step 19, containment.md C-27 and C-28. `.inner-body` was 24px, a
value not on M3's 0/4/8/12/16/20/28/32/48 scale; it is 28px, the
extra-large corner of the dialog and bottom-sheet surface it stands in
for. `.header a` is title-large at weight 400, which its own section
comment and the `h2` under it already said.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:10:02 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 856b6f476b Say a field is in error twice, and stop dashing a read-only one
M3 wants two visual indicators per state and names the pair for a text
field: the error colours and a trailing error icon. The field now draws
one, labelled "Error", unless the caller already trails it or the field
is xs. A read-only field loses its dashed outline: M3 keeps "the same
visual style as an editable field", and the native readonly attribute is
what tells a screen reader. The data-readonly hook stays.

Plan step 20, findings IN-18 and IN-19.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:09:57 +02:00
Andreas ReinholdandClaude Opus 5 7bc4f740e7 Take the icon off the snackbar and lift the FAB clear of it
M3 says to avoid an icon in a snackbar and gives the supporting text no fourth
colour, so the state glyph and the 80% description are gone: a type now only
picks the announcement role. The 40px action and close buttons take the shared
touch-target, Escape dismisses a snackbar that holds the focus, and the host
publishes --material-snackbar-height on <html> so a `fab` button sits above the
snackbar instead of under it. Plan step 18, actions.md ACT-17, ACT-18, ACT-20,
ACT-34, ACT-35.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:09:47 +02:00
Andreas Reinhold / reiniandClaude Opus 5 a237d33426 Ease a collapse open, which its own comment already promised
Plan step 19, containment.md C-24. `interpolate-size: allow-keywords`
was set and nothing transitioned, so the content snapped open. A new
resources/css/components/collapse.css transitions `block-size` on
`::details-content` over the fast spatial spring, with
`content-visibility` `allow-discrete` beside it so the section stays
rendered while it closes. The `<details>` carries `data-collapse` for
the rule to find.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:09:23 +02:00
Andreas Reinhold / reiniandClaude Opus 5 c3bed743aa Hold the navigation bar item to its own label and state-layer tokens
NavigationBarTokens names one label font for both icon positions, label-medium;
the horizontal layout had taken label-large from the rail's item, a different
component with a token of its own. And the item's state layer washed in
on-surface over a secondary-container pill, where the only state-layer tokens M3
states for a navigation item — the rail's, which the rail already follows — are
on-secondary-container for all six states.
Plan: docs/plans/material-3-alignment.md, step 21 (navigation N-08, N-19).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:09:05 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 42d62ed00c Make a table's density the caller's decision
Density was applied to every fine-pointer device with no way out, which
M3 forbids outright: "don't apply density by default; offer an explicit
density opt-in instead, keeping opt-out targets at >= 48x48 CSS px". A
row is now 52px and `dense` is a prop that tightens it to 36. The row
divider also takes outline-variant itself rather than 60% of it, since M3
keeps opacity for state layers and disabled.

Plan step 20, findings IN-16 and IN-30.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:08:13 +02:00
Andreas Reinhold / reiniandClaude Opus 5 73937c3726 Align a side sheet's actions left, in M3's 72dp row
Plan step 19, containment.md C-10. The action row was right-aligned with
24px above it; the side-sheet specs table puts bottom actions on the
left in a 72dp row with 16dp above and 24dp below. A dialog's actions
stay trailing-aligned — M3 specifies the two differently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:08:13 +02:00
Andreas Reinhold / reiniandClaude Opus 5 59ca88a0cf Give a vertical toolbar 24dp and a standard one primary buttons
M3 asks a vertical toolbar for a 24dp margin from the window edge where a
horizontal one keeps 16, and its standard-toolbar colour list ends "Standard
button (Primary)" — the vibrant row of the same list was already honoured, so
the two were being read inconsistently. Both rules are unlayered, because an
icon button's ink is a utility.
Plan: docs/plans/material-3-alignment.md, step 21 (navigation N-12, N-13).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:08:13 +02:00
Andreas Reinhold / reiniandClaude Opus 5 256d4f0603 Open a bottom sheet at half the screen, and colour its handle
Plan step 19, containment.md C-19 and C-09. A modal sheet opened at 90%
of the viewport where M3 caps the initial position at 50% and lets the
person pull it up; `height` now defaults to `50dvh` and whatever is
passed is held under a ceiling of the screen less M3's 72dp top margin.
The drag handle is `on-surface-variant` undiluted, as
SheetBottomTokens' DockedDragHandleColor and the component's own header
comment already said.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:07:48 +02:00
Andreas Reinhold / reiniandClaude Opus 5 789e8b2970 Centre the app bar's headline on a grid and curve its search bar
The centred headline was absolutely positioned 56px in from each edge — room for
exactly one of M3's two trailing icon buttons, so a long title ellipsised behind
them. It is now the middle column of a three-column row and shrinks against
whatever is really beside it. The search container follows M3's curve, filling
the row to 312dp and then only half of what is left, so the showcase drops the
hand-written max-width it had been working around it with.
Plan: docs/plans/material-3-alignment.md, step 21 (navigation N-09, N-11).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:07:30 +02:00
Andreas ReinholdandClaude Opus 5 eac7f6a1de Name the FAB menu, scroll it, and let its items leave
Its trigger holds two aria-hidden icons and wrote aria-label only when a caller
passed `label`, so an unnamed button was the default; `label` now falls back to
"Toggle menu" and is always written. The list had no ceiling, so six items ran
off a landscape phone — it now scrolls under the FAB, which stays put — and its
items rose into place but vanished on close, because the popover took its
display away before they could move. Plan step 18, actions.md ACT-21, ACT-32,
ACT-33.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:07:22 +02:00
Andreas Reinhold / reiniandClaude Opus 5 675cfc806a Give a card per-state elevation and M3's two keyboard models
Plan step 19, containment.md C-13, C-23 and the directly actionable card
of C-13/C-18. A card had one elevation and never changed it, and its
hover replaced the elevated card's shadow with the state layer; it now
names its variant in `data-card` and moves a step on hover (elevated
1 to 2, filled and outlined 0 to 1) with the layer over it. The 12 to 16
corner morph on hover is gone: M3 gives a card one shape and lists shape
morph for buttons, FABs and list items only. New `actionable` prop for
M3's directly actionable card — the card is the one tab stop, with
`role="button"` (or `link`), its `title` as the name, and Enter or Space
reaching the opener from resources/js/list-rows.js.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:07:14 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 5989e9ea92 Reach the page numbers and the sort button with a finger
Both were 40px or less, which is M3's state-layer size, not its 48px
target: the paginator's steps and page numbers keep their 40px circle and
now catch presses over 48, and the sort button — a title-small line and a
16px arrow, about 20px tall — does the same, since the cell's padding
belongs to the cell and not to the button inside it.

Plan step 20, findings IN-15 and IN-17.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:06:40 +02:00
Andreas Reinhold / reiniandClaude Opus 5 0aef7f1f96 Scroll a long section nav instead of wrapping it onto a grid
Five or more sections wrapped onto a 3- or 4-column grid, which left the tab
bar's divider under the last row only and stranded every upper row's active
indicator against nothing. From five they are now what M3 prescribes and its
accessibility page defends: a scrollable tab bar, each tab as wide as its label,
offset 52dp. Four or fewer still share the row. The 20px icons take the 20 cut.
Plan: docs/plans/material-3-alignment.md, step 21 (navigation N-16).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:06:15 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 dd3d8afdb9 Walk a chip set with the arrow keys
M3's chip keyboard table makes a set one tab stop that the arrows move
through; ten filter chips cost ten Tab presses today. The set now keeps a
roving tabindex — re-applied whenever a Livewire morph rewrites the chips
— and walks left, right, Home and End, following the writing direction.
The input chip's remove button also grows to a full 48x48 target, where
it was 34px wide.

Plan step 20, findings IN-13 and IN-14.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:05:41 +02:00
Andreas ReinholdandClaude Opus 5 deb7ff8ab5 Take disabled off the FAB, and fix its extended metrics
M3 is explicit twice over — never disable a FAB, remove it instead — and the
prop painted the full disabled treatment with nothing warning against it. It is
gone; hiding the FAB is the documented answer. The extended FAB's icon-to-label
gap goes to M3's 16dp at md and 20dp at lg, the small one takes the baseline's
80dp minimum width, and the root gains a `data-fab` hook for a place that draws
a nested FAB its own way. Plan step 18, actions.md ACT-08, ACT-16, ACT-31.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:05:04 +02:00
Andreas Reinhold / reiniandClaude Opus 5 a9b5efb4b2 Put the tab bar's offsets, indicator inset and ring on M3's numbers
A scrollable set now starts 52dp in, so it reads as scrollable; a primary
indicator is inset 2dp at each side while a secondary one still spans the tab;
a link marked aria-current takes the active label colour the indicator already
followed; and the focus ring is 2px outside the tab, as everywhere else in the
package, paid for with 5px of room around the bar that the state layer and the
indicator reach back into.
Plan: docs/plans/material-3-alignment.md, step 21 (navigation N-10, N-18,
N-20, N-21).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:04:23 +02:00
Andreas Reinhold / reiniandClaude Opus 5 26a9230a48 Give a list item M3's gaps, alignment and expressive icon size
Plan step 19, containment.md C-14, C-15 and C-26. The leading and
trailing gap was 12px where ListItem.kt's LeadingContentEndPadding and
TrailingContentStartPadding are 16dp; a three-line item was
middle-aligned and padded 10px where M3 top-aligns an 88dp item and
Compose pads it 12; and a segmented list's icons stayed at the baseline
24dp instead of ListTokens' expressive 20dp, which the item now reads
from its list with `@aware` and draws from the 20 optical cut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:04:19 +02:00
Andreas ReinholdandClaude Opus 5 9828c9258a Put the menu on M3's Expressive spacing and behaviour
Items were 44px tall with the baseline menu's 12px sides, the separator had half
its 8dp padding, a disabled item was skipped by the keyboard entirely, a chosen
item was told by colour and shape alone, and the list simply faded in place. Now:
48px rows with 16px either side, an 8px separator, disabled items focusable but
never activatable, a trailing check on a selected item, and an enter-exit that
scales the list out of the corner nearest its trigger — the scale on the spatial
spring, the fade on the effects one. Plan step 18, actions.md ACT-11, ACT-12,
ACT-13, ACT-26, ACT-27, ACT-28.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:04:03 +02:00
Andreas Reinhold / reiniandClaude Opus 5 dcd28707dd Let a segmented row answer, and inset a list's dividers
Plan step 19, containment.md C-08, C-21, C-22 and C-16. The segmented
fill was declared unlayered, so it beat every state layer in
@layer components and a segmented row lit up for nothing: it now sits in
the layer, `:where()`d to weigh nothing, ahead of the state rules. Its
tone is `surface`, which is ListTokens' ItemSegmentedContainerColor, and
a keyboard-focused row morphs to the large corner like a pressed one.
`<x-list dividers>` draws ListTokens' 16dp-inset rule instead of a
full-bleed `divide-y`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:03:12 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 47ed4603d0 Draw the small icons from the 20 optical cut
CheckboxTokens' icon size is 18px, the same as the box it fills; the tick
and the dash were 16. Every icon these components draw at 20px or under —
the tick, the switch's check and cross, the chip's leading, trailing,
remove and filter-check icons, the date picker's menu arrows and the sort
arrow — now asks for the optical size 20 cut, which is drawn for that
size rather than scaled down from 24.

Plan step 20, finding IN-12 (and core C16's optical sizes).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:03:02 +02:00
Andreas ReinholdandClaude Opus 5 429be9b64f Fill the FAB and icon-button glyphs, and cut small ones at 20
M3 asks a FAB's icon and a default (non-toggle) icon button's icon to be filled,
never outlined; the toggle half of that rule was already right, so only the
`$selected === null` case changed. Every glyph these components draw at 20px now
passes `optical="20"` for the cut M3 draws at that size — button xs/sm, group,
menu item, the alert, snackbar and FAB-menu close buttons, the stat's icon.
Plan step 18, actions.md ACT-14, ACT-15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:01:55 +02:00
Andreas Reinhold / reiniandClaude Opus 5 02d908c526 Pin a dialog's headline and actions, and let it be an alert dialog
Plan step 19, containment.md C-06, C-17 and C-20. The whole box scrolled,
so a long dialog scrolled its headline and its buttons away; the header,
the body and the action row are now separate rows of the flex column,
each carrying the 24dp padding the box used to hold, and only the body
scrolls. `separator` draws the divider under the header and above the
actions, where it now stays put. The full-screen header is M3's 56dp
(was 64) and its action bar 56 (was ~72). New `alert` prop for M3's "on
web, basic dialogs should have the alert dialog role", and a `subtitle`
is always the dialog's `aria-describedby`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:01:42 +02:00
Andreas Reinhold / reiniandClaude Opus 5 94e2a900d2 Render a tab panel's id and its hidden state on the server
Every panel was visible until Alpine booted — four role="tabpanel" regions at
once for a screen reader, and aria-controls pointing at ids that did not exist
yet. The panel now carries its id, its aria-labelledby and, unless it is the
chosen one, display:none from the server. A slot renders before the component
around it, so the panel reads <x-tabs>'s attributes off the component stack and
repeats its id and initial-tab lines; both files say so.
Plan: docs/plans/material-3-alignment.md, step 14 (navigation N-05).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:01:37 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 9624d4bfd4 Hold the time picker's two selectors apart
The 24-hour clock has no period selector beside the hour and minute
boxes, so M3 widens them to 114px; AM/PM takes its own
tertiary-container rather than borrowing the boxes' primary-container;
a disabled period button uses M3's 12% disabled container, not 10%; and
the pair is a radio group, which is what M3's labelling table asks for,
so the arrows move between AM and PM.

Plan step 20, findings IN-10, IN-11, IN-26 and IN-27.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:01:23 +02:00
Andreas ReinholdandClaude Opus 5 573d94fe6e Give the xs button M3's 16dp padding, and stop groups wrapping
ButtonXSmallTokens puts 16dp either side of an xs label, where the button, the
<x-group> segment and --group-pad all wrote 12px; the variants table calls 16dp
the Expressive value twice over. A standard button group also dropped onto a
second line on a narrow window, which M3 forbids — and the press expansion only
ever reaches a neighbour on the same line. Plan step 18, actions.md ACT-09,
ACT-10.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:00:12 +02:00
Andreas Reinhold / reiniandClaude Opus 5 a99fbd3794 Build the theme and contrast rows on the connected button group
`mode="picker"` hand-rolled an outlined segmented button, point for point the
design M3 Expressive deprecates, and its own arrow keys neither wrapped nor
handled Up/Down. Both rows are now <x-group>: native radios in a fieldset, so the
browser supplies the radiogroup, both axes, the wrap and the roving tab stop, and
the store is bound through an x-model accessor — a contrast row still marks the
resolved level but writes through setContrast(). A new `label` prop legends the
row. Plan: docs/plans/material-3-alignment.md, step 14 (navigation N-04, N-17).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:00:07 +02:00
Andreas ReinholdandClaude Opus 5 597656c88a Take colour off the overshooting spatial spring
Four components animated background-color and color on ease-spatial-fast, whose
fast variant peaks at 1.094 — the one thing tokens/motion.css says a colour must
never do. Tailwind cannot emit two springs on one element, so a small unlayered
file gives shape and size the spatial spring and colour and elevation the
effects one in a single property list. Plan step 18, actions.md ACT-19.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:59:30 +02:00
Andreas Reinhold / reiniandClaude Opus 5 f895b553b6 Leave carousel items unmasked under reduced motion
Plan step 12, containment.md C-05. Only the parallax half of M3's rule
was honoured: the mask was still written every frame, so items kept
growing and shrinking between the keylines. Under reduced motion the
inset, the shift and the label fade are now all zero, so every item
stays at the strategy's large size and the keylines only decide where
the row snaps. `--material-carousel-pin` went with it: with no mask
there is nothing to pin the content to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:59:15 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 f30fd575cf Draw the search bar and its view as the contained style
Expressive deprecates the divided style, so the results lose their
divider; the full-screen layout takes its own surface-container-low, one
step from the docked one; the docked view opens over a scrim, as M3's
variants table says it does; and the bar is bounded at M3's 720px, grows
to that width while focused, and rests with the 24px leading and trailing
padding the specs table gives an unfocused bar. --search-width sets the
resting width for M3's 360px bar.

Plan step 20, findings IN-07, IN-08, IN-09, IN-20 and IN-21.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:58:42 +02:00
Andreas ReinholdandClaude Opus 5 d560e7449c Put the split button on its own geometry
It borrowed the connected group's corner variables, so its inner corners were
wrong at four of five sizes and shrank under the finger where SplitButton*Tokens
grows them; its chevron took the icon-button table's size at every step, sat dead
centre, and turned over on the expressive spring. groups.css now carries
--split-inner, --split-icon and --split-nudge per size, and the rotation uses
M3's standard scheme. Plan step 18, actions.md ACT-06, ACT-07, ACT-29, ACT-30.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:58:13 +02:00
Andreas Reinhold / reiniandClaude Opus 5 335ea4f4f1 Give every side sheet the close affordance M3 requires
Plan step 12, containment.md C-04. `with-close-button` defaulted to
false, so a drawer could render with no exit at all once
`close-on-escape` and the scrim were off. It now defaults to true, and
`:with-close-button="false"` is ignored where nothing else closes the
sheet: Escape off, the scrim off, or a pane, which has neither.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:57:55 +02:00
Andreas Reinhold / reiniandClaude Opus 5 d64fb91303 Announce a selectable list as M3's list box of options
Plan step 12, containment.md C-03. `selected` was colour alone on a
`listitem`, which cannot carry a selected state at all. `<x-list
selectable>` (or `selection="single|multi"`) now makes the container a
`role="listbox"` and each item an `option` with `aria-selected`; a plain
list keeps `role="list"` and marks a selected item `aria-current`. A
selected option draws a trailing check as M3's second cue, which
`icon-right` replaces. The item reads its list's mode with `@aware`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:57:00 +02:00
Andreas ReinholdandClaude Opus 5 e4de067350 Give a connected segment M3's 48px target and width
An <x-group> segment is a plain label, 32px or 40px tall, with nothing reaching
past it, and nothing gave a connected <x-button-group> its 48dp minimum width
either — both numbers M3 names and tells you not to reduce. The segment now
carries the shared `touch-target` utility and a 48px floor, and groups.css puts
the floor on the buttons a caller nests in a connected group. <x-button> drops
its hand-rolled ::after for the same utility. Plan step 11, actions.md ACT-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:56:52 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 66f9d36a37 Draw the slider on M3's own specs table
The md handle is 52px, not the 44 the MDC tokens gave it; the value
indicator is M3's 44x48 label container, not Flutter's 32px pill; and a
stop indicator, a tick and the inset icon now take the "on" colour of the
part of the track they sit on, which M3's nine colour roles spell out and
the library had inverted. Space held with an arrow moves by the same
large interval as PageUp, M3's "Space & Arrows" row.

Plan step 20, findings IN-04, IN-05, IN-06 and IN-24.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:55:41 +02:00
Andreas ReinholdandClaude Opus 5 c1ca157341 Scroll a menu that is too long for the window
The popover was fit-content with overflow visible, so a long menu ran past the
edge of the top layer, where the page's own scrolling cannot reach it. It now
caps at 18rem — less on a short window — and scrolls, as M3's menu behaviour
asks, and the keyboard brings the item it moves to into view. Plan step 11,
actions.md ACT-04.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:55:38 +02:00
Andreas Reinhold / reiniandClaude Opus 5 98afef7b45 Rest a FAB nested in the rail at elevation 0
M3 puts a FAB nested in another component at level 0, and the documented pattern
puts one in the rail's header, so every documented use sat at elevation 3. The
rule is unlayered: the FAB's shadow is a utility and a layer would lose to it.
Plan: docs/plans/material-3-alignment.md, step 14 (navigation N-03).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:54:52 +02:00
Andreas ReinholdandClaude Opus 5 6e24ff2cf8 Never time out a snackbar that carries an action
M3's accessibility page forbids it outright: an actioned snackbar has to wait
for the person to read it and reach its action. An entry with an action and no
timeout of its own is now untimed, the close button it already draws being the
way out; a timeout written out still wins. Plan step 11, actions.md ACT-03.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:54:42 +02:00
Andreas Reinhold / reiniandClaude Opus 5 7f67856916 Stop a disabled list item answering the keyboard
Plan step 12, containment.md C-02. `pointer-events-none` blocked the
pointer but not Tab and Enter: the item's `<a href>` stayed in the tab
order and navigated, and nothing announced it disabled. A disabled item
now renders its title as text, is no longer a `data-list-row`, and
carries `aria-disabled="true"`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:54:25 +02:00
Andreas Reinhold / reiniandClaude Opus 5 4d4862189a Mark a FAB's root with data-fab
A FAB nested in another component has to be restyled from that component's
stylesheet, and its root carried no hook. Added here rather than with the rule
that needs it, so the actions stream's own use of the hook merges cleanly.
Plan: docs/plans/material-3-alignment.md, step 14 (navigation N-03).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:54:09 +02:00
Andreas ReinholdandClaude Opus 5 04b655fa9b Keep the snackbar's live region in the page
The region carrying aria-live was created together with its message, inside the
x-if that draws the snackbar, so a screen reader had nothing to notice changing.
It moves to the permanent host, which is polite and atomic as M3 asks; a type
now only picks the region's role, and the explicit aria-live keeps an error
polite too. Plan step 11, actions.md ACT-02.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:53:51 +02:00
Andreas Reinhold / reiniandClaude Opus 5 f02fa1a78d Give the bottom sheet's drag handle a 48dp target
Plan step 12, containment.md C-01. The handle button was 32x4px: the
padding that makes M3's 48dp hit target sat on the wrapper, which is not
the control. `touch-target` on the button and 22px above and below it —
SheetDefaults.kt's DragHandleVerticalPadding — make the pressed area
48x48 while the handle is still drawn 32x4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:53:46 +02:00
Andreas Reinhold / reiniandClaude Opus 5 700869ded4 Lift a bottom-placed toolbar above the navigation bar
`place="bottom"` ignored --material-bottom-bar, so a floating toolbar overlapped
the shell's navigation bar and a docked one landed squarely on it. Both now clear
it — max(), not a sum, since that height already swallows the bottom safe area —
and the docs say what M3 says: never show a docked toolbar and a navigation bar
together. Plan: docs/plans/material-3-alignment.md, step 14 (navigation N-02).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:53:37 +02:00
Andreas ReinholdandClaude Opus 5 91f49be6d7 Draw a button's spinner in the button's own ink
The loading indicator inside a filled or tonal button was painted in
text-primary on a primary container — 1:1 contrast. M3 asks an indicator
embedded in another component to take that component's label colour, so the
button now passes text-current, which loading.blade.php reads as a caller
colour and leaves alone. Plan step 11, actions.md ACT-01.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:52:26 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 da4952e684 Give an unlabelled checkbox, radio or switch a 48px target
A selection control with a label is pressed anywhere along its row, but
one without — a table's "select all", a row's tick — was only its 18, 20
or 32px box, because the 40px state layer is a ::before that catches no
pointer. The box now wears the shared touch-target utility, which M3 asks
for on all three (checkbox, radio and switch specs: target size 48dp).

Plan step 13, finding IN-03.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:52:23 +02:00
Andreas Reinhold / reiniandClaude Opus 5 4a4982b1ef Reach M3's 48px target from every 40px navigation control
The theme toggle and the account-menu avatar are drawn 40px and had no target
extension; both now carry `touch-target`, and the avatar's clipping moves from
the button (which would have cut the pseudo-target off) to the <img>. The rail's
hand-rolled `after:` target becomes the same utility.
Plan: docs/plans/material-3-alignment.md, step 14 (navigation N-01).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:52:09 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 35548dc13b Leave a disabled field's outline dark on hover
The hover declaration sat on .field-box while the disabled edge is
inherited from .field, and a direct declaration beats an inherited one
whatever the selectors weigh — so a disabled outlined field lit a
full-opacity on-surface outline. It is now scoped past a disabled control
and, like every other state layer here, kept inside @media (hover: hover)
so a tap does not leave it stuck. M3 gives a disabled control no state.

Plan step 13, finding IN-02.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:50:38 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 31663c48ba Say how many search results there are, in a combobox
M3's search accessibility page asks that results be announced when they
appear and read as a list. The bar now wraps its input in a
role="combobox" that carries aria-expanded and aria-controls — ARIA gives
a bare textbox neither — the results container is a role="list", and a
polite live region counts the results whenever the view's DOM settles.

Plan step 13, finding IN-01.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:50:15 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 0adfb6fff4 Show M3's window size classes on a Layout page of the showcase
The page names the class this window is in, lights the five cards through the
compiled variants themselves, and tabulates what each class changes: navigation,
panes, dialogs and sheets, margins. Plan step 28, in part.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:44:53 +02:00
Andreas Reinhold / reini 18023166bb Merge branch 'worktree-agent-a9537eabbf38e0608'
# Conflicts:
#	resources/views/components/scheme-picker.blade.php
#	resources/views/components/theme-script.blade.php
#	resources/views/showcase/sections/colour.blade.php
#	tests/Feature/Components/ThemeScriptTest.php
2026-09-14 05:43:37 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 4ba28b8579 Skip the swatch levels when the picker draws nothing
Without profiles the component renders nothing, so it should not read the scheme
file once per contrast level first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:41:14 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 6aa8a48084 Hold the mail theme to the standard contrast level
A mail client resolves no attribute, so the JSON's top level — the standard roles —
is what it wears, whatever levels the file carries beside them.
Plan: docs/plans/material-3-alignment.md, step 7 (core C2).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:40:35 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 7a17e58ee6 Write the contrast levels and the harmonisation flag down
The skill's colour and theme sections and the README's scheme and theme.* bullets
now carry what the command writes: the three levels, --harmonize, the state colours
built as error is, data-contrast, theme.contrast, motion.scheme, and the store's
contrast, resolvedContrast and setContrast(). Plan: docs/plans/material-3-alignment.md,
steps 6 and 7 (core C1, C2, C15).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:39:46 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 57d9e0aa6f Offer the contrast level where the theme is chosen
<x-theme-toggle mode="contrast"> is the picker's row for M3's three levels, marking
the one in force so the operating system's setting shows while the choice is system.
The scheme picker's dots follow the level on screen instead of always promising the
standard colours, and the showcase's colour section puts the three levels side by
side — data-scheme beside data-contrast, the way a profile's level blocks key.
Plan: docs/plans/material-3-alignment.md, step 7 (core C2).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:38:31 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 216a15307a Square the full-screen dialog with the M3 corner token, and guard every package view
The one rounded-none left behind by the scale clearing becomes rounded-corner-none,
so the design guard's test of the package's own views needs no filter any more:
the breakpoint migration landed too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:38:02 +02:00
Andreas Reinhold / reini f4f0ecb3b8 Merge branch 'worktree-agent-aadcbb716875e9282' 2026-09-14 05:37:13 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 ceaad7cfe9 Rewrap the drawer's pane paragraph
A comment-only follow-up to the pane's move to `expanded`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:36:42 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 9fb3126038 Scan for Tailwind breakpoints, and drive the shell by class in the browser
Plan steps 15-17's testing. `tests/Feature/BreakpointsTest.php` reads every
file under resources/views, resources/css and resources/js — showcase example
heredocs included — and fails on any Tailwind breakpoint prefix and on any
media query or `matchMedia` string at 40, 48, 64 or 80rem (or 640/768/1024/
1280px, or the 39.99rem that stood for "below 640"). Both scans were checked
against a deliberately reintroduced `sm:w-auto` and an 80rem query.

Every render test that asserted a `sm:`/`lg:`/`xl:` class now asserts the M3
class, the drawer's pane query is 52.5rem, and the theme script keeps
`data-rail-auto` through `wire:navigate`.

NavigationTest drives the shell at 599/600/839/840/1199/1200/1600 with the
expectation each class carries: the bar on a compact window, the 96px rail from
the medium class's first pixel, a standard rail collapsed through expanded whose
menu button widens it in place with no scrim, and the rail expanded to begin
with from large and at extra-large. (Not run here — the Browser suite is out of
scope for this stream.)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:35:43 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 afc8e199ae Teach the M3 window size classes in the skill and the README
Plan steps 15-17. Every breakpoint the skill named is now a window size class:
the wrapper idiom (`max-medium:hidden`), `<x-app-shell>`'s section gets a table
of the four classes with their navigation and margin, `<x-drawer pane>` is
"from expanded" at 360dp, and `<x-section-nav>`, `<x-search>`, `<x-datepicker>`,
`<x-modal fullscreen>`, `<x-button fab|responsive>`, `<x-navigation-bar>`,
`<x-navigation-rail mode="adaptive">`, `$store.rail` and the FAB and fab-menu
margins follow. A new Tokens bullet states the rule once: `medium:` 600,
`expanded:` 840, `large:` 1200, `extra-large:` 1600, Tailwind's screens cleared,
scripts through resources/js/breakpoints.js, a container query (`@md:`) a
different thing.

The README gains the same warning where it tells an application how to import
the package's CSS, since a leftover `sm:` from another project now compiles to
nothing rather than to 640px.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:35:43 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 7084aac788 Resolve the contrast level and the motion scheme before the first paint
The head script reads livewire-material.theme.contrast (standard, medium, high or
system, kept under its own storage key), asks the operating system while the choice
is system and follows it, and writes <html data-contrast> — nothing for standard,
which is what the stylesheet's plain blocks already are. It writes
<html data-motion="standard"> for motion.scheme, and both attributes, with the new
choice and key ones, survive wire:navigate. $store.theme gains contrast,
resolvedContrast and setContrast(); the theme-color meta takes its surface from the
resolved level. Plan: docs/plans/material-3-alignment.md, steps 5 and 7 (core C2, C9).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:34:29 +02:00
Andreas Reinhold / reini 56e28ef54a Merge branch 'worktree-agent-a221f2b34317526ab' 2026-09-14 05:33:13 +02:00
Andreas Reinhold / reiniandClaude Opus 5 e2d249a409 Say what the design guard now reports and what to write instead
The "Testing the design" section of the README and of the Boost skill list the
cleared breakpoints and scales with their M3 replacement, the arbitrary-colour
check, and the two opt-in checks for the absolutes and for opacity on ink.
Plan: docs/plans/material-3-alignment.md, step 9 (core C4a, C13).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:32:31 +02:00
Andreas Reinhold / reiniandClaude Opus 5 faf5132c7a Name the M3 replacement for every value the theme cleared
DesignGuard gains checks for Tailwind's breakpoint prefixes, for the radius,
shadow, type-size, weight, leading, tracking, easing and duration scales
tokens/theme.css clears, and for a colour written as a value; each violation
carries path:line, the token and the M3 utility to use instead. forbidAbsolutes()
and forbidOpacityInk() are opt-in like forbidColours(), because the package's own
disabled styles are drawn with M3's 38 %/12 % opacities. A fixture and a test per
pattern and per hint; the package's own views are asserted against every check
but the breakpoint one, which plan step 15 migrates.
Plan: docs/plans/material-3-alignment.md, step 9 (core C4a, C13).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:32:25 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 4263982369 Give success, warning and info the 2025 spec and three contrast levels
The three state colours are DynamicColors on their own tonal palette now, built
exactly as Google builds error/on-error/error-container/on-error-container in both
specs, so the scheme's contrast level, dark tones, spec version and platform reach
them as they reach every other role; --harmonize (and a profile's harmonize) pulls
each source towards the seed, off by default. material:scheme also writes M3's
medium (0.5) and high (1.0) levels for both themes and every profile, keyed on
data-contrast and never on a media query, and --contrast now moves the standard
block alone. Scheme::load() takes the level; the mail theme stays on standard.
Plan: docs/plans/material-3-alignment.md, steps 6 and 7 (core C1, C2, C15).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:30:48 +02:00
Andreas Reinhold / reini 39823d71df Merge branch 'worktree-agent-afa50210f14592c5f' 2026-09-14 05:30:12 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 5b0ca26857 Sample the springs over M3's web durations, and add the Standard scheme
bin/springs.mjs is the sampler behind motion.css: damping ratio and stiffness in,
the 49-point linear() and the settle time out. Run with --settle it reproduces
the six curves the file carried byte for byte, so only the window changed.

Each spring is now sampled over the duration M3 publishes for the web (spatial
350/500/650 ms, effects 150/200/300 ms) instead of stopping at its own settle
time, so the *-duration tokens read Google's numbers. Sampling stays in real
time — point i is the spring at duration × i / 48 — so the bounce lands at the
same millisecond as before; a longer duration just holds the tail flat at 1, and
a shorter one pins the last point, at most 0.3% of the travel in one frame. Each
token's comment records damping, stiffness, settle time and sampled duration.

M3's second motion scheme is here too: [data-motion="standard"] swaps the three
spatial springs (damping 0.9, 0.2% overshoot instead of 9%) and their durations;
the effects springs sample identically in both schemes, so they are shared. The
reduced-motion block names both selectors, so it still zeroes every duration.

Plan: docs/plans/material-3-alignment.md step 5, findings core C8, C9.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:29:32 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 fd1e063d4c Give the app shell one shape per M3 window size class
Plan step 17, on N-06, N-07 and C-07. The shell now changes at 600, 840 and
1200 and nowhere else: a compact window keeps the navigation bar and the modal
rail; `medium` (600-839) gets the collapsed rail in the layout and no bar;
`expanded` (840-1199) gets a standard rail, collapsed, whose menu button
expands it in place rather than over a scrim; `large` and above start it
expanded, which is what M3 prefers once there is room.

`data-rail` alone could not say "collapsed at expanded, expanded at large",
since it carries `rail.default` for a visitor who never chose. <x-theme-script>
now also writes `data-rail-auto` while nothing is stored, the `rail-collapsed:`
variant reads it in the 840-1199 band, and `$store.rail.auto` mirrors it for
Alpine; the first press of the menu button drops it, so a remembered choice
still wins in both bands. `rail.default` and the rest of `$store.rail` are
unchanged, and the attribute rides through `wire:navigate` with the others.

`--material-margin` carries M3's window margin on the shell -- 16px compact,
24px from `medium` -- and the content region is padded with it, so the showcase
pages drop their own gutters.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:29:17 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 2feb623ec0 Take the typescale's tracking from Compose, and reset roundness
Tracking now comes from androidx's TypeScaleTokens.kt as sp/16 rem, so Display
Large is -0.0125rem, Title Medium and Body Medium 0.0125rem where material-web's
pre-Expressive numbers stood. The emphasized set gets its own
--md-sys-typescale-emphasized-<style>-tracking, which the emphasized utilities
read: M3 widens four of them and takes Display Large back to zero. Every regular
utility sets `font-variation-settings: normal`, because the property inherits and
body copy inside an emphasized heading is meant to read as itself.

bin/check-font.mjs (fontkit) prints the packaged woff2's fvar axes, so a
re-subset cannot silently drop the ROND axis the emphasized styles depend on;
`npm run check:font` runs it and tests/Feature/FontTest.php runs it through the
configured node binary, skipping when node cannot run (PHP cannot open a woff2
without Brotli).

Plan: docs/plans/material-3-alignment.md step 4, findings core C5, C6, C7.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:29:09 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 17723d2a76 Ask breakpoints.js, not Tailwind's screens, for a window class
Plan step 16. `navigation.js` takes its standard-rail threshold from
`from('expanded')` (840px) instead of the 64rem it had, and `navigation.css`'s
three 64rem queries follow, so the whole expanded class gets the in-layout
collapsible rail M3 asks for rather than one that opens over a scrim (N-06);
its two 40rem queries become 37.5rem, the compact/medium boundary the bar's own
container query already used (N-07). `search.js` and `datepicker.js` swap their
39.99rem media strings for `upTo('medium')`, so the full-screen search view and
the modal date picker end at 600px, not 640px.

The time picker's landscape layout stops keying on width at all: M3 swaps it on
orientation and viewport height, so it is now landscape plus a window too short
for the upright dial (35rem, the dialog's own height), and the two dial-shrink
rules key on height alone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 04:06:35 +02:00
Andreas Reinhold / reini 125465c9cb Merge branch 'worktree-agent-ac2cdea6121b46ea6' 2026-09-14 04:05:33 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 1134dbac0b Show both symbol cuts in the showcase icon search
The search draws its 4,135 symbols as CSS masks through the symbol route, so
the route learned `?optical=20` — the 24 cut otherwise, as on <x-icon> — and
the section got a checkbox that switches the cut and draws the grid at 20px,
where the difference in stroke weight is the point.

Plan step 10; finding C16.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 04:04:42 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 699827d696 Let <x-icon> pick the optical size 20 cut
M3's optical size axis redraws a symbol so its strokes look equally heavy at
every size; drawing the 24 cut at 20px thins them by about a sixth (styles.md
§ Icons). `optical` picks the cut — 24 by default, 20 for an icon drawn at 20px
or smaller — and combines with `filled`; anything else falls back to 24, so no
caller can land on a folder that does not exist. SvgFile::symbol() takes the
size as a fourth argument and resolves the `-20` folders; the catalogue is the
same in both cuts, so symbolNames() and DesignGuard's icon-name check are
unchanged. The callers that size their own icons pass it in a later step.

Plan step 10; finding C16.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 04:04:32 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 5fc75a32eb Test that the guideline and the design skill cannot drift, and attribute Google
BoostResourcesTest: the guideline stays under 120 lines and verbatim, the skill
carries its frontmatter and every guideline section as a chapter, names every
--md-sys-*/--md-ref-* token family declared in resources/css/tokens and every M3
breakpoint, and ends with an Attribution chapter. NOTICE records that the rules
and wording come from m3.material.io (CC BY 4.0) and the token values from
androidx Compose (Apache-2.0), since docs/ is export-ignored and the skill ships.
Plan steps 29 and 30.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 04:04:25 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 b3960d5eca Add the material-3-design skill, the concept reference behind the guideline
resources/boost/skills/material-3-design/SKILL.md: the tables the guideline
compresses — every colour role with its purpose, pair and utility, the surface
container hierarchy, contrast levels, elevation by component, the corner scale
and the corner of each component, the type scale with roles and the emphasized
set, the spring tokens and direction rules, the state table and targets, the
window size classes with navigation, panes and margins, spacing tokens, icon
axes, an accessibility checklist and the don'ts — each M3 name beside the
library's utility, each chapter with Google's source pages, and an Attribution
chapter. Loaded on demand by Boost. Plan step 27 (core C24, C19).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 04:04:17 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 16fe29839f Write Material 3's rules as an always-on Boost guideline
resources/boost/guidelines/material-3.blade.php: the rules that change what an
agent writes — colour roles and pairs, surface containers and elevation, the
corner scale by component, type roles, spatial vs effects motion, states and
48px targets, the five window size classes, accessibility, icons, and the
don'ts M3 states — one line each with the utility to use, under 120 lines
because Boost inlines it into every session. Plan step 26 (core C24).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 04:04:09 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 b1642aed1c Key the views on M3's window size classes, not Tailwind's
Plan step 15. Every `sm:`/`md:`/`lg:`/`xl:` in the components, the showcase
(sections, layout, shell) and the pagination and error views becomes the M3
window size class the audits' breakpoint maps prescribe: the full-screen
dialog, the FAB, the snackbar's width, `radio inline`, the section picker and
the page numbers move from 640px to `medium` (600px, M3's compact boundary,
N-07 / C-07 / IN-28); `button responsive` moves from 1024px to `expanded`
(840px); `section-nav`'s grid-to-row switch from 1280px to `large` (1200px,
N-07); and `drawer pane` from 1280px to `expanded`, where M3 shows two panes
for list-detail (C-07). The pane's default width is now 360dp, M3's fixed
supporting pane at expanded.

Showcase example grids take two columns from `medium` and three or more from
`expanded`; the showcase's own theme picker swaps at `expanded`, the nearest
class boundary to the 768px it used (no M3 rule applies to it).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 04:03:54 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 c22d693334 Ship Material Symbols in the 20 optical-size cut
M3 draws icons at 20 dp from the optical size 20 cut, whose strokes stay as
heavy as the 24 dp design's instead of thinning with the glyph (styles.md
§ Icons, the optical size axis). The package only shipped the 24 cut, so every
20 px icon was a scaled-down 24 (core audit C16). bin/fetch-symbols now checks
out both cuts from the same commit of google/material-design-icons and writes
the new one to resources/svg/symbols/{outlined-20,filled-20}: 4,135 symbols
each, the same names and the same normalisation as the 24 cut.

Plan step 10; finding C16.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 04:00:16 +02:00
Andreas Reinhold / reiniandClaude Fable 5.1 25ff5a8d71 Key breakpoints, scales, inks and states on M3's tokens
Tailwind's sm…2xl are cleared for M3's window size classes (medium 600, expanded
840, large 1200, extra-large 1600; resources/js/breakpoints.js for scripts), and
its radius, shadow, text-size, weight, leading, tracking and easing scales are
cleared like its palette, so only M3's utilities compile. The semantic inks are
roles rather than opacities (M3 reserves 38% for disabled). state.css declares
M3's state opacities as tokens, adds the dragged layer and a touch-target utility.
Plan: docs/plans/material-3-alignment.md, steps 1, 2, 3 and 8.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 03:54:21 +02:00
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
Andreas Reinhold / reiniandClaude Opus 5 b1fc0c9cfa Keep a connected group's inner corners round
tests / feature (8.4) (push) Successful in 1m17s
tests / feature (8.5) (push) Successful in 1m21s
tests / browser (chrome, chromium) (push) Successful in 3m55s
tests / browser (firefox, firefox) (push) Successful in 5m34s
tests / lint (push) Successful in 1m5s
tests / browser (safari, webkit) (push) Successful in 5m54s
Connected segments and split halves wrote their full corners as
--md-sys-shape-corner-full, 9999px, beside 8px inner corners on the same
element. A box whose radii add up to more than its side has every radius
scaled by one factor, so the inner corners shrank to a hundredth of a pixel
and drew square. Full is now half the size's height (--group-full), which no
side can overflow. A browser test fails on any radius in a group or split
button larger than half its height.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 22:46:10 +02:00
Andreas Reinhold / reiniandClaude Opus 5 a980f5b0d0 Expect the section picker to mark its page, not a checked item
The browser test still looked for the menuitemcheckbox the picker drew before
it marked the current section with aria-current.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 22:41:51 +02:00
Andreas Reinhold / reiniandClaude Opus 5 a4bc046d1d Speak a shell destination's badge in words, and stop a null offset on PHP 8.5
<x-app-shell> passed no badge label to its rail and bar items, so a badge
like 0/3 reached a screen reader bare; a destination's badgeLabel now does.

The head script indexed the profiles with a null scheme when none are
configured, which PHP 8.5 reports as a deprecated null array offset on every
page with theme.meta on. It reads the default scheme without the lookup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 22:20:37 +02:00
Andreas Reinhold / reiniandClaude Opus 5 433ddb2baa Keep aria-pressed off a selected link button
ARIA defines aria-pressed for buttons, not links, so a selected <x-button
link> keeps the selected look without announcing itself as a toggle; the
caller marks the page with aria-current.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 21:36:35 +02:00
Andreas Reinhold / reiniandClaude Opus 5 e6f4357a2a Add a solid status label to the badge
A status label came tonal (the colour's container) or outlined; a label that
has to stand out in the colour itself fell back to the filled count, which is
16px and hidden from screen readers. solid draws the label shape in the
colour's filled pair and is spoken like the others.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 21:08:55 +02:00
Andreas Reinhold / reiniandClaude Opus 5 ea529bf785 Let a list-detail pane close on Escape when asked
A drawer that is a pane from xl ignored Escape, whatever close-on-escape
said: the page beside a pane stays in use, so that is the right default. An
application whose pane is a transient detail (ReStride's activity and
workout panes) can now pass pane-close-on-escape and have Escape close it at
every width.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 20:31:33 +02:00
Andreas Reinhold / reiniandClaude Opus 5 869ccdf447 Mark a section picker's current page as the page, with its badge
The phone picker of <x-section-nav> drew the current section as a checked
menuitemcheckbox in tertiary-container, which a screen reader announces as a
choice rather than where you are, and it dropped the sections' badges, so an
open support count showed on the tabs but not on a phone. <x-menu-item> gains
current (aria-current="page", the selected shape in secondary-container,
the navigation indicator's colour) and badge (a dot or a count at the end of
the row), and the picker uses both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 19:56:54 +02:00
Andreas Reinhold / reiniandClaude Opus 5 65788d0c61 Keep a date picker's and an app bar's settings on their own component
materialDatepicker set its first day, format, min, max and year range in
init() without declaring them, and materialAppBar did the same with its
resize observer and scroll handler. Alpine writes an undeclared property to
the outermost x-data scope, so two pickers inside one page scope (ReStride's
plan setup wraps its form in x-data) shared the last one's min and first day,
and a second app bar would have taken the first one's observer. Both now
declare them; a browser test puts two pickers in an outer scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 19:17:11 +02:00
Andreas Reinhold / reiniandClaude Opus 5 8640d815c8 Keep open menus and rich tooltips open through a Livewire render
<x-menu>, <x-fab-menu> and <x-rich-tooltip> gave their popover an id
that is new with every render, and Livewire's morph matches an element
without a wire:key by its id. So every render of the component around
them swapped the popover for a closed copy: an open menu closed, its
listeners stayed behind on the old element (Escape or a press outside
then left aria-expanded="true" on the trigger and focus unreturned), a
keep-open item's wire:click closed its menu when the response came, and
a rich tooltip went on showing the detached bubble, so it never opened
again. <x-carousel>'s row had the same kind of id: after a render it
scrolled without its listeners, and its items stopped re-masking.

The popover, the bubble and the row now carry a wire:key, so the morph
patches them in place and changes the id and anchor name together with
the trigger's, as it already did for everything else. The key goes
through an attribute bag: Livewire compiles a wire:key written in a
template into the key of the loop iteration around it, which would have
given every child component after the menu in a row the same key.

A morph also removes the menu button's ARIA attributes, which only
script writes. menu.js now writes them again after every morph, so the
button of a menu that stays open, and a FAB menu's close look, still
say it is open, and aria-controls names the popover's new id.

A second press on an open menu's button opened it again, render or not:
the popover closes on the press, and the guard against the click that
follows was timed from the toggle event, which is queued and arrives
after that click. It is timed from beforetoggle now.

Browser tests with Livewire probes in Chromium, Firefox and WebKit, and
a render test for the keys and the keys of the child components after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:50:14 +02:00
Andreas Reinhold / reiniandClaude Opus 5 ab7317faae Add hint-class to the group and icon-class to menu items
<x-group hint-class> adds classes to the hint, as the text fields'
hint-class does, and a validation message still replaces the hint.
<x-menu-item icon-class> adds classes to the leading icon, for an icon
whose colour means something of its own, such as a sport's glyph.

A colour class there has to win over the component's own colour, and
which of two colour utilities wins depends on the order Tailwind emits
them (text-error comes before text-on-surface-variant). So when either
prop is given, the component's own colour is written with a :where()
variant that carries no specificity, as the fields' hint colour sits
in the components layer. A disabled item's icon stays disabled. Without
the props the markup is unchanged. The skill now also lists the fields'
hint-class, which it had left out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:18:39 +02:00
Andreas Reinhold / reiniandClaude Opus 5 a83d7f62ea Hang a menu on its menu button rather than the trigger's wrapper
<x-menu> named its CSS anchor on the <span> around the trigger slot.
A trigger taken out of the flow, such as <x-button fab> fixed to the
bottom corner of a phone's window, left that span behind as an empty
box where the page put it, and the menu opened there. menu.js now moves
the name onto the menu button, beside any name the button already
carries for its tooltip, and moves it again whenever a Livewire morph
puts the server's attributes and a fresh name back. The wrapper keeps
the name until Alpine starts, or when there is no menu button.

A menu that fits neither below nor above its start edge now also tries
the opposite side and end together, so a default-position menu on a
FAB in the bottom-right corner opens above it, end-aligned. Before, it
fell back to its base position and overflowed the window.

This also applies to <x-fab-menu>, <x-account-menu>, <x-split-button>
and the <x-section-nav> picker, which share menu.js.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:18:39 +02:00
Andreas Reinhold / reiniandClaude Opus 5 fb29169d44 Let the date picker take a first day of the week and a format
An application that lets each person choose when their week starts and
how dates are written needs the picker to follow that choice rather
than the locale. <x-datepicker> takes week-start, 0 (Sunday) to 6
(Saturday), and format, dd, MM and yyyy each once around one delimiter
(dd.MM.yyyy, dd/MM/yyyy, MM/dd/yyyy, yyyy-MM-dd). They replace what
Intl derives for firstDayOfWeek() and inputFormat(), so the field, the
server-rendered value, the typed-date reader (year first too), the
calendar's columns and weekday header, Home and End, the dialog's text
fields and ranges all follow them. Month and weekday names stay the
locale's, and wire:model still stores Y-m-d. Values that are neither
are ignored, and without the props nothing changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:18:39 +02:00
Andreas Reinhold / reiniandClaude Opus 5 fdb13313f7 Keep the current section lit while a Livewire component updates
<x-section-nav> and <x-app-shell> mark an item current when its URL is
the request's. While a Livewire component on the page re-renders, the
request is Livewire's update endpoint, so no section or destination
stayed lit after the morph. Both now compare with the page's URL from
Livewire::originalUrl() during a Livewire update request; a normal
request compares exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:18:39 +02:00
Andreas Reinhold / reiniandClaude Opus 5 d77ecf5e2a Add an illustration slot to the empty state
An application with its own artwork for an empty collection puts it in
the `illustration` slot, which is drawn in place of the shape and icon.
The slot's attributes go on the element around it, so its class sets the
colour an SVG's currentColor takes. A slot holding only whitespace or
comments counts as empty (hasActualContent), and without the slot the
empty state renders as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:11:00 +02:00
Andreas Reinhold / reiniandClaude Opus 5 e63c0f684c Bind the collapse's open state through wire:model and x-model
An application keeps a disclosure's state in a Livewire property
(`<x-collapse wire:model="fineTuning">`) or an Alpine one. Either binding
now works both ways: toggling the details writes the property, and the
property changing, in an action or in Alpine, opens or closes it.

`wire:model` is entangled as the dialog and sheets entangle theirs, and
taken off the element. The server renders `open` from the property, so
the first paint matches it. `x-model` binds through `x-modelable`, with
`open` as the first paint until Alpine starts. The state is named
`collapseOpen` so it never hides an `open` that a dialog in the slot reads
from a scope around it.

Without a binding the element has no Alpine and renders as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:10:59 +02:00
Andreas Reinhold / reiniandClaude Opus 5 973cc0b68c Render badge slots as HTML and add neutral and plain badge colours
The slot was cast to a string and escaped, so an icon beside a badge's
word showed as literal markup. It renders as HTML now; `value` stays
escaped, and a slot holding only whitespace or comments is still a dot
(hasActualContent rather than isNotEmpty).

`color="neutral"` draws neutral ink on every variant: a dot or count in
on-surface-variant with surface text (the ink the outline badge already
writes in), a tonal label in surface-container-high with
on-surface-variant text, and an outline in outline-variant.

`color="plain"` emits no background, text or border colour in any
variant, keeping shape, size and type, so an application's own colour
classes paint it without racing the badge's.

Every existing colour renders the same classes as before, and an unknown
colour still falls back to error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:10:59 +02:00
Andreas Reinhold / reiniandClaude Opus 5 07e008d942 Add sticky toasts, action events and toast hooks
A sticky toast stays until it is dismissed or its action pressed, for a
question that must be answered ("A new version is ready" with Reload).
It is kept aside rather than queued, so it never holds up ordinary
toasts: one that arrives while it shows takes its place, and the sticky
toast comes back once the queue is empty. One is kept at a time; a newer
sticky toast replaces it. Every other toast keeps today's queue.

An action's `event` names a window event dispatched when it is pressed,
beside `handler`, for toasts whose detail cannot carry a function. The
snackbar now closes before either runs, so a toast they show is not the
one dismissed.

`data-toast` and `data-toast-action` give applications stable hooks for
their tests.

The queue now forgets a cleared timer in next(): a toast dismissed by a
click that neither hovered nor focused it first, as a screen reader
activates a button, left its timer running and cut the next toast short.

The showcase's snackbar buttons had no Alpine scope and did nothing; they
are wrapped in one, with a sticky example.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:10:59 +02:00
Andreas Reinhold / reiniandClaude Opus 5 53bb000425 Let an application replace the safe-area insets and dock on the bottom bar
Components read the device's safe-area insets straight from env(), which a
browser test cannot fake and an application drawing its own status strip
cannot extend. Every inset the package reads, in its CSS and its views, is
now var(--material-safe-top|bottom|left|right, env(safe-area-inset-…)):
unchanged while the variables are unset.

The app shell's --material-bottom-bar also adds
var(--material-bottom-extra, 0px), so an application that docks something on
the phone's navigation bar (an offline banner) sets its height once and the
snackbar, a fab button and the page's bottom padding clear it.

A guard test fails on any env(safe-area-inset-*) outside such a variable, and
AppShellTest's assertion on the bar height's class follows the new value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:03:20 +02:00
Andreas Reinhold / reiniandClaude Opus 5 bcf0d4f4e3 Keep the theme-color meta in step with the theme
An app installed as a PWA, and a mobile browser, colour their bar from
<meta name="theme-color">, which the package left alone: the bar kept the
server's one colour whatever theme the visitor chose.

With the new `theme.meta` option (off by default), <x-theme-script> sets the
content of every theme-color meta without a `media` attribute to the
resolved theme's surface, from the scheme data and for the profile in
<html data-scheme>, adding one to <head> when the page has none. A
MutationObserver on <html> follows every later change of data-theme or
data-scheme: $store.theme's set() and toggle(), an OS change while
`system`, a profile preview, an application's own script. wire:navigate's
head merge puts the next page's server-rendered meta in place, so it is
painted again as the page is swapped in and on livewire:navigated. Turned
off, the script is byte-for-byte what it was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 17:59:34 +02:00
Andreas Reinhold / reiniandClaude Opus 5 3a7aa96ec7 Add a colour spec choice to material:scheme
The scheme script always generated with material-color-utilities' 2025 spec.
An application whose palette was generated with M3's original 2021 colour
(ReStride) could not reproduce it. The script now takes `spec` ('2025' by
default, or '2021') and refuses anything else, and the command takes
`--spec`, validated before Node runs.

Colour profiles may set their own `spec`, `success`, `warning` and `info`;
without them the command's options apply. The stylesheet header writes out
every option that differs from its default, so the recorded command
regenerates the file, and a profile whose spec differs from the header's
names it. Default output is byte-for-byte unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 17:52:50 +02:00
8701 changed files with 61439 additions and 8093 deletions
+4 -1
View File
@@ -2,18 +2,21 @@
*.woff2 binary
# Development only: kept out of the archives Composer installs.
# Development only: kept out of the archives Composer installs, and out of the copy a path
# repository with "symlink": false makes (Composer mirrors it through these rules).
/.editorconfig export-ignore
/.gitattributes export-ignore
/.github export-ignore
/.gitignore export-ignore
/bin export-ignore
/docs export-ignore
/node_modules export-ignore
/package-lock.json export-ignore
/package.json export-ignore
/phpunit.xml export-ignore
/pint.json export-ignore
/testbench.yaml export-ignore
/tests export-ignore
/vendor export-ignore
/vite.config.js export-ignore
/workbench export-ignore
+1 -1
View File
@@ -66,7 +66,7 @@ jobs:
- name: Run feature tests
run: vendor/bin/pest --testsuite=Feature
# One job per engine: the floor is Chrome 125, Firefox 147 and Safari 18.4, and the
# One job per engine: the floor is Chrome 125, Firefox 151 and Safari 18.4, and the
# components lean on platform features (dialog, popover, anchor positioning) that
# differ between them. Pest calls the engines chrome, firefox and safari; Playwright
# installs them as chromium, firefox and webkit.
+1
View File
@@ -10,3 +10,4 @@
# Planning notes stay local
/docs/plans
.browser-lock
+11
View File
@@ -5,6 +5,8 @@ This package bundles or derives from the following works.
Material Symbols (resources/svg/symbols)
From google/material-design-icons, commit recorded in resources/svg/symbols/SOURCE.
Rounded, weight 400, grade 0, in two cuts of the same source: optical size 24
(outlined, filled) and optical size 20 (outlined-20, filled-20).
Copyright Google LLC. Apache License 2.0.
M3 Expressive shapes (resources/svg/shapes)
@@ -50,4 +52,13 @@ Spring motion constants (resources/css/tokens/motion.css)
From androidx Compose Material 3, tokens/ExpressiveMotionTokens.kt.
Copyright The Android Open Source Project. Apache License 2.0.
Material Design 3 documentation (resources/boost/guidelines/material-3.blade.php,
resources/boost/skills/material-3-design/SKILL.md, docs/reference/m3)
Rules, tables and wording condensed from https://m3.material.io (Foundations, Styles,
Components), which Google publishes under the Creative Commons Attribution 4.0 License
except as otherwise noted; the numeric token values from androidx Compose Material 3's
token files.
Copyright Google LLC (CC BY 4.0, https://creativecommons.org/licenses/by/4.0/);
Copyright The Android Open Source Project (Apache License 2.0).
A copy of the Apache License 2.0 is available at https://www.apache.org/licenses/LICENSE-2.0.
+26 -17
View File
@@ -1,18 +1,18 @@
# Livewire Material
Material 3 Expressive components for Laravel and Livewire, built on Tailwind CSS 4.
Material 3 Expressive components for Laravel and Livewire, in plain CSS.
- Anonymous Blade components for the current M3 Expressive catalogue: buttons and FABs, menus, chips, text fields, selection controls, sliders, pickers, dialogs and sheets, lists, cards, carousels, progress and loading indicators, snackbars, tabs, app bars, toolbars, navigation bars and rails, an adaptive app shell, data tables and pagination.
- Anonymous Blade components for the current M3 Expressive catalogue: buttons and FABs, menus, chips, text fields, selection controls, sliders, pickers, dialogs and sheets, lists, cards, carousels, progress and loading indicators, snackbars, tabs, app bars, toolbars, navigation bars and rails, an adaptive scaffold, the layout components (panes, list-detail, supporting pane, feed), data tables and pagination.
- A colour scheme generated from one seed colour with Google's colour science (`php artisan material:scheme`), light and dark, and a theme that is chosen before the first paint.
- The full Material Symbols Rounded set and the M3 Expressive shapes, drawn inline without an icon package.
- Error pages and a Markdown mail theme in the same scheme.
- A showcase of every component in the application's own scheme, a design guard for tests, and Laravel Boost guidelines and a skill for AI agents.
- A showcase of every component in the application's own scheme, a design guard for tests, and for AI agents two Laravel Boost guidelines and two skills: the library's own, and Material 3's rules and tables beside its utilities.
No JavaScript libraries beyond the Alpine that ships with Livewire. Browsers: Chrome 125+, Firefox 147+, Safari 18.4+.
No JavaScript libraries beyond the Alpine that ships with Livewire. Browsers: Chrome 125+, Firefox 151+, Safari 18.4+.
## Requirements
PHP 8.4+, Laravel 13, Livewire 4, Tailwind CSS 4 with Vite, and Node (for `material:scheme`).
PHP 8.4+, Laravel 13, Livewire 4, Vite, and Node (for `material:scheme`).
## Installation
@@ -27,14 +27,13 @@ composer require nonameweb/livewire-material
The application's build imports from `vendor/`, so Composer packages must be installed before `npm run build` — in a Dockerfile, copy `composer.json`, run `composer install`, then build the assets.
The package's stylesheets are plain CSS, no build step of its own and no Tailwind anywhere in the stack. `all.css` brings the foundation and every component; an application can instead import `foundation.css` first and then the stylesheet of each component its views render (`resources/css/components/button.css`, `resources/css/layout/scaffold.css`, …), since each imports the stylesheets of the components it draws and Vite keeps a file several of them import once. The error pages need no import: they take the foundation, scheme and font from the application's build and inline their own layout's rules beside it.
```css
/* resources/css/app.css */
@import 'tailwindcss';
@import '../../vendor/nonameweb/livewire-material/resources/css/material.css';
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import '../../vendor/nonameweb/livewire-material/resources/css/all.css';
@import './material-scheme.css';
@source '../../vendor/nonameweb/livewire-material/resources/views';
@source '../../vendor/nonameweb/livewire-material/src';
```
```js
@@ -44,6 +43,8 @@ import '../../vendor/nonameweb/livewire-material/resources/js/material.js'
Do not install Alpine separately; Livewire provides it.
An application writes no utility layer of its own: layout components (`<x-scaffold>`, `<x-pane>`, `<x-stack>`, `<x-row>`, `<x-grid>`, the canonical layouts) take M3's spacing tokens and breakpoints as props, a small set of `md-type-*` and `md-ink-*` classes covers text on plain elements, and `--md-sys-*` custom properties serve the rest of an application's own stylesheet. Breakpoints are M3's window size classes and only those — compact below `medium` (600px), then `expanded` (840px), `large` (1200px) and `extra-large` (1600px) — written as literal range media queries (`@media (width >= 840px)`) in the package's CSS; a script asks `resources/js/breakpoints.js` (`from()` / `upTo()`) for the same numbers rather than writing its own query.
### Layout
The theme script goes in `<head>`, before `@vite`, so the page paints in the visitor's theme:
@@ -57,7 +58,7 @@ The theme script goes in `<head>`, before `@vite`, so the page paints in the vis
<x-theme-script />
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="bg-surface font-sans text-on-surface antialiased">
<body>
{{ $slot }}
<x-toast />
</body>
@@ -72,11 +73,13 @@ Generate the scheme from a seed colour. It writes `resources/css/material-scheme
php artisan material:scheme "#4f46e5" --variant=tonal-spot
```
Variants: `tonal-spot`, `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--contrast` runs from -1 to 1; `--success`, `--warning` and `--info` seed the state colours. Regenerate instead of editing the file.
Variants: `tonal-spot`, `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--spec` is the colour spec: `2025` (M3 Expressive, the default) or `2021` (M3 as it first shipped, for a palette generated before Expressive). `--success`, `--warning` and `--info` seed the state colours, which are built exactly as M3 builds `error`; `--harmonize` pulls them towards the seed. The stylesheet's header records the command that regenerates it; regenerate instead of editing the file.
Every scheme is written at M3's three contrast levels — standard, medium (3:1) and high (7:1), for light and dark — keyed on `<html data-contrast>`, which the head script sets before the first paint from the visitor's choice or the operating system's. `--contrast` sets the standard level alone (below 0.5); `<x-theme-toggle mode="contrast" />` lets someone choose.
#### Colour profiles
To let an installation switch between several schemes, list them as `profiles` in the config (name ⇒ `label`, `seed`, `variant`) and run `php artisan material:scheme` without a seed: every profile lands in the same stylesheet under `<html data-scheme>`. Tell the package which one is active — `Scheme::resolveProfileUsing(fn () => Setting::get('color_profile'))` in a service provider — and the head script, mails and error pages follow it. `<x-scheme-picker wire:model="colorProfile" />` lets someone choose, previewing each profile on the page.
To let an installation switch between several schemes, list them as `profiles` in the config (name ⇒ `label`, `seed`, `variant`, and optionally `contrast`, `harmonize`, `spec`, `success`, `warning`, `info`, which otherwise come from the command's options) and run `php artisan material:scheme` without a seed: every profile lands in the same stylesheet under `<html data-scheme>`. Tell the package which one is active — `Scheme::resolveProfileUsing(fn () => Setting::get('color_profile'))` in a service provider — and the head script, mails and error pages follow it. `<x-scheme-picker wire:model="colorProfile" />` lets someone choose, previewing each profile on the page.
### Configuration
@@ -86,6 +89,9 @@ php artisan vendor:publish --tag=livewire-material-config
- `prefix` — components are `<x-button>`, `<x-card>`… Set `'m'` when a name clashes with the application's own components, and they become `<x-m::button>`. `<x-livewire-material::button>` always works.
- `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys` (an earlier toggle's localStorage keys, adopted once).
- `theme.contrast.default` (`system`, `standard`, `medium` or `high`) and `theme.contrast.storage_key` — M3's contrast level, resolved before the first paint into `<html data-contrast>` and followed on the operating system while `system`.
- `theme.meta` — keep `<meta name="theme-color">` (an installed web app's or a mobile browser's bar) on the resolved theme's `surface` and the active colour profile, before the first paint and after every change, `wire:navigate` included; one is added when the page has none (default `false`).
- `motion.scheme` — M3's motion scheme: `expressive` (default) or `standard`, the restrained springs, written to `<html data-motion>`.
- `profiles`, `profile` — colour profiles and the default one (see Colour profiles).
- `fields.variant` — text fields `outlined` (default) or `filled`.
- `pagination` — draw Laravel's and Livewire's paginators in M3 (default `true`).
@@ -129,7 +135,7 @@ Every component, prop and slot is documented in the Boost skill (`resources/boos
## Showcase
While the application runs locally (or with `MATERIAL_SHOWCASE=true`), `/material` shows every token and component, in every variant, in the application's own scheme and theme: an overview, and a page per section behind a navigation rail (the package's own app shell), with a search over every section, example and component (press `/`).
While the application runs locally (or with `MATERIAL_SHOWCASE=true`), `/material` shows every token and component, in every variant, in the application's own scheme and theme: an overview, and a page per section behind a navigation rail (the package's own scaffold), with a search over every section, example and component (press `/`).
## Testing the design
@@ -137,17 +143,20 @@ While the application runs locally (or with `MATERIAL_SHOWCASE=true`), `/materia
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
it('uses only what compiles', function () {
expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])
expect(DesignGuard::scan([resource_path('views'), resource_path('js'), resource_path('css'), app_path()])
->missingStylesheets(resource_path('css/app.css'))
->forbidColours(['tertiary'])
->violations())->toBe([]);
});
```
The guard fails on maryUI tags, daisyUI classes, colours the theme does not declare, unknown Material Symbol names and Blade directives written inside component tags.
The guard reads every path it is given and fails, with `path:line` and the 2.0.0 replacement, on any Tailwind utility or variant still in a view, PHP or JS file — none compiles, since the application carries no Tailwind — and on 1.x's own utilities (`type-body-md`, `text-meta`, `rounded-corner-lg`, `focus-ring`): a layout component and prop (`flex gap-4``<x-row gap="space200">`), an `md-*` class (`truncate``md-truncate`), or a token for the application's own CSS (`rounded-lg``var(--md-sys-shape-corner-lg)`). A class the application's own stylesheets declare is exempt. In the `.css` files it is given (`material-scheme.css` skipped) it fails on a literal colour, radius, shadow, font, easing or duration, and on a media query off M3's 600/840/1200/1600px; a `var()`, or a `calc()`/`min()`/`max()`/`clamp()` built on one, is fine. It also fails on unknown Material Symbol names and Blade directives written inside component tags.
`missingStylesheets($cssEntry)` checks the entry's relative `@import` graph, followed through every package file's own imports, against the package tags the views render (unprefixed, under the configured prefix, or `<x-livewire-material::…>`) and `->links()`, and names each missing `@import` line once; a tag the application shadows with its own component is reported instead. It reads imports only, so leave `resource_path('css')` out of `scan()` while the stylesheets still hold literals. `forbidColours([...])` names roles the application leaves out, and fails wherever one (with its `on-` and container roles) is still written: `var(--md-sys-color-…)` in CSS or an inline `style`, its `md-ink-*` class, or a component's `color`/`tone` prop. `forbid($pattern, $reason)` adds any pattern of your own, line by line.
## AI agents
With [Laravel Boost](https://github.com/laravel/boost), `php artisan boost:install` (or `boost:update --discover`) picks up the package's guideline and the `livewire-material-development` skill.
With [Laravel Boost](https://github.com/laravel/boost), `php artisan boost:install` (or `boost:update --discover`) picks up the package's two guidelines — the library's own, and `material-3`, a page of M3's rules an agent reads in every session — and two skills: `livewire-material-development` (every component, prop, slot and trap) and `material-3-design` (M3's colour roles, surfaces, elevation, shape, type, motion, states, window size classes and accessibility, with the library's utility beside each M3 name and Google's source page for each chapter).
## Developing the package
+231
View File
@@ -0,0 +1,231 @@
# Upgrading
## From 2.0.0 to 2.1.0
- **Browsers:** Firefox 151 or later (was 147), for container style queries on a custom property;
Chrome 125 and Safari 18.4 are unchanged.
- **`<x-navigation-rail>`** publishes its value as `--md-navigation-rail-value`, `collapsed` or
`expanded` (M3's two rail values). Content an application puts in a rail reads it with
`@container style(--md-navigation-rail-value: collapsed)` instead of copying the rail's
conditions from `navigation-rail.css`.
- **`<x-loading size="96">`** sizes the loading indicator in px, 24 to 240 (M3's responsive range),
with the container and the shape in proportion. A width and height of the application's own
still work.
## From 1.x to 2.0.0
2.0.0 aligns the library with Material Design 3 Expressive as Google documents it
(m3.material.io, checked page by page; the audits are in `docs/audits/m3-alignment/`). Most of
the change is inside the components. What reaches an application is below, in the order to do it.
### 1. Breakpoints are M3's window size classes
Tailwind's `sm:`, `md:`, `lg:`, `xl:` and `2xl:` no longer compile, and there are no responsive
variants in their place: the breakpoints are M3's window size classes — compact below 600px, then
medium (600px), expanded (840px), large (1200px) and extra-large (1600px) — and only those. A
layout component takes the class as a prop (`hide-below`, `hide-from`, `stack-below`, `<x-grid>`'s
`columns` map); the application's own CSS writes the width as a range media query:
| Was | Becomes |
|---|---|
| `sm:` / `max-sm:` (640) | `medium` · `@media (width >= 600px)` / `(width < 600px)` |
| `md:` / `max-md:` (768) | `medium` or `expanded` — choose by what the layout means |
| `lg:` / `max-lg:` (1024) | `expanded` · `@media (width >= 840px)` / `(width < 840px)` |
| `xl:` / `max-xl:` (1280) | `large` · `@media (width >= 1200px)` / `(width < 1200px)` |
| `2xl:` (1536) | `extra-large` · `@media (width >= 1600px)` |
`<div class="hidden lg:block">` is `<x-stack hide-below="expanded">`; `flex flex-col sm:flex-row` is
`<x-row stack-below="medium">`; `grid-cols-1 lg:grid-cols-2` is
`<x-grid :columns="['compact' => 1, 'expanded' => 2]">`. Scripts read the same numbers from
`resources/js/breakpoints.js` (`from('expanded')`, `upTo('medium')`). `DesignGuard` reports every
leftover prefix with its replacement.
### 2. Only M3's scales, as tokens
Tailwind's radius, shadow, text-size, weight, leading, tracking and easing utilities compile to
nothing, and so do the 1.x utilities that stood for M3's scales. A text style is a class; the
rest is a token in the application's own CSS:
| Was | Becomes |
|---|---|
| `rounded-sm``rounded-4xl`, `rounded-corner-*` (1.x) | `var(--md-sys-shape-corner-xs)``var(--md-sys-shape-corner-xxl)` (`-full`, `-none`), or `<x-surface corner="xs">` |
| `shadow-sm``shadow-2xl`, `shadow-elevation-*` (1.x) | `var(--md-sys-elevation-1)``var(--md-sys-elevation-5)` |
| `text-xs``text-9xl`, `leading-*`, `tracking-*`, `font-medium``font-black`, `type-*` (1.x) | one `md-type-*` class (`md-type-body-md`, `md-type-emphasized-title-md` …), or `font: var(--md-sys-typescale-body-md)` with its `-tracking` |
| `ease-in`, `ease-out`, `ease-in-out`, `duration-300`, `ease-spatial-*` (1.x) | `var(--md-sys-motion-spatial-*)` / `var(--md-sys-motion-effects-*)` with its `-duration`, in a `transition` |
| `gap-4`, `p-4`, `space-y-2` | `gap="space200"`, `<x-surface padding="space200">`, `<x-stack gap="space100">`, or `var(--md-sys-measurement-space200)` |
| `state-layer`, `focus-ring`, `touch-target`, `link` (1.x) | `md-state-layer`, `md-focus-ring`, `md-touch-target`, `md-link` |
The spacing tokens are the 4px grid Tailwind's scale was (`space200` is 16px). `DesignGuard` names
each one with its replacement.
### 3. Regenerate the colour scheme
```bash
php artisan material:scheme "#4f46e5" --variant=tonal-spot # the command in the file's header
```
- `success`, `warning` and `info` are now built on the 2025 colour spec with the contrast level,
like `error`; their values change. `--harmonize` blends them toward the seed (off by default).
- The stylesheet gains medium and high contrast blocks, and `material-scheme.json` gains a
`contrast` key; `light`/`dark` at the top level are still the standard scheme, so the mail
theme needs nothing. `--contrast` must now be below 0.5; medium (0.5) and high (1.0) are
generated alongside.
- Config: `theme.contrast` (`default` `system` | `standard` | `medium` | `high`, `storage_key`)
and `motion.scheme` (`expressive` | `standard`). `$store.theme` gains `contrast`,
`resolvedContrast` and `setContrast()`; `<x-theme-toggle mode="contrast">` is a row of three.
### 4. Inks are roles, not opacities
1.x's `text-meta`, `text-body`, `text-quiet`, `border-chrome`, `border-structure` and
`border-divider` are gone with the rest of the utilities. Text takes the role's `md-ink-*` class —
`md-ink-variant` (on-surface-variant) for `text-meta` and `text-body`, `md-ink-quiet` (outline) for
`text-quiet` — and a line is `<x-divider>`, `<x-surface outlined>` or
`var(--md-sys-color-outline-variant)` in the application's CSS. Where the old translucent grey was
intended, the role is the same colour; where a template relied on the opacity to blend over a
colour, use the role directly.
### 5. Changed defaults and props
- `<x-toast>` no longer draws a state icon; `type` only picks the announcement role (`alert` for
error and warning, `status` otherwise). An actioned snackbar stays until acted on; Escape
dismisses a focused one.
- `<x-fab>` has no `disabled` prop: M3 says to remove a FAB whose action is unavailable, so hide
it instead (a form-submit FAB uses `wire:loading.attr="disabled"`).
- `<x-alert>` is `role="status"` for every colour; pass `assertive` where the notice answers
something the person just did.
- `<x-button size="xs">` and `<x-group size="xs">` are 8px wider; a `<x-button-group>` no longer
wraps; connected `xs`/`sm` segments have a 48px minimum width.
- `<x-menu-item>` rows are 48px with 16px sides; a selected item draws a trailing check unless it
has `icon-right`; a long menu scrolls.
- `<x-drawer>` renders a close button by default (`:with-close-button="false"` to drop it, ignored
on a `standard` sheet or when Escape and the scrim are off) and left-aligns its `actions` in a
72px row. `<x-drawer pane>` is removed; use `<x-list-detail>` for the second pane of a
list-detail layout.
- `<x-bottom-sheet>` opens at `50dvh` (was `90dvh`); any `height` is capped at
`calc(100dvh - 72px)`.
- `<x-carousel>` `padding` defaults to `16`; `layout="full-screen"` scrolls vertically with
edge-to-edge items; items, not the row, are the tab stops.
- `<x-list dividers>` draws a 16px-inset rule; `<x-list selectable>` or `selection="single|multi"`
makes it a `listbox` of `option`s with `aria-selected`.
- `<x-card>` changes elevation on hover instead of its corner; `data-md-card` carries the variant.
- A button directly in `<x-stack>` (stretched, the default) or `<x-form>` keeps its label's width at
the start edge instead of filling the width, as M3 asks; a full-width submit is your own CSS.
- `<x-modal>` pins its header and actions and scrolls only the body; a `box-class` that set
`overflow` no longer applies.
- `<x-table>` rows are 52px and the automatic fine-pointer density is gone: pass `dense` for 36px
rows (`size="xs" dense` is 24px).
- A field in error draws a trailing `error` icon unless it already trails something; a read-only
field no longer draws a dashed outline.
- The time picker's AM/PM buttons are radios (`aria-checked`); the password reveal no longer sets
`aria-pressed`.
- `<x-slider size="md">` grows from 44 to 52px and every value indicator from 32 to 44px tall.
- `<x-theme-toggle mode="picker"|"contrast">` is a connected group over native radios: the
`data-theme-option` hooks are gone; target `input[name="material-theme"][value="…"]`.
- `<x-tab>` panels render `id`, `aria-labelledby` and `display: none` from the server.
- `<x-section-nav>` with five or more sections is a scrollable tab bar, not a grid. The tab bar is
10px taller; the focus ring sits outside.
- The navigation bar's horizontal label is `label-md`; `place="bottom"` toolbars sit above
`--material-bottom-bar`; a standard toolbar's icon buttons are `primary`. An application that
set `--material-bottom-bar` to lift the snackbar over its own bottom toolbar removes it: the
toolbar now reads it to place itself, and publishes `--material-bottom-toolbar`, which the
snackbar clears and the page pads its end with.
- The centred app bar headline is a grid column; the `search` variant bounds its own width, so
drop hand-written `max-w-*` wrappers.
- `<x-app-shell>` is renamed `<x-scaffold>`, with no alias; it is a column with a nested row
and gains `banner` and `fab` slots. `<x-navigation-rail>`'s `header` slot takes one
`<x-fab label icon>` that morphs (replace the two-FAB `rail-collapsed:` swap); new `divider`
and `fill` props. The `rail-collapsed:` variant is gone: style what the application puts in a
rail by the rail's value, `@container style(--md-navigation-rail-value: collapsed)` (2.1.0).
`data-app-shell`, `data-app-shell-bar`, `data-app-shell-actions` and
`data-app-shell-banner` are `data-md-scaffold`, `data-md-scaffold-bar`, `data-md-scaffold-actions`
and `data-md-scaffold-banner`; the skip link is `data-md-skip-link`.
- `<x-icon optical="20">` selects the optical-size-20 cut; the components pass it for their own
small icons, applications pass it for icons drawn at 20px or less.
- A text field stops at 40rem wide from medium (600px), as M3 bounds fields on wider windows; a
width rule of the application's own on the field beats it, and `full` removes it.
- Every `<x-modal>` draws a rule under its header and over its actions while its body scrolls,
and neither when the body fits. `separator` now means "always draw both rules" and no longer
renders two `<x-divider>` elements; the spacing between header, body and actions moved to
M3's split gaps, so a dialog that fits is a few pixels shorter.
- A snackbar with a `description` is 68px tall, and below medium (600px) a two-line snackbar with an
action puts the action under the text. Alt+G moves focus to an actioned snackbar.
- `<x-bottom-sheet>`'s drag now follows the pointer and settles on the nearest preset height when
`heights` or `snap` is set; without stops it behaves as before.
- A chip set with `scroll` shows a scroll button over each overflowing edge on fine pointers.
### 6. `material.css` is gone, and so is Tailwind
The 1.x single-import shortcut no longer exists, and Tailwind has left the whole stack — the
package, its showcase, error pages and Workbench build carry none, and an application drops it
too. The package's CSS is plain, no build step of its own, in one entry:
`resources/css/all.css` for everything, or `foundation.css` first and then the stylesheet of each
component the views render, opening with the layer statement every package stylesheet does
(see Installation in `README.md`). An application's views write no utility layer of their own
either: layout components (`<x-scaffold>`, `<x-pane>`, `<x-stack>`, `<x-row>`, `<x-grid>`, the
canonical layouts) take M3's spacing tokens and breakpoints as props, a small set of `md-type-*`
and `md-ink-*` classes covers text on plain elements, and `--md-sys-*` custom properties serve the
rest of an application's own stylesheet. The foundation smooths text in grayscale, as Tailwind's
`antialiased` class did: drop the class.
### 7. New in 2.0.0
Nothing to migrate, but worth knowing: submenus (`<x-menu-item submenu>`), a filtering menu
(`<x-menu filter>`), a menu that opens as a bottom sheet on compact windows
(`<x-menu sheet-at-compact>`), gap-grouped menu items (`<x-menu-group gap>`), square button groups
(`shape="square"`), managed selection on connected groups (`selection="single|multi" required`),
FAB collapse on scroll (`<x-fab collapse-on-scroll>`), the tall navigation bar and hide-on-scroll
(`tall`, `hide-on-scroll`; `tall-bar`, `hide-bar-on-scroll` on the shell), the narrow, centred and
hide-when-collapsed rail (`width="narrow"`, `align="center"`, `hide-when-collapsed`), a docked
toolbar with a FAB and a rounded large-screen form (`rounded`), app bar actions that overflow into
a menu (`:actions="[…]"`), the list item's `video` slot, the card's dragged state, the standard
side sheet (`<x-drawer standard>`), bottom sheet preset heights, the multi-aspect carousel
(`layout="multi-aspect"`), the divider with a subheader (`<x-divider text>`), the character counter
(`counter`), the vertical slider (`orientation="vertical"`), the full-screen range date picker on
compact windows, search's icon entry point and suggestions (`trigger="icon"`, `suggestions` slot),
the contrast toggle (`<x-theme-toggle mode="contrast">`) and the Standard motion scheme.
### 8. Tests and guards
`DesignGuard` changes with the stack:
- **Removed:** the maryUI tag and daisyUI class checks, `forbidAbsolutes()` and `forbidOpacityInk()`.
Every Tailwind utility now compiles to nothing and is reported with its replacement, so
`bg-white` and `text-on-surface/60` still are.
- **Retargeted:** `forbidColours([...])` keeps its signature and reports a left-out role where 2.0.0
writes one: `var(--md-sys-color-…)` in CSS or an inline `style`, an `md-ink-*` class, a
component's `color`/`tone` prop.
- **New:** `missingStylesheets($cssEntry)` names each `@import` the views need, and
`unusedStylesheets($cssEntry)` each one they no longer do; a `.css` file passed
to `scan()` is checked for literal values and off-scale media queries.
Browser tests that assert widths switch at 640/1024/1280 now
switch at 600/840/1200; tests that read `role="alert"` on an alert, `aria-pressed` on the time
picker's period buttons or `data-theme-option` need the new hooks above.
Every component hook is prefixed `data-md-`: `data-toolbar-place` is `data-md-toolbar-place`,
`data-account-menu` is `data-md-account-menu`, `data-field-copy` is `data-md-field-copy`. The
attributes on `<html>` keep their names (`data-theme`, `data-contrast`, `data-scheme`,
`data-motion`, `data-rail`). A component's inner parts also carry its name:
| 1.x | 2.0.0 |
|---|---|
| `data-app-shell`, `-bar`, `-actions`, `-banner` | `data-md-scaffold`, `-bar`, `-actions`, `-banner` |
| `data-theme-option="dark"` | `input[name="material-theme"][value="dark"]` |
| `data-scheme-option="teal"` | `data-md-scheme-picker-option="teal"` |
| `data-account-theme` | `data-md-account-menu-theme` |
| `data-section-picker` | `data-md-section-nav-picker` |
| `data-material-carousel`, `-item`, `-content`, `-label`, `-surface` | `data-md-carousel`, `-item`, `-content`, `-label`, `-surface` |
| `data-sheet` (drawer) | `data-md-drawer-sheet` |
| `data-drag-handle` (bottom sheet) | `data-md-bottom-sheet-handle` |
| `data-check`, `data-mixed` (checkbox) | `data-md-checkbox-check`, `data-md-checkbox-mixed` |
| `data-on`, `data-off`, `data-handle` (toggle) | `data-md-switch-on`, `data-md-switch-off`, `data-md-switch-handle` |
| `data-handle`, `data-thumb`, `data-tick`, `data-stop`, `data-segment`, `data-track-icon` (slider) | `data-md-slider-handle`, `-thumb`, `-tick`, `-stop`, `-segment`, `-icon` |
Rename the negative assertions too: `->not->toContain('data-app-bar')` passes against 2.0.0
whatever the page renders.
### 9. For AI agents
`php artisan boost:update --discover` picks up the new `material-3` guideline and the
`material-3-design` skill, which state M3's rules and tables beside the library's classes, props
and tokens.
+60
View File
@@ -0,0 +1,60 @@
/**
* Checks that the packaged Google Sans Flex subset still carries the variable axes the CSS uses.
*
* Run from the repository root with `npm run check:font` (or `node bin/check-font.mjs [woff2]`).
* tests/Feature/FontTest.php runs it through the configured `node` binary as well, because PHP
* cannot open a woff2 without the Brotli extension.
*
* wght 400700 — every typescale weight (font.css `font-weight: 400 700`, type.css's regular,
* medium and bold reference tokens).
* ROND 0100 — the roundness axis every `type-emphasized-*` utility sets to 100. Re-subset
* the font without it and the emphasized styles quietly stop being round.
*
* Output is one JSON object on stdout — {file, postscriptName, numGlyphs, axes: {tag: {name, min,
* default, max}}} — and the exit status is 1, with the reason on stderr, when an axis is missing
* or narrower than the range above.
*/
import { openSync } from 'fontkit'
import { fileURLToPath } from 'node:url'
/** The axes the stylesheets depend on, and the range each one has to cover. */
const REQUIRED = {
wght: { min: 400, max: 700 },
ROND: { min: 0, max: 100 },
}
const file = process.argv[2] ?? fileURLToPath(new URL('../resources/fonts/google-sans-flex/GoogleSansFlex-Latin.woff2', import.meta.url))
let font
try {
font = openSync(file)
} catch (error) {
process.stderr.write(`${file}: ${error.message}\n`)
process.exit(1)
}
const axes = font.variationAxes ?? {}
const problems = Object.entries(REQUIRED).flatMap(([tag, range]) => {
const axis = axes[tag]
if (!axis) {
return [`${tag} is missing; the subset has ${Object.keys(axes).join(', ') || 'no variable axes'}.`]
}
return axis.min > range.min || axis.max < range.max
? [`${tag} covers ${axis.min}${axis.max}, not the ${range.min}${range.max} the stylesheets ask for.`]
: []
})
process.stdout.write(`${JSON.stringify({
file,
postscriptName: font.postscriptName,
numGlyphs: font.numGlyphs,
axes,
})}\n`)
if (problems.length > 0) {
process.stderr.write(`${file}\n${problems.map((problem) => ` ${problem}`).join('\n')}\n`)
process.exit(1)
}
+38 -17
View File
@@ -2,12 +2,15 @@
#
# Refresh resources/svg/symbols from google/material-design-icons (Apache-2.0).
#
# Material Symbols Rounded at weight 400, grade 0 and optical size 24, outlined and
# filled, for every symbol Google publishes. Maintenance only: applications never run
# this, and bin/ is not part of the archive Composer installs.
# Material Symbols Rounded at weight 400 and grade 0, outlined and filled, in two cuts:
# optical size 24 (the standard size) and optical size 20. M3 draws an icon at 20 dp or
# smaller from the 20 cut, whose strokes stay as heavy as the 24 dp design's instead of
# thinning as the glyph is scaled down (docs/reference/m3/styles.md § Icons, the optical
# size axis). Maintenance only: applications never run this, and bin/ is not part of the
# archive Composer installs.
#
# The repository is ~5 GB, so it is cloned without blobs and checked out sparsely:
# git downloads only the two files per symbol that are kept. The @material-symbols
# git downloads only the four files per symbol that are kept. The @material-symbols
# npm packages were not used because their SVGs are drawn at optical size 48, whose
# strokes are thinner than the 24px design.
@@ -24,6 +27,7 @@ cd "$work/repo"
git sparse-checkout set --no-cone \
'/symbols/web/*/materialsymbolsrounded/*_24px.svg' \
'/symbols/web/*/materialsymbolsrounded/*_20px.svg' \
'!/symbols/web/*/materialsymbolsrounded/*_wght[0-9]*' \
'!/symbols/web/*/materialsymbolsrounded/*_grad200*' \
'!/symbols/web/*/materialsymbolsrounded/*_gradN25*'
@@ -31,14 +35,28 @@ git sparse-checkout set --no-cone \
git checkout --quiet "$(git symbolic-ref --short HEAD)"
out="$root/resources/svg/symbols"
rm -rf "$out/outlined" "$out/filled"
mkdir -p "$out/outlined" "$out/filled"
# The 24 cut keeps the folder names it has always had; the 20 cut is suffixed, so a
# name resolves to a folder without a lookup table (Support\SvgFile::symbol()).
folder() { # optical size, style
case "$1" in
24) echo "$out/$2" ;;
*) echo "$out/$2-$1" ;;
esac
}
for size in 24 20; do
for style in outlined filled; do
rm -rf "$(folder "$size" "$style")"
mkdir -p "$(folder "$size" "$style")"
done
done
# Google's files carry width="24" height="24" and no fill. Drop the size so CSS decides
# it, and paint in currentColor so an icon takes the colour of the text around it.
normalise() {
sed -E \
-e 's/ (width|height)="[0-9]+"//g' \
-e 's/ (width|height)="[0-9]+(px)?"//g' \
-e 's/<svg /<svg fill="currentColor" /' \
"$1"
}
@@ -47,19 +65,22 @@ missing=0
for dir in symbols/web/*/materialsymbolsrounded; do
name="$(basename "$(dirname "$dir")")"
outlined="$dir/${name}_24px.svg"
filled="$dir/${name}_fill1_24px.svg"
if [[ ! -f "$outlined" || ! -f "$filled" ]]; then
echo "skipped $name: no outlined and filled 24px pair" >&2
missing=$((missing + 1))
continue
fi
for size in 24 20; do
outlined="$dir/${name}_${size}px.svg"
filled="$dir/${name}_fill1_${size}px.svg"
normalise "$outlined" > "$out/outlined/$name.svg"
normalise "$filled" > "$out/filled/$name.svg"
if [[ ! -f "$outlined" || ! -f "$filled" ]]; then
echo "skipped $name: no outlined and filled ${size}px pair" >&2
missing=$((missing + 1))
continue
fi
normalise "$outlined" > "$(folder "$size" outlined)/$name.svg"
normalise "$filled" > "$(folder "$size" filled)/$name.svg"
done
done
git rev-parse HEAD > "$out/SOURCE"
echo "$(ls "$out/outlined" | wc -l | tr -d ' ') symbols from google/material-design-icons@$(cut -c1-12 "$out/SOURCE"), $missing skipped"
echo "$(ls "$out/outlined" | wc -l | tr -d ' ') symbols at optical size 24 and $(ls "$out/outlined-20" | wc -l | tr -d ' ') at 20 from google/material-design-icons@$(cut -c1-12 "$out/SOURCE"), $missing skipped"
+216 -30
View File
@@ -4,14 +4,23 @@
* Rebuild with `npm run build:scheme` after changing this file or upgrading
* @material/material-color-utilities; the bundle is committed so applications need
* nothing but `node`. (The published library imports without file extensions, which
* plain Node refuses, so it cannot be run unbundled anyway.)
* plain Node refuses, so it cannot be run unbundled anyway — and two of the classes
* below are imported by path, which only a bundler resolves.)
*
* Input: one JSON argument — {seed, variant, contrast, success, warning, info}.
* Output: JSON on stdout — {seed, variant, spec, contrast, light: {role: hex}, dark: {role: hex}}.
* Input: one JSON argument — {seed, variant, spec, contrast, harmonize, success, warning,
* info}. `spec` is the colour spec, '2025' (M3 Expressive, the default) or '2021' (M3 as it
* first shipped). `contrast` is the standard level, below M3's medium (0.5).
* Output: JSON on stdout — {seed, variant, spec, harmonize, contrast: {standard, medium:
* {light, dark}, high: {light, dark}}, light: {role: hex}, dark: {role: hex}}. The top-level
* light and dark are the standard level; medium (0.5) and high (1.0) are M3's other two
* contrast levels, generated for both themes so a stylesheet can key them on an attribute.
*/
import {
argbFromHex,
customColor,
Blend,
clampDouble,
DynamicColor,
extendSpecVersion,
hexFromArgb,
Hct,
MaterialDynamicColors,
@@ -24,7 +33,12 @@ import {
SchemeRainbow,
SchemeTonalSpot,
SchemeVibrant,
TonalPalette,
Variant,
} from '@material/material-color-utilities'
// Google exports neither from the package's index; the bundler resolves the path.
import { ContrastCurve } from '../node_modules/@material/material-color-utilities/dynamiccolor/contrast_curve.js'
import { ToneDeltaPair } from '../node_modules/@material/material-color-utilities/dynamiccolor/tone_delta_pair.js'
const VARIANTS = {
'tonal-spot': SchemeTonalSpot,
@@ -38,9 +52,15 @@ const VARIANTS = {
'fruit-salad': SchemeFruitSalad,
}
// M3's three contrast levels (styles/color/roles, "What's new May 2025"): standard is the
// scheme's own level, medium and high are fixed. Every level is generated for both themes.
const LEVELS = { standard: 0, medium: 0.5, high: 1 }
// Key colours seed the palettes; they are not roles a stylesheet paints with.
const NOT_ROLES = /_palette_key_color$/
const STATES = ['success', 'warning', 'info']
function fail(message) {
process.stderr.write(`${message}\n`)
process.exit(1)
@@ -56,25 +76,186 @@ try {
const hex = /^#[0-9a-f]{6}$/i
const Scheme = VARIANTS[input.variant]
const SPECS = ['2021', '2025']
const spec = input.spec ?? '2025'
if (!hex.test(input.seed ?? '')) fail(`The seed must be a #rrggbb colour, "${input.seed}" given.`)
if (!Scheme) fail(`Unknown variant "${input.variant}". Use one of: ${Object.keys(VARIANTS).join(', ')}.`)
if (!SPECS.includes(spec)) fail(`Unknown spec "${spec}". Use one of: ${SPECS.join(', ')}.`)
for (const state of ['success', 'warning', 'info']) {
for (const state of STATES) {
if (!hex.test(input[state] ?? '')) fail(`The ${state} colour must be a #rrggbb colour, "${input[state]}" given.`)
}
const contrast = Number(input.contrast ?? 0)
const harmonize = Boolean(input.harmonize ?? false)
if (!(contrast >= -1 && contrast <= 1)) fail(`The contrast level must be between -1 and 1, "${input.contrast}" given.`)
if (!(contrast >= -1 && contrast < LEVELS.medium)) {
fail(`The standard contrast level runs from -1 to below ${LEVELS.medium}, "${input.contrast}" given; medium and high are generated as their own blocks.`)
}
const source = Hct.fromInt(argbFromHex(input.seed))
const colors = new MaterialDynamicColors()
function roles(isDark) {
/**
* The contrast curves of Google's 2025 spec, from its private getCurve()
* (dynamiccolor/color_spec_2025.js).
*/
function curve(defaultContrast) {
return {
1.5: new ContrastCurve(1.5, 1.5, 3, 5.5),
3: new ContrastCurve(3, 3, 4.5, 7),
4.5: new ContrastCurve(4.5, 4.5, 7, 11),
6: new ContrastCurve(6, 6, 7, 11),
7: new ContrastCurve(7, 7, 11, 21),
}[defaultContrast]
}
/** The tone that holds the palette's chroma, searching up or down — Google's findBestToneForChroma. */
function bestToneForChroma(hue, chroma, tone, byDecreasingTone) {
let answer = tone
let best = Hct.from(hue, chroma, answer)
while (best.chroma < chroma) {
if (tone < 0 || tone > 100) break
tone += byDecreasingTone ? -1 : 1
const candidate = Hct.from(hue, chroma, tone)
if (best.chroma < candidate.chroma) {
best = candidate
answer = tone
}
}
return answer
}
/** Google's tMaxC: the most chromatic tone, from the top. */
function tMaxC(palette, lowerBound = 0, upperBound = 100) {
return clampDouble(lowerBound, upperBound, bestToneForChroma(palette.hue, palette.chroma, 100, true))
}
/** Google's tMinC: the most chromatic tone, from the bottom. */
function tMinC(palette, lowerBound = 0, upperBound = 100) {
return clampDouble(lowerBound, upperBound, bestToneForChroma(palette.hue, palette.chroma, 0, false))
}
/**
* success, warning and info as M3 custom colours: the four roles of a semantic colour, built on
* their own tonal palette exactly as Google builds error / on-error / error-container /
* on-error-container — the same tones, contrast curves and tone delta pairs in both specs
* (dynamiccolor/color_spec_2021.js and color_spec_2025.js, "Errors [E]"). Because they are
* DynamicColors against the page's own DynamicScheme, they follow its contrast level, its dark
* tones, its spec version and its platform the way every other role does; the 2021 recipe they
* used before (customColor) was fixed at tones 40/100/90/10 whatever the scheme asked for.
*/
function stateRoles(name, palette) {
const of = () => palette
let color, onColor, container, onContainer
color = extendSpecVersion(
DynamicColor.fromPalette({
name,
palette: of,
tone: (s) => (s.isDark ? 80 : 40),
isBackground: true,
background: (s) => colors.highestSurface(s),
contrastCurve: () => new ContrastCurve(3, 4.5, 7, 7),
toneDeltaPair: () => new ToneDeltaPair(container, color, 10, 'nearer', false),
}),
'2025',
DynamicColor.fromPalette({
name,
palette: of,
tone: (s) => (s.platform === 'phone' ? (s.isDark ? tMinC(palette, 0, 98) : tMaxC(palette)) : tMinC(palette)),
isBackground: true,
background: (s) => (s.platform === 'phone' ? colors.highestSurface(s) : colors.surfaceContainerHigh()),
contrastCurve: (s) => (s.platform === 'phone' ? curve(4.5) : curve(7)),
toneDeltaPair: (s) => (s.platform === 'phone' ? new ToneDeltaPair(container, color, 5, 'relative_lighter', true, 'farther') : undefined),
}),
)
onColor = extendSpecVersion(
DynamicColor.fromPalette({
name: `on_${name}`,
palette: of,
tone: (s) => (s.isDark ? 20 : 100),
background: () => color,
contrastCurve: () => new ContrastCurve(4.5, 7, 11, 21),
}),
'2025',
DynamicColor.fromPalette({
name: `on_${name}`,
palette: of,
// Google's on-error sits on error-dim off the phone; a state colour has no dim role.
background: () => color,
contrastCurve: (s) => (s.platform === 'phone' ? curve(6) : curve(7)),
}),
)
container = extendSpecVersion(
DynamicColor.fromPalette({
name: `${name}_container`,
palette: of,
tone: (s) => (s.isDark ? 30 : 90),
isBackground: true,
background: (s) => colors.highestSurface(s),
contrastCurve: () => new ContrastCurve(1, 1, 3, 4.5),
toneDeltaPair: () => new ToneDeltaPair(container, color, 10, 'nearer', false),
}),
'2025',
DynamicColor.fromPalette({
name: `${name}_container`,
palette: of,
tone: (s) => (s.platform === 'watch' ? 30 : s.isDark ? tMinC(palette, 30, 93) : tMaxC(palette, 0, 90)),
isBackground: true,
background: (s) => (s.platform === 'phone' ? colors.highestSurface(s) : undefined),
toneDeltaPair: (s) => (s.platform === 'watch' ? new ToneDeltaPair(container, color, 10, 'darker', true, 'farther') : undefined),
contrastCurve: (s) => (s.platform === 'phone' && s.contrastLevel > 0 ? curve(1.5) : undefined),
}),
)
onContainer = extendSpecVersion(
DynamicColor.fromPalette({
name: `on_${name}_container`,
palette: of,
tone: (s) => (s.variant === Variant.MONOCHROME ? (s.isDark ? 90 : 10) : s.isDark ? 90 : 30),
background: () => container,
contrastCurve: () => new ContrastCurve(3, 4.5, 7, 11),
}),
'2025',
DynamicColor.fromPalette({
name: `on_${name}_container`,
palette: of,
background: () => container,
contrastCurve: (s) => (s.platform === 'phone' ? curve(4.5) : curve(7)),
}),
)
return {
[name]: color,
[`on-${name}`]: onColor,
[`${name}-container`]: container,
[`on-${name}-container`]: onContainer,
}
}
// Harmonisation pulls a state's hue towards the seed, so the three read as part of the scheme
// (styles/color/advanced/define-new-colors). Off by default: a state has to stay recognisable
// whatever the brand is, and a green that has turned blue no longer says "success".
const states = Object.assign({}, ...STATES.map((state) => {
const value = argbFromHex(input[state])
return stateRoles(state, TonalPalette.fromInt(harmonize ? Blend.harmonize(value, argbFromHex(input.seed)) : value))
}))
function roles(isDark, contrastLevel) {
// The 2025 spec is M3 Expressive's colour; the library falls back to 2021 for the
// variants the new spec does not define (fidelity, content, monochrome, …).
const scheme = new Scheme(source, isDark, contrast, '2025')
// variants the new spec does not define (fidelity, content, monochrome, …). 2021 is
// M3's original colour, for an application whose palette was generated with it.
const scheme = new Scheme(source, isDark, contrastLevel, spec)
const out = {}
for (const color of colors.allColors) {
@@ -83,36 +264,41 @@ function roles(isDark) {
}
}
for (const [role, color] of Object.entries(states)) {
out[role] = hexFromArgb(color.getArgb(scheme))
}
return { scheme, out }
}
const light = roles(false)
const dark = roles(true)
/** One contrast level: both themes, with each theme's inverse state colours from the other. */
function level(contrastLevel) {
const light = roles(false, contrastLevel)
const dark = roles(true, contrastLevel)
// success, warning and info are M3 custom colours, harmonisation off: harmonising pulls
// each hue towards the seed, and a state has to stay recognisable whatever the brand is.
for (const state of ['success', 'warning', 'info']) {
const group = customColor(argbFromHex(input.seed), { name: state, value: argbFromHex(input[state]), blend: false })
for (const [theme, colours] of [['light', light.out], ['dark', dark.out]]) {
colours[state] = hexFromArgb(group[theme].color)
colours[`on-${state}`] = hexFromArgb(group[theme].onColor)
colours[`${state}-container`] = hexFromArgb(group[theme].colorContainer)
colours[`on-${state}-container`] = hexFromArgb(group[theme].onColorContainer)
// A state colour drawn on the inverse surface (a snackbar's icon) is the other theme's.
for (const state of ['error', ...STATES]) {
light.out[`inverse-${state}`] = dark.out[state]
dark.out[`inverse-${state}`] = light.out[state]
}
return { spec: light.scheme.specVersion, light: light.out, dark: dark.out }
}
// A state colour drawn on the inverse surface (a snackbar's icon) is the other theme's.
for (const state of ['error', 'success', 'warning', 'info']) {
light.out[`inverse-${state}`] = dark.out[state]
dark.out[`inverse-${state}`] = light.out[state]
}
const standard = level(contrast)
const medium = level(LEVELS.medium)
const high = level(LEVELS.high)
process.stdout.write(JSON.stringify({
seed: input.seed.toLowerCase(),
variant: input.variant,
spec: light.scheme.specVersion,
contrast,
light: light.out,
dark: dark.out,
spec: standard.spec,
harmonize,
contrast: {
standard: contrast,
medium: { light: medium.light, dark: medium.dark },
high: { light: high.light, dark: high.dark },
},
light: standard.light,
dark: standard.dark,
}))
+184
View File
@@ -0,0 +1,184 @@
/**
* Samples M3's motion springs into the `linear()` easings of resources/css/tokens/motion.css.
*
* Run from the repository root with `npm run build:springs` (or `node bin/springs.mjs`).
* Maintenance only: plain Node 22+, no dependencies, deterministic output — running it twice
* changes nothing. Paste the blocks it prints over the spring tokens in motion.css.
*
* node bin/springs.mjs every spring of both schemes, over Google's web durations
* node bin/springs.mjs --settle the same springs sampled to their own settle time instead
* node bin/springs.mjs 0.6 800 350 one spring: damping ratio, stiffness, duration in ms
*
* The maths is androidx's `SpringSimulation.updateValues` (Apache-2.0, © Google LLC) for a
* spring that starts at rest and travels from 0 to 1, which is what an easing has to describe.
* With the natural frequency ω = √stiffness, r = −ζω and the damped frequency ωd = ω√(1 ζ²):
*
* underdamped (ζ < 1) x(t) = 1 + e^(rt) · (cos(ωd·t) + (r / ωd) · sin(ωd·t))
* critical (ζ = 1) x(t) = 1 (1 + ω·t) · e^(−ω·t)
*
* The spatial springs are underdamped and overshoot; the effects springs are critically damped
* and never do (a colour must not overshoot). An overdamped spring (ζ > 1) is not in either
* scheme and is not implemented.
*
* *Settle time* here is the first whole 10 ms after which the spring stays within 0.1 % of its
* travel — found by scanning, because an underdamped spring crosses that band on the way to a
* later peak that may still be outside it. It is what the spring costs in real time, not a
* token: M3 publishes a duration per spring for the web, and that is what motion.css uses.
*
* *Sampling* takes the spring's value at real time `duration · i / 48` for i = 0…48 — 49 points,
* finer than a frame at 60 Hz — and rounds to four decimals, so the physics play out in real
* milliseconds whatever the duration is. Where the duration is past the settle time the tail is
* flat at 1; where it falls short the last point is pinned to 1 so the animation still lands
* exactly on its target, closing a gap of at most 0.3 % of the travel in the final frame.
*/
/** Compose's two motion schemes: {Expressive,Standard}MotionTokens.kt, androidx-main @ 1608250. */
const SCHEMES = {
expressive: {
'spatial-fast': { damping: 0.6, stiffness: 800 },
'spatial-default': { damping: 0.8, stiffness: 380 },
'spatial-slow': { damping: 0.8, stiffness: 200 },
'effects-fast': { damping: 1, stiffness: 3800 },
'effects-default': { damping: 1, stiffness: 1600 },
'effects-slow': { damping: 1, stiffness: 800 },
},
standard: {
'spatial-fast': { damping: 0.9, stiffness: 1400 },
'spatial-default': { damping: 0.9, stiffness: 700 },
'spatial-slow': { damping: 0.9, stiffness: 300 },
'effects-fast': { damping: 1, stiffness: 3800 },
'effects-default': { damping: 1, stiffness: 1600 },
'effects-slow': { damping: 1, stiffness: 800 },
},
}
/** The durations M3 publishes for the web, one per spring (docs/reference/m3/styles.md § Motion). */
const DURATIONS = {
expressive: {
'spatial-fast': 350,
'spatial-default': 500,
'spatial-slow': 650,
'effects-fast': 150,
'effects-default': 200,
'effects-slow': 300,
},
standard: {
'spatial-fast': 350,
'spatial-default': 500,
'spatial-slow': 750,
'effects-fast': 150,
'effects-default': 200,
'effects-slow': 300,
},
}
/** How close to its target the spring has to stay to count as settled: 0.1 % of the travel. */
const THRESHOLD = 0.001
/** The number of points a sampled easing has; 48 intervals divide every duration evenly. */
const POINTS = 49
/** The spring's value `ms` after it starts at rest and travels from 0 to 1. */
function spring({ damping, stiffness }, ms) {
const naturalFreq = Math.sqrt(stiffness)
const t = ms / 1000
if (damping > 1) {
throw new Error(`Overdamped springs (damping ${damping}) are not in either M3 scheme.`)
}
if (damping === 1) {
return 1 - (1 + naturalFreq * t) * Math.exp(-naturalFreq * t)
}
const r = -damping * naturalFreq
const dampedFreq = naturalFreq * Math.sqrt(1 - damping ** 2)
return 1 + Math.exp(r * t) * (-Math.cos(dampedFreq * t) + (r / dampedFreq) * Math.sin(dampedFreq * t))
}
/**
* The first whole 10 ms after which the spring never leaves the 0.1 % band again.
*
* Scanned in 0.05 ms steps up to where the decay envelope alone is inside the band (for the
* critically damped branch, √e times its half-life is a safe bound on the same idea), since the
* band can be re-entered and left again while the spring rings down.
*/
function settleTime(constants) {
const naturalFreq = Math.sqrt(constants.stiffness)
const decay = constants.damping * naturalFreq
const bound = ((Math.log(1 / THRESHOLD) + 10) / decay) * 1000
let last = 0
for (let ms = 0; ms <= bound; ms += 0.05) {
if (Math.abs(1 - spring(constants, ms)) >= THRESHOLD) {
last = ms
}
}
return Math.ceil(last / 10) * 10
}
/** Where an underdamped spring overshoots furthest: half a period of its damped oscillation. */
function peak(constants) {
if (constants.damping >= 1) {
return null
}
const dampedFreq = Math.sqrt(constants.stiffness) * Math.sqrt(1 - constants.damping ** 2)
const ms = (Math.PI / dampedFreq) * 1000
return { ms: Math.round(ms), value: spring(constants, ms) }
}
/** The spring as a `linear()` easing of 49 points over `duration` ms. */
function sample(constants, duration) {
const points = Array.from({ length: POINTS }, (_, i) => {
const value = spring(constants, (duration * i) / (POINTS - 1))
return Number(value.toFixed(4))
})
points[POINTS - 1] = 1
return `linear(${points.join(', ')})`
}
/** One spring as the CSS motion.css declares it: a derivation comment and the two tokens. */
function declare(name, constants, duration) {
const settles = settleTime(constants)
const overshoot = peak(constants)
const derivation = [
`damping ${constants.damping.toFixed(1)}, stiffness ${constants.stiffness}`,
`settles in ${settles}ms`,
overshoot ? `peaks at ${overshoot.value.toFixed(3)} at ${overshoot.ms}ms` : null,
`sampled over ${duration}ms`,
].filter(Boolean)
return [
` /* ${derivation.join('; ')} */`,
` --md-sys-motion-${name}: ${sample(constants, duration)};`,
` --md-sys-motion-${name}-duration: ${duration}ms;`,
].join('\n')
}
const args = process.argv.slice(2)
const toSettle = args.includes('--settle')
const numbers = args.filter((argument) => !argument.startsWith('--')).map(Number)
if (numbers.length >= 2) {
const constants = { damping: numbers[0], stiffness: numbers[1] }
process.stdout.write(`${declare('custom', constants, numbers[2] ?? settleTime(constants))}\n`)
} else {
const blocks = Object.entries(SCHEMES).map(([scheme, springs]) =>
[
`/* ${scheme} */`,
...Object.entries(springs).map(([name, constants]) =>
declare(name, constants, toSettle ? settleTime(constants) : DURATIONS[scheme][name]),
),
].join('\n'),
)
process.stdout.write(`${blocks.join('\n\n')}\n`)
}
+1 -2
View File
@@ -11,8 +11,7 @@
"components",
"material",
"material-design",
"material-3",
"tailwindcss"
"material-3"
],
"homepage": "https://gitea.nonameweb.ch/noNameWEB/livewire-material",
"authors": [
+48 -4
View File
@@ -28,12 +28,47 @@ return [
| localStorage under 'storage_key'; values found under 'legacy_keys' (an
| earlier theme toggle's key) are adopted once and then removed.
|
| 'meta' keeps <meta name="theme-color"> (the colour an installed web app
| or a mobile browser gives its bar) on the resolved theme's surface, and
| the active colour profile's: the head script sets it before the first
| paint, adds one when the page has none, and follows every later change,
| wire:navigate included. A theme-color meta with a `media` attribute is
| left alone.
|
| 'contrast' is M3's contrast level, the same three the scheme is generated
| in: 'standard', 'medium' (3:1) or 'high' (7:1), or 'system' to follow the
| operating system's own contrast setting until the visitor chooses. The
| head script writes it to <html data-contrast> before the first paint —
| standard, having the plain blocks, writes no attribute.
|
*/
'theme' => [
'default' => 'system',
'storage_key' => 'material-theme',
'legacy_keys' => [],
'meta' => false,
'contrast' => [
'default' => 'system',
'storage_key' => 'material-contrast',
],
],
/*
|--------------------------------------------------------------------------
| Motion
|--------------------------------------------------------------------------
|
| M3's two motion schemes. 'expressive' (the default) is the bouncy one the
| library draws with; 'standard' is the restrained set, "minimal bounce,
| for utilitarian products" — the head script writes it to
| <html data-motion> before the first paint, and the spatial springs swap.
| The effects springs are the same in both.
|
*/
'motion' => [
'scheme' => 'expressive',
],
/*
@@ -113,7 +148,9 @@ return [
|--------------------------------------------------------------------------
|
| Named schemes an installation can switch between. Each one is a 'label',
| a 'seed' (#rrggbb), a 'variant' and an optional 'contrast'. Without a
| a 'seed' (#rrggbb), a 'variant' and an optional 'contrast'; 'spec'
| ('2025' or '2021') and the 'success', 'warning' and 'info' sources are
| optional too, taken from the command's options when left out. Without a
| seed, `php artisan material:scheme` generates every profile into one
| stylesheet keyed by <html data-scheme>. 'profile' names the default one
| (else the first); the application says which is active with
@@ -154,9 +191,16 @@ return [
|--------------------------------------------------------------------------
|
| Every component in every variant, rendered in the application's own
| scheme. Off unless the application runs locally. 'vite' names the entry
| points that import this package's CSS and JavaScript; the error pages
| load them too, showcase or not.
| scheme. Off unless the application runs locally. 'vite' names the
| application's own entry points: its JavaScript (Livewire, Alpine) and
| the CSS entry that imports this package's stylesheets. The showcase's
| pages pass @vite() only the entries that are not a stylesheet: their
| CSS is a bundle of their own (all.css, showcase.css and the scheme,
| served by ShowcaseAssetController), so they need nothing from the
| application's build. The error pages pass this whole list, CSS
| included, to @vite() (ErrorPage::assets()) for the foundation, scheme
| and font, and inline their own layout's rules beside it
| (ErrorPage::layoutStyles()), so the entry needs no import for them.
|
*/
+811
View File
@@ -0,0 +1,811 @@
# Audit: actions and communication
Scope: `<x-button>`, `<x-button-group>`, `<x-group>`, `<x-split-button>`, `<x-fab>`, `<x-fab-menu>`,
`<x-fab-menu-item>`, `<x-menu>`/`<x-menu-item>`/`<x-menu-group>`/`<x-menu-separator>`, `<x-badge>`,
`<x-alert>`, `<x-progress>`, `<x-loading>`, `<x-toast>`, `<x-tooltip>`, `<x-rich-tooltip>`,
`<x-stat>`, `<x-empty-state>`, plus `resources/css/components/{groups,menu}.css`,
`resources/css/tokens/{state,motion,shape}.css`, `resources/js/{menu,snackbar,tooltip,rich-tooltip,progress,figure}.js`
and `src/Concerns/Toasts.php`.
Sources are named per finding. `RC-A` = `docs/reference/m3/reference-components-a.md`, `RF` =
`reference-foundations.md`, `RS` = `reference-styles.md`, `tokens/X.kt` = the androidx Compose
Material 3 token file in `docs/reference/m3/tokens/`. Two numbers were fetched live from androidx-main
during this audit (`Button.kt`, `ButtonGroup.kt`) and are marked as such.
## Summary
The button family is the strongest part of this library: all five Expressive sizes, the icon-button
width matrix, the corner/press-morph scale, the outline widths, the per-variant toggle colours and
the per-size type roles match the androidx tokens *exactly*, digit for digit — I could not find a
single wrong number in `<x-button>`'s own size tables, and the icon-button widths (narrow/default/wide
× five sizes, fifteen values) are all correct. The problems are concentrated elsewhere: the split
button reuses the connected button group's corner variables, so its inner corners are wrong at four of
five sizes and morph in the *opposite* direction to M3 on press; the snackbar's live region is created
together with its message (so the announcement is unreliable) and auto-dismisses even when it carries
an action, which M3's accessibility page forbids outright; a spinner inside a filled button is drawn
in `text-primary` on a `bg-primary` container, i.e. invisible; `<x-group>`'s segments carry no 48 px
touch target; and `<x-menu>` has no maximum height, so a long menu runs off the viewport with no way
to scroll it. Colour is animated on the overshooting *spatial* spring in four components, which the
library's own `motion.css` comment says must never happen. Nothing in this group implements a
deprecated M3 component — segmented buttons and the small FAB are correctly absent — and the two
non-M3 pieces (alert, stat, empty state) stay inside M3's colour roles, type scale and shape scale.
Counts: 5 must-fix, 17 should-fix, 14 nice-to-have (36 findings).
## Findings
### ACT-01 · button / loading · A button's spinner is invisible on a filled or tonal button
- Severity: must-fix
- M3 says: "when embedded in another component (e.g. a button), the active indicator should match the
label/icon color and the **track should be removed** in that context" (RC-A, Progress indicators →
Accessibility; same rule repeated for the loading indicator: "when embedded in another component
(e.g. a button), the indicator needs ≥3:1 against that component").
- Library does: `resources/views/components/button.blade.php:207` renders
`<x-loading :class="$iconSize" :label="false" />` — only a size class. `loading.blade.php:22`
detects a caller colour with `preg_match('/(^|\s)text-(?!…)/')`, which `size-5` does not match, so
`loading.blade.php:31` adds `text-primary`. The SVG is `fill="currentColor"`
(`resources/svg/loading-indicator/*.svg`), so a `spinner` on `<x-button variant="filled">`
(`bg-primary`) paints primary on primary — contrast 1:1. Same on `tonal` (primary on
secondary-container) and on `variant="filled" color="error"`.
- Fix: in `button.blade.php:207` pass `:class="$iconSize.' text-current'"``text-current` satisfies
`loading.blade.php`'s `$coloured` test, so no `text-primary` is added and the indicator inherits the
button's label colour.
- Effort: S
- Breaks API? no
### ACT-02 · toast · The snackbar's live region is created together with its message
- Severity: must-fix
- M3 says: "Announcement: use a **live region with a 'polite'/queued** announcement (not assertive) on
Android and web" (RC-A, Snackbar → Accessibility).
- Library does: `resources/views/components/toast.blade.php:41` wraps the whole snackbar in
`<template x-if="current">`, and the element carrying `role` and `aria-live="polite"` is inside it
(`toast.blade.php:45-46`). The region therefore enters the DOM with its text already present; a
live region must exist beforehand and have its *contents* change for assistive technology to
announce it reliably. The outer wrapper (`toast.blade.php:33`) is permanent but carries no
`aria-live`. Secondary issue on the same element: `x-bind:role` sets `alert` (implicitly assertive)
while `aria-live="polite"` is hard-coded beside it; the explicit `aria-live` wins, so the intent
("errors interrupt") is not realised anyway — and M3 asks for polite regardless.
- Fix: move `aria-live="polite"` and `aria-atomic="true"` onto the permanent wrapper at
`toast.blade.php:33`, drop `x-bind:role` / `aria-live` from the inner div (or keep `role="status"`
on the wrapper). The text nodes then change inside a region that was already there.
- Effort: S
- Breaks API? no
### ACT-03 · toast / snackbar.js · A snackbar with an action still auto-dismisses
- Severity: must-fix
- M3 says: "**Snackbars with an action must never auto-dismiss** — let people read/act at their own
pace; snackbars without actions may auto-dismiss (common range 4-10 seconds)" (RC-A, Snackbar →
Accessibility). The guidelines page repeats it as a design requirement: "make the snackbar
actionable so it doesn't auto-dismiss".
- Library does: `resources/js/snackbar.js:60` sets `timeout: sticky || toast.timeout === 0 || … ? 0 :
(toast.timeout ?? DEFAULT_TIMEOUT_MS)` — the presence of `action` is not considered, so
`materialToast('Share deleted', { action: { label: 'Undo', … } })` (exactly the showcase example,
`showcase/sections/communication.blade.php:22`) disappears after 4 s. Hover/focus pauses the timer
(`snackbar.js:97-117`), which helps a mouse user but not a screen-reader or keyboard user who has
not yet reached it.
- Fix: in `snackbar.js:55-63`, treat an entry with `action` as untimed: `timeout: sticky ||
toast.action || toast.timeout === 0 … ? 0 : …`. The close button already appears for an actioned
toast (`toast.blade.php:76`), so dismissal stays possible. Let an explicit `timeout` still override
it if the caller insists.
- Effort: S
- Breaks API? no (behaviour change only; `timeout` keeps working)
### ACT-04 · menu · A long menu has no maximum height and cannot be scrolled
- Severity: must-fix
- M3 says: "Scrolling: a menu that can't fit all items **scrolls and shows a persistent scrollbar**"
(RC-A, Menus → Behaviour and guidelines).
- Library does: `resources/views/components/menu.blade.php:70` sets
`min-w-28 max-w-70 overflow-visible … [inset:auto]` — there is no `max-block-size` and overflow is
explicitly *visible*. A popover positioned with `position-area` and `inset: auto` is sized
`fit-content`; it is not clipped to the position area, so a menu with (say) 20 items extends past
the viewport and the items beyond the edge cannot be reached — the top layer does not scroll with
the page. The library's own dropdown CSS already does this correctly:
`resources/css/components/menu.css:24-25` (`max-block-size: 18rem; overflow-y: auto`) and
`menu.css:99` for `::picker(select)`.
- Fix: on `menu.blade.php:70` replace `overflow-visible` with
`max-h-72 overflow-y-auto` (18rem, matching `.field-menu`), or
`max-h-[min(18rem,calc(100vh-2rem))]`. Verify in Chrome that `position-try-fallbacks` still flips
before the cap bites.
- Effort: S
- Breaks API? no
### ACT-05 · group / button-group · Connected segments miss M3's 48 dp target and 48 dp minimum width
- Severity: must-fix
- M3 says: "Each button needs a minimum **48x48dp** target; XS/S groups get extra inner padding
specifically to preserve this — **don't reduce it**" and "Minimum widths: XS and S connected button
groups have a 48dp target area and a **48dp minimum width**" (RC-A, Button groups → Accessibility
and → Specs). RF:78 repeats the 48 × 48 rule, RF:82 gives "at least 48 × 48 **CSS pixels**" for web.
- Library does: `<x-button>` solves this for itself with the pseudo-element at
`button.blade.php:172` (`after:min-h-12 after:min-w-12` on `xs`/`sm`). `<x-group>` does **not**: its
segments are plain `<label>`s (`group.blade.php:75-83`) sized `h-8` (xs) or `h-10` (sm)
(`group.blade.php:48-49`) with no target expansion, so the whole `<x-group size="sm">` — the
default, and the only form in the showcase — offers a 40 px-tall target. Separately, nothing
enforces the 48 dp *minimum width*: a connected `<x-button-group connected>` of `sm` icon buttons
is 40 px wide per segment (`button.blade.php:141`), and `groups.css` has no `min-width`.
- Fix: (a) add the same pseudo-element to `group.blade.php:75-83` for `xs`/`sm`
(`after:absolute after:top-1/2 after:left-1/2 after:size-full after:min-h-12 after:min-w-12
after:-translate-x-1/2 after:-translate-y-1/2`); (b) in `groups.css`, add
`[data-button-group='connected'][data-size='xs'] > *, [data-button-group='connected'][data-size='sm'] > * { min-inline-size: 3rem; }`.
- Effort: S
- Breaks API? no
### ACT-06 · split-button · Inner corners are wrong at four of five sizes and morph the wrong way
- Severity: should-fix
- M3 says (RC-A, Split button → Specs, from `SplitButton{Size}Tokens.kt`, cross-checked against the
site's own "inner corner radius" figure):
| Size | Inner corner (rest) | Inner corner (hover/press) |
| --- | --- | --- |
| XS | 4dp | 8dp |
| S | 4dp | 12dp |
| M | 4dp | 12dp |
| L | 8dp | 20dp |
| XL | 12dp | 20dp |
Verified directly: `tokens/SplitButtonSmallTokens.kt` — `InnerCornerCornerSize =
CornerValueExtraSmall`, `InnerHoveredCornerCornerSize = InnerPressedCornerCornerSize =
CornerValueMedium`; `tokens/SplitButtonMediumTokens.kt` — the same pair. The inner corner **grows**
under the finger.
- Library does: `resources/views/components/split-button.blade.php:39` emits
`data-button-group="split"`, and `resources/css/components/groups.css:54-66` gives `[data-split]`
the *connected group's* variables — `--group-inner` (rest) and `--group-inner-pressed` (pressed).
Those are 8 px/4 px at `sm` and `md`, 16 px/12 px at `lg`, 20 px/16 px at `xl`
(`groups.css:32-36`). So rest is 8/8/16/20 px where M3 wants 4/4/8/12, and pressed *shrinks* to
4/4/12/16 px where M3 wants it to *grow* to 12/12/20/20. Only `xs` is right at rest (4 px), and its
pressed value (2 px, `groups.css:32`) should be 8 px.
- Fix: give `[data-split]` its own variables in `groups.css` rather than reusing `--group-inner*`,
e.g. `[data-button-group='split'][data-size='sm'] { --split-inner: var(--md-sys-shape-corner-xs);
--split-inner-pressed: var(--md-sys-shape-corner-md); }` per size, and point the `[data-split]` /
`[data-split]:active` rules (`groups.css:54-66`) at them.
- Effort: M
- Breaks API? no
### ACT-07 · split-button · The trailing menu icon is the wrong size at every size step
- Severity: should-fix
- M3 says: trailing icon 22dp (XS), 22dp (S), 26dp (M), 38dp (L), 50dp (XL) (RC-A, Split button →
Specs; `tokens/SplitButtonSmallTokens.kt` `TrailingIconSize = 22.0.dp`,
`SplitButtonMediumTokens.kt` `= 26.0.dp`).
- Library does: `split-button.blade.php:56-66` renders the trailing half as a plain icon-only
`<x-button>`, which picks its icon size from the icon-button table
(`button.blade.php:155`): 20 / 24 / 24 / 32 / 40 px. Every step is undersized (S is 24 vs 22 —
oversized — and M/L/XL are 2/6/10 px small). The trailing *button widths* are all correct
(48/48/56/96/136 px, `split-button.blade.php:36` plus the icon-button defaults), so only the glyph
is off.
- Fix: pass an explicit icon size from `split-button.blade.php`, e.g. add
`['xs' => 'size-5.5', 'sm' => 'size-5.5', 'md' => 'size-6.5', 'lg' => 'size-9.5', 'xl' => 'size-12.5'][$size]`
to the trailing button's `:class` (Tailwind's 4 px scale gives 22 px as `size-5.5`).
- Effort: S
- Breaks API? no
### ACT-08 · fab · The extended FAB's icon↔label gap is too small at md and lg
- Severity: should-fix
- M3 says: icon↔label gap 8dp (small), **16dp** (medium), **20dp** (large) — RC-A, Extended FAB →
Specs; verified in `tokens/ExtendedFabMediumTokens.kt` (`IconLabelSpace = 16.0.dp`) and
`tokens/ExtendedFabLargeTokens.kt` (`IconLabelSpace = 20.0.dp`).
- Library does: `resources/views/components/fab.blade.php:39` — `gap-2` (8 px) small ✓,
`gap-3` (12 px) medium ✗, `gap-4` (16 px) large ✗.
- Fix: `gap-4` for `md` and `gap-5` for `lg` in `fab.blade.php:39`. (Heights, corners, icon sizes and
the 26 px/28 px paddings on the same line are all correct.)
- Effort: S
- Breaks API? no
### ACT-09 · button · The xs button's horizontal padding is 12 px, not 16 dp
- Severity: should-fix
- M3 says: XS leading/trailing padding = **16dp** (RC-A, Buttons → Specs;
`tokens/ButtonXSmallTokens.kt` `LeadingSpace = 16.0.dp`, `TrailingSpace = 16.0.dp`). The variants
table on the same page makes the point twice: 24 dp is "Not recommended. Use 16dp", and 16 dp is
the Expressive value.
- Library does: `button.blade.php:147` — `'xs' => 'h-8 gap-2 px-3 …'` (12 px). The same 12 px is
written into the group spacing (`groups.css:32`, `--group-pad: 0.75rem`) and into `<x-group>`'s
segments (`group.blade.php:48`), so all three are consistently 4 px short.
- Fix: `px-4` in `button.blade.php:147` and `group.blade.php:48`; `--group-pad: 1rem` in
`groups.css:32`.
- Effort: S
- Breaks API? no (visual width of xs buttons changes by 8 px)
### ACT-10 · button-group · A standard group is allowed to wrap onto a second line
- Severity: should-fix
- M3 says: "Adaptive/resizing: groups move through layouts as **a single line and never wrap to a
second line**; multiple groups can stack vertically but don't interact with each other" (RC-A,
Button groups → Behaviour and guidelines).
- Library does: `button-group.blade.php:35` adds `flex-wrap` to every non-connected group. On a
narrow window a three-button group breaks into two rows, and the press-expansion rules in
`groups.css:38-48` (which only look at `+` siblings) then compress a neighbour on a different line.
- Fix: drop `'flex-wrap' => ! $connected` from `button-group.blade.php:35`. If overflow is a concern,
M3's answer is a smaller size or fewer buttons, not wrapping.
- Effort: S
- Breaks API? no (a class hook disappears from the rendered output)
### ACT-11 · menu-item · Leading and trailing space is 12 px where the Expressive menu wants 16 dp
- Severity: should-fix
- M3 says: `tokens/SegmentedMenuTokens.kt` — `ItemLeadingSpace = 16.0dp`, `ItemTrailingSpace = 16.0dp`,
`ItemBetweenSpace = 12.0dp`. (The site publishes no numbers for the Expressive vertical menu — RC-A,
Menus → Specs says the measurements section is diagram-only — so the token file is the only source,
and it is the one the component's own header comment cites.)
- Library does: `menu-item.blade.php:46` — `px-3` (12 px) with `gap-3` (12 px). The gap is right; the
side padding is the *baseline* menu's 12 dp (RC-A, Menus → Specs, baseline table), not the
Expressive one. `menu-group.blade.php:6` and `menu-separator.blade.php:3` inherit the same 12 px.
- Fix: `px-4` on `menu-item.blade.php:46`, and `px-4`/`mx-4` on the group label and the separator so
the list stays aligned.
- Effort: S
- Breaks API? no
### ACT-12 · menu-separator · Divider padding is 4 px, half of M3's 8 dp
- Severity: should-fix
- M3 says: "Divider top/bottom padding | **8dp**; Divider height | 1dp" (RC-A, Menus → Specs).
- Library does: `menu-separator.blade.php:3` — `my-1` (4 px) with `h-px` ✓ and
`bg-outline-variant` ✓.
- Fix: `my-2` in `menu-separator.blade.php:3`.
- Effort: S
- Breaks API? no
### ACT-13 · menu.js · Disabled menu items cannot be focused
- Severity: should-fix
- M3 says: "Interactability: **disabled items can still receive focus** (just aren't selectable);
dividers and gaps can never receive focus" (RC-A, Menus → Accessibility). The guidelines page adds
"Disable a menu item that's conditionally unavailable rather than removing it" — the point of
keeping it is that people can find out it exists.
- Library does: `resources/js/menu.js:183` filters them out —
`.filter((item) => item.getAttribute('aria-disabled') !== 'true')` — so arrow keys, Home/End and
typeahead skip them entirely, and `menu-item.blade.php:53` also sets `pointer-events-none`. A
disabled item is announced only if the user happens to read the menu with a virtual cursor.
- Fix: keep disabled items in `items()` and instead refuse activation (the check already exists in
`menu.js:237`). Two lists are cleanest: `items()` for focus movement, `items().filter(enabled)` for
activation. `menu-item.blade.php:53` can keep `pointer-events-none` for the pointer.
- Effort: S
- Breaks API? no
### ACT-14 · fab · The FAB's icon is drawn outlined
- Severity: should-fix
- M3 says: "Icon (should be **filled**, not outlined…)" (RC-A, FAB → Anatomy) and "icon should be
filled (not outlined) and unambiguous" (→ Behaviour and guidelines). The same rule is why
`<x-button>` fills a *selected* toggle's icon.
- Library does: `fab.blade.php:68` renders `<x-icon :name="$icon" :class="$iconSize" />` with no
`filled`, so the Material Symbol's outlined face is used. `fab-menu.blade.php:53` has the same
issue for the FAB-menu trigger, and `fab-menu-item.blade.php:43` for its items.
- Fix: add `filled` to `fab.blade.php:68` and `fab-menu.blade.php:53`. (Leave the FAB-menu *items*
outlined only if you decide they read as list items rather than FABs; M3's FAB-menu anatomy shows
filled icons there too.)
- Effort: S
- Breaks API? no
### ACT-15 · button / icon button · A default (non-toggle) icon button uses an outlined icon
- Severity: should-fix
- M3 says: "**Default icon buttons should use filled icons**; toggle buttons use outlined icon
unselected → filled icon selected" (RC-A, Icon buttons → Behaviour and guidelines).
- Library does: `button.blade.php:213` — `:filled="$selected === true"`. For an icon button that is
not a toggle (`$selected === null`) that evaluates to `false`, so the outlined face is drawn where
M3 asks for the filled one. The toggle half of the rule is implemented correctly.
- Fix: `:filled="$selected === true || ($iconOnly && $selected === null)"` in `button.blade.php:213`.
This is a visible, opinionated change across every icon button in an application — if the library
prefers outlined icon buttons, say so in the header comment as a deliberate deviation rather than
leaving it implicit.
- Effort: S
- Breaks API? no (visual change)
### ACT-16 · fab · `disabled` exists on a component M3 says must never be disabled
- Severity: should-fix
- M3 says: "**Never disable a FAB** — if its action is unavailable, remove the FAB entirely instead"
(RC-A, FAB → Accessibility). RF:389 repeats it from the states side: "'if the action… is
unavailable, the FAB shouldn't appear' (don't just disable it) — a Don't example specifically about
FABs". The same page's inheritance table lists FABs under "Disabled … NOT inherited by" (RF:381).
- Library does: `fab.blade.php:23` declares a `disabled` prop and `fab.blade.php:52` paints the full
disabled treatment (`disabled:bg-on-surface/10 disabled:text-on-surface/38 disabled:shadow-none`).
Nothing in the header comment or in SKILL.md (`### <x-fab>`, line 277) warns against it — SKILL.md
simply lists `disabled` among the props.
- Fix: cheapest honest fix is documentation — say in `fab.blade.php`'s header and in SKILL.md that M3
forbids a disabled FAB and that the prop exists only for a form-submit FAB that must block a double
submit. Removing the prop is the M3-pure option and would break callers.
- Effort: S
- Breaks API? no (if documented), yes (if removed)
### ACT-17 · toast / button · The snackbar covers the `fab` button on a phone
- Severity: should-fix
- M3 says: "Placement: bottom of the UI, in front of main content; **nudge upward to avoid
overlapping FABs**/docked toolbars… Snackbars should appear **above** FABs — never in front of or
behind one" (RC-A, Snackbar → Behaviour and guidelines).
- Library does: both sit at the same offset. `toast.blade.php:36` —
`fixed inset-x-4 z-50 bottom-[calc(var(--material-bottom-bar,0px)+1rem)]`; `button.blade.php:177`
(the `fab` prop, below `sm`) — `max-sm:fixed max-sm:end-4
max-sm:bottom-[calc(var(--material-bottom-bar,0px)+1rem)] max-sm:z-30`. The snackbar is full width
below `sm` (`sm:w-auto` only applies from 640 px up, `toast.blade.php:51`), so it lands *on top of*
the FAB, which is exactly the "never in front of" case. `<x-fab>` placed by the application has the
same problem, since the toast knows nothing about it.
- Fix: mirror the existing `--material-bottom-bar` mechanism — have `<x-button fab>` (and the
documented wrapper for `<x-fab>`) publish `--material-fab: 4.5rem`, and make `toast.blade.php:36`
read `bottom-[calc(var(--material-bottom-bar,0px)+var(--material-fab,0px)+1rem)]`. Document the
variable next to `--material-bottom-extra` in SKILL.md's "Safe areas".
- Effort: M
- Breaks API? no (a new opt-in CSS variable)
### ACT-18 · toast / alert · 40 px controls inside the snackbar and the alert miss the 48 px target
- Severity: should-fix
- M3 says: web interaction targets are "at least 48 × 48 **CSS pixels**" (RF:82), and "Interaction
targets must stay ≥48×48dp even when the visible element is scaled down… a 36dp-tall button can
keep a 48dp target" (RF:561-563).
- Library does: `toast.blade.php:73` (the action, `h-10` = 40 px), `toast.blade.php:77` (the close
button, `size-10` = 40 px) and `alert.blade.php:61` (the dismiss button, `size-10`). None of them
has the pseudo-element trick `<x-button>` uses at `button.blade.php:172`. The snackbar container is
`min-h-12` (48 px) so there is no room to grow vertically without the pseudo-element.
- Fix: add `relative after:absolute after:top-1/2 after:left-1/2 after:size-full after:min-h-12
after:min-w-12 after:-translate-x-1/2 after:-translate-y-1/2` to those three buttons (they already
carry `state-layer`, which sets `position: relative`).
- Effort: S
- Breaks API? no
### ACT-19 · button / group / menu-item / fab-menu · Colour is animated on the overshooting spatial spring
- Severity: should-fix
- M3 says: "Two spring **styles**: **Spatial** (movement — x/y position, rotation, size, rounded
corners; overshoots and bounces into place) and **Effects** (color, opacity; **no overshoot**)"
(RS:472-473). `resources/css/tokens/motion.css:11-13` states the same rule in the library's own
words: "effects — colour and opacity. Critically damped; **a colour must never overshoot**."
- Library does: four components put colour on the spatial spring, whose fast variant peaks at 1.094
(`motion.css:28-30`):
- `button.blade.php:163` — `transition-[border-radius,background-color,color,box-shadow,padding,margin] … ease-spatial-fast`
- `group.blade.php:77` — `transition-[border-radius,background-color,color] … ease-spatial-fast`
- `menu-item.blade.php:48` — `transition-[border-radius,background-color] … ease-spatial-fast`
- `fab-menu.blade.php:49` — `transition-[border-radius,background-color,color,box-shadow] … ease-spatial-default`
- Fix: split each into two transitions, e.g. on `button.blade.php:163`
`transition-[border-radius,padding,margin] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast`
plus a second declaration for `background-color,color,box-shadow` with
`duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast`. Tailwind cannot emit two
`transition` shorthands on one element, so this wants a small utility in
`resources/css/components/` (e.g. `.state-transition`) rather than utility classes.
- Effort: M
- Breaks API? no
### ACT-20 · toast · The snackbar draws a status icon, which M3 tells you not to
- Severity: should-fix
- M3 says: "**Avoid icons in a snackbar** (use a dialog instead if an icon feels necessary); avoid
stylized text/inline links" (RC-A, Snackbar → Behaviour and guidelines). The specs page does list an
optional icon slot, but it is the *close* affordance ("2. Icon (optional close affordance) — …the
guidelines page anatomy instead lists 'Close button (optional)' in the same slot", RC-A, Snackbar →
Anatomy).
- Library does: `toast.blade.php:53-65` draws a `check_circle` / `error` / `warning` / `info` glyph
for every typed toast, and `Toasts.php:19-37` makes typing the *only* API — `$this->success(…)`
always sets `type: 'success'`. The glyphs are painted in the library's own
`inverse-success/error/warning/info` roles (`scheme.css:76-79`), which is a sound extension of
`inverse-primary`, so the colour is not the problem; the icon's presence is.
- Fix: either drop the leading icon and let the type only choose the announcement role, or document
the deviation in `toast.blade.php`'s header (it currently presents the icon as M3-sanctioned: "A
type draws its state icon in the inverse state colour"). If the icon stays, keep it out of the
accessible name — it is already `aria-hidden` via `<x-icon>`.
- Effort: S
- Breaks API? no (if documented), yes for the visual contract (if removed)
### ACT-21 · fab-menu · The trigger has no accessible name unless `label` is passed
- Severity: should-fix
- M3 says: "Labeling (Web): the FAB menu is a FAB + Menu combo — follow FAB accessibility guidance for
the trigger… **the FAB's own accessibility label should describe what menu it opens**"; and for the
close button "Label 'Toggle menu', Role Button, State Expanded/Collapsed" (RC-A, FAB menu →
Accessibility).
- Library does: `fab-menu.blade.php:46` writes `aria-label` only `@if ($label)`. `label` has no
default (`fab-menu.blade.php:21`), and the only content of the button is two `<x-icon>`s, which are
`aria-hidden="true"` by default (`icon.blade.php:27`). A `<x-fab-menu>` without `label` is therefore
an unnamed button. `<x-menu>` has the same shape but delegates naming to the trigger the caller
supplies, so the gap is specific to the FAB menu, which builds its own button.
- Fix: make `label` required, or default it to `__('Toggle menu')` in `fab-menu.blade.php:21` and
always emit `aria-label`. Note the expanded/collapsed state is already handled — `menu.js:140`
writes `aria-haspopup`/`aria-controls`/`aria-expanded` onto the button it finds in the trigger.
- Effort: S
- Breaks API? no
### ACT-22 · rich-tooltip · The bubble is never associated with its trigger
- Severity: should-fix
- M3 says: "Assistive tech must be able to **receive the tooltip's message** and activate it via
keyboard/switch input" (RC-A, Tooltips → Accessibility); "Role: **Tooltip** (or an equivalent role)
on the container".
- Library does: `rich-tooltip.blade.php:41-56` gives the bubble `role="tooltip"` (or `dialog` when
`persistent`) and an `aria-label` of the title, but nothing points at it: the trigger gets no
`aria-describedby`, and for `persistent` no `aria-haspopup="dialog"`/`aria-expanded` either. A
screen-reader user focusing the trigger hears only the trigger's own label; the explanation — the
whole point of a rich tooltip — is never announced. (`<x-tooltip>` is deliberately `aria-hidden`
and documents why, which is defensible for an icon button whose `aria-label` *is* the tooltip text;
it is not defensible for the standalone form, e.g.
`showcase/sections/communication.blade.php:30-32`, where the tip says something the trigger does
not.)
- Fix: in `rich-tooltip.blade.php`, put `aria-describedby="material-rich-tooltip-{{ $key }}"` on the
wrapper's focusable child (or document that the caller must), and for `persistent` add
`aria-haspopup="dialog"` + an `aria-expanded` written by `rich-tooltip.js`. For the standalone
`<x-tooltip>`, either drop `aria-hidden` and describe the trigger, or document in SKILL.md that the
trigger must carry the same words itself.
- Effort: M
- Breaks API? no
### ACT-23 · badge · The floating badge sits 24 px off M3's anchor geometry
- Severity: nice-to-have
- M3 says: "Small badge: distance from top-trailing icon corner to bottom-leading badge corner (H×W)
| **6×6dp**"; "Large badge: … | **14×12dp**" (RC-A, Badges → Specs). For a 24 dp icon that puts a
small badge flush in the icon's top-trailing corner (top 0, end 0) and a large badge at
top 2 px / start `calc(100% - 12px)`.
- Library does: `badge.blade.php:79-80` — `absolute top-0.5 end-0.5` for the dot (2 px in on both
axes, where M3 wants 0) and `absolute -top-1 start-[calc(100%-0.75rem)]` for the count (the `start`
is exactly right; the top is 4 px where M3 wants 2 px).
- Fix: `top-0 end-0` for the dot and `-top-0.5` for the count in `badge.blade.php:79-80`.
- Effort: S
- Breaks API? no
### ACT-24 · badge · The `outline` status badge draws its edge in `outline-variant`
- Severity: nice-to-have
- M3 says: a badge "must be ≥**3:1**, same rule whether default or custom color" (RC-A, Badges →
Accessibility). `outline-variant` is M3's *decorative* boundary role (dividers); `outline` is the
role for a boundary that must be seen.
- Library does: `badge.blade.php:68` — `'border-outline-variant text-on-surface-variant'`. On
`surface` in the library's own scheme `outline-variant` is around 1.5:1, so the badge's shape is
barely visible. (`<x-button variant="outlined">` uses the same role, but there the M3 table
explicitly names it: "Outlined container … **Outline variant (outline)**" — RC-A, Buttons → colour
table — so the button is inside spec and the badge, which has no M3 table, is not.)
- Fix: `border-outline` in `badge.blade.php:68`.
- Effort: S
- Breaks API? no
### ACT-25 · tooltip.js · A tooltip disappears the instant the pointer leaves
- Severity: nice-to-have
- M3 says: "tooltips are **transient by default** — both plain and rich tooltips disappear **1.5
seconds** after the cursor/focus leaves the target region; opening a new tooltip immediately closes
any other open one (**only one tooltip visible at a time**)" (RC-A, Tooltips → Specs).
- Library does: `resources/js/tooltip.js:40-43` hides on `pointerleave`, `pointerdown` and
`focusout` with no delay; `rich-tooltip.js:43` uses a 200 ms grace instead of 1500 ms. Nothing
closes other open tooltips — because the bubble is `popover="manual"`
(`tooltip.blade.php:35`), a tooltip held open by keyboard focus stays up while a second one opens
on hover.
- Fix: `hide(1500)` in `tooltip.js` (add the same `delay` parameter `rich-tooltip.js:37` already
has), and keep a module-level `let open = null` that `show()` hides before showing its own.
- Effort: S
- Breaks API? no
### ACT-26 · menu · The open/close transition is a plain fade
- Severity: nice-to-have
- M3 says: "Motion: menus use an **enter/exit transition tying them visually to their trigger**; the
trigger shows a pressed state while the menu is open" (RC-A, Menus → Behaviour and guidelines).
- Library does: `menu.blade.php:72` transitions `opacity` only — `translate` is in the
`transition-[…]` list but no translate or scale value is ever set, so the menu simply fades in
place. The library's own `::picker(select)` does it properly (`menu.css:116-128`: opacity + scale
0.95 from `transform-origin: top`, on the spatial spring).
- Fix: mirror `menu.css:116-128` on `menu.blade.php:72` — add
`origin-top scale-95 open:scale-100 starting:open:scale-95` and put the scale on the spatial spring
while opacity stays on effects.
- Effort: S
- Breaks API? no
### ACT-27 · menu-item · A selected item is told by colour and shape only
- Severity: nice-to-have
- M3 says: "by default, selected items change **both shape and color**; default contrast between
selected/unselected items is 3:1; it's **recommended to add yet another cue** on top (e.g. a
checkmark) so selection isn't conveyed by color/shape alone" (RC-A, Menus → Accessibility).
- Library does: `menu-item.blade.php:51` changes container colour and corner, and
`menu-item.blade.php:87` fills the leading icon; `aria-checked` is set
(`menu-item.blade.php:57`), so assistive tech is fine. No visible tick. Again the library's own
select menu does it — `menu.css:179-188` draws a `::checkmark` at the row's end.
- Fix: when `$selected === true` and no `iconRight` is given, render a trailing `check` icon in
`menu-item.blade.php:105-107`.
- Effort: S
- Breaks API? no
### ACT-28 · menu-item · 44 px rows, where the rest of the library's menus use 48 px
- Severity: nice-to-have
- M3 says: `tokens/SegmentedMenuTokens.kt` `Item = 44.0.dp` (what the component's header cites, and it
is correct), while the baseline menu's published spec is "List item height | **48dp**" (RC-A, Menus
→ Specs) and the same page's accessibility section asks for "≥**48×48dp** targets" inside menu item
slots.
- Library does: `menu-item.blade.php:46` — `min-h-11` (44 px). `resources/css/components/menu.css:55`
(`.field-option`) and `menu.css:134` (native `option`) both use `min-block-size: 3rem` (48 px), so
a `<x-menu>` and a `<x-select>` dropdown in the same form do not match.
- Fix: either `min-h-12` on `menu-item.blade.php:46` (consistent, and clears the 48 px target), or
keep 44 px and note the inconsistency deliberately. Both readings are defensible; the token is the
more specific source, the accessibility page the more binding one.
- Effort: S
- Breaks API? no
### ACT-29 · split-button · The trailing chevron is not nudged off centre
- Severity: nice-to-have
- M3 says: "Menu icon offset from center when unselected (site, specs page): XS/S = **1dp**, M =
2dp, L = 3dp, XL = 6dp" (RC-A, Split button → Specs) — the chevron sits slightly towards the
leading button so the pair reads as one control.
- Library does: `split-button.blade.php:56-66` centres the icon (`justify-center` from
`button.blade.php:162`).
- Fix: add a per-size negative `ms-px`/`-me-*` or `translate-x` to the trailing button's `:class` in
`split-button.blade.php:36`.
- Effort: S
- Breaks API? no
### ACT-30 · split-button · The chevron rotates on the expressive spring
- Severity: nice-to-have
- M3 says: "The trailing (menu) button uses the **standard motion scheme, not expressive**, when
rotating; it rotates 180° inward on open/close" (RC-A, Split button → Behaviour and guidelines).
- Library does: `groups.css:85-87` — `transition: rotate var(--md-sys-motion-spatial-fast-duration)
var(--md-sys-motion-spatial-fast)`, i.e. the expressive spring, which overshoots past 180° and
comes back. The 180° rotation itself (`groups.css:89-90`) is right.
- Fix: the library has no standard-scheme spring token; `--md-sys-motion-easing-standard` with
`--md-sys-motion-duration-short` (`motion.css:52,58`) is the closest published equivalent.
- Effort: S
- Breaks API? no
### ACT-31 · fab · The small extended FAB's minimum width is 56 px, not 80 dp
- Severity: nice-to-have
- M3 says: baseline extended FAB "Container width | Dynamic, **80dp min**" (RC-A, Extended FAB →
Specs). `ExtendedFabSmallTokens.kt` publishes no minimum, and the small extended FAB is the
baseline's replacement, so 80 dp is the only number on record.
- Library does: `fab.blade.php:39` — `min-w-14` (56 px) for `sm`, `min-w-20`/`min-w-24` for md/lg
(which match those sizes' heights rather than any published minimum).
- Fix: `min-w-20` (80 px) for the small extended FAB in `fab.blade.php:39`.
- Effort: S
- Breaks API? no
### ACT-32 · fab-menu · Margins do not grow in large windows, and items cannot scroll
- Severity: nice-to-have
- M3 says: "Menu/FAB margin from screen edge | 16dp (compact/medium windows), **24dp** (large/extra-large
windows)"; "Extra bottom margin when opened from medium FAB | 40dp… large FAB | 56dp"; "On short
viewports (e.g. landscape phone), items can **scroll — behind the close button**, which stays
fixed" (RC-A, FAB menu → Specs and → Behaviour).
- Library does: `<x-fab-menu>` does not place itself (by design), and both the header comment
(`fab-menu.blade.php:3`) and SKILL.md line 283 show a fixed `end-4 bottom-4` (16 px) with no
large-window variant. The list (`fab-menu.blade.php:69-75`) has no `max-height`/`overflow`, so on a
landscape phone six 56 px items plus gaps (~360 px) push the menu off screen.
- Fix: document `sm:end-6 sm:bottom-6`-style margins in the header and SKILL.md (Tailwind has no
1200 px breakpoint by default — see the breakpoint map), and add
`max-h-[calc(100vh-8rem)] overflow-y-auto` to `fab-menu.blade.php:70`.
- Effort: S
- Breaks API? no
### ACT-33 · fab-menu / menu · Items enter but never exit
- Severity: nice-to-have
- M3 says: "Motion: FAB transforms into the close button; items animate in/out via an **enter-exit
transition**, originating from one of the FAB's trailing corners" (RC-A, FAB menu → Behaviour).
- Library does: `fab-menu-item.blade.php:27` has `starting:translate-y-2 starting:opacity-0` for the
entry, but the popover is hidden with `display: none` on close and the items carry no
`transition-discrete`/`@starting-style` exit pair, so they vanish instantly. `<x-menu>` fades out
correctly (`menu.blade.php:72` includes `display,overlay` and `transition-discrete`).
- Fix: add `transition-[translate,opacity,display,overlay] transition-discrete` to
`fab-menu-item.blade.php:27` and drive the closed state from the popover's `:not(:popover-open)`.
- Effort: M
- Breaks API? no
### ACT-34 · toast · Escape does not dismiss a focused snackbar
- Severity: nice-to-have
- M3 says: "Keyboard: Tab moves between interactive elements; **Esc dismisses the focused snackbar**";
and "on web, provide a documented keyboard shortcut (e.g. Alt+G) to jump focus to a snackbar with an
action" (RC-A, Snackbar → Accessibility).
- Library does: `snackbar.js` binds no key handler; `toast.blade.php:41-82` binds only pointer and
focus events. Escape does nothing, and there is no way to reach an actioned snackbar from the
keyboard except by tabbing past the whole page (the host is the last element in `<body>`).
- Fix: add a `keydown` listener in `snackbar.js:38` that calls `dismiss()` on Escape while the
snackbar contains `document.activeElement`; optionally a documented shortcut that focuses
`[data-toast-action]`.
- Effort: S
- Breaks API? no
### ACT-35 · toast · The description line is dimmed with an opacity instead of a role
- Severity: nice-to-have
- M3 says: the snackbar's roles are "container = **Inverse surface**; supporting text/icon = **Inverse
on surface**; action label = **Inverse primary**" (RC-A, Snackbar → Specs) — there is no fourth
role, and supporting text is one block of up to two lines.
- Library does: `toast.blade.php:69` — `type-body-md opacity-80` on the description, i.e.
inverse-on-surface at 80 %, a colour outside the roles that also drops the contrast of the longer
half of the message.
- Fix: drop `opacity-80` and let both lines be `inverse-on-surface`; M3 distinguishes them by position,
not tone.
- Effort: S
- Breaks API? no
### ACT-36 · alert · `role="alert"` on content that is present at page load
- Severity: nice-to-have
- M3 says: for the nearest equivalent (the snackbar) "use a **live region with a 'polite'/queued**
announcement (**not assertive**)" (RC-A, Snackbar → Accessibility). M3 publishes no alert/banner
component at all — the current site has no banner page; the only survivals are legacy mentions in
the states inheritance list (RF:384) and the elevation table (RS:320).
- Library does: `alert.blade.php:38` — `role="{{ in_array($color, ['error','warning']) ? 'alert' :
'status' }}"`. `role="alert"` is an assertive live region; when the alert is part of the rendered
page (the normal case — the showcase renders four of them statically,
`showcase/sections/communication.blade.php:47-53`) some screen readers announce it over the page
title on load, and a Livewire morph that re-renders the region can re-announce it.
- Fix: default both to `role="status"` and add an opt-in prop (`assertive`) for the case where the
alert really is injected in response to an action.
- Effort: S
- Breaks API? yes (the rendered role changes; tests asserting `role="alert"` would need updating)
## Deliberate deviations
- **The text button's label is `primary`, not `on-surface-variant`** (`button.blade.php:20-22`,
`:100-127`). Holds up. The M3 specs table reads "Text icon & label | **Primary** | -- | --" (RC-A,
Buttons → colour table), and the M2→M3 notes say "neutral text button no longer recommended". The
library is right, and it is also right that an *icon-only* text button (M3's "standard icon button")
takes `on-surface-variant` — "Standard icon | On surface variant | On surface variant | Primary"
(RC-A, Icon buttons → colour table). Both readings are implemented in the same `match` at
`button.blade.php:123-126`, which is exactly correct.
- **A selected round toggle button turns square** (`button.blade.php:132-136`). Ambiguous, and the
library picked the site's reading. The guidelines page says "By default, toggle buttons change from
**round to square when selected**" (RC-A, Buttons → Behaviour), but `tokens/ButtonSmallTokens.kt`
and `ButtonMediumTokens.kt` both set `SelectedContainerShapeRound = CornerFull`, i.e. a selected
round *label* button stays round in Compose. For *icon* buttons the tokens agree with the site
(`SmallIconButtonTokens.SelectedContainerShapeRound = CornerMedium`,
`…ShapeSquare = CornerFull`; `LargeIconButtonTokens`/`XLargeIconButtonTokens` are the same pattern
with ExtraLarge), and the library implements that swap exactly. Worth a line in the header comment
saying which source won for label buttons.
- **A selected text button takes the tonal container** (`button.blade.php:18`, `:123-126`). Holds up as
an extension: M3 gives text buttons no toggle states at all, so anything is an invention, and
borrowing the tonal selected colours keeps it inside the roles and satisfies the two-indicator rule.
- **Press expansion is a fixed step per size, not 15 % of width** (`groups.css:9`, `:32-48`). Holds up.
`ButtonGroupDefaults.ExpandedRatio = 0.15f` and the layout applies `expandedRatio * width / 2` per
side, clamped to the neighbour's padding (fetched live from androidx-main `ButtonGroup.kt:186`,
`:482`, `:500-511`). CSS cannot express a percentage of an intrinsic width, and every size's
`--group-grow` (4/6/8/16/20 px) is safely below its `--group-pad`, so the neighbour never reaches
negative padding. The animation uses `ease-spatial-fast`, matching Compose's
`MotionSchemeKeyTokens.FastSpatial` (`ButtonGroup.kt`, defaultAnimationSpec).
- **`--group-full` is half the height rather than `corner-full`** (`groups.css:19-23`, `:32-36`).
Holds up, and the values are right: 16/20/28/48/68 px are exactly half of 32/40/56/96/136 dp, which
is what `ConnectedButtonGroupSmallTokens.SelectedInnerCornerCornerSizePercent = 50%` means.
- **The `fab` prop on `<x-button>`** (`button.blade.php:177`). Inside spec as far as the *component*
goes: an extended FAB at 56 px, corner 16 dp, title-medium, primary-container, elevation 3 and a
16 px margin, which is the small extended FAB exactly (`ExtendedFabSmallTokens.kt`). Two caveats:
M3 says an extended FAB needs "**No tooltip** — the visible label already serves that role
(explicit Don't)" while `button.blade.php:225` will happily attach one; and the elevation never
reaches Level 4 on hover because `max-sm:shadow-elevation-3` outranks `hover:shadow-elevation-1`
in Tailwind's variant order.
- **`<x-group>` binds native radios/checkboxes, so the arrow keys move the choice**
(`group.blade.php:9-10`, `:84-91`). Ambiguous. M3's button-group accessibility says "Keyboard: **Tab**
navigates to the next button; Space or Enter activates/selects the focused button" (RC-A, Button
groups → Accessibility), which is roving-tabindex-free but also not a radio group. The library's
choice matches WAI-ARIA's radiogroup pattern and gives `wire:model` for free; it is a defensible
reading of "single-select, selection-required", and the `multiple` form (checkboxes) does behave
exactly as M3 describes.
- **The alert exists at all** (`alert.blade.php:1-10`). Holds up, with a caveat. M3 publishes no
banner and no alert; its own guidance routes in-page notices to a snackbar (transient, low priority)
or a dialog (blocking, high priority) — and an "your storage is almost full" notice is neither. The
component stays inside the foundations: container roles only, `rounded-corner-md`, title-small +
body-medium, text buttons in the actions slot. The header comment should add *when not to* use it
(a confirmation belongs in `<x-modal>`, a result of an action in `<x-toast>`), since the M3 site
will not tell a reader that.
- **`<x-stat>` and `<x-empty-state>`** (`stat.blade.php:3-8`, `empty-state.blade.php:8-11`). Hold up.
Both draw only from M3's roles (`surface-container`, `secondary-container`/`on-secondary-container`),
the shape scale (`rounded-corner-lg`), the Expressive emphasized type roles
(`type-emphasized-headline-md`, which `type.css` does define) and the Expressive shape library
(`cookie-9`). The counting figure honours reduced motion by reading
`--md-sys-motion-spatial-slow-duration`, which `motion.css:63-74` zeroes (`figure.js:59-64`, `:75`).
Nothing here draws a colour outside the roles.
- **`<x-progress>`'s non-primary track colours** (`progress.blade.php:64-68`). Holds up. M3 fixes the
track at `secondary-container` for the primary indicator (RC-A, Progress indicators → Specs), which
the library does for `primary` and `secondary`; the extra colours take their own container, which is
the only consistent extension.
- **`thick` grows the container beyond M3's `WaveHeight`** (`progress.blade.php:11-13`,
`:154-162`). Holds up: M3 publishes 10 dp only for the 4 dp stroke, "Track thickness | Configurable"
is an Expressive configuration with no published table, and adding the extra stroke to the container
keeps the 3 dp amplitude intact (`progress.js:1333`, `waveHeight = height - stroke`).
## Aligned
Verified correct — no need to re-check:
- **Button sizes**: 32/40/56/96/136 px heights, 8/8/8/12/16 px gaps, 20/20/24/32/40 px icons,
16/24/48/64 px padding at sm/md/lg/xl, 1/1/1/2/3 px outlines (`button.blade.php:147-158` against
RC-A Buttons → Specs and `tokens/Button*.kt`).
- **Button corners**: square 12/12/16/28/28 px and pressed 8/8/12/16/16 px, and the pressed value is
applied to round buttons too, as M3 requires ("round and square buttons should have the same pressed
shape") — `button.blade.php:129-136`, `:166`.
- **Button type roles per size**: label-large / label-large / title-medium / headline-small /
headline-large. Confirmed live against androidx-main `Button.kt:1645-1648`
(`buttonHeight < mediumHeight -> labelLarge; < largeHeight -> titleMedium; < xLargeHeight ->
headlineSmall; else -> headlineLarge`).
- **Every toggle colour pair**, all four variants × three states, matches the specs table exactly
(`button.blade.php:118-127`): filled unselected = surface-container/on-surface-variant, tonal
selected = secondary/on-secondary, outlined selected = inverse-surface/inverse-on-surface, elevated
selected = primary/on-primary, standard icon button selected = primary.
- **Icon-button widths**: all fifteen values (narrow/default/wide × XSXL) derive exactly from the
token paddings — 28/32/40, 32/40/52, 48/56/72, 64/96/128, 104/136/184 px (`button.blade.php:140-144`).
- **Icon-button selected shape swap** (round ⇄ square, `button.blade.php:133`) matches
`Small/Large/XLargeIconButtonTokens.SelectedContainerShape*` at every size. (RC-A's note that the
swap is "inverted for XS/S/M vs L/XL" is wrong — the token files are consistent, and the library is
right.)
- **Disabled**: content at 38 %, container at 10 % (`button.blade.php:174-175`) matches
`FilledButtonTokens/TonalButtonTokens/ElevatedButtonTokens.DisabledContainerOpacity = 0.1f` and
`DisabledLabelTextOpacity = 0.38f`; elevation removed when disabled ✓.
- **Elevation**: filled/tonal rest 0 → hover Level 1; elevated rest Level 1 → hover Level 2
(`button.blade.php:169-170`); FAB rest Level 3 → hover Level 4 (`fab.blade.php:49`); menu Level 2
(`menu.blade.php:70`); snackbar Level 3 (`toast.blade.php:51`); rich tooltip Level 2
(`rich-tooltip.blade.php:50`); plain tooltip none (`tooltip.blade.php:40`). All match.
- **48 px touch target on xs/sm buttons** (`button.blade.php:172`).
- **State layer**: content colour at 8 % hover / 10 % focus / 10 % press, hover gated behind
`@media (hover: hover)`, suppressed when disabled (`state.css:13-44`) — matches RF:367-375 exactly,
including "the state layer's color equals the content's 'on' color" (it uses `currentColor`).
- **Focus ring**: 3 px `secondary`, 2 px offset, keyboard-only (`state.css:46-53`), and
`SegmentedMenuTokens.ItemFocusIndicatorColor = Secondary` confirms the role for menus.
- **Standard group spacing**: 18/12/8/8/8 px (`button-group.blade.php:36-38`).
- **Connected group**: 2 px gaps and inner corners 4/8/8/16/20 px, pressed smaller, selected at 50 %
of the height (`button-group.blade.php:34`, `groups.css:32-36`, `:54-71`).
- **Split button**: 2 px between halves, leading padding 12/10 (xs) and 16/12 (sm) px, symmetric
24/48/64 px at md/lg/xl, trailing button widths 48/48/56/96/136 px, outer corners full, trailing
half rounds fully while open, chevron rotates 180°, `text` variant excluded, menu 4 px away,
`aria-expanded` on the trailing button, default label "More options"
(`split-button.blade.php:33-66`, `groups.css:68-90`).
- **FAB**: 56/80/96 px with 16/20/28 px corners and 24/28/32 px icons; primary/secondary/tertiary
container by default with the Expressive non-container styles under `variant="filled"`; no small FAB
and no surface style (`fab.blade.php:34-42`).
- **FAB menu**: 56 px close button, full corner when open, 20 px close icon, 8 px to the first item,
56 px items with full corners, 24 px icons, 8 px icon gap, 24 px padding, 4 px between items,
elevation 3 throughout, trailing-edge alignment (`fab-menu.blade.php:48-75`,
`fab-menu-item.blade.php:25`) — every number matches `tokens/FabMenuBaselineTokens.kt`.
- **Menu container**: surface-container-low / tertiary-container for `vibrant`, 16 px corner,
elevation 2, 112280 px width (`menu.blade.php:70-79`) — matches
`StandardMenuTokens.ContainerColor`, `VibrantMenuTokens`, `SegmentedMenuTokens.ContainerShape =
CornerLarge`, `ContainerElevation = Level2`, and the baseline width table.
- **Menu item**: body-large label, 20 px icons, 4 px corners opening to 12 px at the ends, selected =
tertiary-container with a 12 px corner, body-medium description, label-small trailing text — all
five confirmed against `SegmentedMenuTokens` (`ItemLabelTextFont = BodyLarge`, `ItemLeadingIconSize
= 20dp`, `ItemShape = CornerExtraSmall`, `ItemFirstChildShape = CornerMedium`, `ItemSelectedShape =
CornerMedium`, `ItemSupportingTextFont = BodyMedium`, `ItemTrailingSupportingTextFont = LabelSmall`).
- **Menu keyboard**: Enter/Space/Down open on the first item, Up on the last, arrows/Home/End move,
typeahead, Tab closes, Escape closes and returns focus to the trigger, activation closes unless
`keep-open` (`menu.js:153-242`, `menu.blade.php:53-55`) — the WAI-ARIA menu-button pattern, and it
matches M3's own keyboard table apart from submenus.
- **`current` uses `secondary-container`** (`menu-item.blade.php:52`) — the navigation-indicator role,
correctly distinguished from a checked choice.
- **Badge**: 6 px dot, 16 px tall count with a 16 px minimum width and 4 px padding, label-small,
full corners, error/on-error by default, `aria-hidden` unless labelled, `max` overflow to "999+"
within M3's four-character limit (`badge.blade.php:74-85`) — matches `tokens/BadgeTokens.kt`.
- **Progress**: 4 px (8 px thick) stroke, 40 px circular (48 px wavy), 4 px track gap, 4 px stop
indicator on linear determinate only, primary indicator with a secondary-container track, 40/20/15 px
wavelengths, 3 dp linear amplitude, round caps, reduced-motion handling, `role="progressbar"` with
`aria-valuenow` only while determinate (`progress.blade.php:70-197`, `progress.js:61-79`, `:1333`).
- **Loading indicator**: 48 px container, 38 px shape, primary by default,
on-primary-container-on-primary-container when `contained`, `role="progressbar"`, rests under
reduced motion (`loading.blade.php:27-42`) — matches `LoadingIndicatorTokens` and the contained
colour rule exactly.
- **Snackbar colours and type**: inverse-surface container, inverse-on-surface text, inverse-primary
label-large action rendered as a text button, body-medium supporting text, 4 px corner, elevation 3,
48 px single-line height, one at a time, 4 s default inside M3's 410 s range, hover/focus pauses,
never steals focus (`toast.blade.php:51-73`, `snackbar.js:13`, `:84-117`) — matches
`tokens/SnackbarTokens.kt` value for value.
- **Plain tooltip**: inverse-surface, inverse-on-surface, body-small, 4 px corner, 8 px horizontal
padding giving a 24 px container, 4 px from the target, no elevation, flips when there is no room
(`tooltip.blade.php:40-45`).
- **Rich tooltip**: surface-container, 12 px corner, elevation 2, 12/8/16 px padding, title-small
subhead and body-medium text in on-surface-variant, label-large primary actions, persistent opens on
press and light-dismisses, the pointer can travel onto the bubble to reach the actions
(`rich-tooltip.blade.php:50-65`, `rich-tooltip.js:20-46`).
- **Motion springs**: damping 0.6 / stiffness 800 fast-spatial and 1.0 / 3800 fast-effects
(`motion.css:28-42`) match RS:524-525 exactly, and every duration is zeroed under reduced motion.
## Missing
- **Submenus** (`<x-menu>`): M3's Expressive vertical menu specifies submenus, the Left/Right arrow
keys that open and close them, and the shape morph that marks the active one ("the focused
submenu's corners become more rounded while others become less rounded"). Nothing in the library
implements them; `menu.js:201-219` has no Left/Right case.
- **Grouped menu layout by gap**: M3's Expressive "Grouped" layout separates clusters with a *gap*
(`SegmentedMenuTokens.SegmentedGap = 2dp`, `GroupPadding = 4dp`, `GroupShape = CornerSmall`).
`<x-menu-group>` implements the labelled form and `<x-menu-separator>` the divider form — which is
what M3 recommends for web ("on web, use dividers to separate items") — but the gap layout itself,
and the 2 px gap between adjacent items that `ItemShape = 4dp` corners imply, are absent.
- **Menus as a filtering surface / embedded text field** ("autocomplete", RC-A Menus → Behaviour).
Partly covered by `<x-choices searchable>` in another group.
- **Square-by-default button groups**: M3 lists "Default shape | Round, **square**" as a button-group
configuration. `<x-button-group>` has no `shape` prop; the corner variables in `groups.css` assume a
round group, so a group of `shape="square"` buttons gets round outer corners.
- **Selection-required / multi-select semantics on `<x-button-group connected>`**: M3 lists
single-select, multi-select and selection-required as configurations. `<x-group>` covers
single/multi with real inputs; the plain `<x-button-group connected>` leaves selection entirely to
the caller's `aria-pressed`.
- **Snackbar keyboard shortcut** to jump focus to an actioned snackbar (M3 suggests Alt+G on web).
- **Two-line snackbar height** (68 dp in `SnackbarTokens.TwoLinesContainerHeight`, 64 dp in the site's
prose) is not expressed — the container grows organically from `min-h-12`, which lands close but is
not pinned.
- **FAB ↔ extended FAB scroll collapse** ("an extended FAB can collapse to a FAB on scroll and
re-expand at the bottom of the view"). `<x-button fab>` swaps on window width only.
- **Adaptive menu → bottom sheet at compact** (M3: "at compact breakpoints, consider swapping a menu
for a bottom sheet"). `<x-bottom-sheet>` exists, but nothing connects the two.
- Deliberately absent and correctly so: segmented buttons (deprecated in favour of the connected
button group, which `<x-group>` provides), the small FAB ("Not recommended. Use a larger size"), the
baseline extended FAB ("Not recommended. Use small extended FAB"), surface-coloured FABs ("no longer
recommended").
## Breakpoint map
| Component | Library breakpoint used | M3 window size class it stands in for | Gap |
| --- | --- | --- | --- |
| `<x-button fab>` (`button.blade.php:177`) | `max-sm` (< 640 px) → extended FAB pinned bottom-end; ≥ 640 px → filled button | Compact (< 600) gets the FAB; medium+ (≥ 600) gets the inline button | 40 px: windows 600639 px are "medium" in M3 but still get the phone FAB |
| `<x-button responsive>` (`button.blade.php:218`) | `max-lg` (< 1024 px) hides the label | M3 has no label-hiding rule; the nearest is "on large screens, buttons can reposition" at expanded (≥ 840) | 184 px, and the rule itself is an invention — an icon-only button at 8401023 px loses its visible label while keeping its `aria-label` |
| `<x-toast>` (`toast.blade.php:36-51`) | `sm` (≥ 640 px) switches from full-width to `w-auto min-w-86`, and `bottom-start` moves to `start-6` | Compact (< 600) full-width fixed insets; medium+ (≥ 600) scales horizontally for line length | 40 px. Also: M3 allows a full-width snackbar only "when the UI has no persistent nav components", and `<x-app-shell>` shows a bottom navigation bar in exactly that range |
| `<x-fab-menu>` margins (documented, `fab-menu.blade.php:3`, SKILL.md:283) | none — a fixed `end-4 bottom-4` (16 px) in the docs | 16 dp at compact/medium, **24 dp** at large (≥ 1200) / extra-large (≥ 1600) | The 24 dp step is missing; Tailwind's `xl` is 1280 px and `2xl` is 1536 px, so neither lines up with 1200/1600 |
| `<x-fab>` size choice | none — the caller picks `sm`/`md`/`lg` | M3: medium FAB "most recommended" for compact/medium, large FAB for expanded+ | No adaptive guidance in the component or SKILL.md; the default is the 56 px baseline FAB, which M3 calls the *smallest* of the three |
| `<x-menu>` | none | M3: "at compact breakpoints, consider swapping a menu for a bottom sheet" | Not implemented (see Missing) |
| `<x-button-group>`, `<x-group>`, `<x-split-button>`, `<x-badge>`, `<x-progress>`, `<x-loading>`, `<x-tooltip>`, `<x-rich-tooltip>`, `<x-alert>`, `<x-stat>`, `<x-empty-state>` | none | M3 defines no width-dependent behaviour for these (button groups are "fixed or flexible" by author choice; `<x-group>` already fills its row, which is M3's connected-group rule) | none |
+597
View File
@@ -0,0 +1,597 @@
# 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 |
+292
View File
@@ -0,0 +1,292 @@
# Audit: core (tokens, scheme, theme, icons, shapes, guard, showcase foundations, AI docs)
Ground truth: `docs/reference/m3/styles.md`, `docs/reference/m3/foundations.md`, raw pages under `m3/raw/`.
Library paths are relative to `/Users/surtic86/Privat/Development/livewire-material`.
## Summary
The core is in good shape: every 2025-spec colour role (the `*-dim` roles and the fixed roles included) is generated and mapped, the legacy `surface-tint`/`surface-variant` are correctly left out, the corner scale is M3 Expressive's ten steps, the five shadow levels are Google's web geometry, the six springs carry the exact Compose damping/stiffness constants, the state layer uses M3's 8/10/10 % and hover is pointer-only, the typescale sizes/line heights/weights match, and theme switching honours both the OS and the visitor's choice. The 35 shapes and the full Symbols set are ported from Google's sources.
The gaps are around the edges of the system rather than in it. (1) The three custom state colours (`success`/`warning`/`info`) are built with the 2021 `customColor` recipe, so they ignore the `--contrast` level and the 2025 spec's tone rules that `error` follows. (2) Only the standard contrast level exists; M3 (May 2025) makes standard/medium/high part of the colour system. (3) The semantic ink utilities (`text-meta` 60 %, `text-quiet` 38 %, `border-divider` 40 %) are M2-style opacity emphasis, which M3 replaced with `on-surface-variant`/`outline-variant`, and 38 % collides with M3's disabled opacity. (4) Tailwind's own radius, shadow, type-size, weight, easing and duration utilities still compile, so an application (or an AI agent) can silently leave the M3 scale; only colours were cleared. (5) There are no tokens or variants for M3's five breakpoints, no `dragged` state, and no shared touch-target utility. (6) Type tracking follows the older material-web numbers rather than Compose's, and the emphasized styles reuse baseline tracking. (7) The AI docs list the utilities but explain none of M3's rules for choosing between them; that is the "core concept" document the user asked for.
Counts: must-fix 1 · should-fix 12 · nice-to-have 11.
## Findings
### C1 · scheme command · custom colours ignore the contrast level and the 2025 spec
- Severity: must-fix
- M3 says: "Custom components support contrast levels simply by using Material color roles… the role's resolved value changes per contrast level automatically" (reference-styles §Color/Contrast levels; site `styles/color/roles`). Aug 2024: on-container roles became "more colourful while remaining accessible"; the 2025 spec that the library asks for (`--spec=2025`) applies those curves to `error`. `define-new-colors`: custom colours should behave like the built-in roles.
- Library does: `resources/node/scheme.mjs` (tail) builds `success`/`warning`/`info` with material-color-utilities' `customColor()` (`pe(...)`, `blend:false`): fixed tones 40/100/90/10 in light and 80/20/30/90 in dark, no contrast curve, no spec awareness. So `php artisan material:scheme --contrast=1` raises every M3 role but leaves the three state colours at standard contrast, and their `on-*-container` pairs are tone 10/90 while `on-error-container` (2025) is `#6e0523`-style tone ~25. Visible in `resources/css/tokens/scheme.css:66-77` vs `:62-65`.
- Fix: in `bin/scheme.mjs` (source of the bundle), define the three colours as `DynamicColor`s on their own `TonalPalette` exactly as `color_spec_2025.js` defines `error`/`onError`/`errorContainer`/`onErrorContainer` (same `ContrastCurve`s and `ToneDeltaPair`s), evaluated against the same `DynamicScheme` (so `contrastLevel`, `isDark`, `specVersion`, platform all apply). Rebuild `resources/node/scheme.mjs`; regenerate `tokens/scheme.css`, `scheme.json`, the workbench scheme. Add a `SchemeCommandTest` case: contrast 1 changes `success`.
- Effort: M
- Breaks API? no (role names unchanged; values change)
### C2 · scheme · only the standard contrast level is generated
- Severity: should-fix
- M3 says: "Three levels of contrast: standard, medium (3:1 minimum), high (7:1)… contrast settings apply automatically in both light and dark theme" (reference-styles §Color; `styles/color/roles`, "What's new May 2025"). Accessibility principle "Honor individuals — build in customizable features" (reference-foundations §Accessibility).
- Library does: `SchemeCommand` (`src/Console/SchemeCommand.php:24`) takes `--contrast` once and writes a single light and dark block; nothing at runtime can raise contrast; `theme.js` has no contrast state; no `@media (prefers-contrast: more)`.
- Fix: have `material:scheme` also emit `[data-contrast="medium"]` (contrast 0.5) and `[data-contrast="high"]` (1.0) blocks for light and dark (and per profile), plus `@media (prefers-contrast: more) { :root:not([data-contrast]) {…} }` as the OS default; add `contrast` to `$store.theme` (`resources/js/theme.js`) and to the head script's attribute set (`theme-script.blade.php`, kept across `wire:navigate` like `data-theme`); `<x-theme-toggle mode="picker">` or a new `<x-contrast-picker>` exposes it. Document in SKILL.md §Theme.
- Effort: M
- Breaks API? no (opt-in)
### C3 · theme.css · M2-style opacity inks and lines instead of M3 roles
- Severity: should-fix
- M3 says: "Default typography color is on surface (or on surface variant as a strong alternative)"; dividers use **outline variant**, not a lighter mix; "Always apply color roles, never static hex values"; 0.38 is the **disabled** opacity (reference-foundations §States values; reference-styles §Color rules, §Typography accessibility).
- Library does: `resources/css/tokens/theme.css:99-106` defines `--color-meta` = on-surface 60 %, `--color-quiet` = on-surface 38 %, `--color-chrome` = outline-variant 60 %, `--color-divider` = outline-variant 40 % via `color-mix()`. `text-quiet` therefore looks exactly like disabled text (`text-on-surface/38`, used 21× in components), and `border-divider` is lighter than M3's divider. None of these utilities is used by a package component (grep: only the showcase swatches and one `border-divider`/`divide-divider`), so they exist for ReStride's templates.
- Fix: keep the names, repoint them to roles: `--color-body` → on-surface-variant (already), `--color-meta` → on-surface-variant, `--color-quiet` → outline, `--color-structure` → outline-variant (already), `--color-chrome` → outline-variant, `--color-divider` → outline-variant. Say in SKILL.md that emphasis is a role, not an opacity, and that 38 % means disabled. If ReStride needs the lighter divider, it overrides the variable in its own `@theme`.
- Effort: S
- Breaks API? no (names stay; rendered colour changes in ReStride)
### C4 · theme.css · Tailwind's default radius, shadow, type-size, weight, easing and duration utilities still compile
- Severity: should-fix
- M3 says: "Do use design tokens instead of hardcoded values" (reference-foundations §Design tokens); the corner scale, elevation levels, typescale and motion tokens are the only values (reference-styles §Shape, §Elevation, §Typography rule "avoid changing type size", §Motion).
- Library does: `theme.css:19-25` clears `--color-*` only. `rounded-lg` (Tailwind: 8 px, M3 "large" is 16 px), `rounded-xl` (12 px vs 28 px), `shadow-md`, `text-sm`/`text-lg`, `font-bold`, `leading-*`, `tracking-*`, `ease-in-out`, `duration-300` all still compile with Tailwind's values. The package's own views are clean (only `rounded-full` ×4 and `rounded-none` ×1, both fine), but consuming apps and AI agents are not stopped. `DesignGuard` does not look for them.
- Fix: two steps. (a) Now, non-breaking: `DesignGuard` gains a "value outside the M3 scale" check for `rounded-(xs|sm|md|lg|xl|2xl|3xl|4xl)`, `shadow-(2xs|xs|sm|md|lg|xl|2xl)`, `text-(xs|sm|base|lg|xl|\dxl)`, `font-(thin|…|black)`, `leading-*`, `tracking-*`, `ease-(in|out|in-out)`, `duration-\d+`, `blur-*`? (no), each with the M3 utility to use instead. (b) In the next major: `@theme { --radius-*: initial; --shadow-*: initial; --ease-*: initial; --text-*: initial; --font-weight-*: initial; --leading-*: initial; --tracking-*: initial; }` before re-declaring the M3 ones, mirroring what was done for colour (keep `--radius-full`/`rounded-none` behaviour by declaring `--radius-full: 9999px`).
- Effort: S (guard) + S (theme, but a major-version change)
- Breaks API? (a) no · (b) yes for applications using Tailwind defaults
### C5 · type.css · tracking follows material-web, not Compose; emphasized styles reuse baseline tracking
- Severity: should-fix
- M3 says (reference-styles §Typography, `TypeScaleTokens.kt`): Display Large 0.2 sp; Title Medium 0.2 sp; Body Medium 0.2 sp; emphasized: Display Large 0, Title Medium 0.15, Body Large 0.15, Body Medium 0.25 (others unchanged).
- Library does: `resources/css/tokens/type.css:21` display-lg tracking 0.015625 rem (0.25 px, material-web); `:39` title-md 0.009375 rem (0.15 px, M3: 0.2 sp = 0.0125 rem); `:48` body-md 0.015625 rem (0.25 px, M3: 0.2 sp); every `type-emphasized-*` utility (`:101` onward) uses the baseline `-tracking` variable, so emphasized display-lg keeps 0.25 px instead of 0, emphasized body-lg keeps 0.5 px instead of 0.15.
- Fix: add `--md-sys-typescale-emphasized-*-tracking` variables with Compose's values and use them in the emphasized utilities; correct the three baseline values to Compose's (sp/16 rem). Sub-pixel, but it is what the Expressive token file says and the header comment cites material-web, which is pre-Expressive.
- Effort: S
- Breaks API? no
### C6 · type.css · regular styles do not reset `font-variation-settings`
- Severity: nice-to-have
- M3 says: emphasized styles are applied deliberately, per element (reference-styles §Typography rules).
- Library does: `type-emphasized-*` sets `font-variation-settings: "ROND" 100` (`type.css:107`), an inherited property; `type-*` utilities set none, so `type-body-md` text inside an element carrying `type-emphasized-title-md` (a card slot under an emphasized wrapper) renders fully rounded.
- Fix: every regular utility sets `font-variation-settings: normal` (or `"ROND" 0`).
- Effort: S
- Breaks API? no
### C7 · font · variable axes of the subset are unverified here
- Severity: nice-to-have (verification)
- M3 says: brand/plain typefaces are the product's choice; Roboto is the default (reference-styles §Typography fonts). Google Sans Flex is Google's Expressive typeface (the site's shape page: "M3 shapes and Google Sans Flex share roundness attributes"), so the choice is aligned.
- Library does: `font.css` claims weight 400700 and `ROND` 0100 are kept. I could not open the woff2 (no fontTools on this machine).
- Fix: add a test (or a `bin/` check) that reads the `fvar` table and asserts the `wght` and `ROND` axes exist, so a future re-subset cannot silently drop the axis the emphasized styles depend on.
- Effort: S
- Breaks API? no
### C8 · motion.css · spring durations differ from the web equivalents Google publishes
- Severity: should-fix (with a counter-argument)
- M3 says (`styles/motion/overview/specs`, reference-styles §Motion "Web curve equivalents"): expressive spatial fast/default/slow = 350/500/650 ms, effects = 150/200/300 ms, each with a cubic-bezier; "springs aren't natively supported on the web, so Web should use these approximated curves with matching duration".
- Library does: `motion.css:33-52` samples the real springs into `linear()` (more faithful than Google's cubic-bezier) but stops at the settle time: 360/440/600 ms and 150/240/330 ms. Default spatial is 12 % quicker than Google's web number, slow effects 10 % slower.
- Fix: either (a) sample each spring over Google's published web duration (the curve reaches 1 earlier and holds, so the bounce timing is unchanged) so the `*-duration` tokens read 350/500/650/150/200/300, or (b) keep the settle times and record why in the header. Also add the six published cubic-bezier curves as `--md-sys-motion-spring-*-bezier` fallbacks for `@supports not (animation-timing-function: linear(0,1))` (Safari < 17.2 is below the floor, so this is documentation more than need). (a) is "what Google says".
- Effort: S
- Breaks API? no
### C9 · motion.css · only the Expressive motion scheme exists
- Severity: nice-to-have
- M3 says: two preset schemes, Expressive (default) and Standard ("minimal bounce, for utilitarian products"), swappable product-wide (reference-styles §Motion principles; `StandardMotionTokens.kt`: fast spatial 0.9/1400, default 0.9/700, slow 0.9/300; effects identical).
- Library does: six springs, Expressive only.
- Fix: sample the three Standard spatial springs too and let `[data-motion="standard"]` on `<html>` (or a config flag rendered by the theme script) swap the `--md-sys-motion-spatial-*` variables. Effects are the same in both schemes.
- Effort: S
- Breaks API? no
### C10 · state.css · no `dragged` state, no disabled tokens, no shared touch target
- Severity: should-fix
- M3 says: state layer opacities hover 0.08, focus 0.10, pressed 0.10, **dragged 0.16**; disabled content 0.38; "the size of state layers is 40dp while the interactive target size is 48dp"; target spacing 8 dp (reference-foundations §States, §Accessibility values).
- Library does: `state.css:26-40` covers hover/focus/active; nothing for dragged (cards, chips, list items and the slider are the components M3 gives a dragged state); no `--md-sys-state-*` custom properties, so components hard-code `/38`, `/12`, `/10` (21, 10 and 4 occurrences); the 48 px target is built ad hoc in `button.blade.php:167` with an `after:` pseudo-element and nowhere shared.
- Fix: in `state.css` declare `--md-sys-state-hover-opacity: 0.08`, `focus 0.10`, `pressed 0.10`, `dragged 0.16`, `disabled-content 0.38`, `disabled-container 0.12` and use them in `state-layer` (add `&[data-dragged]::before { opacity: var(--md-sys-state-dragged-opacity) }`); add a `touch-target` utility (the `after:` pseudo-element from the button, `min 48×48`) so small icon buttons, chips, checkboxes, radios and switch share one implementation.
- Effort: S
- Breaks API? no
### C11 · state.css · focus ring spec has no source on the M3 site
- Severity: nice-to-have (documentation)
- M3 says: only "a ring-like keyboard focus indicator" (reference-foundations §States; the site defines no thickness/offset/colour — see its "Cross-cutting gaps" 1).
- Library does: `focus-ring` = 3 px `secondary`, offset 2 px (`state.css:47-53`), which is material-web's `md-focus-ring` default (3 px, outward offset 2 px, secondary).
- Fix: none to the code; cite material-web in the header so nobody "corrects" it to the site's silence.
- Effort: S
- Breaks API? no
### C12 · core · no tokens or variants for M3's breakpoints
- Severity: should-fix
- M3 says: five breakpoints — compact < 600, medium 600839, expanded 8401199, large 12001599, extra-large ≥ 1600 dp — with the navigation component, pane count and dialog/menu choice per breakpoint (reference-foundations §Layout breakpoints tables).
- Library does: nothing in the tokens; components switch on Tailwind's `sm` 640 / `md` 768 / `lg` 1024 / `xl` 1280 (the navigation audit maps each use). An application cannot write "at the medium breakpoint" at all.
- Fix: `theme.css` adds `--breakpoint-medium: 600px; --breakpoint-expanded: 840px; --breakpoint-large: 1200px; --breakpoint-extra-large: 1600px` (Tailwind 4 turns these into `medium:`, `expanded:`, `large:`, `extra-large:` variants; `max-medium:` etc. come for free), keeps `sm/md/lg/xl` for now, and the components migrate to the M3 variants in their own findings. Add pane tokens as custom properties: `--md-sys-layout-pane-fixed: 360px` / `412px`, `--md-sys-layout-side-sheet-max: 400px`, margins 16 px compact / 24 px otherwise (older M3 layout page values; the current site gives no numeric margin table — mark as such).
- Effort: S (tokens) — the component migration is counted in the other audits
- Breaks API? no
### C13 · DesignGuard · no check for values outside the M3 scale, hex colours, or `white`/`black`
- Severity: should-fix
- M3 says: "Always apply color roles, never static hex values or raw tonal-palette values" (reference-styles §Color rules).
- Library does: `src/Testing/DesignGuard.php:26-33` flags the Tailwind palette and daisyUI colours; it does not flag arbitrary values (`bg-[#1d7afc]`, `text-[rgb(…)]`), `bg-white`/`text-black` (re-added in `theme.css:21-22` and not M3 roles; the M3 white is `surface-container-lowest` in light), or any of the non-token utilities in C4.
- Fix: add patterns for `(bg|text|border|…)-\[#`, `-\[rgb`, `-\[hsl`, `-\[oklch`, and an opt-in `forbidAbsolutes()` for `white`/`black`; add the C4 list with a hint per match ("`rounded-lg``rounded-corner-lg`").
- Effort: S
- Breaks API? no
### C14 · theme-toggle · the picker is drawn as M3's deprecated segmented button
- Severity: should-fix
- M3 says: segmented buttons are "M3 only", replaced in M3 Expressive by the connected button group (reference-components-a §Segmented buttons / §Button groups; the plan doc lists segmented button among the six deprecated components).
- Library does: `resources/views/components/theme-toggle.blade.php:27-49` (`mode="picker"`) draws an outlined 40 px group with `border-outline` and `aria-checked` fills — the segmented-button anatomy — while `<x-group>` (the connected button group) already exists.
- Fix: render the picker with `<x-livewire-material::group>` (three `wire:model`-less radio options bound to `$store.theme`), or reuse its classes; keep `role="radiogroup"` and the arrow-key behaviour.
- Effort: S
- Breaks API? no (`data-theme-option` hooks can stay)
### C15 · scheme · harmonisation of the custom colours is not offered
- Severity: nice-to-have
- M3 says: "you can choose to harmonize your static colors to the scheme's primary color… while retaining the semantic meaning"; "colors can stay completely static and forgo harmonization if their values are tied to literal sources such as brand colors" (`styles/color/advanced/define-new-colors`).
- Library does: `customColor(..., blend:false)` always (scheme.mjs tail); no option.
- Fix: `--harmonize` flag on `material:scheme` (and `harmonize` per profile) passed as `blend:true`; default off, as today. Do together with C1.
- Effort: S
- Breaks API? no
### C16 · icons · one weight/grade/optical size for every context
- Severity: nice-to-have
- M3 says: grade 25 "for a light icon on dark background"; optical size 20 for 20 dp icons in dense/desktop layouts; 40/48 for display pairing (reference-styles §Icons axes table).
- Library does: Material Symbols Rounded 400/0/24 only (`bin/fetch-symbols`, `icon.blade.php` header); buttons `xs`/`sm` draw 20 px icons from the 24-opsz outlines (`button.blade.php:147-149`), so strokes are ~17 % thinner than a true opsz-20 glyph; dark theme keeps grade 0.
- Fix: none that is cheap — each extra cut is another 4,135 SVGs (~2.6 MB). Record the trade-off in the icon header and SKILL.md; if ever needed, ship `opsz20` for the outlined set only and let `<x-icon size="20">` pick it.
- Effort: L
- Breaks API? no
### C17 · scheme · `background`/`on-background` are emitted, `surface-tint`/`surface-variant` are not
- Severity: nice-to-have (informational, aligned)
- M3 says: background/on-background are legacy roles with the same values as surface/on-surface; surface-variant was superseded by the surface-container family (Feb 2023); surface tint is deprecated (reference-styles §Color roles table).
- Library does: emits and maps `background`/`on-background` (`scheme.css:16-17`, `theme.css:29-30`), omits the two deprecated ones. Correct. Consider marking `bg-background` as "same as `bg-surface`; prefer surface" in SKILL.md so agents do not treat them as two surfaces.
- Effort: S
- Breaks API? no
### C18 · scheme · the package default is the 2025 rendering of the baseline seed, not the site's baseline table
- Severity: nice-to-have (informational)
- M3 says: the baseline palette table (`PaletteTokens.kt`, site `styles/color/static/baseline`) is the 2021 spec: primary 40 = `#6750A4`, on-primary = white.
- Library does: `tokens/scheme.css` is `#6750a4` tonal-spot through the 2025 spec (`primary #655789`, `on-primary #fdf7ff`). Both are Google's; the header says so. Nothing to fix; the showcase colour page could say "spec 2025" so a reader comparing with the site's swatches is not puzzled.
- Effort: S
- Breaks API? no
### C19 · elevation · no expression of "which component rests at which level"
- Severity: nice-to-have (documentation)
- M3 says: level 3 = dialogs, FAB, pickers, search; level 2 = menus, scrolled app bar, navigation bar, rich tooltip, toolbar; level 1 = elevated button/card/chip, modal sheets; level 0 = everything else; +1 level on hover (reference-styles §Elevation tokens table); scrim 32 %.
- Library does: shadow tokens only (`elevation.css`); the showcase shows five boxes with no mapping; components pick levels individually (correctly where I looked: menu 2, FAB 3, elevated button 1→2 on hover, `bg-scrim/32` ×4).
- Fix: put the table in the new guideline/skill and in the showcase elevation section, with M3's rule "tonal separation first, shadows for floating things and interaction".
- Effort: S
- Breaks API? no
### C20 · showcase · the foundation pages show tokens but not M3's rules for them
- Severity: nice-to-have
- M3 says: (as in C19, plus) surface container hierarchy by emphasis; outline vs outline-variant; type roles and when emphasized is used; spatial vs effects; corner scale per component class; optical roundness (outer padding = inner).
- Library does: `showcase/sections/{colour,type,shape,elevation,motion,icons}.blade.php` list every utility with one sentence each; the colour page groups "Ink and lines" as if they were roles (see C3).
- Fix: one short "when to use" paragraph per section, drawn from the same guideline text (C24), so the showcase and the AI docs cannot drift.
- Effort: S
- Breaks API? no
### C21 · theme script · `data-theme` only, no `prefers-color-scheme` in CSS — aligned
- Severity: nice-to-have (no change)
- M3 says: light/dark is a user preference; honour the OS and the person's choice (reference-foundations §Accessibility principles; §Color "built-in dark theme").
- Library does: resolves `system` before first paint, follows OS changes while `system`, keeps the attributes across `wire:navigate`, declares `color-scheme` per block. Correct.
- Fix: none. (C2 adds contrast to the same mechanism.)
### C22 · reduced motion · stricter than the site, aligned with its intent
- Severity: nice-to-have (no change)
- M3 says: no global reduced-motion rule on `styles/motion`; component pages ask for parallax/expansion to be removed under reduced motion (reference-styles §Motion accessibility).
- Library does: zeroes every duration token (`motion.css:66-77`); carousel, progress, tabs and figure check the media query themselves. Correct; the header's "anything that animates without them is a bug" is a good rule to repeat in the guideline.
### C23 · icons · accessibility of `<x-icon>` — aligned
- Severity: nice-to-have (no change)
- M3 says: decorative icons hidden; meaningful icons labelled; don't put the role in the label (reference-foundations §Accessibility labelling).
- Library does: `aria-hidden` by default, `role="img"` + `aria-label` when `label` is given, `focusable="false"` (`icon.blade.php:27-33`). Correct.
### C24 · AI docs · the M3 core concepts are not written down anywhere an agent reads
- Severity: should-fix (this is the user's explicit request)
- M3 says: see the list under "Concepts missing from the AI docs" below.
- Library does: `resources/boost/guidelines/core.blade.php` (6 bullets: what the package is, no maryUI, theme script, showcase, error pages/mail) and SKILL.md §Tokens/§Theme/§Conventions list the utilities and three rules (elevation is for floating things; pair easing with duration; `dark:` follows `data-theme`). Nothing says what a role, a container level, a corner size, a type role or a spring is *for*, nor any layout/accessibility rule.
- Fix: a second always-on guideline `resources/boost/guidelines/material-3.blade.php` (short: the rules that change what an agent writes) and a fuller `resources/boost/skills/material-3-design/SKILL.md` (the concept reference with the tables), both generated from one source or tested against each other; Boost 2.8 loads every file under `resources/boost/guidelines` and every skill directory (verified in `laravel/boost/src/Install/GuidelineComposer.php:320-351`, `SkillComposer.php:127-141`). Add the drift test.
- Effort: M
- Breaks API? no
## Deliberate deviations
| Where | Deviation | Reason given | Holds up? |
| --- | --- | --- | --- |
| `shape.css` | `corner-full` = 9999px instead of material-web's `50cqmin` | container units need a declared container | Yes. Compose's `CircleShape` is 50 % of the smaller dimension = a stadium; 9999px gives the same on rectangles, `50%` would not. |
| `elevation.css` | `color-mix()` instead of `hsl(from …)` | browser floor | Yes; same result. |
| `motion.css` | springs sampled to `linear()` rather than Google's cubic-bezier approximations | closer to the physics | Yes for the curve; the durations differ from Google's published web numbers (C8). |
| `motion.css` | reduced motion zeroes durations | one place instead of per component | Stricter than the site; consistent with "Honor individuals". |
| `theme.css` | colour blocks are `@theme inline` | nested `data-theme` sections | Sound; verified by a browser test per the plan doc. |
| `theme.css` | white/black re-added | absolutes | M3 has no such roles; white is `surface-container-lowest` (light). Harmless, but the guard should at least be able to flag them (C13). |
| `theme.css` | `--color-meta/quiet/divider/chrome` opacity mixes | ReStride's templates | Does **not** hold against M3's text (C3): M3 dropped opacity-based emphasis with M3; 38 % is disabled. |
| `type.css` | Google Sans Flex for brand and plain, `ROND` 100 on emphasized | Expressive's typeface | Holds: the site names Google Sans Flex on the shape page and leaves the typeface to the product; `ROND` is that font's own axis, not an M3 token. |
| scheme | `surface-tint`/`surface-variant` omitted | superseded/deprecated | Holds (reference-styles §Color roles). |
| scheme | success/warning/info via `customColor`, unharmonised | "harmonisation off" | Off is allowed; the 2021 recipe and contrast-blindness are not (C1, C15). |
| `icon` | one Symbols cut, 400/0/24, SVG not font | any name works, no runtime download | Holds for weight; grade/optical size are lost (C16). |
| `theme-script` | OS never read by CSS | the script resolves `system` | Holds. |
## Aligned (keep as is)
- All 2025-spec colour roles generated and mapped, `*-dim` and fixed roles included; `scheme.json` and `Scheme.php` fill missing roles from the default; every pair (`x` / `on-x`) comes from Google's algorithm.
- Corner scale: none 0, xs 4, sm 8, md 12, lg 16, lg-increased 20, xl 28, xl-increased 32, xxl 48, full — exactly M3 Expressive's ten steps, same names.
- Elevation: five levels with material-web's shadow geometry; tonal separation preferred; scrim at 32 % where used.
- Motion: spatial 0.6/800, 0.8/380, 0.8/200; effects 1.0/3800, 1.0/1600, 1.0/800 — the Compose Expressive constants; legacy easing tokens present; reduced motion handled centrally and in the four scripted components.
- Typescale: all 15 sizes, line heights and weights match `TypeScaleTokens.kt`, emphasized set present (weights right).
- State layer: 8/10/10 %, content colour, hover only on `(hover: hover)`, disabled removes it; focus ring only on `:focus-visible`.
- Theme: `data-theme` resolved before paint, OS followed while `system`, choice persisted, legacy keys adopted, attributes survive `wire:navigate`, `color-scheme` declared, `dark:` variant on `data-theme`.
- Shapes: the 35 Expressive shapes ported from androidx, all fill the same box, decorative and hidden from AT.
- Symbols: Google's own 24 px Rounded files (not the 48-opsz npm cut), outlined and filled, `currentColor`, hidden unless labelled.
- Safe areas: `--material-safe-*` = M3's safety region.
- DesignGuard already enforces "roles, not palette" and "no maryUI/daisyUI".
## Missing (M3 things the core does not offer at all)
- Contrast levels medium/high (C2).
- Breakpoint tokens/variants and pane/margin tokens (C12).
- Dragged state, disabled tokens, a shared touch target (C10).
- Standard motion scheme (C9).
- Harmonisation option for custom colours (C15).
- An M3 concept guideline for agents (C24).
## Breakpoint map (core only)
| Item | Library | M3 breakpoint | Gap |
| --- | --- | --- | --- |
| tokens | none; Tailwind `sm` 640 / `md` 768 / `lg` 1024 / `xl` 1280 / `2xl` 1536 | compact <600 / medium 600 / expanded 840 / large 1200 / extra-large 1600 | no M3 breakpoint is expressible; nearest Tailwind values are 4088 px off (see navigation audit for each component) |
## Concepts missing from the AI docs (input for the "Material 3 core concept" guideline)
Each line: what M3 says → whether the package docs say it today.
Colour
- Roles are "paint-by-number" slots; a colour is always a role, never a hex or palette tone → docs say "every colour class names an M3 role" (yes) but not why or the hex rule.
- Pair `x` with `on-x` only; a container is a fill, "on" is for text/icons on it; don't mix pairs (primary + secondary-container + on-surface) → no.
- Primary = high-emphasis actions; secondary = less prominent, tonal fills; tertiary = complementary accent; error = static semantic → no.
- Surface = page; surface-container-lowest…highest = emphasis hierarchy, not elevation; navigation on surface-container; same mapping for a region across breakpoints → no.
- `outline` for boundaries that must read (text fields), `outline-variant` for dividers and cards; never `outline` on dividers → no.
- Fixed/dim roles: "if you aren't sure, you probably shouldn't"; never where contrast matters → no.
- Inverse roles are for the snackbar/inverse surfaces only → no.
- success/warning/info are the package's custom roles, built like error; semantic colours are never dynamic-coloured away → no.
- Low emphasis is `on-surface-variant`, 38 % means disabled; links are primary **and** underlined → no (the opposite: `text-quiet` is documented as decoration).
- Contrast targets 4.5:1 small text, 3:1 large text/graphics, 3:1 clustered elements; disabled exempt → no.
- Three contrast levels exist → no.
Elevation
- Tonal separation first; shadows for floating elements and interaction only; the level table per component; +1 on hover; scrim 32 % → partly ("for what floats over content").
Shape
- Which corner for what: full for buttons/chips/FAB-menu, xs 4 for text fields/snackbar, sm 8 for chips, md 12 for cards, lg 16 for FAB, xl 28 for dialogs/sheets/menus in Expressive, xxl 48 for large containers → no.
- Optical roundness: inner radius = outer radius padding → no.
- Shapes are decoration, never semantic; use sparingly; press morph is the interaction cue → no.
Typography
- Role purposes: display (big, short), headline (short, high emphasis), title (medium, short), body (paragraphs), label (inside components; buttons use label-lg) → no.
- Emphasized styles are opt-in, for selection, primary actions, headlines, badges — not decoration → no.
- 4060 characters per line; tabular numbers where values change; don't change sizes when customising → no (only "never assemble text-* by hand").
Motion
- Spatial springs for position/size/shape (they overshoot), effects for colour/opacity (never overshoot); fast for small elements, default for most, slow for large → partly (the pairing rule, one sentence).
- Enter = decelerate, permanent exit = accelerate, temporary exit = emphasized; exits shorter than enters → no.
- Reduced motion: everything through the tokens goes instant; parallax/expansion removed → no.
States
- Six states; two visual indicators per state; hover 8 % / focus 10 % / press 10 % / drag 16 %; disabled 38 % content, 12 % container, no hover; state layer takes the content colour → no (utilities only).
- 48 × 48 target, 8 dp between targets, never below 48 by default, density is opt-in → no.
Layout
- Five breakpoints and what changes at each (navigation bar → collapsed rail → expanded rail; 1 → 2 → 3 panes; bottom sheet → menu; full-screen dialog → basic dialog) → no.
- Scaffold: bars, rails, panes; safety regions; pane widths 360/412, side sheet ≤ 400 → partly (safe areas documented).
- RTL: leading/trailing, mirror directional icons, use logical utilities (`ps-`, `ms-`, `start-`) → no.
Accessibility
- Landmarks (one main/banner/contentinfo; label repeated navs; no role in the label); headings in order, one H1; dialog focus in/out; labels for icon-only controls; decorative images hidden; keyboard shortcuts need a modifier → no.
Icons
- Filled = active/selected; keep one weight per group; 24 default, 20 in dense UI; label complex icons below 20; 48 target → partly (`filled` documented).
+692
View File
@@ -0,0 +1,692 @@
# Audit: inputs
Scope: text fields and their chrome (`form`, `field`, `input`, `password`, `textarea`, `select`, `file`),
selection controls (`checkbox`, `radio`, `toggle`), `slider`, `chip` / `chip-set` / `choices`,
`datepicker`, `timepicker`, `search`, and the data pieces (`table`, `sort-header`, pagination views).
Sources cited below: `docs/reference/m3/reference-components-b.md` (RB) and `docs/reference/m3/reference-foundations.md`
(RF) — both built from m3.material.io; raw page dumps `docs/reference/m3/raw/components_*_specs.md`; androidx
token files in `docs/reference/m3/tokens/*.kt`. `reference-styles.md` did not exist while this audit ran, so
shape/type/elevation numbers come from the raw `styles_*` dumps and the token files.
## Summary
This is an unusually faithful M3 implementation. The text-field chrome, the three selection controls, the
chip family and both pickers reproduce the androidx token numbers almost exactly — the switch matches
`SwitchTokens` in every dimension, the date picker matches `DatePickerModalTokens` (360 × 120 header,
40 px day in a 48 px cell, today's 1 px primary outline), and the time picker matches the site's own
spec table for the dial, the selector handle and the time-selector boxes. The biggest problems are
accessibility rather than geometry: `<x-search>` announces nothing when results appear and puts
`aria-expanded` on a plain `<input type="search">` with orphan `role="listitem"` results; a disabled text
field still lights its outline on hover because the hover declaration sits on a descendant of the element
carrying the disabled value; and a label-less checkbox, radio or switch has an 1852 px hit target instead
of 48 px. Measurable deviations cluster in the slider (the M-size handle is 44 px where M3 says 52 px, the
value indicator is a 32 × 36 pill where M3 says 44 × 48, and the stop/tick/icon colours are inverted from
the site's roles) and in the search view (it draws the deprecated *divided* style's divider and uses
surface-container-high where the full-screen layout should be surface-container-low). Missing entirely:
the slider's Expressive vertical orientation, the full-screen range date picker, and any width bound on
the search bar or a text field at medium/expanded widths. Several of the library's own androidx-token
citations in header comments name numbers that the M3 site's spec tables contradict — all listed in
**Token-citation disagreements** below.
## Findings
### IN-01 · search · results appear with no announcement, and the input's ARIA is invalid
- Severity: must-fix
- M3 says: "Screen reader must announce when suggestions/results appear (autosuggest change)" and
"Suggestions/results use the list component and are announced as a list by screen readers (follow list
accessibility guidelines for item labels)" — RB §Search/Accessibility (m3.material.io/components/search/accessibility).
- Library does: `resources/views/components/search.blade.php:47-64` puts `aria-controls` and
`aria-expanded` on a bare `<input type="search">` with no `role="combobox"`. ARIA 1.2 does not list
`aria-expanded` as supported on `textbox`, so the state is dropped by conforming AT. The view
(`search.blade.php:75-90`) has no role and no live region, and `[data-search-results]`
(`search.blade.php:86`) is a plain `<div>` whose children are `<x-list-item>`s rendered as
`role="listitem"` (`resources/views/components/list-item.blade.php:41`) — an orphan role with no
`role="list"` owner. `grep -n 'aria-live' resources/views/components/search.blade.php` returns nothing.
`<x-choices>` already does this correctly (`choices.blade.php:124-130,154-157`: `role="combobox"`,
`aria-autocomplete="list"`, `role="listbox"`/`role="option"`, `aria-activedescendant`).
- Fix: in `search.blade.php`, add `role="list"` to the `[data-search-results]` div (or wrap the slot in
`<x-list>`), and either (a) mirror `<x-choices>`: `role="combobox" aria-autocomplete="list"` on the
input and roving `aria-activedescendant` over the results, or (b) keep the plain search field, drop
`aria-expanded`, and add a visually hidden `<span aria-live="polite">` that `search.js` fills with the
result count whenever `[data-search-results]` mutates (the `MutationObserver` plumbing already exists in
`resources/js/chips.js:99-117` as a pattern).
- Effort: M
- Breaks API? no
### IN-02 · field · a disabled text field still lights its outline on hover
- Severity: must-fix
- M3 says: "**Disabled**: communicated via color change + reduced elevation; not focusable, draggable, or
pressable; **no hover/other state layer**" — RF §States (foundations/interaction/states/applying-states).
- Library does: `resources/css/components/field.css:390-392` declares
`.field-box:hover { --field-edge: var(--md-sys-color-on-surface); }` **on `.field-box`**, while the
disabled value at `field.css:423-425` is declared on `.field` and only *inherits* down. A direct
declaration beats an inherited one regardless of selector specificity, so hovering a disabled outlined
field paints a full-opacity `on-surface` outline instead of `on-surface/12`. The rule is also outside
`@media (hover: hover)`, unlike the filled variant's background at `field.css:458-462`, so the state
sticks after a tap on touch. (The filled background *is* correctly suppressed — `field.css:514-519`
wins on specificity there; only `--field-edge` breaks.)
- Fix: `resources/css/components/field.css:390` → wrap in `@media (hover: hover)` and scope to
`.field:not(:has(.field-control:disabled)) .field-box:hover`.
- Effort: S
- Breaks API? no
### IN-03 · checkbox / radio / toggle · a control without a label has an 1852 px hit target
- Severity: must-fix
- M3 says: checkbox "Target size 48dp", radio "Target size 48dp", switch "Target | Size | 48dp"
(RB §Checkbox/Specs, §Radio Button/Specs, §Switch/Specs); "The size of state layers is 40dp while the
interactive target size is 48dp" (RF §States); and for all three, "Don't apply density by default —
this drops targets below the 48x48 CSS px minimum" (RB §Checkbox/Accessibility, §Radio/Accessibility,
§Switch/Accessibility).
- Library does: in `resources/views/components/checkbox.blade.php:27-55` the `<label>` wraps only
`<span data-checkbox>` when `label` and `hint` are both blank, and `[data-checkbox]` is `1.125rem`
square (`resources/css/components/selection.css:105-108`). The 40 px state layer is a `::before` with
`pointer-events: none` (`selection.css:51-59`), so it adds nothing to the target. Result: an 18 × 18
target. `[data-radio]` is 20 × 20 (`selection.css:169-172`) and `[data-switch]` is 52 × 32
(`selection.css:225-231`) — 32 px tall, also under 48. A label-less checkbox is a real use (the
select-all in a table header, a row checkbox), and `<x-toggle>` documents `aria-label` as the label-less
path (`toggle.blade.php:5`), so the case is expected.
- Fix: in `resources/css/components/selection.css`, add a pointer-catching pseudo-element rather than
growing the visual box — e.g.
`[data-checkbox]::after { content:""; position:absolute; inset:-0.9375rem; }` (18 → 48),
`[data-radio]::after { inset:-0.875rem; }` (20 → 48), and
`[data-switch]::after { content:""; position:absolute; inset:-0.5rem 0; }` (32 → 48). Keep them behind
the input so clicks still reach it, or put `min-block-size:3rem` on the `[data-selection]` row.
- Effort: S
- Breaks API? no
### IN-04 · slider · the M-size handle is 44 px where M3 Expressive specifies 52 dp
- Severity: should-fix
- M3 says: sliders/specs, "Handle height | 44dp | 44dp | **52dp** | 68dp | 108dp" for XS/S/M/L/XL
(RB §Sliders/Specs, verbatim site table).
- Library does: `resources/views/components/slider.blade.php:247-250` maps
`'md' => 'h-11 group-data-focused/thumb:h-9.5'` — 44 px, the same as XS and S. The header comment at
`slider.blade.php:44` states the intent as "handles of 44, 44, 44, 68 and 108px", citing MDC-Android's
`md.comp.slider.*` tokens, which disagrees with the site table for M.
- Fix: `slider.blade.php:248``'md' => 'h-13 group-data-focused/thumb:h-11.5'` (52 px, 46 px focused,
keeping the 6 px focus-ring decrease the comment describes), and correct the header comment.
- Effort: S
- Breaks API? no
### IN-05 · slider · the value indicator is a 32 × 36 pill where M3 Expressive specifies 44 × 48
- Severity: should-fix
- M3 says: sliders/specs, "Label container height | 44dp (all sizes)" and "Label container width | 48dp
(all sizes)" (RB §Sliders/Specs). Container `Inverse surface`, label `Inverse on surface`, font
`Label large` (`SliderTokens.ValueIndicatorContainerColor / ValueIndicatorLabelTextColor /
ValueIndicatorLabelTextFont`).
- Library does: `resources/views/components/slider.blade.php:346``h-8 min-w-9 ... px-2.5` = 32 px tall,
36 px minimum width. The colours and font are right (`bg-inverse-surface text-inverse-on-surface
type-label-lg`). The header at `slider.blade.php:39-41` explains the 32 px as Flutter's
`RoundedRectSliderValueIndicatorShape`, i.e. a non-Expressive source.
- Fix: `slider.blade.php:346``h-11 min-w-12`; then re-derive the `$labelBottom` offsets at
`slider.blade.php:252` (they are hard-coded against a 32 px pill: `calc(50%+1.625rem)` = handle half +
4 px gap + 32 px). Note M3 also gives `ValueIndicatorActiveBottomSpace = 12dp` in `SliderTokens.kt`
where the library uses 4 px — worth reconciling in the same change.
- Effort: M
- Breaks API? no
### IN-06 · slider · stop indicators and the inset icon use inverted colour roles
- Severity: should-fix
- M3 says: the 9 slider colour roles, in anatomy order, are "1. Inverse surface 2. Inverse on surface
3. Primary 4. **On primary** 5. Primary 6. Secondary container 7. **On secondary container**
8. On secondary container 9. On primary" — `docs/reference/m3/raw/components_sliders_specs.md:150-160`
(verbatim). That is: a stop/icon **on the active track** is `On primary`; a stop/icon **on the inactive
track** is `On secondary container`.
- Library does: `resources/views/components/slider.blade.php:227-238` inverts both —
`$tickInk['primary'] = 'bg-primary data-active:bg-secondary-container'` (so an inactive-track tick is
`primary` and an active-track tick is `secondary-container`), and `$iconInk['primary'] =
'text-primary data-active:text-secondary-container'`. The end stop indicators at
`slider.blade.php:311` use `$activeInk` (`bg-primary`) even though they sit on the inactive track.
Contested reading: `SliderTokens.kt` has `StopIndicatorColor = SecondaryContainer` and
`StopIndicatorColorSelected = SecondaryContainer`, which would make the active-track stop
secondary-container (the library's choice) and the inactive-track stop invisible. The site's own colour
list is the more coherent of the two and is the newer Expressive text.
- Fix: in `slider.blade.php:227-238`, swap to
`'primary' => 'bg-on-secondary-container data-active:bg-on-primary'` (and the parallel
`text-on-…` for `$iconInk`), and give `$stops` at `slider.blade.php:311` `bg-on-secondary-container`.
Repeat for the `secondary`/`tertiary`/`error`/`success`/`warning`/`info` rows.
- Effort: S
- Breaks API? no
### IN-07 · search · the view draws the *divided* style's divider, which M3 Expressive deprecates
- Severity: should-fix
- M3 says: "| Style | Contained | -- | Available | | | Divided | Available | **Not recommended. Use
contained.** |" and "In the divided (baseline) style, **a divider separates the search bar and
results**" — `docs/reference/m3/raw/components_search_specs.md:106-107,134`. The divided style "Doesn't have
the latest visual style, motion, or flexibility" (ibid. :46).
- Library does: `resources/css/components/search.css:160`
`[data-search-results] { border-top: 1px solid var(--md-sys-color-outline); }`. The file's own header
(`search.css:6-9`) describes the contained style. Second reading: Compose's `SearchBar` does draw a
`HorizontalDivider` between the input and the content, so this may be tracking the Compose
implementation rather than the site; the site's contained-style images show no divider.
- Fix: `resources/css/components/search.css:160` → drop the `border-top` (keep `padding-block: 0.5rem`).
If a separation cue is wanted, use `outline-variant`, not the darker `outline` role.
- Effort: S
- Breaks API? no
### IN-08 · search · the full-screen layout uses the docked layout's container colour
- Severity: should-fix
- M3 says: "Full-screen search color roles used in light and dark themes: 1. **Surface container low**
2. On surface variant 3. On surface variant 4. Surface container high 5. On surface variant 6. On
surface" vs "Docked search color roles: 1. **Surface container high** …" —
`docs/reference/m3/raw/components_search_specs.md:196-232`. RB §Search/Behaviour also warns: "Never use
Surface container high on a Surface container background (contrast too low, blends in) — keep container
roles at least one step apart."
- Library does: `resources/css/components/search.css:140` sets
`[data-search-view] { background-color: var(--md-sys-color-surface-container-high); }` for both
presentations; `search.css:174-181` changes only geometry for `[data-full-screen]`.
- Fix: add
`[data-search][data-full-screen] [data-search-view] { background-color: var(--md-sys-color-surface-container-low); }`
to `resources/css/components/search.css` around line 174.
- Effort: S
- Breaks API? no
### IN-09 · search · the docked view has no scrim
- Severity: should-fix
- M3 says: "**Docked** (opens a results list below the bar **with a scrim over the rest of the content**
— best for medium/expanded windows)" — RB §Search/Variants and configurations.
- Library does: `resources/views/components/search.blade.php:27-37` raises the root to `z-index: 50`
when open (`search.css:24-26`) and closes on `pointerdown.outside`, but paints nothing over the page.
The full-screen presentation covers the page instead (`search.css:174-181`), which is correct for
compact.
- Fix: in `search.blade.php`, add a `<div data-search-scrim x-show="open && ! fullScreen">` before
`[data-search-bar]` and style it in `search.css` as
`position: fixed; inset: 0; z-index: -1; background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);`
— the same 32 % the datepicker backdrop uses (`datepicker.css:111-113`).
- Effort: M
- Breaks API? no
### IN-10 · timepicker · the 24-hour vertical time selector stays 96 px where M3 specifies 114 dp
- Severity: should-fix
- M3 says: time-pickers/specs, "Time selector container | Width | 96dp" and "| Width (24h vertical) |
**114dp**" (RB §Time Pickers/Specs); confirmed by
`docs/reference/m3/tokens/TimePickerTokens.kt``TimeSelector24HVerticalContainerWidth = 114.0.dp`.
- Library does: `resources/css/components/timepicker.css:106-107` sets
`[data-timepicker-box] { width: 6rem; height: 5rem; }` unconditionally. The dial already carries the
cycle as `data-cycle="24"` on `[data-timepicker-dial]` (`timepicker.blade.php:265`), but the display
boxes never read it. The header comment (`timepicker.blade.php:33`) names only "96×80 time selector
boxes", so the 114 dp token was not carried over.
- Fix: put the cycle on the display too (`timepicker.blade.php:214``x-bind:data-cycle="is24 ? '24' : '12'"`)
and add `[data-timepicker-display][data-cycle="24"] [data-timepicker-box] { width: 7.125rem; }` in
`timepicker.css`, scoped out of the landscape block at `timepicker.css:403-447`.
- Effort: S
- Breaks API? no
### IN-11 · timepicker · the selected AM/PM button is primary-container, not tertiary-container
- Severity: should-fix
- M3 says: the dial colour list includes "Tertiary container, On tertiary container" (RB §Time
Pickers/Specs, 17 site roles), and `docs/reference/m3/tokens/TimePickerTokens.kt` gives
`PeriodSelectorSelectedContainerColor = TertiaryContainer`,
`PeriodSelectorSelectedLabelTextColor = OnTertiaryContainer`,
`PeriodSelectorOutlineColor = Outline`, `PeriodSelectorOutlineWidth = 1.0.dp`,
`PeriodSelectorContainerShape = CornerSmall`. The time **selector** (hour/minute), separately, is
`PrimaryContainer` / `OnPrimaryContainer`.
- Library does: `resources/css/components/timepicker.css:160-169` gives the selected period button
`background-color: var(--md-sys-color-primary-container); color: var(--md-sys-color-on-primary-container)`
— identical to the selected hour/minute box at `timepicker.css:120-124`, so the two different M3
emphases collapse into one. It also drops the 1 dp `Outline` stroke and replaces the shared
corner-small container with two separate pills (full → 12 px on selection). The header
(`timepicker.blade.php:37-39`) justifies this as Compose's current `isUpdatedTimepickerToggleEnabled`
default and mentions "not the outlined pair its tokens still describe" — but not the tertiary→primary
colour swap, which is the part a reader would not expect.
- Fix: either restore `tertiary-container` / `on-tertiary-container` at `timepicker.css:165-167` (keeping
the shape morph, which is a defensible Expressive reading), or document the colour swap explicitly in
the header comment. Recommend the former: it restores the hour/minute vs. AM/PM distinction M3 draws.
- Effort: S
- Breaks API? no
### IN-12 · checkbox · the tick and dash icons are 16 px where M3 specifies 18 dp
- Severity: should-fix
- M3 says: checkbox/specs, "Icon size | 18dp"; `docs/reference/m3/tokens/CheckboxTokens.kt`
`IconSize = 18.0.dp` (= `ContainerSize`, i.e. the icon fills the box).
- Library does: `resources/views/components/checkbox.blade.php:41-42` renders both
`<x-icon name="check" class="size-4" />` and `name="remove" class="size-4"` — 16 px inside an 18 px box
(`selection.css:105-108`). `resources/css/components/selection.css:1-3` cites `CheckboxTokens` as the
source, so the 18 dp figure was rounded to Tailwind's nearest step.
- Fix: `checkbox.blade.php:41-42``class="size-4.5"` (Tailwind 4's 4.5 = 18 px; the chip already uses
`size-4.5` for its 18 dp icons, `chip.blade.php:233,254,282`).
- Effort: S
- Breaks API? no
### IN-13 · chip-set · chips are Tab stops only; M3's arrow-key navigation is missing
- Severity: should-fix
- M3 says: chips/accessibility keyboard table — "Tab | Moves focus to enabled chip or chip group;
Space or Enter | Activates, selects, or deselects the focused chip; Backspace or Delete | Removes
currently focused input chip; **Arrows | Moves focus between chips**" (RB §Chips/Accessibility), and
"only one chip can be in focus even though many can be selected".
- Library does: `resources/views/components/chip-set.blade.php:31-54` renders `role="group"` with every
chip its own natural tab stop; `resources/js/chips.js` implements Backspace/Delete
(`chips.js:38-52`) and focus hand-off on removal (`chips.js:60-79`) but binds no arrow keys. A ten-chip
filter set therefore costs ten Tab presses to pass.
- Fix: add an `x-on:keydown.arrow-right/left/home/end` handler to the `[data-chip-set]` div in
`chip-set.blade.php` (both the `scroll` and wrap branches) that moves focus across
`[data-chip]` using the existing `CONTROLS` selector from `chips.js:13`, with a roving `tabindex`
so the set is one tab stop. Note the trade-off: a filter chip is a real `<input type="checkbox">` in a
`<label>` (`chip.blade.php:181-189`), so roving tabindex must be applied to the inputs, not the labels.
- Effort: M
- Breaks API? no
### IN-14 · chip (input) · the remove button's target is 34 px wide against M3's 48 dp minimum
- Severity: should-fix
- M3 says: input chip specs, "Target size for close icon | **Min 48dp**"; and "minimum target size 48dp
regardless of placement/density (target may extend beyond the visible chip container)" —
RB §Chips/Specs and §Chips/Behaviour.
- Library does: `resources/views/components/chip.blade.php:250` gives the remove button
`after:-inset-x-2 after:-inset-y-3.75` on an 18 px icon (`size-4.5`, line 247) → 18 + 2×15 = 48 px tall
but 18 + 2×8 = **34 px wide**.
- Fix: `chip.blade.php:250``after:-inset-x-3.75 after:-inset-y-3.75` (48 × 48). The chip's own 48 px
target strip (`chip.blade.php:93`) already overlaps the label, so the extra 7 px each side only
overlaps the label's target, which M3 explicitly permits ("target may extend beyond the visible chip
container"); the remove button is later in source order and sits above it.
- Effort: S
- Breaks API? no
### IN-15 · pagination · page and step controls are 40 px targets
- Severity: should-fix
- M3 says: "The size of state layers is 40dp while the interactive target size is 48dp" (RF §States);
accessibility: keep targets ≥ 48 × 48 CSS px (RF §Accessibility/Values). Pagination is not an M3
component, so foundations are the yardstick per the brief.
- Library does: `resources/views/pagination/livewire/tailwind.blade.php:22`
`$step = 'grid size-10 place-items-center …'` (40 px) is used for the page buttons, the prev/next
buttons and the current page, with `gap-1` between them (line 35); same at
`resources/views/pagination/laravel/tailwind.blade.php:7-9`. (The two `simple-tailwind` views are not
affected — they use `<x-button variant="outlined">`, whose sizing belongs to the buttons group.) The
file's own header (line 7) calls them "40px icon-button targets", conflating M3's 40 dp *state layer*
with its 48 dp *target*.
- Fix: keep the 40 px visual and add the target — in the two full pagination views change `$step` to
`'relative grid size-10 place-items-center after:absolute after:-inset-1 …'`, or wrap each button in a
48 px grid cell. Also correct the header comment.
- Effort: S
- Breaks API? no
### IN-16 · table · density is applied by default on every fine-pointer device
- Severity: should-fix
- M3 says: "**Don't apply density by default** — this drops targets below the 48x48 CSS px minimum;
offer an explicit density opt-in instead, keeping opt-out targets at ≥48x48 CSS px" — RF
§Accessibility/Rules, repeated on checkbox, chips, radio, switch and text-field accessibility pages.
(Data tables themselves have no M3 spec — RB §Data Tables confirms the component is absent from
m3.material.io — so foundations govern.)
- Library does: `resources/css/components/table.css:37-45` tightens `--cell-y` from `0.75rem` to
`0.5rem` under `@media (pointer: fine)` with no opt-out, and to `0.25rem` at `data-size="xs"`. With
`body-md`'s 20 px line box that is a 36 px row (28 px at `xs`). Even on a coarse pointer the default row
is 12 + 20 + 12 = 44 px, under 48. Rows are clickable (`data-list-row`,
`resources/js/list-rows.js`), so the row *is* a target.
- Fix: `resources/css/components/table.css` — raise the coarse-pointer default to
`--cell-y: 0.875rem` (14 px → 48 px rows) and turn the fine-pointer step into an opt-in
(`[data-table][data-density="compact"]`) rather than a media query, exposed as a `density` prop on
`resources/views/components/table.blade.php:10-12`.
- Effort: M
- Breaks API? yes — adds a prop and changes default row height; existing `size="xs"` keeps its meaning.
### IN-17 · sort-header · the sort button's hit target is about 20 px tall
- Severity: should-fix
- M3 says: 48 × 48 minimum target, no density by default (RF §Accessibility/Values and /Rules).
- Library does: `resources/views/components/sort-header.blade.php:21-30` renders
`class="group/sort focus-ring inline-flex cursor-pointer items-center gap-1 rounded-corner-xs"` with no
padding of its own. Its box is the `title-sm` line box (20 px, `resources/css/tokens/type.css:45`) plus
a 16 px icon — roughly 20 px tall. The surrounding `<th>` padding (`table.css:47-51`) belongs to the
cell, not the button, so it is not part of the target.
- Fix: `sort-header.blade.php:26` → add `-my-2 py-2 -mx-1 px-1` (or `min-h-12` with the `<th>` padding
removed for sorting cells) so the button covers its cell.
- Effort: S
- Breaks API? no (the `class` prop still lands on the `<th>`)
### IN-18 · field · the error state has only one visual indicator
- Severity: should-fix
- M3 says: "**States have two visual indicators to ensure accessibility**" (RF §States/Principles), and
for text fields specifically: error text "strongly recommended to pair with a **trailing error icon**
for a second, non-color-dependent cue" (RB §Text Fields/Behaviour); a non-actionable error icon gets the
accessibility label "Error." (RB §Text Fields/Accessibility).
- Library does: `resources/views/components/field.blade.php:39-45` sets only `data-invalid`, and
`resources/css/components/field.css:403-415,446-448` changes only colours — outline, label and support
text all move to `error`. No icon is added. `<x-input>`'s `icon-right` slot is free, so the hook exists.
- Fix: in `field.blade.php`, when `$messages !== []` and no `$trailing` is set, render
`<x-icon name="error" class="field-trailing size-(--field-icon)" aria-label="{{ __('Error') }}" />` into
the trailing position; suppress it at `data-size="xs"` where there is no room.
- Effort: S
- Breaks API? no (additive; callers passing `icon-right` keep theirs)
### IN-19 · field · a read-only field is drawn with a dashed outline
- Severity: nice-to-have
- M3 says: "**Read-only fields**: same visual style as an editable field, but clearly labeled read-only"
— RB §Text Fields/Behaviour.
- Library does: `resources/css/components/field.css:419-421`
`.field[data-readonly] .field-outline { border-style: dashed; }`, set from
`input.blade.php:38`, `textarea.blade.php:27` and `datepicker.blade.php:161`. The comment at
`field.css:417-418` explains the intent (a date picker makes its own input read-only). The native
`readonly` attribute does carry the state to AT, so the accessibility half of M3's rule is met; only
"same visual style" is not.
- Fix: either drop the dashed rule, or keep it and record it in the header comment as a deliberate
departure. If kept, note that the date picker's *docked* field is **not** read-only
(`datepicker.blade.php:182-189` binds typing), so only `mode="modal"`/`"input"` and caller-set
`readonly` fields are affected.
- Effort: S
- Breaks API? no
### IN-20 · search · the bar has no minimum or maximum width and does not widen on focus
- Severity: nice-to-have
- M3 says: search bar "Container | Width | Min: 360dp, max: 720dp"; docked container the same; and the
Expressive update lists "Motion: the search bar grows wider when focused" — RB §Search/Specs and
§Search/Status.
- Library does: `resources/css/components/search.css:28-41` sizes `[data-search-bar]` only by height;
width is the parent's. `[data-search-view]` (`search.css:130-147`) is `inset-inline: 0` with
`max-height: min(40rem, 70dvh)` and no `min-height` (M3: docked height "Min: 240dp, max: 2/3 of screen
height" — 70 dvh vs 66.7 % is close enough to ignore). No focus-widening transition.
- Fix: `search.css:28` → add `max-width: 45rem;` (720 px) to `[data-search-bar]` and `[data-search-view]`,
and `min-height: 15rem` (240 px) to the docked view. The focus-widen motion would need a
`--search-width` custom property toggled by `[data-open]` and animated on the effects-default spring.
- Effort: S (widths) / M (motion)
- Breaks API? no
### IN-21 · search · the unfocused leading padding is 16 px where M3 gives 24 dp
- Severity: nice-to-have
- M3 says: unfocused search bar "Leading padding | Unfocused: 24dp, focused: 12dp" and "Trailing padding |
Unfocused: 24dp, focused: 12dp"; contained focused style "Leading padding | 16dp | Trailing padding |
16dp" — `docs/reference/m3/raw/components_search_specs.md:338-346,376-386`.
- Library does: `resources/css/components/search.css:28-35` gives the bar `padding-inline: 0.25rem` and
`resources/css/components/search.css:53-61` gives the leading/clear boxes `width: 3rem` — so the icon
glyph starts 4 + 12 = 16 px from the container edge in every state.
- Both readings: the site's measurement diagram is an image, so "leading padding" may mean edge-to-tap-
target (24 + 12 = 36 px to the glyph, which looks wrong in a 56 dp bar) or edge-to-glyph (24 px). Under
the second reading the focused/contained value (16 dp) is exactly what the library does and only the
unfocused state is 8 px short.
- Fix: if pursued, `search.css:28``padding-inline: 0.75rem` when `:not([data-open])` and `0.25rem`
when `[data-open]`, transitioned on the effects-fast duration.
- Effort: S
- Breaks API? no
### IN-22 · datepicker · Shift+M and Shift+Y do not reach the month/year dropdowns
- Severity: nice-to-have
- M3 says: date-pickers/accessibility keyboard table — "Shift + M | Moves to the month list dropdown;
Shift + Y | Moves to the year list dropdown" (RB §Date Pickers/Accessibility).
- Library does: `resources/js/datepicker.js:578-600` handles Arrows, Home, End, PageUp/PageDown (with
Shift for ±12 months), Space and Enter; there is no Shift+M / Shift+Y branch. The menu buttons exist
and are focusable (`datepicker.blade.php:287-296,309-318,328-337`).
- Fix: add two cases to the `gridKey` map in `resources/js/datepicker.js:585-592` that focus
`[data-datepicker-menu-button]` (the month one in the docked layout, the combined one in the modal).
- Effort: S
- Breaks API? no
### IN-23 · choices · the combobox has no Home/End keys
- Severity: nice-to-have
- M3 says: nothing directly — M3 has no combobox component. WAI-ARIA APG's combobox-with-listbox pattern,
which the component already follows (`role="combobox"`, `aria-autocomplete`, `aria-activedescendant`),
specifies Home/End within the popup.
- Library does: `resources/views/components/choices.blade.php:140-145` binds ArrowDown, ArrowUp, Enter,
Escape and Tab only.
- Fix: add `x-on:keydown.home.prevent` / `.end.prevent` at `choices.blade.php:142` setting
`active = 0` / `active = filtered.length - 1` and calling `reveal()`.
- Effort: S
- Breaks API? no
### IN-24 · slider · Space+Arrows (the large-interval step) is not implemented
- Severity: nice-to-have
- M3 says: sliders/accessibility keyboard table — "Space & Arrows | Increase/decrease the value by one
**interval** or one stop indicator" (RB §Sliders/Accessibility), on top of Arrows (one value) and
Home/End.
- Library does: the native `<input type="range">` supplies Arrows and Home/End
(`slider.blade.php:357-379`); the SKILL documents PageUp/PageDown as a tenth of the range
(`SKILL.md:562`), which is an extension, not M3's Space+Arrows.
- Fix: add a `keydown` handler in `resources/js/slider.js` that, while Space is held, multiplies the arrow
step. Low value — PageUp/PageDown already covers the need and is the more common web idiom.
- Effort: S
- Breaks API? no
### IN-25 · chips · no dragged state
- Severity: nice-to-have
- M3 says: chips are the only component in this group whose state list includes **Dragged**
(RB §Chips/Specs, "States (site, all 4 variants): Enabled, Disabled, Hovered, Focused, Pressed,
**Dragged**"); dragged state layer opacity is 0.16 (RF §States/Values). Input chips also support
"reordering, moving between fields ('To' → 'CC')" (RB §Chips/Behaviour).
- Library does: `resources/views/components/chip.blade.php:158` and the `state-layer` utility
(`resources/css/tokens/state.css:13-45`) cover hover 8 %, focus 10 % and active 10 % only. No chip is
draggable.
- Fix: only worth doing alongside a reordering feature — then add
`[draggable]:active`/`[data-dragged]` at 16 %.
- Effort: L
- Breaks API? no (additive)
### IN-26 · timepicker · the AM/PM buttons are toggle buttons, not radios
- Severity: nice-to-have
- M3 says: time-pickers/accessibility labelling table — "AM/PM selection | AM or PM | **Radio button (in
list)** (Wiz/Compose) | Checkbox (in list) (Android Views)" (RB §Time Pickers/Accessibility).
- Library does: `resources/views/components/timepicker.blade.php:239-251` and `:333-345` render two
`<button aria-pressed>` in a `role="group"`. A screen reader hears "AM, toggle button, pressed" rather
than "AM, radio button, selected, 1 of 2"; both convey the state, and `aria-pressed` avoids needing
arrow-key roving, so this is defensible. Noted because the M3 table is explicit.
- Fix: if changed, `role="radiogroup"` on the wrapper and `role="radio" aria-checked` on the buttons,
with Left/Right arrow handling.
- Effort: S
- Breaks API? no
### IN-27 · timepicker · the disabled period button uses a 10 % container instead of 12 %
- Severity: nice-to-have
- M3 says: disabled container opacity is **0.12**, disabled content 0.38 (RF §States/Values). M3 defines
no disabled state for the period selector at all (its state list is Enabled, Hover, Focus, Pressed —
RB §Time Pickers/Specs), so this is the library's own `min`/`max` extension and only has to stay inside
M3's rules.
- Library does: `resources/css/components/timepicker.css:172-174`
`background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent)` with the content
at 38 % (correct).
- Fix: `timepicker.css:173``12%`.
- Effort: S
- Breaks API? no
### IN-28 · radio · `inline` lays radios in a row, which M3 cautions against
- Severity: nice-to-have
- M3 says: radio buttons are "Always vertically stacked (never horizontal — flagged as a 'Caution', not
an outright 'Don't')" (RB §Radio Button/Behaviour); also "Recommended for five or fewer options" and
"One option should always be pre-selected in a radio group".
- Library does: `resources/views/components/radio.blade.php:18,39` offers
`inline``sm:flex sm:flex-wrap sm:gap-x-6`, and `value` (the pre-selection) is optional
(`radio.blade.php:19`). Neither is wrong — M3's is a caution — but the SKILL entry
(`SKILL.md:552`) presents `inline` without the caveat.
- Fix: add one sentence to `SKILL.md:552` and to the `radio.blade.php` header noting M3 prefers a vertical
stack, and that a group should normally ship a pre-selected `value`.
- Effort: S
- Breaks API? no
### IN-29 · password · the reveal button carries both a changing label and `aria-pressed`
- Severity: nice-to-have
- M3 says: "Interactive trailing icons need a functional label (e.g., 'Show password' / 'Hide password')"
— RB §Text Fields/Accessibility. It says nothing about a pressed state.
- Library does: `resources/views/components/password.blade.php:41-44` binds *both*
`aria-label` (flipping between "Show password" and "Hide password") **and** `aria-pressed`. Screen
readers then announce e.g. "Hide password, toggle button, pressed" — the state twice, once inverted
relative to the label's promise. M3 names only the label pattern; the repo's own recent commit
"Keep aria-pressed off a selected link button" shows the same concern elsewhere.
- Fix: `password.blade.php:42,44` → drop `aria-pressed` and keep the flipping label (the M3-documented
pattern), or keep `aria-pressed` with a fixed "Show password" label. Not both.
- Effort: S
- Breaks API? no
### IN-30 · table · the row divider is a colour outside the role set
- Severity: nice-to-have
- M3 says: colour is applied through the roles; RF §Design tokens frames system tokens as the sanctioned
vocabulary and the styles pages give no "60 % of outline variant" value. Opacity modifiers in M3 are
reserved for state layers (8/10/10/16 %) and disabled (12/38 %).
- Library does: `resources/css/components/table.css:66-68`
`border-bottom: 1px solid color-mix(in srgb, var(--md-sys-color-outline-variant) 60%, transparent)`.
(The header rule at `table.css:58` correctly uses full `outline-variant`.) Data tables have no M3 spec,
so this is a foundations-level nit, not a spec breach.
- Fix: `table.css:67``var(--md-sys-color-outline-variant)`, and let the heavier header rule read as
heavier by keeping the header's `title-sm` weight contrast, or use `surface-container-highest` if a
lighter rule is wanted.
- Effort: S
- Breaks API? no
## Deliberate deviations
The code explains these; the verdict column is whether the stated reason survives Google's text.
| Where | Deviation and its stated reason | Holds up? |
|---|---|---|
| `field.css:8-12` | The outlined field's notch is MUI's `<fieldset>`/`<legend>` technique so the gap is right on any background | Yes. M3 does not prescribe a mechanism; the rendered result (4 dp corners, 1 dp / 2 dp focus outline, `body-small` floated label) matches `OutlinedTextFieldTokens` exactly. |
| `field.blade.php:9-13` | `size="sm"` (40 px) / `xs` (32 px) for unlabelled toolbar controls | Yes — M3's rule is "don't apply density **by default**; offer an explicit opt-in" (RF §Accessibility). These are opt-in and documented as unlabelled-control sizes. |
| `field.css:417-421` | Read-only fields get a dashed outline "marked on the field, not read off the control" | Partly — see IN-19; M3 says a read-only field keeps the same visual style. |
| `select.blade.php:1-8` | The native `<select>` is kept "on purpose", with the M3 menu only where `appearance: base-select` exists | Yes. M3's exposed-dropdown guidance is about the rendered result, and the native control gives the keyboard, type-ahead and AT semantics for free. |
| `file.blade.php:1-13` | "M3 has no file field" — the native input is kept and only `::file-selector-button` is restyled as a tonal pill | Yes. RB confirms no file-input component exists; the pill uses `secondary-container` / `on-secondary-container` with 8 % hover and 12 %/38 % disabled — inside M3's rules. |
| `chip.blade.php:29-30` | "input chips are flat only, as in M3" | Partly. RB §Chips/Variants says "**All** chips can be elevated (default elevation 0)"; Compose has no `ElevatedInputChip`, which is presumably the real source. The restriction is safe but the stated justification is the implementation, not the spec. |
| `chip.blade.php:88-89` | Padding is 1 px short of the token because a CSS border takes room where Compose draws it inside | Yes — a correct compensation; the rendered box matches 16/8/12/4 dp. |
| `timepicker.blade.php:37-39` | The period selector follows Compose's `isUpdatedTimepickerToggleEnabled` default — "two separate shape-morphing toggle buttons in primary-container, not the outlined pair its tokens still describe" | Partly — see IN-11. The shape morph is a plausible Expressive reading; the tertiary→primary colour swap is undocumented and the site still lists Tertiary container. |
| `datepicker.js:18-19` | "The keyboard is WAI-ARIA's date picker dialog: … Home and End go to the start and end of the week" | Defensible. M3's own table says "Home/End | Move to the first day of the month" (RB §Date Pickers/Accessibility) — a row the reference itself flags as terse/ambiguous. The APG behaviour is the stronger web convention; worth a one-line note in the header that M3's table differs. |
| `slider.blade.php:39-41` | The value indicator is "M3's as Flutter draws it (`RoundedRectSliderValueIndicatorShape`): a 32px inverse-surface pill" because "Compose leaves the value label to the app" | No — see IN-05. M3 Expressive's sliders/specs table gives an explicit 44 dp × 48 dp label container that neither source was consulted for. |
| `search.css:83-87` | `[data-search-bar] button:focus-visible { outline-offset: -3px }` instead of the package's `+2px` | Yes, in context — the ring is drawn inside a 48 px box that is flush with the 56 px bar; a +2 px ring would clip. |
| `table.css:7-10` | Density is keyed "on the pointer, not the width, so a touch tablet in landscape keeps rows a finger can hit" | Partly — the pointer key is a good idea, but the coarse-pointer default is still 44 px (IN-16). |
| `chip-set.blade.php:12-14` | `scroll` keeps chips on one line that scrolls sideways "as M3 lays chips out on a narrow screen" | Yes. RB §Chips/Behaviour: "place inline as a single row, wrapping to new lines when needed, or **scroll horizontally** if the field is only one row tall". |
### Token-citation disagreements
Places where a header comment's androidx/MDC figure contradicts the M3 site's spec table:
| File:line | Comment claims | M3 site says | Effect in code |
|---|---|---|---|
| `slider.blade.php:44` | "handles of 44, 44, **44**, 68 and 108px" (md.comp.slider.* tokens) | 44 / 44 / **52** / 68 / 108 (sliders/specs) | Yes — `slider.blade.php:248` is 44 px (IN-04) |
| `slider.blade.php:40-41` | value indicator "a **32px** inverse-surface pill … 4px above the handle" (Flutter) | Label container **44 dp** high, **48 dp** wide, all sizes; `ValueIndicatorActiveBottomSpace = 12dp` | Yes — `slider.blade.php:346,252` (IN-05) |
| `timepicker.blade.php:33` | "96×80 time selector boxes" | 96 dp, **and 114 dp for the 24-hour vertical layout** | Yes — `timepicker.css:106` (IN-10) |
| `timepicker.blade.php:37-39` | period selector "in **primary-container**, not the outlined pair its tokens still describe" | `PeriodSelectorSelectedContainerColor = TertiaryContainer`; site colour list includes Tertiary container / On tertiary container | Yes — `timepicker.css:165-167` (IN-11) |
| `selection.css:1-3` | cites `CheckboxTokens` | `IconSize = 18.0.dp` | Yes — `checkbox.blade.php:41-42` uses `size-4` (16 px) (IN-12) |
| `search.css:1-10` | cites `SearchBarTokens` / `SearchViewTokens`; "surface-container-high" for the view | Docked: Surface container high ✓; **Full-screen: Surface container low** | Yes — `search.css:140` (IN-08) |
| `pagination/livewire/tailwind.blade.php:7` | "Page numbers are **40px icon-button targets**" | 40 dp is the state layer; the target is 48 dp (RF §States) | Yes — `size-10` (IN-15) |
| `chip.blade.php:35-38` | 32 px, small corner, label-large, 18 px icons, 24 px avatar, 16/8/8 and 12/8/4 padding | identical (chips/specs, all four variants) | No — all correct |
| `field.css:8-12` | `FilledTextFieldTokens` / `OutlinedTextFieldTokens` | 56 dp height, `CornerExtraSmall` = 4 dp, outline 1/2 dp, indicator 1/2 dp, disabled 12 %/38 %/4 %, `BodyLarge` input, `BodySmall` supporting | No — all correct |
| `datepicker.blade.php:39-45` | 360 px, extra-large (modal) / large (docked) corner, elevation 3, surface-container-high, 40 px days in 48 px cells, today outlined primary, range middle secondary-container | `DatePickerModalTokens`: 360 × 568, `CornerExtraLarge`, Level3, Surface container high, cell 40 × 40, today outline 1 dp, range indicator 40 dp | No — all correct (the 48 px cell is the library's own target addition, which M3's 48 dp target rule requires) |
| `selection.css:219-223` | switch handle centres at 16 px / 36 px, sizes 16 / 24 / 28 px "measured inside the track's 2px outline" | `SwitchTokens`: track 32 × 52, outline 2, handles 16 / 24 / 28 | Numbers correct; the phrase "inside the track's 2px outline" is wrong (the handle is positioned against the track's outer box, `selection.css:249-251`) — comment only |
## Aligned
Verified correct; no need to re-check.
- **Text field geometry** — 56 px container, 16 px side padding (12 px with icons via `--field-start`),
24 px leading icon, 4 dp corner, 1 px → 2 px outline/indicator on focus, `body-large` input,
`body-small` floated label and supporting text, 4 px supporting-text top padding
(`field.css:27-73,295-344,439-444`).
- **Text field colours and states** — outline `outline`, hover `on-surface`, focus `primary`, filled
container `surface-container-highest`, filled resting indicator `on-surface-variant`, filled hover
`on-surface` 8 %, disabled outline 12 % / content 38 % / filled container 4 % — all match
`OutlinedTextFieldTokens` / `FilledTextFieldTokens` (`field.css:33-44,390-437,450-520`).
- **Error replaces the hint, never stacks** (`field.blade.php:73-81`), with `role="alert"` on the error —
exactly RB §Text Fields/Behaviour and /Accessibility.
- **Required asterisk is in the label** (`field.css:346-349`, in both the label and the notch legend) —
RB §Text Fields/Accessibility, "the label must include the asterisk".
- **Date input is never masked while typing** — `datepicker.js:350-366` only commits a whole, valid date;
`parse` (`datepicker.js:274-300`) accepts any separator and optional leading zeros. This is the exact
rule in RB §Date Pickers/Accessibility.
- **Switch** — every dimension matches `SwitchTokens`: 52 × 32 track, 2 px outline, 16 / 24 / 28 px handle,
40 px state layer, 16 px icons, and the full colour map (selected track `primary`, handle `on-primary`,
hover/focus/pressed handle `primary-container`, unselected track `surface-container-highest`, handle
`outline`, disabled 12 %/38 %) — `selection.css:219-343`.
- **Checkbox / radio boxes** — 18 px box with 2 px `on-surface-variant` outline and 2 dp corner, 20 px
radio with a 2 px ring, both in 40 px state layers, `primary` when selected, error variants
(`selection.css:103-217`). Indeterminate uses a dash icon and is kept in sync through the
`indeterminate` DOM property (`field.js:32-34,49-80`) — the only correct way to do it in HTML.
- **State-layer opacities everywhere in the group** — 8 % hover (always behind `@media (hover: hover)`),
10 % focus, 10 % press, tinted with the content's "on" colour: `selection.css:73-101`,
`field.css:268-282`, `datepicker.css:250-268`, `chip.blade.php:158,249`, `state.css:13-45`.
- **Focus ring** — 3 px `secondary` at +2 px offset, consistently (`state.css:46-53`, `field.css:274-278`,
`selection.css:98-101,241-244`, `datepicker.css:270-275`, `timepicker.css:207-208`,
`slider.blade.php:335`).
- **Chips** — 32 px height, 8 dp corner, `label-large`, 18 px icons, 24 px avatar, 16/8 dp label padding
(1 px short for the CSS border), 8 dp between elements, 8 px chip-to-chip gap, a 48 px `::after` target
strip, filter selected `secondary-container`/`on-secondary-container`, stroke `outline-variant`
(the Aug-2024 update), elevated on `surface-container-low` at elevation 1 → 2 on hover, disabled
12 %/38 % (`chip.blade.php:91-125,197-198`, `chip-set.blade.php:51`).
- **Chip keyboard removal** — Backspace/Delete on a focused input chip removes it and moves focus to the
previous/next chip, with a `wire:key`-aware re-focus across Livewire morphs (`chips.js:38-118`). Remove
buttons are named "Remove {label}" (`chip.blade.php:242`). Exactly RB §Chips/Accessibility.
- **Date picker anatomy and geometry** — 360 px container, 120 px header (128 px for range),
`headline-large` headline (`title-large` for range), `surface-container-high` at elevation 3,
`corner-xl` modal / `corner-lg` docked, 40 px day in a 48 px cell, `body-large` day and weekday labels,
today outlined 1 px `primary`, selected `primary`/`on-primary`, range band
`secondary-container`/`on-secondary-container` 40 px tall, 72 × 36 year cells, divider under the header
(`datepicker.css:64-436`).
- **Date picker semantics** — `<table role="grid">` with `<th scope="col">`, per-cell `aria-label` naming
the full date, `aria-selected`, `aria-current="date"`, roving `tabindex`, `aria-live` month announcement,
and both entry methods always available — matching M3's labelling table
(`datepicker.blade.php:284,344-378`).
- **Time picker geometry** — 24 dp container padding, 96 × 80 time selectors in `display-large` with a
`corner-small` container, 52 × 80 (vertical) / 216 × 38 (horizontal) period selector, 256 px dial in
`surface-container-highest` with `body-large` numbers, 48 px selector handle, 8 px centre, 2 px track,
96 × 72 input fields in `display-medium` — every number in RB §Time Pickers/Specs
(`timepicker.css:50-460`).
- **Slider** — track heights 16/24/40/56/96, corners 8/8/12/16/28, 4 px handle narrowing to 2 px on
press/focus, 6 px active-handle spacing, 4 px stop indicators, 24/24/32 px inset icons restricted to
M/L/XL standard sliders only (never range or centered, exactly RB §Sliders/Variants), `primary` active
track and handle, `secondary-container` inactive track, `inverse-surface`/`inverse-on-surface` value
indicator in `label-large`, disabled 38 %/12 % (`slider.blade.php:132-252,283-354`).
- **Slider accessibility** — real `<input type="range">`s, so Arrows/Home/End and AT come free; a range
slider is `role="group"` with `aria-labelledby` and "Range start"/"Range end" handle labels; the
drawing is `aria-hidden` (`slider.blade.php:265-379`).
- **Reduced motion** — all six spring/easing duration tokens collapse to 0 ms under
`prefers-reduced-motion: reduce` (`resources/css/tokens/motion.css:63-75`), and every component in this
group animates through those tokens, so nothing needs a per-component opt-out.
- **`<x-choices>` combobox semantics** — `role="combobox"`, `aria-autocomplete="list"`, `aria-controls`,
`aria-expanded`, `aria-activedescendant`, `role="listbox"`/`role="option"`/`aria-selected`,
`aria-disabled` (`choices.blade.php:121-179`). This is what `<x-search>` should look like (IN-01).
- **Search geometry** — 56 px pill, `corner-full`, `surface-container-high`, 8 % hover, `body-large`
input, 48 px leading/clear targets, docked view `corner-xl` at elevation 3, 72 px full-screen header
with safe-area padding, `corner-none` full-screen — all matching `SearchBarTokens` / `SearchViewTokens`
(`search.css:20-181`). Escape releases focus, the back arrow dismisses, and the full-screen view traps
focus and locks scroll (`search.blade.php:29-32`, `search.js:39-97`).
- **Pagination colour choice** — the current page is `secondary-container`/`on-secondary-container`, never
the action colour, with `aria-current="page"` (`pagination/livewire/tailwind.blade.php:55`). This is the
right reading of RF §Interaction/Selection ("active indicator" components).
- **Table roles** — headers `title-small` `on-surface-variant` over an `outline-variant` rule, selected
row `secondary-container`/`on-secondary-container` keyed on `aria-selected="true"`, `aria-sort` on the
sorted `<th>` (`table.css:53-77`, `sort-header.blade.php:20`). RF §Interaction/Selection lists data
tables among the components that inherit selection styling, and this is that styling.
## Missing
M3 components, variants or configurations in this group that the library does not implement.
- **Slider — vertical orientation.** RB §Sliders/Variants: the Expressive configuration table lists
"Orientation | Horizontal (default) | Available | Available; Vertical | -- | Available". The library is
horizontal only (`slider.blade.php` has no orientation prop). M3's caveat that range sliders must stay
horizontal would apply.
- **Date pickers — the full-screen range picker.** RB §Date Pickers/Anatomy describes a 14-element
full-screen range-selection picker with a close (×) icon button and a **Save** confirmation, used at
compact breakpoints. `<x-datepicker range>` always uses the 360 px modal dialog
(`datepicker.css:64-129`), which on a phone is cramped for a two-month range scroll.
- **Search — the other two entry points.** RB §Search/Behaviour names three: search bar (implemented),
**search app bar** (a top app bar whose whole row is the search field, opening the Search view), and
**search icon button** (search as a secondary action that expands). Only the first exists.
- **Search — the Expressive focus-widen motion** ("the search bar grows wider when focused",
RB §Search/Status). See IN-20.
- **Text fields — the character counter.** RB §Text Fields/Variants lists "supporting text + character
counter" as a configuration of both variants, with its own accessibility label ("Character count,
5/20"). No `<x-input>`/`<x-textarea>` prop provides it; `maxlength` passes through to the control but
nothing is rendered.
- **Text fields — width bounds at medium/expanded.** RB §Text Fields/Behaviour: "compact breakpoints can
let a text field span full width; medium/expanded should bound it with flexible margins/other
containers — **never let it span the full width of a large screen**." `<x-form>`
(`form.blade.php:14`) is an unbounded one-column grid; nothing in the package caps a field's width.
Currently the caller's job, undocumented in `SKILL.md:521-535`.
- **Checkbox — the ≥ 840 dp grouping rule.** RB §Checkbox/Behaviour: "in expanded breakpoints (≥840dp),
group checkboxes inside a contained region (e.g., a side sheet)". No affordance or documentation.
- **Chips — the horizontal-overflow affordances.** RB §Chips/Accessibility prescribes either a leading
"Show all" filter chip that reflows the list downward, or a leading button opening a menu of all chip
options. `<x-chip-set scroll>` fades the scrollable edge instead — a reasonable web idiom, but neither
of M3's two named patterns is offered.
- **Time picker — the 24-hour inner/outer ring is implemented** (`timepicker.blade.php:126-129`), so this
is *not* missing; noted because it is easy to assume otherwise.
## Breakpoint map
| Component | Library breakpoint used | M3 window size class it stands in for | Gap |
|---|---|---|---|
| `<x-search>` full-screen ↔ docked | `(max-width: 39.99rem)` = < 640 px (`resources/js/search.js:11`) | Compact < 600 dp (full-screen), medium/expanded ≥ 600 dp (docked) | 40 px late — 600639 px gets the full-screen view where M3 wants docked |
| `<x-datepicker>` docked → modal dialog | `(max-width: 39.99rem)` = < 640 px (`resources/js/datepicker.js:23`) | Compact < 600 dp (M3: "Used at compact breakpoints as a full-screen dialog"; docked "Used at medium/expanded") | 40 px late; also M3 wants *full-screen* at compact, the library shows a 360 px centred dialog |
| `<x-timepicker>` landscape layout | `(orientation: landscape) and (min-width: 37rem)` = ≥ 592 px (`timepicker.css:403`) | No width class — M3 keys this on device orientation and **viewport height** (RB §Time Pickers/Behaviour) | 8 px under 600; the height-based dial shrink at `max-height: 22.75rem` / `21.625rem` (`timepicker.css:449,455`) is Compose's `ClockFaceSizeModifier`, not an M3 window class — acceptable |
| `<x-radio inline>` | `sm:` = ≥ 640 px (`radio.blade.php:39`) | Compact < 600 dp stacks; medium+ may lay out horizontally (M3 cautions against horizontal at any width) | 40 px late, and the direction of the rule is a caution either way (IN-28) |
| Pagination numbers vs "Page 2 of 7" | `max-sm:` / `sm:` = 640 px (`pagination/livewire/tailwind.blade.php:31-32,48,53`) | Compact < 600 dp | 40 px late — 600639 px loses the page numbers where M3's medium class would keep them |
| `<x-table>` density | `@media (pointer: fine)` (`table.css:37`) | None — M3 has no pointer-keyed density rule; density is an explicit user opt-in at any size | Not a size-class substitution; the problem is that it is on by default (IN-16) |
| `<x-field>` / `<x-form>` width | none | Medium/expanded should bound a text field's width (RB §Text Fields/Behaviour) | Not implemented at all — see **Missing** |
| `<x-chip-set scroll>` | none (a prop, not a breakpoint) | M3 expects wrap-or-scroll chosen by available width | Caller decides; no adaptive switch |
| `<x-chip type="filter">` trailing icon | none | M3: a filter chip's trailing icon is for medium/expanded; at compact the whole chip must be the tap target | Satisfied incidentally — the whole chip is always the target (`chip.blade.php:200`) |
| `<x-search>` docked max height | `min(40rem, 70dvh)` (`search.css:136`) | Docked container height min 240 dp, max 2/3 of screen height | 70 dvh vs 66.7 % — negligible; the missing 240 dp minimum is the real gap (IN-20) |
Note on the Tailwind scale generally: this group consistently uses `sm` (640 px) where M3's compact
boundary is 600 dp. The fix is one shared media query — a `--material-compact: (max-width: 37.49rem)`
custom media (or a `xs`-ish Tailwind screen at 600 px) referenced by `search.js:11`, `datepicker.js:23`,
`radio.blade.php:39` and the pagination views — rather than five separate edits.
+637
View File
@@ -0,0 +1,637 @@
# Audit: navigation
Scope: `app-bar`, `toolbar`, `tabs`/`tab`, `navigation-bar(-item)`, `navigation-rail(-item|-section)`,
`app-shell`, `section-nav`, `account-menu`, `theme-toggle`, `scheme-picker`, their CSS/JS, the showcase
usage and the matching SKILL.md sections.
Sources abbreviated below: **RCB** = `docs/reference/m3/reference-components-b.md`; **RF** =
`docs/reference/m3/reference-foundations.md`; **raw/** = `docs/reference/m3/raw/<page>.md`; **tok/** =
`docs/reference/m3/tokens/<File>.kt` (androidx-main). Anything fetched live is named with its URL.
## Summary
The component-level work is unusually faithful: every numeric token I checked for the app bar
(64/112/136/120/152dp heights, title-large → headline-medium → display-small, 4dp edge spacing), the
navigation bar (64dp, 56×32 indicator, 40dp horizontal pill, 16dp indicator spaces, and even Compose's
`calculateCenteredContentHorizontalPadding` formula reproduced as `calc(10% * (count + 3))`), the rail
(96dp collapsed, 220360dp expanded, 44dp top space, 40dp header space, 56dp expanded item, modal =
surface-container + elevation 2 + `CornerLarge` inner edge) and both toolbars (64dp, 16dp/432dp docked,
8dp/4dp + `CornerFull` floating) matches the androidx token files exactly. The catalogue is also right
about Expressive: the deprecated bottom app bar and navigation drawer are absent, the flexible app bars
and the flexible navigation bar are what is implemented, and the expanded rail correctly plays the
drawer's role. The real problems are three: **the adaptive breakpoint map is Tailwind's, not M3's**`sm`
640 stands in for 600 and, much worse, `lg` 1024 stands in for 840, so every window from 840 to 1023px is
denied the standard expanded rail and gets a modal that re-closes on every navigation; **two hand-rolled
buttons (`theme-toggle`, `account-menu`) are 40×40 with no 48px target**, below M3's stated minimum, while
the library's own `<x-button>` and the rail's menu button both do it correctly; and **a few placement
rules are simply not wired up** — a `place="bottom"` toolbar sits on top of the app shell's navigation
bar, a rail-nested FAB keeps elevation 3 where M3 says level 0, and `theme-toggle mode="picker"`
hand-rolls the Expressive-deprecated segmented button even though the library already ships its
successor (`<x-group>`).
## Findings
### N-01 · theme-toggle, account-menu · 40×40 controls with no 48px interaction target
- Severity: must-fix
- M3 says: "Touch target minimum **48 × 48dp**"; "Web default target size: at least 48 × 48 **CSS
pixels**"; and, precisely for this case, "The size of state layers is 40dp while the interactive target
size is 48dp" (RF §Accessibility Values, §States/state-layer mechanics). RF §Layout/Density repeats:
"Interaction targets must stay ≥48×48dp even when the visible element is scaled down."
- Library does: `resources/views/components/theme-toggle.blade.php:65` renders `inline-flex size-10` (40px)
with no target extension; `resources/views/components/account-menu.blade.php:41` renders
`inline-flex size-10 … overflow-hidden` (40px), likewise none. The library already knows the fix: the
icon button applies `after:absolute after:top-1/2 after:left-1/2 after:size-full after:min-h-12
after:min-w-12 after:-translate-x-1/2 after:-translate-y-1/2` at `xs`/`sm`
(`resources/views/components/button.blade.php:171-172`, comment "Below 48px the touch target reaches
past the button, as M3 requires"), and the rail's menu button repeats it at
`resources/views/components/navigation-rail.blade.php:108` (`after:size-12`).
- Fix: add the same `after:` pseudo-target to both class lists. For `account-menu`, `overflow-hidden` on
the button would clip the pseudo-element — move the clipping to the `<img>` (`class="size-full
rounded-corner-full object-cover"`) and drop `overflow-hidden` from the button.
- Effort: S
- Breaks API? no
### N-02 · toolbar, app-shell · a `place="bottom"` toolbar sits on top of the navigation bar
- Severity: must-fix
- M3 says: "Docked toolbar and navigation bar occupy the same screen position — **never show both
simultaneously**" (RCB §Toolbars/Behaviour; raw/components_toolbars_guidelines.md). For the floating
case: "FAB, when present, floats above the nav bar, right-aligned — **must never overlap/cover it**"
(RCB §Navigation Bar/Behaviour), and the toolbar occupies the same rail region as a FAB
(raw/foundations_layout_scaffold_rails.md: "On mobile, a toolbar can float in the rail region" — the
rail region sits *above* the navigation bar, item 1: "A toolbar sits above the navigation bar").
- Library does: `resources/css/components/toolbar.css:75-81` pins the toolbar at
`bottom: calc(1rem + var(--material-safe-bottom, env(safe-area-inset-bottom)))` — it ignores
`--material-bottom-bar`, the variable the shell sets for exactly this purpose
(`resources/views/components/app-shell.blade.php:88`). The library's FAB
(`resources/views/components/button.blade.php:177`,
`max-sm:bottom-[calc(var(--material-bottom-bar,0px)+1rem)]`) and the snackbar
(`resources/views/components/toast.blade.php:36`) both do use it. A docked
`variant="docked" place="bottom"` toolbar (`toolbar.css:91-96`, `bottom: 0`) lands squarely on the
navigation bar with no warning anywhere in the code or in SKILL.md:754-764.
- Fix: in `toolbar.css`, change `[data-toolbar-place="bottom"]`'s `bottom` to
`calc(var(--material-bottom-bar, 0px) + 1rem + var(--material-safe-bottom, env(safe-area-inset-bottom)))`
(mirroring the FAB/toast rule, which already folds the safe area into `--material-bottom-bar`), and for
`[data-toolbar][data-variant="docked"][data-toolbar-place="bottom"]` `bottom: var(--material-bottom-bar, 0px)`.
Add one line to SKILL.md's `<x-toolbar>` section: a docked toolbar and a navigation bar must never be on
screen together — show the bar on primary pages, the toolbar on secondary/contextual ones.
- Effort: S
- Breaks API? no
### N-03 · navigation-rail · a FAB nested in the rail keeps elevation 3
- Severity: must-fix
- M3 says: "When nested within another component, such as the navigation rail, the FAB's resting
elevation should be **level 0**" (raw/components_navigation-rail_guidelines.md:213; RCB §Navigation
Rail/Behaviour: "the site explicitly states this contrasts with a standalone FAB's normal elevation").
- Library does: `resources/views/components/fab.blade.php:49` always applies
`shadow-elevation-3 hover:shadow-elevation-4`; `resources/css/components/navigation.css` contains no
rule for a FAB inside a rail (grep for `fab` in it returns nothing). The documented pattern
(`navigation-rail.blade.php:7-9`, SKILL.md:717-719, `showcase/shell.blade.php:48-49`) puts a FAB in the
`header` slot, so every documented use is at elevation 3.
- Fix: the FAB has no attribute hook today (`fab.blade.php:65` renders `<{{ $tag }} {{ $attributes }}>`
with classes only), so add `data-fab` to its merged attributes, then add an **unlayered** rule to
`navigation.css``shadow-elevation-3` is a Tailwind utility, so a `@layer components` rule would lose,
exactly as `toolbar.css:10-12` already explains for its vibrant recolouring:
`[data-navigation-rail-header] [data-fab], [data-navigation-rail-header] [data-fab]:hover { box-shadow: none; }`.
- Effort: S
- Breaks API? no
### N-04 · theme-toggle · `mode="picker"` renders the Expressive-deprecated segmented button
- Severity: must-fix
- M3 says: segmented buttons "are deprecated in the expressive update"; "connected button groups should
replace the baseline segmented button" (`docs/reference/m3/reference-components-a.md:343-345, :224, :257`).
- Library does: `resources/views/components/theme-toggle.blade.php:22-48` hand-rolls one — a 40px
(`h-10`) outlined stadium (`rounded-corner-full border border-outline`) whose segments share dividing
borders (`not-first:border-s`) and whose selected segment is `bg-secondary-container` with a check icon.
That is the `OutlinedSegmentedButtonTokens` design (40dp, `CornerFull`, 1dp outline, label-large),
point-for-point. Its own header comment says so: "picker: M3's segmented buttons for the three
choices". Meanwhile the library ships the successor twice over:
`resources/views/components/group.blade.php:1-2` ("An M3 Expressive connected button group — the
successor of the segmented button", native radios, arrow-key movement, selected segment rounds fully)
and `resources/views/components/button-group.blade.php` (`connected`). The showcase puts the deprecated
control in its own chrome at `showcase/layout.blade.php:148`.
- Fix: re-implement `mode="picker"` on top of `<x-group>` with
`:options="[['id' => 'light', 'name' => __('Light'), 'icon' => 'light_mode'], …]"` and an `x-model`-ish
binding onto `$store.theme.choice` (a small `x-data` wrapper writing `$store.theme.set()` on change). The
selected-segment shape morph, the radiogroup semantics and the arrow keys then come for free — which
also resolves N-17.
- Effort: M
- Breaks API? no (the `mode="picker"` prop name and the `data-theme-toggle="picker"` /
`data-theme-option` hooks can be kept; the internal markup changes, so any app CSS targeting the inner
buttons would move)
### N-05 · tabs · every panel is visible until Alpine initialises
- Severity: must-fix
- M3 says: tabs show one destination at a time; the active indicator plus a single visible panel is the
whole mechanism (RCB §Tabs/Anatomy, /Behaviour). Exposing all panels also breaks the ARIA tabs
contract — only the panel for the selected tab may be rendered/visible.
- Library does: `resources/views/components/tab.blade.php:10` hides the panel with `x-show` only, and
there is no `x-cloak` or server-side `hidden`/`style="display:none"`. `[x-cloak]{display:none!important}`
*is* defined (`resources/css/tokens/state.css:68-70`) but unused here. Until Alpine boots, all panels
stack vertically (the showcase's 4-tab example renders all four bodies), and a screen reader reaching
the page early sees four `role="tabpanel"` regions at once. The same line binds `x-bind:id`, so
`aria-controls="…-panel"` on every tab button (`tabs.blade.php:65`) dangles until Alpine runs.
- Fix: in `tab.blade.php`, render the id server-side the way the tab buttons already do (pass the `$id`
down, or reuse `tabsId` via a `@aware`/slot prop) and add `x-cloak` plus a server-rendered
`@if ($name !== $initial) style="display: none" @endif` — or simplest: keep `x-show`, add `x-cloak`, and
add a CSS rule that un-cloaks the initially selected panel. Any of these removes both the flash and the
dangling `aria-controls`.
- Effort: S
- Breaks API? no
### N-06 · app-shell, navigation-rail · `lg` (1024px) stands in for M3's expanded breakpoint (840dp)
- Severity: should-fix
- M3 says: breakpoints are Compact <600, Medium 600839, **Expanded 8401199**, Large 12001599,
Extra-large ≥1600 (RF §Layout/Breakpoints). For Expanded the recommended navigation is "**Modal or
standard expanded navigation rail**", and the swap table gives Expanded a "Standard expanded navigation
rail" (RF §Layout/Breakpoints tables). The rail page: "Expanded → extra-large → use a rail (never a
bar); choose Standard vs. Modal based on space and destination count" (RCB §Navigation Rail/Behaviour).
- Library does: the threshold is Tailwind's `lg` = 64rem = **1024px**, in three places that must agree —
`resources/js/navigation.js:16` (`const WIDE = '(min-width: 64rem)'`), `resources/css/components/navigation.css:51`
and `:57` (the `rail-collapsed` variant) and `:184-188` (the adaptive rail's 96px layout width). Below
it, `materialNavigationRail.modal` is true (`navigation.js:116-118`) and `expanded` ignores
`$store.rail.collapsed` (`:124-130`).
**What a user sees at 8401023px** (a landscape tablet, a half-screen desktop window, a 12.9" iPad):
a 96px collapsed rail; pressing its menu button opens the expanded rail *as a modal over a scrim* with a
focus trap; `document.addEventListener('livewire:navigating', … hide())` (`navigation.js:86`) closes it
on every destination click, so the rail must be re-opened for each navigation; and the remembered
`$store.rail` = expanded preference is silently ignored. M3 lists a standard (in-layout) expanded rail
for this whole band.
- Fix: change `WIDE` to `'(min-width: 52.5rem)'` (840px) and the three `64rem` media queries in
`navigation.css` to `52.5rem`. If 840px feels too narrow for a 256px rail beside content, expose the
threshold instead of hard-coding it: a `--material-rail-expanded-at` custom property or a `wide-at` prop
on `<x-app-shell>`/`<x-navigation-rail mode="adaptive">`, defaulting to 840px. Update SKILL.md:665 and
:735, which both say "to `lg`" / "from `lg`".
- Effort: M
- Breaks API? no (unless an app relies on the 1024px switch point visually)
### N-07 · app-shell, navigation-bar · `sm` (640px) stands in for M3's compact/medium boundary (600dp)
- Severity: should-fix
- M3 says: Compact is "Under 600dp"; Medium is "600839dp" (RF §Layout/Breakpoints). The navigation bar
is for "compact (<600dp) and medium (600839dp)" (RCB §Navigation Bar/Behaviour); at Medium the swap
table gives "Collapsed navigation rail" as the primary navigation (RF §Layout/Breakpoints).
- Library does: two different numbers for the same boundary. The **bar's own item layout** switches at the
right place — `@container (width >= 37.5rem)` = 600px (`navigation.css:123`, `:465`, `:519`, `:525`,
`:555`, `:562`), correct and matching `ShortNavigationBarArrangement.Centered`. The **shell**, however,
switches at `sm` = 40rem = 640px: `app-shell.blade.php:142` (`sm:hidden` on the bar wrapper), `:87`
(`sm:flex`), `:88` and `:136` (the `--material-bottom-bar` padding), and `navigation.css:190-194` /
`:235-279` (the adaptive rail is width 0 and `display:none` below 40rem).
**What a user sees at 600639px**: the navigation bar is still the only navigation, but it has already
flipped to the medium horizontal (icon-beside-label, centred) configuration — so the Expressive medium
bar exists in a 40px-wide window band and then disappears entirely at 640px, where the collapsed rail
takes over. The two switch points should be the same number.
- Fix: replace the `sm:`/`max-sm:` utilities in `app-shell.blade.php` (lines 87, 88, 136, 142) with
arbitrary variants at 600px (`max-[37.5rem]:` / `min-[37.5rem]:`), or define a `compact`/`medium`
custom variant in the theme so the whole package has one name for 600px; change the two `40rem` media
queries in `navigation.css` to `37.5rem`. Also update the documented wrapper in
`navigation-bar.blade.php:4` and SKILL.md:700-707, which teach `sm:hidden`.
- Effort: M
- Breaks API? no (the documented `sm:hidden` wrapper idiom changes in the docs)
### N-08 · navigation-bar-item · the horizontal (medium) item label is label-large, not label-medium
- Severity: should-fix
- M3 says: `NavigationBarTokens.LabelTextFont = TypographyKeyTokens.LabelMedium`
(tok/NavigationBarTokens.kt). There is no per-layout label font: `NavigationBarHorizontalItemTokens`
defines only `ActiveIndicatorHeight`/`LeadingSpace`/`TrailingSpace`/`IconSize`, and Compose's
`ShortNavigationBarItem` passes `labelTextStyle = NavigationBarTokens.LabelTextFont.value` for *both*
icon positions (androidx `ShortNavigationBar.kt:220`, fetched from
https://raw.githubusercontent.com/androidx/androidx/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ShortNavigationBar.kt).
- Library does: `resources/css/components/navigation.css:147-148` sets
`font: var(--md-sys-typescale-label-lg)` inside `@container (width >= 37.5rem)`. (The rail's horizontal
item *is* label-large — `NavigationRailHorizontalItemTokens.LabelTextFont = LabelLarge` — which is
probably where this came from; the nav bar has no such token.)
- Fix: `navigation.css:147-148` → keep `label-md` (delete the two lines; the base `[data-navigation-pill]`
rule at `:105-106` already sets label-medium).
- Effort: S
- Breaks API? no
### N-09 · app-bar · the search variant's container does not follow the 312dp / 50% growth rule
- Severity: should-fix
- M3 says: "The search container of the search app bar should fill 100% of the space between leading and
trailing app bar elements **until it reaches 312dp. Then, it should only grow further to fill 50% of
that space**" (raw/components_app-bars_guidelines.md:454; RCB §Top App Bar/Behaviour).
- Library does: `resources/css/components/app-bar.css:133-137``[data-app-bar-search] { flex: 1 1 0%; }`,
i.e. 100% of the available space at every width. On a 1600px window the search bar spans ~1500px. The
showcase works around it by hand (`showcase/layout.blade.php:60`, `class="mx-auto w-full max-w-2xl"`),
which is evidence the component's own behaviour is wrong, and `max-w-2xl` (672px) is not the M3 curve
either.
- Fix: in `app-bar.css`, give the search slot the M3 curve:
`[data-app-bar-search] { flex: 1 1 0%; max-width: calc(19.5rem + (100% - 19.5rem) / 2); margin-inline: auto; }`
(19.5rem = 312px; below 312px `max-width` never binds so it still fills 100%). Then drop the manual
`max-w-2xl` in the showcase layout.
- Effort: S
- Breaks API? no
### N-10 · tabs · scrollable tabs have no 52dp leading offset
- Severity: should-fix
- M3 says: "When using scrollable tabs, the first visible tab should be offset by **52dp** from the left
side of the device for both web and mobile"; "Do — Offset the first scrollable tab 52dp from the leading
edge so it's clear that more content is available" (raw/components_tabs_guidelines.md:150, :157; RCB
§Tabs/Variants).
- Library does: `resources/css/components/tabs.css:50-52` only sets `flex: none` on the tabs when
`[data-scrollable]` is present; there is no leading padding anywhere on `[data-tabs-bar]`. The showcase's
scrollable example (`showcase/sections/bars.blade.php:101-105`) starts "January" flush against the
container edge, so nothing signals that the set scrolls.
- Fix: `tabs.css`, add
`[data-tabs-bar][data-scrollable] { padding-inline-start: 3.25rem; }` (52px) — logical property, so RTL
mirrors for free.
- Effort: S
- Breaks API? no
### N-11 · app-bar · the centred headline uses fixed 56px insets and collides with two trailing actions
- Severity: should-fix
- M3 says: the centred configuration is the Expressive replacement for the center-aligned bar ("Merged
into **small**. Use centered-text configuration.", RCB §Top App Bar/Status), and trailing elements are
"up to 2 icon buttons after headline" (RCB §Top App Bar/Behaviour). The headline must not be truncated
(raw/components_app-bars_guidelines.md:337).
- Library does: `resources/css/components/app-bar.css:122-130` absolutely positions the centred headline
with `inset-inline: 3.5rem` — a hard-coded 56px on each side, enough for exactly one 48px icon button.
With the M3-sanctioned two trailing buttons (≈96px plus the 4px row padding), the headline box extends
~40px underneath them; a long title ellipsises *behind* the buttons instead of before them. Nothing in
the code adapts the inset to what the `navigation`/`actions` slots actually contain.
- Fix: replace the absolute positioning with a three-column grid on the row for the centred variant —
`[data-app-bar][data-variant="center"] [data-app-bar-row] { display: grid; grid-template-columns: 1fr auto 1fr; }`
with the leading slot in column 1, the headline (centred, `justify-self: center`, `min-width: 0`) in
column 2 and the trailing slot in column 3 (`justify-self: end`) — the headline then shrinks against
whatever is actually beside it. Failing that, measure the two slots in `app-bar.js` (which already runs
a `ResizeObserver`) and publish `--app-bar-lead`/`--app-bar-trail`.
- Effort: M
- Breaks API? no
### N-12 · toolbar · a vertical floating toolbar placed at `end` uses a 16px margin, M3 wants ≥24dp
- Severity: should-fix
- M3 says: "Horizontal toolbars should have a minimum 16dp margin from the edge of the window";
"**Vertical toolbars should have a minimum 24dp margin**" / "Maintain at least a 24dp margin for
vertical toolbars" (raw/components_toolbars_guidelines.md:334, :346, :350; RCB §Toolbars/Specs).
- Library does: `resources/css/components/toolbar.css:83-89`
`[data-toolbar-place="end"] { inset-inline-end: calc(1rem + var(--material-safe-right, …)); }`, 16px for
every toolbar placed at the end edge, and `place="end"` is documented as the placement "for a vertical
one" (`toolbar.blade.php:6`).
- Fix: `toolbar.css`, split the rule:
`[data-toolbar][data-vertical][data-toolbar-place="end"], [data-toolbar-group][data-vertical][data-toolbar-place="end"] { inset-inline-end: calc(1.5rem + var(--material-safe-right, env(safe-area-inset-right))); }`
(24px) while horizontal placements keep 16px.
- Effort: S
- Breaks API? no
### N-13 · toolbar · standard (non-filled, non-tonal) buttons inside a standard toolbar are on-surface-variant, M3 says Primary
- Severity: should-fix (with a caveat — see both readings)
- M3 says: standard toolbar colour list, verbatim: "1. Surface container / 2. Filled button (Primary, On
primary) / 3. Toggle tonal button (Secondary container, On secondary container) / **4. Standard button
(Primary)**" (raw/components_toolbars_specs.md:228-234; RCB §Toolbars/Specs). The vibrant list gives
"Standard button (On primary container)" (:253) — which the library *does* honour.
- Library does: `resources/css/components/toolbar.css:49-61` recolours buttons only in the vibrant case.
In a standard toolbar the icon buttons keep the library's default icon-button ink,
`text-on-surface-variant` (`resources/views/components/button.blade.php:116` `$quietInk`, :123 for
icon-only text buttons) — the container's own `color: var(--md-sys-color-on-surface-variant)`
(`toolbar.css:28`, `:37`) cannot reach them because they carry a utility.
**The other reading**: M3's own icon-button spec gives an unselected standard icon button
`on-surface-variant`, and the toolbar colour list is annotated from an image showing text/standard
buttons rather than icon buttons. If the library treats the toolbar list as applying to *label* buttons
only, its current choice is defensible. I lean to the finding because the same list's vibrant row *is*
implemented, so the two rows are being read inconsistently.
- Fix: if adopting the spec, add next to the vibrant rules (same unlayered block):
`[data-toolbar]:not([data-vibrant]) [data-icon-button]:not([aria-pressed="true"]) { color: var(--md-sys-color-primary); }`.
If keeping the current behaviour, say so in `toolbar.css`'s header comment with the reason, so the next
reader does not re-litigate it.
- Effort: S
- Breaks API? no
### N-14 · app-shell · the app bar sits inside the content pane, beside the rail, not across the window
- Severity: should-fix (structural; see both readings)
- M3 says: the scaffold is bars → rails → panes, in that nesting order. "Bars can frame the page";
"**Rails create the perimeter space surrounding panes**"
(raw/foundations_layout_scaffold_overview.md:54-58); "Rails are the next level in layout **after bars**,
filling the perimeter space surrounding panes"; "Rails occupy the spaces immediately adjacent to bars"
(raw/foundations_layout_scaffold_rails.md:56, :64). And for the bar itself: "Container always spans 100%
of window width at its default height" (RCB §Top App Bar/Behaviour).
- Library does: `resources/views/components/app-shell.blade.php:97-139` puts `<x-navigation-rail>` as a
flex sibling *before* a column that contains `{{ $top }}` and `<main>`. From `sm` the app bar therefore
starts 96px (or 256px) in from the window's leading edge and is a pane-level bar, not a page-level one.
There is no slot that renders above the rail, so an application cannot opt into the M3 arrangement
without abandoning `<x-app-shell>`.
**The other reading**: `NavigationSuiteScaffold` in Compose nests the whole content — top bar
included — beside the rail, and M3's canonical-layout imagery shows both arrangements. If the library
regards the app bar as a pane bar, the current structure is consistent; it is still worth a documented
decision rather than an accident of markup order.
- Fix: add a `window-top` (or `banner`) slot to `<x-app-shell>` rendered as the first child of
`[data-app-shell]` with the shell root becoming `flex flex-col` and the rail+content row nested inside,
so an application can choose either. Document which slot means which in SKILL.md:663-695.
- Effort: M
- Breaks API? no (additive slot)
### N-15 · account-menu · the avatar trigger has no state layer (no hover or pressed state)
- Severity: should-fix
- M3 says: the six states are Enabled, Disabled, Hover, Focused, Pressed, Dragged, and "States have two
visual indicators to ensure accessibility"; the state layer's colour equals the content's "on" colour,
at 8% hover / 10% focus / 10% pressed (RF §Interaction/States).
- Library does: `resources/views/components/account-menu.blade.php:41`
`focus-ring inline-flex size-10 … rounded-corner-full bg-primary-container … text-on-primary-container`.
It has the focus ring but no `state-layer`, so there is no hover and no pressed feedback at all. Every
comparable trigger in the library (the rail menu button at `navigation-rail.blade.php:108`, the theme
toggle at `theme-toggle.blade.php:65`, every `<x-button>`) carries `state-layer`.
- Fix: add `state-layer` to the class list. Note the ordering constraint from
`resources/css/tokens/state.css:13-27` (the utility sets `position: relative; isolation: isolate` and
paints a `::before` at `z-index: -1`) — with `overflow-hidden` removed per N-01 this composes cleanly.
- Effort: S
- Breaks API? no
### N-16 · section-nav · secondary tabs wrapped onto a 3/4-column grid break the tab bar's divider and indicator
- Severity: should-fix
- M3 says: "Container always spans full width, divided into equal sections; **bottom-edge divider
separates it from content below**" (RCB §Tabs/Behaviour). M3's stated answer to "too many tabs" is
scrollable tabs — "scrollable allows longer labels and more tabs" (RCB §Tabs/Variants) — and its
accessibility page explicitly blesses them: "Horizontal scrolling tabs meet accessibility requirements
because they need to increase in width to respond to label text without affecting the layout"
(raw/components_tabs_accessibility.md:95).
- Library does: `resources/views/components/section-nav.blade.php:31-35, :60` reuses
`[data-tabs-bar][data-variant="secondary"]` but applies `sm:grid sm:grid-cols-3` / `sm:grid-cols-4`, so
from 5 items the "tab bar" becomes two or more rows. `tabs.css:19` puts the 1px outline-variant divider
on the *bar*, so only the last row gets one and the upper rows' 2px active indicators float in mid-air
against nothing. `[data-tab] { min-width: 5.625rem }` (`tabs.css:36`) also fights `grid-cols-4` in a
narrow column.
- Fix: either keep `sm:flex` with `[data-scrollable]` semantics (`flex: none` + the 52dp offset from
N-10), which is what M3 prescribes and what the library already implements for `<x-tabs scrollable>`;
or, if the grid is kept deliberately, move the divider to each row
(`[data-section-nav] [data-tabs-bar] > li { border-bottom: 1px solid var(--md-sys-color-outline-variant); }`
with the bar's own border removed) and drop the `min-width` inside the grid. See also "Deliberate
deviations" — the stated reason for the grid does not survive M3's text.
- Effort: M
- Breaks API? no
### N-17 · theme-toggle · the picker's radiogroup keyboard handling is incomplete
- Severity: should-fix
- M3 says: a single-select segmented control's role is "Radiogroup"
(`docs/reference/m3/reference-components-a.md:392`); RF §Interaction/Inputs and the per-component
accessibility pages defer to the APG pattern, in which a radio group handles Up/Down as well as
Left/Right and wraps from last to first.
- Library does: `resources/views/components/theme-toggle.blade.php:38-39` handles only
`keydown.arrow-right` / `keydown.arrow-left`, via `$el.nextElementSibling` / `previousElementSibling`,
so there is **no wrap** (Right on "System" and Left on "Light" do nothing), **no Up/Down**, and **no
Home/End**. `aria-checked="false"` is also rendered on all three server-side (`:33`), so before Alpine
boots the group reports nothing checked.
- Fix: fixed by N-04 — `<x-group>` uses native radios in a fieldset, where the browser supplies wrap,
both axes and the roving tab stop. If N-04 is not taken, add `arrow-down`/`arrow-up`, wrap with
`nextElementSibling ?? firstElementChild`, and render the initial `aria-checked` from the server
default.
- Effort: S (as part of N-04)
- Breaks API? no
### N-18 · tabs · the primary active indicator is missing its 2dp side inset
- Severity: should-fix
- M3 says: "Primary tab active indicators are additionally **inset 2dp on each side**" (RCB §Tabs/Specs,
from tabs/specs page text); "Active indicator minimum length 24dp".
- Library does: `resources/css/components/tabs.css:119-129``inset-inline: 0` on
`[data-tab-indicator]`, which for a primary tab is scoped to `[data-tab-content]`
(`min-width: 1.5rem` = the 24dp minimum ✓, `tabs.css:108`). The 3px height, the `3px 3px 0 0` corners
and the 2px/square secondary variant are all correct; only the inset is absent.
- Fix: `tabs.css`, in the primary case: `[data-tabs-bar]:not([data-variant="secondary"]) [data-tab-indicator] { inset-inline: 2px; }`.
- Effort: S
- Breaks API? no
### N-19 · navigation-bar-item · the state layer uses on-surface where the rail uses on-secondary-container
- Severity: should-fix
- M3 says: "The state layer's color equals the content's **'on' color**" (RF §Interaction/States,
state-layer mechanics). For navigation items androidx is explicit and uses one colour for active *and*
inactive: `ItemActiveHoveredStateLayer = ItemActiveFocusedStateLayer = ItemActivePressedStateLayer =
ItemInactiveHoveredStateLayer = ItemInactiveFocusedStateLayer = ItemInactivePressedStateLayer =
OnSecondaryContainer` (tok/NavigationRailColorTokens.kt). `NavigationBarTokens.kt` ships no state-layer
tokens, so the rail's are the only stated ones for a navigation item.
- Library does: two different answers for the same kind of item. The rail is right —
`navigation.css:544-552` paints both `::before` layers with
`var(--md-sys-color-on-secondary-container)`. The navigation bar is not: `navigation.css:504-517` leaves
`background-color: var(--md-sys-color-on-surface)` for `[data-navigation-bar-item]`'s indicator and pill
layers. On an active bar item the pill is secondary-container, so the hover layer is an on-surface wash
over secondary-container rather than the on-secondary-container the content uses.
- Fix: `navigation.css`, add `[data-navigation-bar-item] :is([data-navigation-indicator],
[data-navigation-pill])::before { background-color: var(--md-sys-color-on-secondary-container); }` after
the shared rule, matching the rail's treatment two blocks below.
- Effort: S
- Breaks API? no
### N-20 · tabs · the focus ring is drawn inside the tab (`outline-offset: -3px`) instead of M3's 2px out
- Severity: nice-to-have
- M3 says: Foundations calls the indicator only "ring-like" and gives no number (RF §Interaction/States:
"No thickness, offset, or color token is specified anywhere in Foundations"), but the library has picked
one convention for the whole package — `focus-ring` = 3px secondary at `outline-offset: 2px`
(`resources/css/tokens/state.css:46-53`), and the navigation bar/rail items follow it
(`navigation.css:554-583`).
- Library does: `resources/css/components/tabs.css:81-85` uses `outline-offset: -3px`, drawing the ring
inside the tab's own box. (The likely reason is `overflow-x: auto` on the bar clipping an outward ring;
the file says nothing about it.)
- Fix: either keep it and record the reason in `tabs.css`'s header comment, or give the bar
`padding-block: 3px` / `scroll-padding-inline: 3px` room and switch to `outline-offset: 2px` so tabs
match every other focusable thing in the library.
- Effort: S
- Breaks API? no
### N-21 · tabs · `aria-current="page"` gets the indicator but not the active label colour in the primary variant
- Severity: nice-to-have
- M3 says: primary tabs' active label is Primary; secondary tabs' is On surface (RCB §Tabs/Specs).
- Library does: `resources/css/components/tabs.css:87-93` — the active colour rule for the primary variant
matches only `[aria-selected="true"]`, while the secondary rule matches
`:is([aria-selected="true"], [aria-current="page"])` and the indicator rule (`:141`) matches both. So a
link-based tab bar (the pattern `section-nav` uses, `section-nav.blade.php:65-70`) built with
`variant="primary"` would show the indicator but keep the label on-surface-variant.
- Fix: `tabs.css:87` → `[data-tab]:is([aria-selected="true"], [aria-current="page"])`.
- Effort: S
- Breaks API? no
### N-22 · navigation-rail · no vertical divider and no way to turn the container fill off
- Severity: nice-to-have
- M3 says: "Optional vertical divider separates rail from content, placed on the content-adjacent edge";
"container fill can be turned off (transparent) as long as items keep ≥3:1 contrast"; "If a layout
scrolls horizontally, the rail can scroll off-screen or remain fixed. To distinguish that content is
scrolling underneath the rail, use a divider or add elevation to the rail"
(RCB §Navigation Rail/Behaviour; raw/components_navigation-rail_guidelines.md:456).
- Library does: `navigation.css:196-215` always paints `background-color: var(--md-sys-color-surface)` and
draws no divider; there is no prop for either (`navigation-rail.blade.php:60-65` has only `mode`,
`label`, `width`, `menu`).
- Fix: add a `divider` boolean prop rendering `border-inline-end: 1px solid
var(--md-sys-color-outline-variant)` on `[data-navigation-rail-panel]`, and a `fill` prop (default
`true`) that sets `background-color: transparent` when false.
- Effort: S
- Breaks API? no (additive props)
### N-23 · navigation-rail · the FAB → extended FAB transition is a display swap, not a morph
- Severity: nice-to-have
- M3 says: "Rail transitions from collapsed↔expanded should animate the FAB into/out of an Extended FAB"
(RCB §Navigation Rail/Behaviour).
- Library does: the documented pattern is two FABs swapped by `display`
(`navigation-rail.blade.php:42-43`, SKILL.md:737, `showcase/shell.blade.php:48-49`) — the collapsed one
appears at once while the rail's width is still springing. The rail's own width *is* animated on the
spatial spring (`navigation.css:171`), so the mismatch is visible.
- Fix: a single `<x-fab>` whose label is width-animated (`grid-template-columns: 0fr → 1fr` on the label
span, `ease-spatial-default`) inside the `rail-collapsed` variant would morph instead of swap; or at
minimum mention the limitation in SKILL.md.
- Effort: M
- Breaks API? no
### N-24 · navigation-rail · `mode="collapsible"` has no width floor of its own
- Severity: nice-to-have
- M3 says: "Compact → don't use a standard rail, use a navigation bar" (RCB §Navigation Rail/Behaviour).
- Library does: only `mode="adaptive"` is width-aware (`navigation.css:184-194`). A `collapsible` or
`expanded` rail keeps `clamp(13.75rem, 16rem, 22.5rem)` at 360px too — two-thirds of a phone screen.
The docs point applications at `<x-app-shell>` for adaptivity, and the showcase only uses `collapsible`
inside a bounded demo box, so nothing in-repo hits it; an application wiring a bare
`<x-navigation-rail mode="collapsible">` into a page layout will.
- Fix: a one-line guard in `navigation.css` —
`@media (width < 37.5rem) { [data-navigation-rail='collapsible'], [data-navigation-rail='expanded'] { width: 6rem; } }`
plus the matching `rail-collapsed` variant arm — or an explicit sentence in SKILL.md:735 that these two
modes are fixed-width by design and belong behind a `min-[37.5rem]:` wrapper.
- Effort: S
- Breaks API? no
## Deliberate deviations
- **Expanded rail items are full-width pills, not label-hugging** — `navigation-rail-item.blade.php:18-19`:
"Compose's expanded item hugs its label; the package draws the full-width pill, which leaves room for
the count at the end." **Holds up.** M3 says exactly this is allowed: the indicator "hugs the label text
by default (**can be overridden to fill the full container width**, resembling the old navigation drawer
style); target area always spans the full rail width" (RCB §Navigation Rail/Behaviour).
- **No drop shadow on a scrolled app bar, colour change only** — `app-bar.css:12`, `:59-61`. **Holds up,
and is the right reading.** androidx has `AppBarTokens.OnScrollContainerElevation = Level2`, but the
site is explicit: "On scroll: **No drop shadow**, instead a color fill creates separation from content"
(raw/components_app-bars_overview.md:137).
- **Medium/large flexible height keyed on whether a subtitle is present** — `app-bar.css:33-47` maps
medium 112/136 and large 120/152 to without/with subtitle. **Holds up**: those are exactly
`ContainerHeight`/`LargeContainerHeight` in tok/AppBarMediumFlexibleTokens.kt and
tok/AppBarLargeFlexibleTokens.kt, and Compose's flexible app bars pick the taller one when a subtitle is
supplied.
- **Rail container is `surface`, modal is `surface-container`** — `navigation.css:210`, `:229`. **Holds
up**: tok/NavigationRailCollapsedTokens.kt `ContainerColor = Surface`;
tok/NavigationRailExpandedTokens.kt `ModalContainerColor = SurfaceContainer`, `ModalContainerElevation =
Level2`, `ModalContainerShape = CornerLarge` — all three reproduced exactly. (The site's colour list
says "Surface container (optional container fill)"; androidx is the more specific source and the library
follows it.)
- **Every toolbar control stays in the Tab order rather than a roving tabindex** — `toolbar.js:4-5`: "Every
control stays in the Tab order, so a control added by a Livewire render is reachable without any
bookkeeping." **Holds up against M3** (though not against APG's toolbar pattern): M3's own accessibility
page says "Initial focus lands on the first interactive element; **Tab (or Arrows) moves between all
others**" (RCB §Toolbars/Accessibility).
- **Section nav wraps onto a grid instead of scrolling** — `section-nav.blade.php:15-16`: "tabs that scroll
hid the last sections on a tablet." **Does not hold up.** M3 prescribes scrollable tabs for exactly this
case and its accessibility page defends them explicitly ("Horizontal scrolling tabs meet accessibility
requirements…", raw/components_tabs_accessibility.md:95), while the grid breaks the tab bar's divider
and strands the indicators (N-16). If the grid is kept for product reasons, it should stop presenting
itself as `[data-tabs-bar]`.
- **Small app-bar titles truncate with an ellipsis** — `app-bar.css:104-110`. M3 says "Don't truncate the
headline text" *and* "Don't wrap text in a small app bar"
(raw/components_app-bars_guidelines.md:337, :347); its actual answer is "If headline text is long, use a
medium flexible or large flexible app bar" (:342). Truncation is the only remaining web-sane behaviour,
so this is a reasonable forced choice — but it is undocumented. Worth one line in the header comment
pointing authors at `variant="medium"`.
## Aligned
- **App bar**: 64 / 112 / 136 / 120 / 152px heights; title-large + label-medium (small), headline-medium +
label-large (medium flexible), display-small + title-medium (large flexible); 4px leading/trailing space;
0 gap between icon buttons; 16px title inset without a leading button; surface → surface-container on
scroll; on-surface title / on-surface-variant subtitle and trailing icons / on-surface leading icon;
square container. The pure-CSS collapse (negative sticky `top` + sticky inner row) is elegant and
produces no layout shift; the flexible variants' row headline is `aria-hidden` so the real `<h1>` is
never announced twice.
- **Navigation bar**: 64px, surface-container, square, items 0 apart, 56×32 `CornerFull` indicator, 4px
iconlabel space, 6px item vertical padding, 40px horizontal indicator with 16px leading/trailing,
24px icons, on-secondary-container active icon, secondary-container indicator, secondary active label
(vertical) / on-secondary-container (horizontal), on-surface-variant inactive — all correct. The medium
arrangement reproduces `calculateCenteredContentHorizontalPadding` (items occupy `10 × (count + 3)`% of
the bar) exactly, and it switches on a **container** query at 600px, so a bar in a narrow column keeps
the compact layout.
- **Navigation rail**: 96px collapsed, `clamp(220px, width, 360px)` expanded, 44px top space, 40px header
space (32 + 8), 4px between collapsed items, 64px collapsed item, 56px expanded item, 8px/4px iconlabel
spaces, 56×32 collapsed indicator, `CornerFull`. Modal = fixed, surface-container, elevation 2,
`CornerLarge` on the inner edge, 32% scrim, `x-trap.inert.noscroll`, Escape, scrim click, focus returned
to the menu button, closed on `livewire:navigating`. Badges move from the icon (collapsed) to the label
end (expanded), which is M3's stated rule. State layers use on-secondary-container per
NavigationRailColorTokens.
- **Catalogue vs. Expressive**: no bottom app bar, no navigation drawer, no baseline medium/large app bar,
no baseline navigation bar — all four correctly skipped, and the expanded rail (standard + modal) is
what stands in for the drawer, exactly as M3 Expressive directs.
- **Tabs**: 48px (64px stacked), 24px icons, 1px outline-variant divider, 3px primary indicator with
`3px 3px 0 0` corners, 2px square secondary indicator, 24px minimum indicator length, 8px iconlabel gap,
title-small labels, primary/on-surface active colours, 8%/10% state layers, 38% disabled. Real tablist
semantics, arrow keys + Home/End, disabled tabs skipped, `aria-selected`/`tabindex` rendered on the
server, and the indicator moves in a view transition on the spatial spring with a reduced-motion escape.
- **Toolbars**: docked 64px / square / surface-container / 16px ends / 432px between items (as a
`clamp`); floating 64px / `CornerFull` / surface-container / 8px ends / 4px between; vibrant =
primary-container with on-primary-container ink and a surface-container + on-surface selected button —
all matching tok/DockedToolbarTokens.kt and tok/FloatingToolbarTokens.kt. `role="toolbar"` +
`aria-orientation` + RTL-aware arrow keys.
- **Motion**: all durations collapse to 0 under `prefers-reduced-motion`
(`resources/css/tokens/motion.css:63-75`); spatial springs for size/position, effects easing for colour
and opacity, throughout this group.
- **Safe areas**: the app bar pads the top inset, the navigation bar and rail the bottom inset, the docked
toolbar the bottom inset — matching M3's safety-region rule.
- **Links vs. buttons**: navigation items are real `<a>`s with `aria-current="page"` (plus the filled icon
and the indicator — the two-indicator rule) and degrade to `<button>` without a `link`; the section nav
uses links with `aria-current`, not fake tabs.
## Missing
M3 variants/configurations in this group with no implementation at all:
- **Navigation bar, tall container (80dp)** — `NavigationBarTokens.TallContainerHeight = 80.0.dp`
(tok/NavigationBarTokens.kt). Only the 64px short bar exists (`navigation.css:76`); no prop selects the
tall one.
- **Navigation bar, hide-on-scroll** — "Scrolling: hides on scroll-down, reappears on scroll-up — but never
hide it while a screen reader is active" (RCB §Navigation Bar/Behaviour). Not implemented and not
mentioned.
- **Navigation rail, narrow collapsed width (80dp)** — `NavigationRailCollapsedTokens.NarrowContainerWidth
= 80.0.dp`. Only 96px (`navigation.css:174`).
- **Navigation rail, centre alignment** — "Alignment: top or center; on tablets prefer center alignment for
reach. Menu icon and FAB are always top-aligned" (RCB §Navigation Rail/Behaviour). The rail is
top-aligned only.
- **Navigation rail, "hide when collapsed" as an explicit configuration** — it is in M3's configuration
table ("Expanded behavior | Hide when collapsed | — | Available"). The library gets it implicitly below
`sm` in `mode="adaptive"`, but there is no mode that hides a rail in an immersive wide layout.
- **Docked toolbar with a FAB** — M3's configuration table lists "Other elements | With FAB" for both
toolbars; `toolbar.blade.php:26` gates the `fab` slot on `$variant === 'floating'`.
- **App bar, trailing-action overflow at small widths** — "trailing actions collapse into an overflow menu
at smaller breakpoints and reappear at larger ones" (RCB §Top App Bar/Behaviour). Not implemented (and
M3 also tells you not to put an overflow menu in the app bar in the first place, so this is genuinely
optional).
- **Toolbar, large-screen treatments** — "On web/large screens it can gain rounded corners and use dividers
to organize many actions"; "medium+ breakpoints allow centering all elements or centering one key
action while pinning others to the edges" (RCB §Toolbars/Behaviour). The toolbar has no breakpoint
behaviour at all.
Correctly absent (deprecated in M3 Expressive, do **not** add): bottom app bar, navigation drawer,
baseline medium/large top app bar, baseline navigation bar. `theme-toggle mode="picker"` is the one place
a deprecated design slipped back in — see N-04.
## Breakpoint map
M3's classes: Compact <600, Medium 600839, Expanded 8401199, Large 12001599, Extra-large ≥1600
(RF §Layout/Breakpoints). Tailwind's: `sm` 640, `md` 768, `lg` 1024, `xl` 1280, `2xl` 1536.
| Component / behaviour | Library breakpoint used | M3 class it stands in for | Gap |
| --- | --- | --- | --- |
| `app-shell` — bottom navigation bar shown; rail hidden, modal-only | `max-sm` < 640px (`app-shell.blade.php:142`, `:87-88`, `:136`; `navigation.css:190-194`, `:235-279`) | Compact (<600dp) | **+40px.** 600639px is M3 Medium but is driven as Compact |
| `navigation-bar` — item layout vertical → horizontal | container 600px (`navigation.css:123`, `:465`, `:519`, `:525`, `:555`, `:562`) | Medium (600dp) | **exact** — and a container query, so it also works in a narrow column |
| `app-shell` — collapsed rail appears (96px), bar disappears | `sm` ≥ 640px | Medium (600839dp) | **+40px late**; disagrees with the line above, so the horizontal bar layout only exists in a 40px band |
| `app-shell` / `navigation-rail mode="adaptive"` — rail becomes a standard, in-layout collapsible rail; menu button toggles instead of opening a modal | `lg` ≥ 1024px (`js/navigation.js:16`; `navigation.css:51`, `:57`, `:184`) | Expanded (≥840dp) | **+184px.** 8401023px gets a collapsed rail whose expansion is modal, scrimmed, focus-trapped and re-closed by every `wire:navigate`; the remembered "expanded" preference is ignored |
| `app-shell` — content region `max-lg:overflow-x-clip` | `lg` 1024px (`app-shell.blade.php:136`) | Expanded (840dp) | +184px (a clipping backstop, no M3 rule attached) |
| `section-nav` — menu picker → secondary-tab row | `sm` 640px (`section-nav.blade.php:40`, `:59`) | Compact/Medium boundary (600dp) | +40px |
| `section-nav` — 3/4-column grid → single row | `xl` 1280px (`section-nav.blade.php:33-34`) | Large (1200dp) | +80px |
| showcase chrome — theme picker → cycle button | `md` 768px (`showcase/layout.blade.php:148-149`) | none (no M3 rule) | n/a — showcase only |
| `app-bar`, `toolbar`, `tabs` | none | Medium+ adaptive guidance (toolbar centring/rounded corners, app-bar action overflow) | not implemented (see Missing) |
**What a user actually sees in the three bands the brief asks about**
- **600639px** (M3 Medium; e.g. a small tablet in portrait, a resized desktop window): no navigation rail
at all — it is `width: 0; display: none`. The bottom navigation bar is still pinned, but it has already
flipped to the Expressive *medium* configuration (icon beside label in a 40px secondary-container pill,
items grouped in the middle at `10 × (count + 3)`% of the width, label-large instead of label-medium per
N-08). Any destination marked `'bar' => false` is reachable only by an application-supplied menu button
calling `$store.rail.show()`. At 640px the bar vanishes outright and a 96px collapsed rail appears — a
jump, at the wrong number.
- **8401023px** (M3 Expanded; a landscape tablet, a half-screen desktop browser): a 96px collapsed rail
with icon-over-label items. The menu button does **not** expand it in place — `materialNavigationRail.modal`
is still true, so it opens the 256px rail *over* the page on a 32% scrim with a focus trap, and
`livewire:navigating` closes it again, so choosing a destination means re-opening the rail every time.
`$store.rail.collapsed === false` (the visitor's remembered choice, applied to `<html data-rail>` before
first paint) has no effect here. M3 lists a standard expanded rail for this whole band.
- **12001279px** (M3 Large): **no mismatch** — `lg` (1024px) is long past, so the rail is already the
standard collapsible one, expanded by default and remembered in localStorage, which is what M3 wants at
Large. The only thing that changes at 1280px is `section-nav`'s grid → row switch (N-07's `xl` row
above), which is 80px later than M3's 1200dp Large boundary. Nothing in the group does anything
different at 1600px (Extra-large), where M3 suggests considering a third pane.
+37
View File
@@ -0,0 +1,37 @@
# Material Design 3 reference (extracted 2026-09-13)
What Google's documentation says, condensed for auditing this library against it. Every file
keeps the wording of the site where a rule is stated, the tables verbatim, and a source URL per
section. Numbers the site only draws in pictures were taken from androidx Compose Material 3's
token files (`compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/`)
and the Material Components Android component docs, and are marked with their source.
| File | Covers |
|---|---|
| `foundations.md`, `foundations-supplement.md` | accessibility, content design, customisation, design tokens, interaction (gestures, inputs, selection, states), layout (window size classes, scaffold, grids and spacing, canonical layouts, RTL), usability, glossary |
| `styles.md`, `styles-supplement.md` | colour (roles, system, dynamic, custom colours, contrast), elevation, icons, motion (Expressive springs, easing and duration, transitions), shape (corner scale, shapes, morph), spacing, typography |
| `components-actions-communication-containment.md` | buttons, icon buttons, button groups, split button, segmented buttons, FAB, extended FAB, FAB menu, badges, progress indicators, loading indicator, snackbar, tooltips, bottom sheets, cards, carousel, dialogs, divider, lists, side sheets, menus |
| `components-navigation-selection-inputs.md` | app bars, navigation bar, navigation rail, navigation drawer, tabs, toolbars, search, checkbox, chips, radio button, sliders, switch, text fields, date pickers, time pickers, data tables |
| `pages.txt` | the 238 pages under `/foundations`, `/styles` and `/components` in the site's sitemap (watches and XR left out) — the coverage checklist; every one was fetched |
## Refreshing
m3.material.io renders only in a browser. Read it through a reader proxy, one page at a time
(it rate-limits with HTTP 429; retry after a few seconds; add `-H "x-no-cache: true"` for a
stale snapshot):
```bash
curl -sL "https://m3.material.io/sitemap.xml" | grep -o '<loc>[^<]*' | sed 's/<loc>//' # the page list
curl -sL "https://r.jina.ai/https://m3.material.io/components/buttons/specs" # one page
```
The audits that used these files are in `docs/audits/m3-alignment/`.
## Attribution
The text is Google's, from https://m3.material.io, reproduced here so the library can be
checked against it; the token values are from the Android Open Source Project (Apache-2.0, see
`NOTICE`). Google publishes its design documentation under the Creative Commons Attribution 4.0
licence "except as otherwise noted"; confirm the statement in the site's footer before
reproducing these files anywhere else. `docs/` is export-ignored, so they are not part of the
Composer distribution.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+355
View File
@@ -0,0 +1,355 @@
# Material Design 3 — Foundations reference (supplement)
Covers 27 sitemap URLs under `foundations/*` that `reference-foundations.md` did not fetch under their exact
path (per-tab sub-pages the earlier pass reached only via a parent tab, plus a few pages the earlier pass
never visited at all). Only content **not already in `reference-foundations.md`** is written out in full;
pages that turned out to duplicate material already in the reference say so in one line instead of repeating
it. Same structure as the reference: Principles / Rules / Values tables / M3 vs M3 Expressive /
Accessibility / Source URLs, grouped by topic.
---
## Accessibility — assistive technology, designing, writing
### Duplicates (no new content)
- `foundations/designing/overview` — restates the reference's "Do use native elements / Don't use
non-standard elements" dialog example verbatim. One new framing sentence: the designing tabs
(Accessibility markup, Implementing accessibility, ...) exist to **"translate a visual UI into a
text-based, linear user experience that maps to code."** The page only fully rendered two of its stage
tabs (Accessibility markup, Implementing accessibility); a third stage implied by "three stages" was not
captured in this fetch.
### New — Assistive technology (foundations/overview/assistive-technology)
Three assistive-technology categories, quoted:
- **Keyboard**: "Hardware or software directional controllers such as keyboards, a D-pad, or trackball allow
users to jump from selection to selection in a linear fashion."
- **Screen readers**: "A screen reader is a software program that uses either a braille display or reads
text aloud, such as Google's screen reader, TalkBack." Verbalizes visible content (paragraph/button text)
**and** hidden content (alt text for icons, headings). Used by people with vision impairments, reading
difficulty, or temporary reading limitations.
- **Switch input**: "Switches scan the items on your screen, highlighting each item in turn, until you make
a selection. Switch Access lets you interact with your Android device using one or more switches instead
of the touchscreen."
### New — Writing best practices, beyond alt text (foundations/writing/best-practices)
The reference already covers alt text length/rules; this page adds three more image-text mechanisms:
- **Adjacent text**: text in/around an image should present key information about the image (distinct from
alt text and captions).
- **Captions**: text that appears below an image, explaining how it relates to the content; benefits both
sighted and screen-reader users (screen readers **do** read captions, unlike embedded image text).
- **Embedded text in images**: "Screen readers are unable to read text that is embedded in imagery." Any
essential information embedded as image text must be duplicated into the alt text instead. Caution flagged
explicitly for this case.
- Alt text is also framed as an SEO benefit ("Keywords can also improve search engine optimization").
### New — Text resizing (foundations/writing/text-resizing)
**Requirement**: UIs must support a minimum text increase of **200%**.
**Scaling rules** — most components:
- Text and line height scale up proportionally, multiplied by the scale value.
- Padding remains constant at **1x** the default size (e.g., a button keeps 8dp top/bottom and 24dp
left/right padding at 1x, 1.3x, and 2x text scale).
- Spacing between elements in a component remains constant at 1x.
- Components with no text (progress indicators, checkboxes, radio buttons) are **not** affected by text
resizing — don't resize them.
**Multiplier calculation** (when the OS doesn't drive resizing): offer multipliers (e.g. 1.5x, 2x);
`font size = default font size (density 0) × scale value`. Example: 14pt at 1x → 28pt at 2x scale.
**Methods to handle large type** (avoids clipping, overlap, unwanted truncation):
| Method | Rule |
| --- | --- |
| Increase container size | Let containers grow so text doesn't overlap/clip/truncate; design for line-to-line eye tracking |
| Reflow the layout | Stack components (e.g. buttons) that were side-by-side, rather than keeping them fixed-width |
| Enable content to scroll | Prefer vertical scrolling only — never ask users to scroll both vertically and horizontally |
| Touch & hold tooltips | For space-constrained components — **top app bar, navigation bar, navigation rail, and fixed (non-scrolling) tabs** — keep the component's own text at 1x and show the full scaled-up label in a tooltip on touch & hold |
### New — Text truncation (foundations/writing/text-truncation)
**Requirement**: "Content, understandability, and functionality must not be lost when users modify their
type settings." (Exceptions may apply for non-Latin-alphabet languages.)
- **Text wrapping**: wrap text when it's critical, needed for understandability, or space allows. **Don't**
cut off wrapped text without a way to see the rest (e.g., an expand affordance).
- **Height/width of components**: prefer flexible containers that grow to fit content over fixed text-size
limits that don't fit the available space.
- **Ellipsis + hover/link**: truncating to an ellipsis is acceptable **only** if the full text is reachable
via a tooltip or a link that reveals what was truncated. An ellipsis with no reveal mechanism "is not
accessible." Note: this pattern still adds difficulty for some users, so use it deliberately.
### Source URLs
https://m3.material.io/foundations/overview/assistive-technology
https://m3.material.io/foundations/designing/overview
https://m3.material.io/foundations/writing/best-practices
https://m3.material.io/foundations/writing/text-resizing
https://m3.material.io/foundations/writing/text-truncation
---
## Content design — global writing, style guide
### Duplicates (no new content)
- `foundations/content-design/global-writing/overview` — same definition of global writing already quoted
in the reference. One new line: **"This guidance is for all American English that is localized."**
- `foundations/content-design/style-guide/ux-writing-best-practices` — this is the exact content the earlier
pass already captured at `foundations/content-design/style-guide` (explain consequences, scannable
words/formats, sentence case, abbreviations sparingly) — fully identical, word for word.
### New — Grammar and punctuation (foundations/content-design/style-guide/grammar-and-punctuation)
Not covered anywhere in the reference. Rules, quoted/paraphrased:
- **Skip periods on single sentences** — omit in labels, tooltip text, bulleted lists, dialog body text,
hyperlinked text. Use periods for multiple sentences, or long/complex sentences.
- **Use contractions** — easier to scan; exception: spelled-out "do not" can read as more emphatic than
"don't" when caution is needed.
- **Serial (Oxford) comma** — always use it, except before an ampersand. Commas always go inside quotation
marks.
- **Commas in numbers**: use commas for numbers **1,0001,000,000**. Don't use commas when identifying
something (street address, radio frequency, year). Above 1 million, comma use depends on context;
"million" can be abbreviated "M" with rounding when giving a sense of volume rather than an exact count
(e.g. "23M views").
- **Skip colons in headings** — no colon on a heading introducing a list; do use a colon for a list
introduced within body text.
- **Exclamation points sparingly** — fine for greetings/congratulatory moments; avoid for empty states and
routine tasks ("save it for bigger accomplishments").
- **Ellipses sparingly** — indicate an in-progress action or incomplete text (check with engineering, since
truncation ellipses are often automatic). No space before an ellipsis. Omit ellipses from menu items or
buttons that open a dialog or start a process.
- **Parentheses** — only to define acronyms/jargon or cite a source; never for an aside/afterthought (fold
the info into the sentence instead).
- **Ampersands**: "&" is acceptable in headlines, column headers, table headers, navigation labels, and
buttons (space-constrained contexts) — but spelling out "and" improves readability where there's room.
Always spell out "and" in sentences/paragraphs, before the final item in a 3+ item list, and in email
subject lines.
- **Dashes** — three kinds: em dash (—), en dash (), hyphen (-). Avoid em dashes in UX writing (replace with
a comma, period, or new sentence). Use an en dash with no surrounding spaces for a range (e.g. "9 AMNoon").
- **Hyphens** — bind closely related words; also represent negative numbers (e.g. "-100"); never surrounded
by spaces. Defer to AP style when unsure whether a phrase needs one. Hyphenation table:
| Rule | Examples | Why |
| --- | --- | --- |
| Hyphenate adjective phrases | case-by-case basis, best-in-class performance, once-in-a-lifetime opportunity | Multiple words used together as one adjective are hyphenated — but not proper nouns or common phrases ("cell phone number", "chocolate chip cookie") |
| Hyphenate noun phrases | sign-off, drive-through, go-ahead (verb+adverb); higher-up, most-read (adjective-as-noun); jack-of-all-trades, stick-in-the-mud (fixed idioms) | Certain multi-word nouns are always hyphenated |
| Don't hyphenate verb phrases | "look out for falling rocks," "drop off your car," "check in to the room" | A verb+adverb/preposition acting as a verb isn't hyphenated (contrast the noun "check-in"); note "check into" ≠ "check in to" |
- **Italics sparingly** — hard to read; use bold for emphasis instead. Italics are acceptable for a single
word/phrase (a name or title), never a full sentence.
- **No caps blocks** — "Avoid using caps blocks altogether; they're not accessible." Use sentence case for
all product text.
### New — Word choice: pronouns (foundations/content-design/style-guide/word-choice)
Note: this is a **different** page from the already-covered `global-writing/word-choice` (which is about
localization word choice). This one is about person/pronoun choice:
- **Use second person ("you"/"your")** — makes the UI feel like it's speaking to the user about their own
actions; avoid impersonal/robotic phrasing.
- **Don't mix first and second person** in the same context (e.g. "My photos" title next to "Your photos"
description) — pick one address form, or drop the pronoun.
- **Use caution with "I"/"we"** — for a large company like Google, first person can read as robotic or
disconcerting; prefer the user's point of view or remove the pronoun. **Exception**: legal text may use
"I"/"my" to emphasize ownership in an agreement (e.g. "I agree to the terms of service").
### Source URLs
https://m3.material.io/foundations/content-design/global-writing/overview
https://m3.material.io/foundations/content-design/style-guide/grammar-and-punctuation
https://m3.material.io/foundations/content-design/style-guide/ux-writing-best-practices
https://m3.material.io/foundations/content-design/style-guide/word-choice
---
## Design tokens — usage workflow
### Duplicates (no new content)
- `foundations/design-tokens/overview` — restates the reference's token definition, naming scheme,
three-class model (ref/sys/comp), and contexts verbatim (this is the site's single `design-tokens` page,
now also reachable at `/overview`).
### New — Reading a component's token spec table (foundations/design-tokens/overview)
Not in the reference: a documented procedure for finding a token in a component's Specs tab. Tokens there
are grouped first by **state** (enabled, disabled, hover, etc.), then by **element** (the part the token
applies to, e.g. container or label text). Table columns:
| Column | Meaning |
| --- | --- |
| Name | The style aspect the token applies to (e.g. color, font) |
| Token ID | The token defining that style aspect |
| Description | Optional extra info |
| Context/value | The value stored for a given context |
Worked example given on the page: to verify a filled button's label-text color, go to *Common buttons*
*Specs*, find the filled-button token module, locate the "label text" row under elements, and copy the color
token into code (or compare it to the Figma color role).
### New — How to use tokens, a Figma workflow (foundations/design-tokens/how-to-use-tokens)
Entirely new — a step-by-step production workflow, not covered anywhere in the reference:
1. **Install** the Material Theme Builder Figma plugin.
2. **Generate tokens**: Plugins → Material Theme Builder → Open Plugin → Get started. Creates a
`material-theme` artboard with baseline tonal palettes (light/dark) and a default type scale, populated
as Figma styles.
3. **Update token values** — two ways:
- Via the plugin (color only): re-open the plugin, choose new colors; color/text styles update in place.
- Via Figma styles directly: right-click a style → "Go to style definition," hover the style → the
adjust icon (or right-click → Edit style) to change name/description/properties.
4. **Use tokens in mockups**: apply the generated Figma styles to layout elements instead of hand-setting
color/typography, so engineers can trust the design intent transfers.
5. **Use tokens with the Material Design Kit**: duplicate the kit, open the plugin with kit components
selected, and choose **Swap** to replace baseline token values with the custom generated ones.
6. **Export tokens**: plugin's Export tab → choose a target format (Android, Jetpack Compose) → save as a
named `.zip`.
### Source URLs
https://m3.material.io/foundations/design-tokens/overview
https://m3.material.io/foundations/design-tokens/how-to-use-tokens
---
## Interaction: states (per-state one-line definitions)
### Duplicate, with one addition
`foundations/interaction/states/overview` restates the "two visual indicators," "states can combine," and
six-state list already in the reference. New here: the site's own one-line definition for each state
(not quoted in the reference), verbatim:
| State | Definition |
| --- | --- |
| Enabled | "communicates an interactive component or element" |
| Disabled | "communicates an inoperable component or element" |
| Hover | "communicates when a user has placed a cursor above an interactive element" |
| Focused | "communicates when a user has highlighted an element, using an input method such as a keyboard or voice" |
| Pressed | "communicates a user tap" |
| Dragged | "communicates when a user presses and moves an element" |
### Source URL
https://m3.material.io/foundations/interaction/states/overview
---
## Layout — adaptive design, breakpoints per class, canonical layouts
### Duplicates (no new content)
- `foundations/layout/layout-overview/overview` — same 7-parts-of-layout figure and Layout terms glossary
already quoted in the reference (this is the site's `layout-overview` tab, now also at `/overview`).
- `foundations/layout/scaffold/overview` — same Bars/Rails/Panes summary already in the reference.
- `foundations/layout/grids-spacing/overview` — same "grids adapt across breakpoints / spacing groups &
directs attention / density for data-heavy views" summary already in the reference.
- `foundations/layout/breakpoints/overview` — same breakpoint width table, pane/navigation/communication/
action table, five adaptation questions, and common-swappable-components table already fully quoted in
the reference.
- `foundations/usability/overview` and `foundations/usability/applying-m3-expressive` — **both render the
same content** as the reference's Usability section (five NN/g usability aspects, usability-vs-
accessibility distinction, the M3-Expressive design-tactics list — color/contrast, containment, motion,
shape & shape morph, size, typography — and "design based on primary goals"). No new facts on either page.
### New — Adaptive design, defined (foundations/layout/layout-overview/adaptive-design)
Not covered in the reference at all — this is the site's actual definition of the "adaptive design" term
the reference only flagged as a May-2026 rename target:
- **Adaptive vs. responsive**: "While responsive design scales a single layout to fit any screen, adaptive
design customizes a product to optimize the experience on each device." Adapts to **People** (preferences/
settings), **Devices** (watch, phone, foldable, tablet, desktop, XR), and **Usage** (window resize,
orientation, device switch).
- **Three primary experience types** — design starts at mobile and scales up to spatial; all three should
account for touch, pointer, and physical-keyboard input regardless of the device's default input:
| Experience | Notes |
| --- | --- |
| Mobile | Phones, foldables, tablets. Window modes: **full-screen** (default), **split-screen** (2+ apps sharing the screen), **bubbles** (floating multitask windows) |
| Desktop | Free-form windows adapting across breakpoints; split-screen/floating/free-form multitasking. A tablet becomes a desktop experience once a keyboard+mouse connect; an Android phone can become desktop-like via an external monitor |
| Spatial (XR) | Multiple free-form windows in a "virtually limitless" screen. Android XR's immersive **"full space"** mode positions components freely in 3D. A navigation rail can become an **Orbiter**, floating beside the main pane (ties to the glossary's XR "Orbiter" term already in the reference, now with a concrete use case) |
- **Adaptive strategies** (named mechanism, not in the reference): as a pane/window resizes or someone
navigates, panes may change size, enter/exit, or reorganize via three strategies: **show and hide**,
**levitate**, and **reflow**. Panes are "the primary segments of a layout" and can change based on context.
- **Pane display styles**, with sharper definitions/examples than the reference's terse list:
- **Co-planar**: panes displayed side by side.
- **Floating**: a pane displayed above other panes/content, "like a dialog."
- **Docked**: a pane displayed above other content where one edge extends beyond a screen edge, "like a
bottom sheet."
- Compose's **Navigation 3** library lets multiple destinations show on screen simultaneously and adapts
layouts across window sizes/screens automatically.
- **Adapting components** — three component-level adaptation strategies:
| Strategy | Example given |
| --- | --- |
| Resizing | A button may scale with its parent container, or "hug" its content and stay left/right-aligned |
| Showing & hiding | A list item can reveal a description/extra info as its container scales up |
| Presentation changes | A FAB can become an extended FAB; a navigation rail can auto-expand as the window grows |
### New — Breakpoints, per window-size class (foundations/layout/breakpoints/{compact,medium,expanded,large-extra-large})
The reference only has the aggregate breakpoint table; these per-class pages add concrete margins, pane
widths, and navigation placement the reference doesn't state:
| Breakpoint | Width | Margins/spacer | Panes | Navigation | Notes |
| --- | --- | --- | --- | --- | --- |
| Compact | <600dp | **16dp** margins | 1 (always) | Navigation bar or modal expanded navigation rail, placed close to the screen edge for reach | Must transition dynamically on: foldable unfold, portrait→landscape rotation, exiting split-screen, multi-window resize larger, free-form resize |
| Medium | 600839dp | **24dp** margins and spacer | 1 recommended; 2 only for low-density content (e.g. settings) — each pane defaults to 50% width, don't set custom widths; a drag handle can expand either pane to 100% | Single-pane: navigation rail (can hide on secondary destinations if a back button still reaches the primary destination); two-pane: navigation bar (to keep panes at full width) | Reachability: top 25% of screen is hard to reach on horizontal tablets/unfolded foldables — limit interactions there; page defines 3 ergonomic regions (inconvenient reach / comfortable / challenging near bottom edge). Transition triggers: foldable fold, landscape→portrait rotation, full-screen→split-screen, multi-window start, free-form resize |
| Expanded | 8401199dp | **24dp** margins and spacer | 1 (only for dense content like video) or 2 (recommended); fixed-and-flexible layout's fixed pane defaults to **360dp**; a split-pane layout's two flexible panes visually center the spacer, with nav rail + first pane totaling 50% | Navigation rail (collapsed or expanded); hideable on secondary destinations with a back button; use tabs/other in-pane components for secondary/sort/filter nav | Transition triggers: foldable fold, landscape→portrait rotation, full-screen→split-screen, multi-window start, free-form resize |
| Large (12001599dp) & Extra-large (1600dp+) | — | **24dp** margins and spacer | 2 recommended (1 only for dense/media content); fixed-and-flexible fixed pane defaults to **412dp**; extra-large only can add a **standard side sheet as a 3rd pane** (side sheet max width 400dp even though 412dp is the general fixed-pane recommendation) — never exceed 3 panes | Navigation rail, collapsed or expanded depending on content volume; expanded rail suits extra-large best, collapse when space is needed or deep in the hierarchy | Large/extra-large are "most useful for...laptop and desktop devices"; some products may skip them entirely. Watch line length/typography at this size. Transition triggers: full-screen→split-screen, multi-window start, free-form resize |
### New — Canonical layout examples, in detail (foundations/layout/canonical-examples/{overview,feed,list-detail,supporting-pane})
The reference only had a stub (three names + "not fetched, outside foundations/*"); these are the real
`foundations/layout/canonical-examples/*` pages (distinct from the `m3/pages/canonical-examples/*` URLs the
reference declined to follow), each with compact/medium/expanded configurations:
**Overview**
- Three canonical layouts exist: **feed, list-detail, supporting pane** — each with compact/medium/expanded
configurations, meant as a starting point rather than a fixed template.
- Supporting-pane split defined precisely: primary/focus area "typically about two-thirds" of the window;
secondary/supporting area takes the remaining third.
- **Advanced custom layouts**: build on a canonical example or layer scaffold elements directly. The
**"layering"** technique uses the **levitate** adaptive strategy — a pane floats above other content for a
focused, task-oriented moment (examples given: reviewing a shopping basket, responding to comments,
creating a calendar event).
- Availability: Figma M3 Design Kit, Jetpack Compose canonical-layouts sample, Android Views (MDC-Android)
canonical-layouts sample.
**Feed**
- Use case: grid of cards/lists for quick browsing/discovery of large content volumes (news, photos, social
media); grids adapt from single- to multi-column at any size.
- Dividing space: flexible enough to mix proportions/sizes (e.g. small + large cards together) to signal
relationships between items; items reflow on rotation/unfold/multi-window, and item **order is determined
by position**.
- Across breakpoints: **Compact** — stack vertically, one card per row, full pane width. **Medium** — split
into multiple equal-width columns (example shown: 4 columns). **Expanded/large/extra-large** — more
columns than medium, and column width should usually increase as the breakpoint grows.
**List-detail**
- Use case: parent-child content pairings — text message + conversation, file browser + open folder, artist
+ album, settings + category detail, inbox + selected email.
- Visible-panes table (breakpoint dp ranges as stated on this page):
| Breakpoint (dp) | Visible panes |
| --- | --- |
| Compact (0599) | 1 pane |
| Medium (600839) | 1 (recommended) or 2 |
| Expanded (840+) | 2 |
| Large (12001599) | 2 |
| Extra-large (1600+) | 2 |
- **Compact**: single pane only, either list or detail view at a time.
- **Medium**: single pane for information-dense content/deep focus, **or** two-pane to browse and switch
quickly — use a bottom navigation bar or modal navigation rail in two-pane mode to maximize horizontal
space.
- **Expanded/large/extra-large**: two-pane.
- **Behavior**: a **Back button appears in the detail view only for single-pane layouts**; a **selected
state appears in the list view only for two-pane layouts**; use explicit/implicit grouping to direct
visual focus in two-pane layouts. Layouts transition dynamically as available space changes (e.g. rotating
from expanded to medium collapses two panes back to one).
**Supporting pane**
- Use case: secondary content that is "only meaningful in relation to" the primary content (contrast with
list-detail's parent-child relationship — use list-detail instead if that's the actual relationship).
Examples: productivity, document editing + commenting, content/media browsing.
- Placement/width table:
| Supporting-pane placement | Pane width | Breakpoint |
| --- | --- | --- |
| Below the focus pane | Flexible | Compact or Medium |
| Leading or trailing side of the focus pane | Fixed, **360dp** | Expanded |
- **Compact**: supporting pane appears below the focus pane; a bottom sheet is a good way to keep focus on
the primary pane while still giving access to supporting info.
- **Medium**: supporting pane appears below the focus pane (same as compact).
- **Expanded**: supporting pane moves to the leading or trailing side of the focus pane.
### Source URLs
https://m3.material.io/foundations/layout/layout-overview/overview
https://m3.material.io/foundations/layout/layout-overview/adaptive-design
https://m3.material.io/foundations/layout/scaffold/overview
https://m3.material.io/foundations/layout/grids-spacing/overview
https://m3.material.io/foundations/layout/breakpoints/overview
https://m3.material.io/foundations/layout/breakpoints/compact
https://m3.material.io/foundations/layout/breakpoints/medium
https://m3.material.io/foundations/layout/breakpoints/expanded
https://m3.material.io/foundations/layout/breakpoints/large-extra-large
https://m3.material.io/foundations/layout/canonical-examples/overview
https://m3.material.io/foundations/layout/canonical-examples/feed
https://m3.material.io/foundations/layout/canonical-examples/list-detail
https://m3.material.io/foundations/layout/canonical-examples/supporting-pane
https://m3.material.io/foundations/usability/overview
https://m3.material.io/foundations/usability/applying-m3-expressive
+748
View File
@@ -0,0 +1,748 @@
# Material Design 3 — Foundations reference
Extracted verbatim (with light reformatting) from m3.material.io/foundations for auditing an M3 Expressive
component library. Facts not present on the fetched pages are marked "(not on the site; from ...)".
Where the site's own text is ambiguous or inconsistent, both readings are quoted rather than resolved.
---
## Accessibility (foundations/overview, /overview/principles, /designing*, /writing, /building-for-all*)
### Principles
- "Accessibility by default is a core design value for Material." Accessibility requirements and goals are
documented across component pages and guidelines.
- Three principles for accessible design:
- **Honor individuals** — universal defaults rarely meet everyone's needs; build in customizable features
since a person's needs change over time.
- **Learn before, not after** — invest in research across a wide range of abilities before defining
solutions, rather than reacting to problems after launch.
- **Requirements as a starting point** — treat WCAG minimums as a floor for creative solutions, not a
ceiling (dark mode, text-to-speech, speech-to-text all originated as accommodations).
- "Usability focuses on making products intuitive and easy to understand for everyone" while "Accessibility
focuses on making products accessible for people with disabilities. Accessible experiences are
perceivable, operable, understandable and robust, and support people who use assistive technology."
(from foundations/usability)
### Rules (do / don't)
- **Do** use native platform elements/semantic HTML so components automatically carry the right
accessibility markup (e.g., a native `dialog`).
- **Don't** use non-standard elements to fake a standard pattern (e.g., a custom div styled as a dialog) —
it requires extra testing to work with assistive tech.
- Disabled states **do not** need to meet contrast requirements.
- **Landmarks (web only)** — classify page regions with ARIA landmark roles so visual structure is also
conveyed in code. The eight landmark roles: **navigation, search, main, banner, complementary,
contentinfo, region, form**.
- Only one **main**, one **banner**, one **contentinfo** per page.
- Label any landmark role that repeats (e.g., multiple `navigation` regions) with a unique, specific
label; **all regions** should be labeled.
- **Don't repeat the landmark role inside its own label** (e.g., a nav labeled "Navigation").
- **Headings (web only)**:
- Identify headings by content hierarchy, not visual styling.
- "Headings should not skip a level, for example, don't go from H2 to H4 without using an H3."
- Map content to H1H6 in sequential order.
- "A single H1 for the page title is recommended."
- Heading level and visual prominence don't have to match.
- **Focus order / keyboard**:
- Default tab order should follow the DOM and "generally flows from left to right; top to bottom." Use
this default unless a custom pattern requires otherwise.
- Define **initial focus** for a screen and for multi-element components (dialogs, complex cards).
- When a dialog opens, focus moves into it (to a specific interactive element); on close/cancel, focus
returns to the element that triggered it.
- Tab / Shift+Tab move between interactive elements (Shift+Tab reverses); arrow keys navigate within a
component (menu items, grid cells); Enter activates a link/button or submits a focused form.
- Grouped sub-elements can be one tab stop, navigated internally with arrow keys.
- **Keyboard shortcuts**: use two-or-more-key combinations by default. Document all shortcuts (help page
or tutorial). If a shortcut uses a single key, provide at least one of, in preference order:
1. (most preferred) remap to include a non-printable key,
2. (preferred) only activate when the relevant component is focused,
3. (temporary only) allow turning the shortcut off.
- **Labeling elements**:
- Needs a label: interactive icons/buttons with no/insufficient visible text, interactive images,
meaningful visual cues (progress bars, error states), meaningful icons/images, generic links
("Learn more"), buttons with ambiguous/duplicate text ("Save").
- Doesn't need a label: non-interactive UI text (read automatically), buttons with sufficient text
("Download image").
- **Don't include the element's role (button, menu, etc.) in the label text** — the role is added
automatically when the proper role is assigned.
- Decorative icons/images that add nothing for a screen-reader user should be marked decorative/hidden
(e.g., `alt=""`).
### Values
| Item | Value | Source |
| --- | --- | --- |
| Large text/graphics contrast (≥14pt bold / ≥18pt regular) | ≥ 3:1 against background | designing/color-contrast, writing |
| Small text contrast | ≥ 4.5:1 against background | designing/color-contrast, writing |
| Clustered non-text elements (e.g. a group of buttons) vs. background | ≥ 3:1 recommended | designing/color-contrast |
| Standalone elements (e.g. a single FAB) vs. background | not required to meet 3:1 (prominence already differentiates them) | designing/color-contrast |
| Disabled-state contrast | not required to meet contrast requirements | designing/color-contrast, applying-states |
| Touch target minimum | **48 × 48dp** (≈9mm physical; recommended range 710mm) | designing/structure |
| iOS touch target note | iOS recommends 44 × 44dp | designing/structure |
| Pointer (mouse/stylus) target minimum | **44 × 44dp** | designing/structure |
| Target spacing | ≥ 8dp between adjacent targets | designing/structure |
| Web default target size | at least 48 × 48 **CSS pixels** | grids-spacing/density |
| Alt text length | "up to 125 characters" (foundations/writing) **vs.** "recommended length ... is 140 characters" (content-design/alt-text) — site is internally inconsistent; both figures are quoted verbatim | writing, content-design/alt-text |
### Alt text rules (content-design/alt-text, foundations/writing)
- Alt text is read in place of an image by screen readers and shown if an image fails to load.
- **Do** write alt text that conveys context/meaning; **don't** leave the auto-generated filename as alt
text.
- Mark an image `alt=""` (decorative) if removing it loses no information.
- Focus on the meaning/purpose of the image, not exhaustive visual detail.
- **Don't start alt text with "image of"** — screen readers already announce "image".
- Essential vs. non-essential image content: essential info must meet the 3:1 (large) / 4.5:1 (small) contrast
above; decorative/non-essential elements are exempt.
### M3 vs M3 Expressive differences
- No explicit M3-vs-Expressive distinction is drawn on the Accessibility pages themselves; the WCAG-based
contrast and target-size rules are presented as constant across both.
### Accessibility requirements (recap)
- Two-indicator principle for states is asserted only as a bullet: **"States have two visual indicators to
ensure accessibility"** (foundations/interaction/states) — the page does not elaborate further on this
Foundations tier (see Interaction States section below for the gap this leaves).
### Source URLs
https://m3.material.io/foundations/overview
https://m3.material.io/foundations/overview/principles
https://m3.material.io/foundations/designing (tabs: Overview, Color contrast, Structure, Flow, Elements)
https://m3.material.io/foundations/designing/color-contrast
https://m3.material.io/foundations/designing/structure
https://m3.material.io/foundations/designing/flow
https://m3.material.io/foundations/designing/elements
https://m3.material.io/foundations/writing
https://m3.material.io/foundations/building-for-all
https://m3.material.io/foundations/building-for-all/user-needs (identical rendered content to /building-for-all)
https://m3.material.io/foundations/building-for-all/co-design
---
## Content design (foundations/content-design/*)
### Principles
- "UI text should be clear to anyone." Follow **Associated Press (AP) Style** unless Material notes
otherwise.
### Rules (do / don't) — Style guide (content-design/style-guide)
- **Explain consequences** in neutral, direct language; avoid alarming/condescending warnings. Do tell
users what will happen and how to undo it; don't misrepresent consequences to influence a decision.
- **Use scannable words and formats** — specific titles/headings people can skim.
- **Use sentence case.** "Unless otherwise specified, use sentence-style capitalization... All text,
including titles, headings, labels, menu items, navigation components, app bars, and buttons should use
sentence-style capitalization." Products/branded terms may still be capitalized. **Don't use title case.**
- **Use abbreviations sparingly.** Spell out words where possible; avoid Latin abbreviations ("e.g.",
"etc.") — use "for example," "and more" instead.
### Rules — Alt text (see also Accessibility above)
- Recommended alt text length: 140 characters (content-design/alt-text); don't start with "image of".
### Rules — Global writing (content-design/global-writing, /global-writing/word-choice)
- Global writing = writing inclusively for many cultures and optimized for localization/translation.
- **Use global examples; explain local references** — call out (in a message description for translators)
locations, names/nicknames, currencies, temperatures, date formats, and providers (internet/cable) that
don't translate globally.
- Use short, simple sentences; break into bullets/sections with headings.
- Avoid abbreviations; clarify ambiguous pronouns and "this"/"that" references.
- Avoid idiomatic, colloquial, or overly polite expressions (translation risk).
- Reduce technical jargon; clarify ambiguities.
### Rules — Notifications (content-design/notifications)
- Notifications should: be about the user (not the product); be precise, timely, actionable, contextual,
relevant; give easy opt-out controls; never be unsolicited ads.
- Put the most important info first (people skim in an F-shape).
- CTAs should be concise, specific, actionable.
- Make notifications relevant/personal — don't broadcast generically.
- **Avoid dynamic text**, especially in headlines (breaks character limits when translated, doesn't
expand if truncated). If used, pair with no more than one additional word, and prepare a fallback
notification that fits the character budget.
- **Don't use relative day words** ("today"/"tomorrow"/"tonight") — about 20% of users don't see a
notification the day it's sent; use the specific day of the week instead (exception: auto-dismissing
notifications tied to a timestamp).
- Use emoji sparingly; never to accentuate bad news (negative emoji tested poorly); don't replace words
with emoji.
- Don't repeat the app name/logo (already shown by the OS).
- Don't interrupt onboarding with unrelated notifications/emails.
- Give users in-context, clear opt-in/opt-out controls.
### Values
| Element | Character limit |
| --- | --- |
| Notification title | < 29 characters |
| Notification collapsed body | < 40 characters |
| Notification expanded body | < 80 characters (start with the collapsed body and extend it) |
| Notification buttons | 12 buttons, 12 words each |
| SMS, Latin-script languages | < 160 characters |
| SMS, non-Latin-script languages | < 134 characters |
| Alt text | 140 characters recommended (content-design/alt-text) / "up to 125 characters" (foundations/writing) |
### M3 vs M3 Expressive differences
- Not called out on these pages; content-design guidance is presented as version-agnostic.
### Accessibility requirements
- Alt text and captions are the accessibility mechanism this section owns (see Accessibility section for
full alt-text rules); captions benefit both sighted and screen-reader users.
### Source URLs
https://m3.material.io/foundations/content-design/overview
https://m3.material.io/foundations/content-design/alt-text
https://m3.material.io/foundations/content-design/global-writing
https://m3.material.io/foundations/content-design/global-writing/word-choice
https://m3.material.io/foundations/content-design/notifications
https://m3.material.io/foundations/content-design/style-guide
---
## Customization (foundations/customization)
### Principles
- "M3 opens up new possibilities for both brand colors and individual color preferences to converge in
one-of-a-kind experiences."
- Dynamic color lets an app retain brand-defining colors while also honoring a user's device/personal color
preference; works for custom (non-Material) components too.
### Rules
- Build a **custom color scheme** with the M3 color system so the app can respect device/app-level dynamic
color settings; always keep a fallback (non-dynamic) color scheme for users who don't enable dynamic
color.
- Apply dynamic color selectively if desired (e.g., only on a profile screen) rather than app-wide.
### Values
- **Five essential color groups / roles**: Primary, Secondary, Tertiary, Neutral, Neutral Variant. An input
(source) color generates a tonal palette that fills roles such as primary / on-primary / primary
container.
- The Material Theme Builder (Figma plugin) generates **color and type tokens**, exportable to multiple
code formats, including Design System Package (DSP) format.
### M3 vs M3 Expressive differences
- Not explicitly distinguished on this page; dynamic color and the 5-role model are presented as the
current (M3) system without an Expressive-specific callout.
### Accessibility requirements
- "The color system automatically handles critical adjustments that provide accessible color contrast,
legibility, interaction states, and component structure."
### Source URLs
https://m3.material.io/foundations/customization
---
## Design tokens (foundations/design-tokens)
Note: the task brief anticipated separate `/overview`, `/how-to-read-tokens`, `/how-to-use-tokens`
sub-pages; the live site has a **single page** at `foundations/design-tokens` covering all of this content
(confirmed via the site's own top-nav, which links only to `foundations/design-tokens`).
### Principles
- "Design tokens are small, reusable design decisions that make up a design system's visual style. Tokens
replace static values with self-explanatory names."
- A token = a code-like name (e.g. `md.ref.palette.secondary90`) + an associated value (e.g. `#E8DEF8`);
the value can be a color, typeface, measurement, or another token.
- Tokens let design and code stay in sync: "if a designer's mock-ups and an engineer's implementation both
reference the same token... they can be confident that the same [value] is being used in both places,"
even after the underlying value changes.
- Tokens should be used instead of hardcoded values whenever a design choice recurs across components.
### Rules
- **Do** use design tokens instead of hardcoded values.
- Name each token for how/where it's used (e.g. `md.comp.fab.primary.container.color` sets an FAB's
container color).
- **System tokens should point to reference tokens**, not static values, whenever possible.
- **Component tokens should point to a system or reference token**, not hardcoded values, whenever
possible.
- Tokens are recommended when: building a design system from scratch or updating one broadly, applying a
system across a suite of products, wanting to maintain/update styles easily, or wanting dynamic color.
Tokens are **less** helpful for an existing app with values unlikely to change and no design system.
### Values — naming scheme
| Part | Meaning | Example |
| --- | --- | --- |
| System prefix | Always starts with the system name | `md` (Material Design) |
| Class | `ref` = reference, `sys` = system, `comp` = component | `md.ref…`, `md.sys…`, `md.comp…` |
| Role/descriptor | Descriptive words for the token's purpose | `…palette.secondary90`, `…color.secondary-container`, `…fab.primary.container.color` |
### Values — the three token classes
| Class | Prefix | Definition | Example |
| --- | --- | --- | --- |
| Reference tokens | `ref` | "All available tokens with associated values." Point to static values (or other reference tokens); don't change based on context. | `md.ref.palette.secondary90``#E8DEF8`; `md.ref.typeface.plain-medium` → Roboto Medium |
| System tokens | `sys` | "Decisions and roles that give the design system its character" — theming happens here; can point to different ref tokens by context (e.g. light/dark theme). | `md.sys.color.secondary-container``md.ref.palette.secondary90`; `md.sys.typescale.label-medium.font` |
| Component tokens | `comp` | "(in development)" — the design properties assigned to a specific component's elements (container, label, icon, state, size, shape, color, elevation). | Extended FAB container color / label text tokens |
- **Contexts**: conditions under which a token points to a non-default value — "device form factors, dark
theme, dense layouts, and right-to-left writing systems" are the examples given. A context acts like a
tag overriding the default token value.
- Glossary cross-references (foundations/glossary): **Design tokens: Role** = "short nickname describing
the purpose of a design token... Also known as slots" (e.g. "On surface", "Body 1"); **Design tokens:
Value** = "the information defining a design attribute, either stored in a token or hard-coded."
### M3 vs M3 Expressive differences
- Component tokens are explicitly flagged as **"(in development)"** — i.e., not every component's tokens
are finalized/complete as of this fetch.
### Accessibility requirements
- Not directly addressed on this page; contexts (e.g. dark theme, dense layout) are the token mechanism
used elsewhere to satisfy accessibility/adaptation requirements.
### Source URLs
https://m3.material.io/foundations/design-tokens
---
## Interaction: gestures, inputs, selection, states
### Gestures (foundations/interaction/gestures)
**Principles**
- "Gestures help people navigate, take action, or transform content." UI elements should respond to
gestures in real time. Tap, scroll, and swipe are the most common.
**Types (do/don't-style catalogue, no numeric specs given)**
- Tap (navigate/interact), Double tap (zoom in/out), Long press (extra functionality / selection), Scroll
and pan, Swipe (switch peer views like tabs, complete actions, or reveal actions on a list item), Drag,
Pick up and move (long-press + drag to reorder), Pinch (scale), Compound gestures (e.g. pan + pinch on a
map).
- **Predictive back** (Android): swipe left/right to preview going back before committing. Compatible
components: **bottom sheet, navigation bar, navigation rail, search bar, side sheet**.
**Source**: https://m3.material.io/foundations/interaction/gestures
### Inputs (foundations/interaction/inputs)
**Principles**
- "Design for touch, keyboard, and mouse interactions." Embrace multiple input methods within one app.
**Rules / values**
- External input devices: mouse (left/right click, wheel, extra buttons), trackpad (click, gestures,
haptics), physical keyboard (replaces virtual keyboard; media keys; modifier keys), stylus.
- **Hover**: rest a cursor on an interactive element to trigger its hover state (see States below) and, if
applicable, a tooltip.
- **Cursor shapes**: Pointer (default), Hand (links/linked images), Resize arrows (resizable element
edges), I-beam (editable/selectable text). Text-selection click semantics: single click places cursor /
deselects & repositions; double click selects a word; triple click selects a paragraph.
- **Physical keyboard**: show/hide the on-screen (virtual) keyboard based on whether a physical keyboard is
attached (do hide it when a physical keyboard connects; do show it again when disconnected). Enter
typically sends/submits; Spacebar (or media keys) typically play/pause media.
**Source**: https://m3.material.io/foundations/interaction/inputs
### Selection (foundations/interaction/selection)
**Principles**
- Selection can be shown via a check icon, a checkbox, or a surface-color change (or a combination).
- An entire component, or only certain parts of it, can be selected. Selection can be performed by tap,
cursor, keyboard, or voice.
**Values**
- Components that **inherit selection styling**: cards, checkboxes, chips, data tables, icon buttons, list
items, menu items, pickers, radio buttons, segmented buttons, sliders, switch.
- Components that use an **active indicator** instead (only one selected at a time): navigation bar,
navigation drawer, navigation rail, tabs.
**Rules**
- Touch: long-press or two-finger touch (or a shortcut, e.g. tapping an avatar) enters selection mode; tap
additional items to add them; tap each selected item (or a toolbar action) to exit selection mode.
- Long-press + drag can batch-select — **don't** reuse this gesture combo for selection if it's already
used to pick up/move items (e.g. reordering cards).
- Desktop/click: when selection is the primary activity, checkboxes stay visible; when secondary, show a
checkbox on hover for a single item, and for all items once one is selected.
**Source**: https://m3.material.io/foundations/interaction/selection
### States (foundations/interaction/states, /states/state-layers, /states/applying-states)
**Principles**
- "States are visual indicators used to communicate the status of a component or interactive element."
- **"States have two visual indicators to ensure accessibility"** — stated as a bullet on the States
overview page; the site does **not** elaborate on this rule anywhere in Foundations (no page defines
what the two indicators must be, e.g. color + shape/outline). This is a documentation gap to flag when
auditing — Foundations only asserts the principle, not the mechanism.
- States can combine (e.g. selected + hover); apply states consistently across components.
- Six states: **Enabled, Disabled, Hover, Focused, Pressed, Dragged.**
**State layer mechanics (states/state-layers)**
- "A state layer is a semi-transparent covering on an element that indicates its state," providing "a
systematic approach to visualizing states by using opacity." Only one state layer can be applied at a
given time; it can cover the whole element or a circular sub-region.
- The state layer's color equals the **content's "on" color** (e.g. if the container uses **secondary
container** and content uses **on secondary container**, the state layer uses **on secondary
container**).
- **"The size of state layers is 40dp while the interactive target size is 48dp."**
**Values — state layer / disabled opacities** (from the states/state-layers interactive module, exact
figures)
| State | Opacity |
| --- | --- |
| Hover | 0.08 (+8%) |
| Focus | 0.10 (+10%) |
| Pressed | 0.10 (+10%) |
| Dragged | 0.16 (+16%) |
| Disabled | 0.38 |
**Rules — per-state inheritance (applying-states)**
| State | Inherited by | NOT inherited by |
| --- | --- | --- |
| Disabled | Buttons, Cards, Checkboxes, Chips, List items, Radio buttons, Switches, Text fields | App bars, Badges, Dialogs, FABs, Menus, Navigation bar/drawer/rail, Sheets, Tabs, Tooltips |
| Hover | Buttons, Cards, Checkbox, Chips, Date/time pickers, List items, Slider, Switch, Text fields | App bars, Badges, Dialogs, Menus, Navigation bar/drawer/rail, Sheets, Tabs |
| Focused | Buttons, Cards, Checkbox, Chips, Date/time pickers, List items, Selection controls, Text fields | App bars, Badges, Banner, Card, Dialogs, Navigation bar/drawer/rail, Sheets |
| Pressed | Buttons, Cards, Checkbox, Chips, List items, Text fields | App bars, Badges, Bottom navigation, Dialogs, Menus, Sheets, Tabs |
| Dragged | Cards, Chips, List items, Sliders | App bars, Badges, Buttons, Dialogs, Menus, Navigation bar/drawer/rail |
- **Disabled**: communicated via color change + reduced elevation; not focusable, draggable, or pressable;
no hover/other state layer; exempt from contrast requirements; "if the action... is unavailable, the FAB
shouldn't appear" (don't just disable it) — a Don't example specifically about FABs.
- **Hover**: lower-emphasis overlay; appears/disappears with a "low-emphasis animated fade"; only one hover
state active in a layout at a time; can combine with focused/activated/selected/pressed.
- **Focused / keyboard focus indicator**: "When an element is tabbed to, it appears in its focused state
with a ring-like keyboard focus indicator." **No thickness, offset, or color token is specified anywhere
in Foundations** — this page only calls it "ring-like." (Exact focus-ring specs, if they exist, live in
per-component spec pages outside the Foundations section — flag as out-of-scope-for-this-file / a gap
for the audit.) Only one focus state active at a time; initiated by Tab (or equivalent).
- **Pressed**: higher-emphasis than hover; a **ripple overlay** signifies press (can also/alternatively use
an elevation change); only one pressed state active at a time; initiated by tap/click/keyboard/voice
activation.
- **Dragged**: lower-emphasis overlay than pressed, "to avoid distracting users from their task"; can also
use elevation; only one dragged state active at a time; initiated by touch-and-hold + move.
### M3 vs M3 Expressive differences
- Not explicitly called out for interaction/states; the state-layer opacity table and inheritance lists are
presented as the current, single spec (this appears to be the M3 Expressive-era rewrite of the classic M2
state-layer table, but the site does not itself label old vs. new values here).
### Accessibility requirements
- The "two visual indicators" principle (see above) is the accessibility hook for state changes but is left
unelaborated in Foundations.
- Disabled components are fully exempted from contrast requirements.
- Keyboard operability requirements live in Accessibility → Flow (focus order, keyboard shortcuts) above,
and cross-reference into States for the focus indicator.
### Source URLs
https://m3.material.io/foundations/interaction/gestures
https://m3.material.io/foundations/interaction/inputs
https://m3.material.io/foundations/interaction/selection
https://m3.material.io/foundations/interaction/states/overview
https://m3.material.io/foundations/interaction/states/state-layers
https://m3.material.io/foundations/interaction/states/applying-states
---
## Layout (foundations/layout/*)
Note on the task brief's guessed slugs: the live IA does **not** use `/understanding-layout/*` or
`/applying-layout/*`. Real structure discovered via the site's own nav/tab links:
- `layout/layout-overview` (tabs: Overview, **Overview: Parts of layout**`/layout-overview/parts-of-layout`)
- `layout/scaffold` (tabs: Overview, Bars, Rails, Panes)
- `layout/grids-spacing` (tabs: Overview, Grids, Spacing, Density)
- `layout/breakpoints`
- `layout/bidirectionality-rtl`
- `layout/canonical-examples`
### Principles
- "Layout is the visual and strategic arrangement of elements on a screen," used to organize elements,
signal hierarchy, and draw attention to key actions.
- Adapt layouts to the five breakpoints; build from an established canonical layout example; design for
bidirectionality (LTR + RTL); apply consistent arrangement/sizing/spacing.
- Material's components are built with **"leading"/"trailing"** (not left/right) naming so they support RTL
by construction — "extra configuration may be needed to achieve specific RTL situations," though.
- **May-2026 renames** (explicitly called out on the site as new terminology): **"Window size classes
renamed to breakpoints"**; **"Responsive layout renamed to adaptive design."**
### Values — Layout terms glossary (layout-overview)
- **Adaptive design**: techniques letting an interface dynamically respond to user preference, device
type, state, and breakpoints.
- **Bars**: frame the page (app bar, bottom navigation bar).
- **Bidirectionality**: a writing system displaying content RTL.
- **Breakpoints**: "opinionated window sizes where a layout changes to match available space, device
conventions, and ergonomics (previously window size classes)."
- **Column**: one or more vertical blocks of content within a pane.
- **Drag handle**: resizes panes.
- **Fold**: the flexible/hinge area separating two displays on a foldable.
- **Gap**: space between components/elements within a container.
- **Margin**: space between the screen edge and elements inside it.
- **Multi-window mode**: multiple apps sharing one screen simultaneously.
- **Pane**: "a layout container that houses other components and elements within a single app... fixed,
flexible, floating, or semi-permanent."
- **Rails**: perimeter space around panes holding navigation rails, toolbars, pane controls.
- **RTL language**: e.g. Arabic, Hebrew, Farsi — used by "over 2 billion people."
- **Rulers**: opinionated global alignment lines.
- **Safety region**: zones reserved for system UI (status bar, gesture bar) — "shouldn't contain primary
content."
- **Scaffold**: "a fundamental UI design structure that provides a standard platform for assembling key
screen components."
- **Spacer**: space between two panes on a foldable.
- **7 parts of layout** (numbered figure caption): Column, Fold, Margin, Bar, Drag handle, Pane, Rail.
### Values — Breakpoints (layout/breakpoints)
| Breakpoint | Width (dp) | Common devices |
| --- | --- | --- |
| Compact | Under 600dp | Phone in portrait |
| Medium | 600839dp | Tablet in portrait; foldable in portrait (unfolded) |
| Expanded | 8401199dp | Phone/tablet in landscape; foldable in landscape (unfolded); desktop |
| Large | 12001599dp | Desktop |
| Extra-large | 1600dp+ | Desktop; ultra-wide monitors |
- Height breakpoints (compact/medium/expanded) also exist on Android but are rarely needed since most
layouts scroll vertically.
**Breakpoint → recommended panes / navigation / communication / action** (layout/breakpoints)
| Breakpoint | Panes | Navigation | Communication | Action |
| --- | --- | --- | --- | --- |
| Compact | 1 | Navigation bar, modal expanded navigation rail | Simple dialog, full-screen dialog | Bottom sheet |
| Medium | 1 (recommended) or 2 | Navigation bar, modal expanded navigation rail | Simple dialog | Menu |
| Expanded | 1 or 2 (recommended) | Modal or standard expanded navigation rail | Simple dialog | Menu |
| Large | 1 or 2 (recommended) | Modal or standard expanded navigation rail | Simple dialog | Menu |
| Extra-large | 1 to 3 (recommended) | Modal or standard expanded navigation rail | Simple dialog | Menu |
**Common swappable components by breakpoint** (layout/breakpoints)
| Component type | Compact | Medium | Expanded |
| --- | --- | --- | --- |
| Navigation | Navigation bar | Collapsed navigation rail | Collapsed navigation rail |
| Navigation (secondary row) | Modal expanded navigation rail | Modal expanded navigation rail | Standard expanded navigation rail |
| Communication | Basic or full-screen dialog | Basic dialog | Basic dialog |
| Supplemental selection | Bottom sheet | Menu | Menu |
- Pane-count guidance restated: **compact/medium → 1 pane; expanded/large → 2 panes recommended;
extra-large → consider 3 panes.**
- Five questions to ask moving up a breakpoint: what should be **revealed, divided (paned), resized,
repositioned, swapped**. Across breakpoints, keep text to **4060 characters per line**.
- Component-swap example: compact navigation bar → medium/expanded navigation rail; collapsed rail
(medium/expanded) → expanded rail (large/extra-large). **Don't** swap functionally non-equivalent
components (e.g. a button for a menu).
### Values — Scaffold (layout/scaffold, /bars, /rails, /panes)
- Scaffold = **Bars + Rails + Panes**, arranged around a **safety region** that must stay clear of primary
content.
- **Bars**: frame the page; contain app bar (top) or navigation bar (bottom). App bars: title + 12
essential actions (search, back). "**Navigation bars let people switch between 35 primary UI views**"
at compact/medium breakpoints.
- **Rails**: the perimeter layer after bars; hold navigation rails, toolbars, chat inputs, FABs, other
primary controls; on desktop, a leading/trailing rail; on mobile, a toolbar can float in the rail region.
- **Panes**: all content must live in a pane; a layout has **13 panes**, adapting to breakpoint and
language direction (RTL flips navigation to the right).
- **Single-pane** layouts: any breakpoint, recommended at compact/medium.
- **Two-pane** layouts: common at expanded/large/extra-large; fixed-and-flexible panes can appear in
either order.
- **Three-pane** layouts: "Fixed panes at this breakpoint are recommended to be **412dp**, but side
sheets have a default maximum width of **400dp**."
- **Snap widths** when resizing a two-pane layout at expanded/large/extra-large: **360dp**, **412dp**, or
a centered split-pane with spacer.
- **Persistent resizing** (remembers width across sessions/breakpoint changes) is the default
recommendation for most resizable layouts; **temporary resizing** (reverts to default on
close/reopen) is for supporting-pane layouts where resizing is uncommon.
- Multi-pane display styles: **co-planar** (side by side — used for persistent utilities so they stay
accessible), **floating**, **docked**.
- **Accessibility**: coplanar-pane focus order must match visual arrangement; modal floating panes trap
focus (move focus in on open, return it to the trigger on close) and close on outside interaction;
non-modal floating panes let focus move freely in/out and must sit in logical reading order; docked
panes follow the same focus rules as floating panes, plus the coplanar focus-order requirement.
### Values — Grids & spacing (layout/grids-spacing, /grids, /spacing, /density)
- Grids: column count/width/spacing scale with breakpoint (compact fewer columns → expanded/foldable more
columns); **no explicit numeric column-count or gutter table is given on the current site** (this is a
gap versus the classic M2 12-column grid spec — flag if the component library assumes fixed column
counts).
- **Rulers**: Bar & safety rulers (reserve system-UI space), Title ruler (aligns app-bar title), Content
rulers (first ruler = major blocks like hero/headline; secondary rulers = supplementary text/actions).
Margin rulers can be widened/narrowed for expressive effect (e.g., full-bleed photo grid vs. a
wide-margin search bar).
- Spacing is used to **group** (explicit grouping = outlines/dividers/shadows; implicit grouping =
proximity/whitespace only), to **direct attention** (rhythm, similarity, proximity, continuity), and as
**expression** (generous spacing + bright surfaces on the most important content; negative space for
emphasis). No fixed numeric spacing scale (e.g. 4/8/16dp increments) is stated on this page beyond the
8dp target-spacing rule already listed under Accessibility.
- **Density** (component scaling):
- Density scale is numbered starting at **0** (default) and moves negative (**-1, -2, -3**) as space
decreases.
- "Higher density is typically applied by decreasing the top and bottom padding or overall height by
**4dp**" per step.
- Text size should not change as a container scales with density.
- Example figures from the page: 20dp gap between a text field's label and input; a "don't" example shows
a dropdown item shrunk to 38dp of selectable height (too dense — reduces usability).
- **Density must be opt-in, never default**: "People should be able to **opt in** to dense layouts and
components." Settings interactions themselves must keep default target sizes (**48×48 CSS pixels**).
"Don't scale layouts below 48×48dp by default."
- **Interaction targets must stay ≥48×48dp even when the visible element is scaled down** — e.g. a
settings icon can be 24×24dp visually but must keep a 48×48dp target; a 36dp-tall button can keep a
48dp target.
- Don't increase density in focused/high-stakes UI (menus, snackbars, dialogs).
- Pixel density formula: `pixel density = screen width (or height) in px / screen width (or height) in
inches`.
- **Density-independent pixel (dp)**: "A dp is equal to one physical pixel on a screen with a density of
160." Formula: `dp = (width in pixels × 160) / screen density`.
### Values — Bidirectionality / RTL (layout/bidirectionality-rtl)
- Over 2 billion people read/write RTL languages (Arabic, Hebrew, Farsi, Urdu).
- **Mirroring**: LTR ⇄ RTL flips element alignment/order; reading starts top-right in RTL. **Exception**:
graphs/charts stay LTR even in Persian and Urdu.
- **Text rendering** has two parts: alignment (box placement) and directionality (text/element flow).
Common RTL bugs: text entry, cursor position, punctuation, phone numbers, URLs. **Don't** reverse an
email's username/domain order (domain always stays to the right of the username, even though the
username itself can render RTL). **Don't** apply LTR directionality to RTL content (scrambles word
order) — content needs both RTL alignment *and* RTL directionality.
- **Icons**: directional icons (back/forward, send) mirror in RTL. **Exception**: in Hebrew, timelines and
media controls keep LTR directionality; help icons mirror only in some RTL languages (e.g. Urdu,
Persian).
- **Time / progress**: linear progress indicators fill right-to-left in most RTL languages, **except
Hebrew, which stays LTR**. Circular progress indicators always move clockwise regardless of direction.
Media player controls (video/audio) are **always LTR**. Clocks always turn clockwise; on a 12-hour clock
in RTL, the AM/PM symbol moves to the left; the 24-hour clock is common outside English-primary
locales.
- **Canonical layouts in RTL**: list-detail, feed, and supporting-pane layouts are all explicitly "mirrored
in RTL."
- **Component specifics**: badges reposition; toolbars mirror tool order; app bars mirror layout and flip
directional icons; **navigation rail sits on the leading edge — right side in RTL, left in LTR**
(expanded nav rail too); text field leading/trailing icons swap sides; a chip's trailing icon "is always
aligned to the end side of the container" (right for LTR, left for RTL).
- **Gestures**: swipe-to-reveal actions and Android predictive back must mirror their LTR counterparts
(e.g. a delete action revealed by a right-swipe in LTR should be revealed by a left-swipe in RTL).
### Values — Canonical layout examples (layout/canonical-examples)
- Named canonical layouts referenced: **feed**, **list-detail**, **supporting-pane** (each links out to a
dedicated `m3.material.io/m3/pages/canonical-examples/...` page — a legacy/alternate URL namespace not
fetched here since it falls outside `foundations/*`). An "Advanced custom layouts" section exists on the
page but only points onward, with no further numeric content on this page itself.
### M3 vs M3 Expressive differences
- The May-2026 rename ("window size class" → "breakpoint"; "responsive layout" → "adaptive design") is the
only explicit terminology-version marker found in Layout.
- Density (opt-in component scaling) and the layout scaffold (bars/rails/panes model) are presented as the
current single system, introduced/updated as part of the same May-2026 refresh per layout-overview's
"What's new" log ("Introduced layout scaffold... Updated canonical layout examples... Spacing system").
### Accessibility requirements
- Safety regions must stay clear of primary content (keeps content from being obscured by system UI).
- Pane focus-order/trap rules above (coplanar order must match visual order; modal panes trap and restore
focus).
- RTL is treated as an accessibility/global-reach requirement throughout, not an optional locale nicety.
- Minimum 48×48dp interaction targets carry through from Accessibility into density/scaling rules here.
### Source URLs
https://m3.material.io/foundations/layout/layout-overview
https://m3.material.io/foundations/layout/layout-overview/parts-of-layout
https://m3.material.io/foundations/layout/scaffold/overview
https://m3.material.io/foundations/layout/scaffold/bars
https://m3.material.io/foundations/layout/scaffold/rails
https://m3.material.io/foundations/layout/scaffold/panes
https://m3.material.io/foundations/layout/grids-spacing/overview
https://m3.material.io/foundations/layout/grids-spacing/grids
https://m3.material.io/foundations/layout/grids-spacing/spacing
https://m3.material.io/foundations/layout/grids-spacing/density
https://m3.material.io/foundations/layout/breakpoints
https://m3.material.io/foundations/layout/bidirectionality-rtl
https://m3.material.io/foundations/layout/canonical-examples
---
## Usability (foundations/usability)
### Principles
- "Usability focuses on making products intuitive and easy to understand for everyone" — distinct from
Accessibility (see Accessibility section for the exact wording of the distinction).
- Key takeaways: emphasize key actions for visual hierarchy; leverage M3 Expressive design tactics; don't
overwhelm users with visual information; test and iterate.
### Values — Nielsen Norman Group's five usability aspects (quoted directly)
| Aspect | Definition |
| --- | --- |
| Efficiency | Users can efficiently complete tasks and goals |
| Errors | Proper design reduces the likelihood of mistakes, and users can easily correct any errors that do occur |
| Learnability | New users learn to use the product and complete tasks easily, even the first time |
| Memorability | Returning users remember how to use the product |
| Satisfaction | Users are satisfied with the designed experience |
### Rules — Usability design tactics (explicitly framed as **M3 Expressive** tactics)
- **Color & contrast**: use eye-catching primary/secondary colors and contrasting pairs (e.g. purple +
green) for hierarchy; always follow the accessibility contrast rules above.
- **Containment & grouping**: group related elements in subtle containers; break content into sections with
containment/spacing/headings.
- **Motion**: use sparingly to emphasize key moments — "motion can be distracting" if overused.
- **Shape & shape morph**: "The Material shape library has **35 shapes**." Shape adds emphasis/delight,
guides focus, differentiates containers/buttons/animations, signals interaction, sets emotional tone.
Every shape can morph into any other in the set; shape morph communicates interaction states (selected,
tap, swipe, scroll, release, long press) and emphasizes in-progress actions.
- **Size**: the most important action/CTA should be the largest element; larger key actions measurably
improve efficiency, error rate, satisfaction, learnability (per the doc's own claim, no citation given).
- **Typography**: use type scale/weight to separate information hierarchies (largest/most legible text =
primary action; smaller text = secondary/tertiary); group similar content with the same font style.
- Design around **primary / secondary / tertiary goals**: give primary goals the strongest emphasis;
simplify to one primary task per page; make core actions large/reachable; don't stack too many expressive
tactics at once (distracting).
- **Iterate**: test and gather feedback early/often from a range of users/contexts.
### M3 vs M3 Expressive differences
- This entire page's "design tactics" section is explicitly attributed to **M3 Expressive** ("Leverage
[expressive design tactics]... containment, size, shape, color, and typography"), including the 35-shape
library and shape-morph mechanic, which are Expressive-era additions.
### Accessibility requirements
- "Accessibility focuses on making products accessible for people with disabilities... perceivable,
operable, understandable and robust... support people who use assistive technology" (contrasted directly
with usability's broader "intuitive for everyone" framing).
### Source URLs
https://m3.material.io/foundations/usability
---
## Glossary terms that matter for a web component library (foundations/glossary)
Only terms relevant to auditing a component library are excerpted; the full glossary runs AZ (~20KB) and
also defines many component names already covered under Components (out of scope here). Quoted verbatim.
| Term | Definition (verbatim) |
| --- | --- |
| Adaptive design | "A design approach in which the interface changes based on known user, device, or environmental conditions. Adaptive design in Material includes layout and component adaptations." |
| Condition | "A signal that determines when and how an adaptive layout or component should adapt." |
| Contrast | "Difference between colors. For accessibility, contrast refers strictly to the difference in tone. **A difference of 40 in tone guarantees a WCAG contrast ratio ≥ 3.0; a difference of 50 in tone guarantees a contrast ratio ≥ 4.5.**" |
| Customization | "A modification made to a UI reflecting an app, OEM, or individual user's visual preferences and brand." Can be per-element or global (a theme). |
| Dark theme | "A dark theme is a low-light UI that displays mostly dark surfaces." |
| Design tokens | "A design token represents a small, reusable design decision that's part of a design system's visual style. Tokens replace static values with self-explanatory names." |
| Design tokens: Context | "The set of conditions in which tokens can point to non-default values (for example, dark theme, dense layout)." |
| Design tokens: Role | "A shortened version of the system token name (for example, Secondary container color; Headline 1)." |
| Design tokens: Types | Reference tokens (e.g. `md.ref.palette.secondary200`); Component tokens (define a component element's design attributes); System tokens (e.g. `md.sys.color.secondary-container`) |
| Design tokens: Value | "The information defining a design attribute, either stored in a token or hard-coded." |
| Element | (page continues past excerpt boundary — not fully captured; component name only) |
| HCT | "HCT is an abbreviation of hue, chroma, tone. It's the name of the color space that enables dynamic color. HCT is based on CAM16 hue and chroma; the L* construct for luminance from L*a*b* (CIELAB, 1976) is denoted as T for tone." |
| Mode | (component/theming term; not fully excerpted) |
| Orbiter | "Floating UI elements that control the content within spatial panels." (XR-adjacent; likely out of scope) |
| Pane | "The building blocks of a layout. Content and actions are grouped into panes, which adapt the content to best fit the screen." |
| Role | "Short nickname describing the purpose of a design token within a design system. Also known as slots. Examples: On surface; Body 1." |
| Spatial | (XR-adjacent term; out of scope per task instructions) |
| Style | (design-spec term; not fully excerpted) |
| Theme | (not fully excerpted — see "Note: A light scheme is not the same as a light theme" under Color: Scheme) |
### Color-system glossary terms (used throughout Customization/Design tokens)
| Term | Definition (verbatim) |
| --- | --- |
| Color: Baseline scheme | "the group of selected tones that make up the default colors values used for light and dark themes." |
| Color: Dynamic color | "A customization feature in which a user-generated color scheme is mapped to an app's color scheme." Not simply Theme-Builder output or an algorithmic scheme — it's the *presence of a changeable color role*. |
| Color: Extended color | "A color specified (in addition to key colors) in order to fill color roles for custom schemes... brand expression or conventional (semantic) meanings." |
| Color: Key color | "not an extracted color — it's a derivation of the source color... the basis for a tonal palette." |
| Color: Scheme | "Any mapping of color roles to specific tones from a tonal palette." A **dark scheme** ≠ a **dark theme** (scheme vs. theme distinction explicitly noted). |
| Color: Source color | "The single color that's extracted to define all five key colors." |
| Color: Tonal palette | "a 13-tone range that serves as the basis for mapping tones to specific roles." |
| Color: Tone | "colors with the same hue and chroma... degrees of lightness." Code: `Tone.of(hex).get(tone)` or `Tone.of(hue, chroma).get(tone)`. |
| Color: User-generated schemes | colors derived from a user's wallpaper selection or Android preset colors. |
### Source URLs
https://m3.material.io/foundations/glossary
---
## Cross-cutting gaps found (for the auditing agents to be aware of)
1. **Focus indicator specifics** (thickness / offset / color token) are **not defined anywhere in
Foundations** — only "ring-like keyboard focus indicator" is stated (states/applying-states). If the
component library asserts a specific focus-ring spec, that spec's source is not this section of the
site.
2. **The "two visual indicators" accessibility rule for states** is asserted once, as a bullet, with zero
elaboration anywhere in Foundations on what the two indicators must be.
3. **Alt text length is inconsistent across the site itself**: 125 characters (foundations/writing) vs. 140
characters (foundations/content-design/alt-text).
4. **No explicit numeric grid/column/gutter table** exists on the current (M3 Expressive-era) Layout pages,
unlike the classic Material 2 12-column-grid spec; grids/spacing guidance here is qualitative except for
the 8dp target-spacing and pane-width (360/400/412dp) numbers captured above.
5. **Component tokens are explicitly flagged "(in development)"** on the design-tokens page — treat
component-token completeness claims cautiously.
6. Two task-provided sub-page slugs did not exist as separate URLs: `design-tokens/overview`,
`/how-to-read-tokens`, `/how-to-use-tokens` all collapse into the single `foundations/design-tokens`
page; `layout/understanding-layout/*` and `layout/applying-layout/*` do not exist — the real nesting is
`layout/layout-overview/parts-of-layout`, `layout/scaffold/{bars,rails,panes}`, and
`layout/grids-spacing/{grids,spacing,density}` (all fetched and covered above).
+238
View File
@@ -0,0 +1,238 @@
https://m3.material.io/components
https://m3.material.io/components/all-buttons
https://m3.material.io/components/app-bars/accessibility
https://m3.material.io/components/app-bars/guidelines
https://m3.material.io/components/app-bars/overview
https://m3.material.io/components/app-bars/specs
https://m3.material.io/components/badges/accessibility
https://m3.material.io/components/badges/guidelines
https://m3.material.io/components/badges/overview
https://m3.material.io/components/badges/specs
https://m3.material.io/components/bottom-sheets/accessibility
https://m3.material.io/components/bottom-sheets/guidelines
https://m3.material.io/components/bottom-sheets/overview
https://m3.material.io/components/bottom-sheets/specs
https://m3.material.io/components/button-groups/accessibility
https://m3.material.io/components/button-groups/guidelines
https://m3.material.io/components/button-groups/overview
https://m3.material.io/components/button-groups/specs
https://m3.material.io/components/buttons/accessibility
https://m3.material.io/components/buttons/guidelines
https://m3.material.io/components/buttons/overview
https://m3.material.io/components/buttons/specs
https://m3.material.io/components/cards/accessibility
https://m3.material.io/components/cards/guidelines
https://m3.material.io/components/cards/overview
https://m3.material.io/components/cards/specs
https://m3.material.io/components/carousel/accessibility
https://m3.material.io/components/carousel/guidelines
https://m3.material.io/components/carousel/overview
https://m3.material.io/components/carousel/specs
https://m3.material.io/components/checkbox/accessibility
https://m3.material.io/components/checkbox/guidelines
https://m3.material.io/components/checkbox/overview
https://m3.material.io/components/checkbox/specs
https://m3.material.io/components/chips/accessibility
https://m3.material.io/components/chips/guidelines
https://m3.material.io/components/chips/overview
https://m3.material.io/components/chips/specs
https://m3.material.io/components/date-pickers/accessibility
https://m3.material.io/components/date-pickers/guidelines
https://m3.material.io/components/date-pickers/overview
https://m3.material.io/components/date-pickers/specs
https://m3.material.io/components/dialogs/accessibility
https://m3.material.io/components/dialogs/guidelines
https://m3.material.io/components/dialogs/overview
https://m3.material.io/components/dialogs/specs
https://m3.material.io/components/divider/accessibility
https://m3.material.io/components/divider/guidelines
https://m3.material.io/components/divider/overview
https://m3.material.io/components/divider/specs
https://m3.material.io/components/extended-fab/accessibility
https://m3.material.io/components/extended-fab/guidelines
https://m3.material.io/components/extended-fab/overview
https://m3.material.io/components/extended-fab/specs
https://m3.material.io/components/fab-menu/accessibility
https://m3.material.io/components/fab-menu/guidelines
https://m3.material.io/components/fab-menu/overview
https://m3.material.io/components/fab-menu/specs
https://m3.material.io/components/floating-action-button/accessibility
https://m3.material.io/components/floating-action-button/guidelines
https://m3.material.io/components/floating-action-button/overview
https://m3.material.io/components/floating-action-button/specs
https://m3.material.io/components/icon-buttons/accessibility
https://m3.material.io/components/icon-buttons/guidelines
https://m3.material.io/components/icon-buttons/overview
https://m3.material.io/components/icon-buttons/specs
https://m3.material.io/components/lists/accessibility
https://m3.material.io/components/lists/guidelines
https://m3.material.io/components/lists/overview
https://m3.material.io/components/lists/specs
https://m3.material.io/components/loading-indicator/accessibility
https://m3.material.io/components/loading-indicator/guidelines
https://m3.material.io/components/loading-indicator/overview
https://m3.material.io/components/loading-indicator/specs
https://m3.material.io/components/menus/accessibility
https://m3.material.io/components/menus/guidelines
https://m3.material.io/components/menus/overview
https://m3.material.io/components/menus/specs
https://m3.material.io/components/navigation-bar/accessibility
https://m3.material.io/components/navigation-bar/guidelines
https://m3.material.io/components/navigation-bar/overview
https://m3.material.io/components/navigation-bar/specs
https://m3.material.io/components/navigation-drawer/accessibility
https://m3.material.io/components/navigation-drawer/guidelines
https://m3.material.io/components/navigation-drawer/overview
https://m3.material.io/components/navigation-drawer/specs
https://m3.material.io/components/navigation-rail/accessibility
https://m3.material.io/components/navigation-rail/guidelines
https://m3.material.io/components/navigation-rail/overview
https://m3.material.io/components/navigation-rail/specs
https://m3.material.io/components/progress-indicators/accessibility
https://m3.material.io/components/progress-indicators/guidelines
https://m3.material.io/components/progress-indicators/overview
https://m3.material.io/components/progress-indicators/specs
https://m3.material.io/components/radio-button/accessibility
https://m3.material.io/components/radio-button/guidelines
https://m3.material.io/components/radio-button/overview
https://m3.material.io/components/radio-button/specs
https://m3.material.io/components/search/accessibility
https://m3.material.io/components/search/guidelines
https://m3.material.io/components/search/overview
https://m3.material.io/components/search/specs
https://m3.material.io/components/segmented-buttons/accessibility
https://m3.material.io/components/segmented-buttons/guidelines
https://m3.material.io/components/segmented-buttons/overview
https://m3.material.io/components/segmented-buttons/specs
https://m3.material.io/components/side-sheets/accessibility
https://m3.material.io/components/side-sheets/guidelines
https://m3.material.io/components/side-sheets/overview
https://m3.material.io/components/side-sheets/specs
https://m3.material.io/components/sliders/accessibility
https://m3.material.io/components/sliders/guidelines
https://m3.material.io/components/sliders/overview
https://m3.material.io/components/sliders/specs
https://m3.material.io/components/snackbar/accessibility
https://m3.material.io/components/snackbar/guidelines
https://m3.material.io/components/snackbar/overview
https://m3.material.io/components/snackbar/specs
https://m3.material.io/components/split-button/accessibility
https://m3.material.io/components/split-button/guidelines
https://m3.material.io/components/split-button/overview
https://m3.material.io/components/split-button/specs
https://m3.material.io/components/switch/accessibility
https://m3.material.io/components/switch/guidelines
https://m3.material.io/components/switch/overview
https://m3.material.io/components/switch/specs
https://m3.material.io/components/tabs/accessibility
https://m3.material.io/components/tabs/guidelines
https://m3.material.io/components/tabs/overview
https://m3.material.io/components/tabs/specs
https://m3.material.io/components/text-fields/accessibility
https://m3.material.io/components/text-fields/guidelines
https://m3.material.io/components/text-fields/overview
https://m3.material.io/components/text-fields/specs
https://m3.material.io/components/time-pickers/accessibility
https://m3.material.io/components/time-pickers/guidelines
https://m3.material.io/components/time-pickers/overview
https://m3.material.io/components/time-pickers/specs
https://m3.material.io/components/toolbars/accessibility
https://m3.material.io/components/toolbars/guidelines
https://m3.material.io/components/toolbars/overview
https://m3.material.io/components/toolbars/specs
https://m3.material.io/components/tooltips/accessibility
https://m3.material.io/components/tooltips/guidelines
https://m3.material.io/components/tooltips/overview
https://m3.material.io/components/tooltips/specs
https://m3.material.io/foundations
https://m3.material.io/foundations/building-for-all/co-design
https://m3.material.io/foundations/building-for-all/user-needs
https://m3.material.io/foundations/content-design/alt-text
https://m3.material.io/foundations/content-design/global-writing/overview
https://m3.material.io/foundations/content-design/global-writing/word-choice
https://m3.material.io/foundations/content-design/notifications
https://m3.material.io/foundations/content-design/overview
https://m3.material.io/foundations/content-design/style-guide/grammar-and-punctuation
https://m3.material.io/foundations/content-design/style-guide/ux-writing-best-practices
https://m3.material.io/foundations/content-design/style-guide/word-choice
https://m3.material.io/foundations/customization
https://m3.material.io/foundations/design-tokens/how-to-use-tokens
https://m3.material.io/foundations/design-tokens/overview
https://m3.material.io/foundations/designing/color-contrast
https://m3.material.io/foundations/designing/elements
https://m3.material.io/foundations/designing/flow
https://m3.material.io/foundations/designing/overview
https://m3.material.io/foundations/designing/structure
https://m3.material.io/foundations/glossary
https://m3.material.io/foundations/interaction/gestures
https://m3.material.io/foundations/interaction/inputs
https://m3.material.io/foundations/interaction/selection
https://m3.material.io/foundations/interaction/states/applying-states
https://m3.material.io/foundations/interaction/states/overview
https://m3.material.io/foundations/interaction/states/state-layers
https://m3.material.io/foundations/layout/bidirectionality-rtl
https://m3.material.io/foundations/layout/breakpoints/compact
https://m3.material.io/foundations/layout/breakpoints/expanded
https://m3.material.io/foundations/layout/breakpoints/large-extra-large
https://m3.material.io/foundations/layout/breakpoints/medium
https://m3.material.io/foundations/layout/breakpoints/overview
https://m3.material.io/foundations/layout/canonical-examples/feed
https://m3.material.io/foundations/layout/canonical-examples/list-detail
https://m3.material.io/foundations/layout/canonical-examples/overview
https://m3.material.io/foundations/layout/canonical-examples/supporting-pane
https://m3.material.io/foundations/layout/grids-spacing/density
https://m3.material.io/foundations/layout/grids-spacing/grids
https://m3.material.io/foundations/layout/grids-spacing/overview
https://m3.material.io/foundations/layout/grids-spacing/spacing
https://m3.material.io/foundations/layout/layout-overview/adaptive-design
https://m3.material.io/foundations/layout/layout-overview/overview
https://m3.material.io/foundations/layout/layout-overview/parts-of-layout
https://m3.material.io/foundations/layout/scaffold/bars
https://m3.material.io/foundations/layout/scaffold/overview
https://m3.material.io/foundations/layout/scaffold/panes
https://m3.material.io/foundations/layout/scaffold/rails
https://m3.material.io/foundations/overview/assistive-technology
https://m3.material.io/foundations/overview/principles
https://m3.material.io/foundations/usability/applying-m3-expressive
https://m3.material.io/foundations/usability/overview
https://m3.material.io/foundations/writing/best-practices
https://m3.material.io/foundations/writing/text-resizing
https://m3.material.io/foundations/writing/text-truncation
https://m3.material.io/styles
https://m3.material.io/styles/color/advanced/adjust-existing-colors
https://m3.material.io/styles/color/advanced/apply-colors
https://m3.material.io/styles/color/advanced/define-new-colors
https://m3.material.io/styles/color/advanced/overview
https://m3.material.io/styles/color/choosing-a-scheme
https://m3.material.io/styles/color/dynamic/choosing-a-source
https://m3.material.io/styles/color/dynamic/content-based-source
https://m3.material.io/styles/color/dynamic/user-generated-source
https://m3.material.io/styles/color/resources
https://m3.material.io/styles/color/roles
https://m3.material.io/styles/color/static/baseline
https://m3.material.io/styles/color/static/custom-brand
https://m3.material.io/styles/color/system/how-the-system-works
https://m3.material.io/styles/color/system/overview
https://m3.material.io/styles/elevation/applying-elevation
https://m3.material.io/styles/elevation/overview
https://m3.material.io/styles/elevation/tokens
https://m3.material.io/styles/icons/applying-icons
https://m3.material.io/styles/icons/designing-icons
https://m3.material.io/styles/icons/overview
https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration
https://m3.material.io/styles/motion/easing-and-duration/tokens-specs
https://m3.material.io/styles/motion/overview/how-it-works
https://m3.material.io/styles/motion/overview/specs
https://m3.material.io/styles/motion/transitions/applying-transitions
https://m3.material.io/styles/motion/transitions/transition-patterns
https://m3.material.io/styles/shape/corner-radius-scale
https://m3.material.io/styles/shape/overview-principles
https://m3.material.io/styles/shape/shape-morph
https://m3.material.io/styles/spacing/applying-spacing
https://m3.material.io/styles/spacing/overview
https://m3.material.io/styles/spacing/tokens
https://m3.material.io/styles/typography/applying-type
https://m3.material.io/styles/typography/editorial-treatments
https://m3.material.io/styles/typography/fonts
https://m3.material.io/styles/typography/overview
https://m3.material.io/styles/typography/type-scale-tokens
+318
View File
@@ -0,0 +1,318 @@
# Material Design 3 — Styles Supplement (Spacing; Color additions; Motion transition patterns)
Extraction from sitemap pages `reference-styles.md` did not cover (fetched directly, not via in-site
navigation). Same structure as the reference. Only material **not** already in `reference-styles.md` is
included; where a fetched page turned out to be a duplicate of an already-covered page, that is noted
instead of repeating it.
**Duplicate pages found (no new content beyond what's already in the reference):**
- `styles/color/system/overview` — byte-identical to `styles/color/system` (already in the reference's
Color section / Source URLs), *except* it also carries a Resources table not previously captured — see
Color § Values below.
- `styles/color/advanced/overview` — byte-identical to `styles/color/advanced` (already in the reference's
Color § Source URLs; its linked sub-pages apply-colors/define-new-colors/adjust-existing-colors are
already the basis of the reference's "Advanced customization best practices" rules).
- `styles/color/dynamic/choosing-a-source` — byte-identical to `styles/color/dynamic` (already in the
reference's Color § Source URLs), *except* it's the canonical location for the source-selection
decision criteria — see Color § Dynamic sources below (the reference didn't extract these criteria).
- `styles/motion/easing-and-duration/applying-easing-and-duration` — its "Suggested easing and duration
pairs" table, "Choosing an easing set", "Choosing an easing type" (by transition direction), and
"Choosing a duration" (by size / enter vs. exit) sections are already reproduced verbatim in the
reference's Motion § Rules and § Values. The only new fact is an M2-vs-M3 style comparison — see
Motion § Principles below.
---
## Spacing
Absent from `reference-styles.md` entirely — no Spacing section existed. This is a full addition.
### Principles
- Spacing is applied to the margins, padding, and gaps of a component, UI element, or layout.
- Spacing adapts to different values based on context, like mobile vs. desktop, or density settings.
- The spacing system is measured on an **8dp scale**, where **space100 = 8dp**.
- Spacing is applied to the flow of elements (horizontal, vertical) or in relation to elements (leading,
trailing, top, bottom, gap).
- Unlike the color system (which adjusts light/dark theme logic across all components at once), spacing
logic is tailored and built within each component individually — there's no single global spacing
transform.
- Components have padding/margin/gap tokens that map to system spacing tokens, which resolve to final dp
values (component token → system token → value).
### Rules (Google's wording)
- Spacing has three categories: **padding** (space inside an element), **gap** (space between elements in
a grid or container), and **margin** (space outside an element). Position can be vertical, top, bottom,
horizontal, leading, or trailing. **Leading**/**trailing** swap sides in RTL languages.
- **Do** define padding and gaps on the parent container to organize all elements inside.
- **Don't** define margins on child elements — they usually aren't uniform and require more tokens.
- **Use padding & gaps before using margins**: Material rarely uses margins in components; padding/gaps
apply spacing more uniformly. Only use margins for spacing beyond the parent container's padding, or in
layouts.
- Use a horizontal/vertical gap for simple components where gaps are always the same size; complex
components with many different gaps should name them by the elements on each side (e.g. "icon-label
gap") rather than a single generic gap token.
- When text is scaled up to 200%, the same spacing should be preserved by default (spacing does not scale
with text).
- What to use, in priority order:
1. **Pre-tokenized components** — some Material components map to spacing system tokens out of the box
(work is ongoing to hook up all components; this mapping can be customized for form factor/density).
2. **System tokens** — apply these to custom components/layouts, replacing hardcoded values.
3. If the right system token doesn't exist, **customize the system** (see below) and add your own.
- Customizing the system (three approaches, by scope):
- **Customize Material's existing component spacing** — remap a component attribute to a different
system token product-wide (e.g. change "button top padding" from **space125** to **space200** for a
taller default button).
- **Add custom system spacing & patterns** — when you need units beyond what Material provides, or have
a recurring adaptive pattern. Follow the multiplier convention for new tokens (e.g. **space225 = 18dp**
= 8dp × 2.25). Product-specific recurring patterns can get their own pattern token (e.g. if cards and
sheets always adapt horizontal content padding the same way, create a **surface-content padding
horizontal** token for that pattern).
- **Add adaptive layout & density** — map the same component to different system tokens per device type
(mobile vs. desktop), and/or adapt vertical padding to different values per density setting, while
keeping the same core component.
- **Note:** system spacing tokens are currently only used on **Jetpack Compose** (see Availability below)
— this is a stronger platform restriction than color/elevation/shape, which have broader (if uneven)
support.
### Values
#### Availability
| Type | Resource | Status |
| --- | --- | --- |
| Implementation | Android Views (MDC-Android) | Unavailable |
| Implementation | Jetpack Compose | Available |
| Implementation | Web | Unavailable |
#### Spacing scale
The site does not render the token→dp table as text (it's an image diagram); the following is
reconstructed from the images' alt-text and from dp values cited inline elsewhere on the page, and should
be treated as inferred, not a verbatim site table:
| Token | Multiplier | Value |
| --- | --- | --- |
| space25 | 0.25× | 2dp |
| space50 | 0.5× | 4dp |
| space75 | 0.75× | 6dp |
| **space100** | **1×** | **8dp (base unit, `md.sys.measurement.space100`)** |
| space125 | 1.25× | 10dp |
| space200 | 2× | 16dp |
| space300 | 3× | 24dp |
| space400 | 4× | 32dp |
| space500 | 5× | 40dp |
| space600 | 6× | 48dp |
| space700 | 7× | 56dp |
| space800 | 8× | 64dp |
| space900 | 9× | 72dp |
Basis for this reconstruction: the overview page's scale diagram alt-text reads "a spacing scale shows 2,
4, 6, and 8 at the bottom range and 48, 56, 64, and 72 at the top of the range"; the tokens page states the
main range "covers 0x to 9x" of the 8dp base unit and separately defines nested sub-1x units at "0.25x,
0.5x, 0.75x, 1.25x"; the overview page separately lists the same nested dp values ("2dp, 4dp, 6dp, and
10dp"). Values beyond space900 (e.g. the 18dp/space225 example) are explicitly presented as **custom**,
not part of the default set. Material only defines the nested/nonstandard units it actively uses — the
scale is not a complete arithmetic progression at every step.
#### Component spacing — naming convention
| | Going forward | Legacy |
| --- | --- | --- |
| Prefix | `padding`, `margin`, `gap` | `space` |
| Positional modifiers | horizontal, vertical, leading, trailing, top, bottom | leading-space, trailing-space, top-space, bottom-space, between-space |
| Example | "Medium button: leading padding" | "Medium button: leading space" |
#### Parts of spacing — example (Search)
The search container: 8dp vertical padding, 8dp horizontal gaps, 24dp horizontal margins by default
(narrows to 12dp when focused, while padding/gaps stay the same). The 24dp default margin exists
specifically "to ensure accurate placement from the screen edge."
#### Spacing concepts diagrammed on the site (numbered image captions — anatomy, not values)
- Component layout: (1) vertical padding top & bottom, (2) vertical gap, (3) horizontal padding leading &
trailing, (4) horizontal gap.
- Page layout: (1) margin, (2) top padding, (3) horizontal padding leading & trailing, (4) spacer/gap,
(5) vertical gap. Layouts additionally use **panes** (layout containers, which can be fixed, flexible,
floating, or semi-permanent) and **spacers** (space between two panes; contains a drag handle if the
panes are resizable).
### M3 vs M3 Expressive differences
No Expressive-specific spacing changes are called out on these pages (unlike shape/motion/typography,
spacing carries no "M3 Expressive" labelled update section). The system is presented as a single,
version-agnostic linear scale.
### Accessibility
- Spacing must be preserved (not scaled) when text is scaled up to 200%, so layouts stay stable and
legible as text grows — spacing is a support mechanism for text-resizing accessibility, not something
that competes with it.
### Source URLs
- https://m3.material.io/styles/spacing/overview
- https://m3.material.io/styles/spacing/applying-spacing
- https://m3.material.io/styles/spacing/tokens
---
## Color — additions
### Static: Custom brand color scheme
Not previously fetched. Supplements the reference's Color section (which covers baseline/static generally
but not the brand-specific workflow).
**Principles**
- A **brand-based static scheme** is hand-picked by the product team to align with the product's brand
color(s), as opposed to the **baseline** static scheme (Material's own default) or a **dynamic** scheme.
It requires greater ongoing investment since the team creates and maintains it entirely.
- End-users see: an accessible UI with static colors, and "a product that looks like its brand."
**Rules (Google's wording)**
- Brand schemes are still generated through the same tonal-palette machinery as dynamic color: you can
set a **custom source color independently for each of the 6 palette groups** — Primary, Secondary,
Tertiary, Error, Neutral, and Neutral Variant — not just Primary. Setting only Primary and leaving the
rest defaulted is also valid ("use the scheme as-is").
- Workflow (Material Theme Builder Figma plugin): add a new theme, name it (the name becomes the color
role prefix in Figma), set a custom Hex source color for Primary (and optionally Secondary/Tertiary/
Error/Neutral/Neutral Variant) → generates a full custom color scheme with the same role set as baseline.
- Enabling "Generate State Layers" in the plugin settings when creating the theme is called out as
necessary groundwork for interaction-state design (cross-references state layers).
- Point of escalation: "want to further adjust your brand color scheme?" and "need to make adjustments?"
both route to **Advanced customizations** (apply/define/adjust colors) — i.e. custom-brand is the
starting point, advanced customization is the refinement path.
- Develop: export the branded scheme from Material Theme Builder for Jetpack Compose, Android Views,
Flutter, Web, or as a JSON file.
**Source URLs**
- https://m3.material.io/styles/color/static/custom-brand
### Dynamic: choosing and using a source
Two new sub-pages (content-based-source, user-generated-source) plus decision criteria from
choosing-a-source that the reference's Color section states only as a single pipeline description, not as
selection criteria.
**Principles**
- Both user-generated and content-based color go through the same pipeline: the image is digitally
analyzed through **quantization**, a single color is selected as the source color, and tones are chosen
and assigned to each color role (this is the concrete mechanism behind the "dynamic color pipeline"
already summarized in the reference).
- **User-generated color**: sourced from the user's wallpaper.
- **Content-based color**: sourced from in-app content — an album thumbnail, a logo, a video preview.
**Rules (Google's wording) — decision criteria (not previously captured)**
| Source type | Choose it if… |
| --- | --- |
| User-generated | Users would benefit from a personalized experience that's well-tested; you want to showcase the latest Material features |
| Content-based | Content is front-and-center in the product; the team can do some advanced customization; it supports usability of content-centric features like media players; best applied to contained screen elements adjacent to the source image (the source image itself need not stay visible) |
| Multiple sources | Product requirements meet more than one of the above criteria and the team doesn't mind extra customization work; get started with user-generated color first, then customize |
- Example of multiple sources coexisting: a photo app derives content-based color (green) for edit buttons
from the in-app photo, while wallpaper-derived user-generated color (red) drives share buttons elsewhere
in the same screen.
- Content-based color guidance: apply it where it enhances brand identity and personalization (e.g. a
music app deriving color from album artwork; a news app differentiating publications by their content
color).
- QA/testing step for user-generated color (Figma workflow): use the Material Theme Builder's "Dynamic"
mode, add a real image or use the Shuffle icon for a random source color, swap it onto frames, and
**repeat across a range of colors** to get a sense of how the product will appear across different
users' devices/wallpapers — i.e. dynamic-color design review should be done across many source colors,
not just one or two.
- Android implementation reference specifically for content-based color: MDC-Android's
"Content-based dynamic color" doc (separate from the general dynamic-color doc already cited).
**Source URLs**
- https://m3.material.io/styles/color/dynamic/choosing-a-source
- https://m3.material.io/styles/color/dynamic/content-based-source
- https://m3.material.io/styles/color/dynamic/user-generated-source
---
## Motion — Transitions (supplement)
The reference's Motion section already reproduces the easing/duration Rules and Values from
`applying-easing-and-duration` verbatim, and names the six transition patterns in one summary line without
detail. This section supplies the missing detail: what each pattern is for, what it's commonly used with,
platform (Android/iOS) differences, and the accessibility/consistency criteria for transitions overall —
which also fills the gap the reference explicitly flagged ("No general reduced-motion policy was found
stated on `styles/motion` itself").
**Naming note:** the current M3 site does **not** use the older M2 terms "shared axis / fade through /
fade." The current (2026) taxonomy is: **Container transform**, **Forward and backward** (≈ old shared
axis), **Lateral**, **Top level** (≈ old fade through), **Enter and exit** (subsumes old "fade" cases), and
**Skeleton loaders** (new, not an M2 concept). Anyone looking for "shared axis"/"fade through"/"fade" by
name should map to this table.
### Principles — what makes a good transition
- **Follows accessibility settings**: when a platform's reduced-motion setting is on, transitions should
use subtle fades instead of intense sliding/scaling, and disable decorative effects like parallax or
shape morphing. *(This is the missing general reduced-motion policy for `styles/motion` — previously
only found at the component level, e.g. carousel/menus.)*
- **Consistent**: applying the same transition type consistently for the same kind of navigation makes
apps feel cohesive and predictable (e.g. multiple Android apps sharing one forward/backward transition).
- **Stable layouts**: use skeleton loaders (subtle pulsing) so content doesn't shift position or pop in
abruptly as it loads.
- **No jarring jump cuts**: avoid instant, uncued transitions by default — they're disorienting. Exception:
a jump cut may be preferred when pure efficiency is the top priority (e.g. opening a menu in a
productivity app).
- **Coherent spatial model**: transitions should help users understand an app's physical/spatial layout
(e.g. keep a consistent axis when moving between collapsed/expanded carousel views; don't flip between
horizontal and vertical layouts).
- **Unified direction**: elements are grouped and move along one primary axis rather than independently;
only genuinely persistent elements (e.g. a hero image) should remain visible throughout — animating many
persistent elements independently is distracting.
- **Clean fades**: fully fade out old content before fading in new content; avoid overlapping
partially-transparent frames. If a cross-fade is unavoidable, keep it quick and hide it during the
fastest part of the transition (e.g. a dialog entering mid-screen should not slowly fade on top of
content — use a short fade to hide the overlap).
- **Simple style**: transitions are frequent and utility-driven, not a showcase for stylized motion — avoid
overt effects like bouncy springs on common transitions.
### Rules — per-pattern guidance (Google's wording)
| Pattern | Use for | Commonly used with | Do / Don't / Caution |
| --- | --- | --- | --- |
| **Container transform** | Seamlessly transforming an element to show more detail (e.g. a card expanding into a details page); hero moments that should be expressive; shallow hierarchies (expand for detail, then collapse); creating a seamless connection between elements. Most dramatic/expressive pattern — reserve for the right context. | Cards, lists, image galleries, search boxes, sheets, FABs, chips | **Do** use it for hero moments instead of a forward/backward transition. **Don't** use it in apps with deep hierarchies (motion becomes excessive) or in utility-focused navigation (style mismatch). |
| **Forward and backward** | Navigating between screens at consecutive hierarchy levels (e.g. inbox → message thread) | Lists, cards, buttons, links | **Do** use platform defaults — easy to implement, stays current with platform updates. Android fades content as screens slide (reduces motion vs. a full-width slide); iOS uses parallax (background slides slower than foreground). **Caution**: container transform requires custom implementation and can feel excessive if overused for this role. |
| **Lateral** | Navigating between peer content at the same hierarchy level (e.g. swiping tabs in a content library) | Tabs, carousels, image galleries | Elements are grouped and slide in unison with **no fade or parallax**, creating a strong peer relationship and hinting at swipe-ability. **Caution**: fading content while it slides weakens the peer/swipe cue and can be confused with forward/backward. **Don't** use for hierarchical navigation — full-width sliding is excessive and wrongly implies a peer (non-hierarchical) relationship. |
| **Top level** | Navigating between top-level destinations (e.g. tapping a nav bar/rail/drawer item) | Navigation bar, navigation rail, navigation drawer | Exiting screen quickly fades out, then the entering screen fades in — intentionally **no** grouping or persistent elements, since top-level destinations aren't necessarily related. **Don't** use a lateral transition here — it wrongly implies you can swipe between top-level destinations, conflicting with carousel/list swipe gestures. |
| **Enter and exit — within screen bounds** | Introducing/removing a component in the context of the main UI (modal, e.g. a dialog; or non-modal, e.g. a bottom sheet coexisting with content) | FABs, dialogs, menus, snackbars, time pickers, tooltips | Android: expand/collapse along x or y axis only (scale and z-axis motion are avoided — they'd imply an elevation change, which conflicts with M3's reduced-elevation model). iOS: uniform scale to enter, fade to exit. Enter direction is informed by on-screen location, expanding away from the device edge (e.g. a top menu expands downward; a bottom snackbar expands upward). **Don't** use this pattern for navigating hierarchical screens — full-height sliding is excessive and creates an unclear screen-to-screen relationship. |
| **Enter and exit — beyond screen bounds** | Components that slide fully on/off screen | App bars, banners, navigation bar, navigation rail, navigation drawer, sheets | Android: expand/collapse along x or y axis while sliding on/off (adds shape emphasis). iOS: slides on/off without changing shape. Coplanar side sheets shrink the available content area rather than overlaying it. Can be scroll-driven (e.g. a top app bar or nav bar sliding off/on during scroll, to reclaim screen space). Entry/exit location establishes spatial memory: notifications enter from the top (matches the pull-down drawer), a nav drawer enters from the left (matches its off-screen position), a bottom sheet/keyboard enters from the bottom (easiest to reach). |
| **Skeleton loaders** | Transitioning from a temporary loading state to the fully loaded UI | Used in combination with other transition patterns | Subtle pulsing animation indicates indeterminate progress, animating from top-left to bottom-right. Once content loads, it quickly fades in **on top of** the skeleton loader (not instantly popping in). |
### Values
Durations and easing for these patterns are governed by the already-referenced tables (Suggested
easing/duration pairs; legacy duration tokens) — no separate duration table is given per pattern on these
pages. The pattern choice itself is the "value" documented here; see the Rules table above for the
authoritative per-pattern mapping (this *is* the durations/patterns cross-reference the task asked for —
the site does not tie a specific numeric duration to "container transform" etc. beyond the general
begin/end-on-screen vs. enter vs. exit rules already in the reference).
### M2 vs M3 easing note (from `applying-easing-and-duration`; not an M3-vs-Expressive distinction)
- Compared to M2's more utilitarian easing style, **M3 easing is more expressive**: transitions have
"snappy take-offs and very soft landings."
- **M3 durations are slightly longer than M2's**, deliberately, to give transitions time to come to a
gentle rest without feeling abrupt.
### Accessibility requirements
- General reduced-motion policy for transitions (see Principles above): substitute subtle fades for
intense sliding/scaling, and disable decorative effects (parallax, shape morphing) when a platform's
reduced-motion setting is on. This generalizes — and predates in priority — the component-level
reduced-motion notes already in the reference (carousel, menus).
### Source URLs
- https://m3.material.io/styles/motion/transitions/applying-transitions
- https://m3.material.io/styles/motion/transitions/transition-patterns
- https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration (mostly duplicate
of already-referenced content; see M2-vs-M3 note above for the one new fact)
+896
View File
@@ -0,0 +1,896 @@
# Material Design 3 — Styles Reference (Color, Elevation, Icons, Motion, Shape, Typography)
Ground-truth extraction from m3.material.io/styles and its sub-pages, plus androidx Compose Material3
token source files where the site only renders a value inside an unrendered interactive widget.
Anything not from the live site text is explicitly marked "(not on the site; from <source>)".
---
## Color
### Principles
- Color is used to express style and communicate meaning. With dynamic color, Material puts personal
color preferences and individual needs at the forefront of systematic color application.
- The Material color system includes: a built-in set of accessible color relationships (e.g. a dark
surface color is algorithmically paired with a light text-label color so the UI automatically meets
contrast requirements); 26+ color roles mapped to components; a built-in dark theme; a static baseline
color scheme; and dynamic color (user-generated from wallpaper, or content-based from in-app content).
- Color roles are "like the numbers in a paint-by-number canvas" — the connective tissue between UI
elements and what color goes where.
- **Color roles are mapped to Material Components.** Custom components must be mapped to this same
role set.
- **Color roles ensure accessibility.** The system is built on accessible color pairings; color pairs
provide an accessible minimum 3:1 contrast.
- **Color roles are tokenized.**
- General vocabulary used in role names:
- **Surface** — backgrounds and large, low-emphasis areas.
- **Primary / Secondary / Tertiary** — accent roles used to emphasize or de-emphasize foreground elements.
- **Container** — fill color for foreground elements like buttons; not for text or icons.
- **On** — a color for text/icons *on top of* its paired parent color (e.g. "on primary" on "primary").
- **Variant** — a lower-emphasis alternative to its non-variant pair (e.g. "outline variant" vs "outline").
- The system uses a color space called **HCT** (Hue, Chroma, Tone), not HSL/RGB. HCT lets you manipulate
hue and chroma without affecting tone.
- **Hue**: 0360, circular (perception as red/orange/yellow/…).
- **Chroma**: 0 (grey/black/white) up to roughly 120 in HCT (colorfulness); max chroma varies by hue/tone.
- **Tone**: 0 (pure black) to 100 (pure white); determines contrast. HCT tone is not interchangeable
with HSL lightness — two colors can share an HCT tone (perceived brightness) while having different
HSL lightness values.
- Dynamic color pipeline: (1) start with a source color (from wallpaper, in-app content, or hand-picked);
(2) feed it into an algorithm; (3) the algorithm generates key colors; (4) it creates a **tonal palette**
per key color (tones 0100 in steps of 10, plus 95/98/99, some palettes have more); (5) it assigns tones
to the ~26 standard color roles for both light and dark theme; (6) the new colors are applied to the UI.
### Rules (Google's wording)
- Pair and layer color roles only as intended (e.g. primary + on primary + secondary container + on
secondary container) to keep them legible as contrast level changes. **Don't** mix roles improperly
(e.g. primary + primary container + secondary container + on surface) — this can become illegible as
contrast changes.
- **Don't** use the **outline** color for dividers (different contrast requirements) — use **outline
variant** instead.
- **Don't** use **outline** for components containing multiple elements (e.g. cards) — use **outline
variant**.
- **Don't** use **outline variant** to create visual hierarchy or define a target's visual boundary —
use **outline** or another color giving 3:1 contrast with the surface.
- **Caution**: **outline variant** can border targets like chips/buttons only if elements inside them
(icons/text) already meet 4.5:1 contrast.
- Most products won't need the **add-on** color roles (fixed/fixed-dim, on-fixed/on-fixed-variant,
surface-dim/surface-bright). "If you aren't sure whether your product should use the add-on roles, it
probably shouldn't."
- **Don't** use **fixed** colors where contrast is necessary — they don't adapt to light/dark theme and
are likely to cause contrast issues (e.g. a button fill on a permissions screen). **Do** use primary/
secondary/tertiary where contrast is needed.
- All color mappings — especially surface colors — should remain the same for a given layout region
across breakpoints (e.g. body always **surface**, navigation always **surface container**, on both
mobile and tablet).
- Error is a **static** color by default (doesn't change with dynamic color schemes) but still adapts to
light/dark theme.
- Advanced customization best practices:
- Any role starting with "on-" is guaranteed sufficient contrast with its paired role; other role pairs
may not meet the 4.5:1 (small text) / 3:1 (large text) Material contrast requirements — check before
reusing a pairing.
- Always apply **color roles**, never static hex values or raw tonal-palette values, for anything that
should respond to theme, contrast level, or dynamic color.
- Test a dynamic-color component under multiple source-color themes (light/dark; red/yellow/green/blue).
- Limit a screen to two color schemes from different source types (e.g. baseline/user-generated combined
with one content-based source) to avoid visual disarray.
- Don't replace semantic colors (e.g. red error, green success) with content-based dynamic color.
- Don't harmonize colors whose appearance should stay absolutely consistent (e.g. brand colors).
- Defining custom color roles should be considered only if the existing Material roles can't achieve the
desired result.
- Choosing a scheme: use **static (baseline)** if not ready for dynamic color, migrating from M2, building
for enterprise users, or building for iOS. Use **dynamic** if the product should personalize, react to
wallpaper/content, or offer user-controlled contrast.
### Values
#### Color roles (Compose `ColorSchemeKeyTokens`, 47 tokens; tone source `ColorLightTokens.kt` / `ColorDarkTokens.kt`)
Each role resolves to a tone from one of 5 tonal palettes (Primary, Secondary, Tertiary, Neutral,
Neutral Variant) or Error. Format: role — light theme tone — dark theme tone.
| Role | Light tone | Dark tone | Purpose (site wording) |
| --- | --- | --- | --- |
| Primary | Primary40 | Primary80 | High-emphasis fills, text, icons against surface |
| On primary | Primary100 | Primary20 | Text/icons against primary |
| Primary container | Primary90 | Primary30 | Standout fill against surface, key components like FAB |
| On primary container | Primary10 | Primary90 | Text/icons against primary container |
| Secondary | Secondary40 | Secondary80 | Less prominent fills/text/icons against surface |
| On secondary | Secondary100 | Secondary20 | Text/icons against secondary |
| Secondary container | Secondary90 | Secondary30 | Recessive fill, e.g. tonal buttons |
| On secondary container | Secondary10 | Secondary90 | Text/icons against secondary container |
| Tertiary | Tertiary40 | Tertiary80 | Complementary accent, e.g. input fields |
| On tertiary | Tertiary100 | Tertiary20 | Text/icons against tertiary |
| Tertiary container | Tertiary90 | Tertiary30 | Complementary container fill |
| On tertiary container | Tertiary10 | Tertiary90 | Text/icons against tertiary container |
| Error | Error40 | Error80 | Attention-grabbing color for urgency |
| On error | Error100 | Error20 | Text/icons against error |
| Error container | Error90 | Error30 | Attention-grabbing fill |
| On error container | Error10 | Error90 | Text/icons against error container |
| Surface | Neutral98 | Neutral6 | Default background |
| On surface | Neutral10 | Neutral90 | Text/icons on surface / surface container |
| On surface variant | NeutralVariant30 | NeutralVariant80 | Lower-emphasis text/icons on surface |
| Surface variant | NeutralVariant90 | NeutralVariant30 | (legacy; superseded by surface container roles Feb 2023) |
| Background / On background | Neutral98 / Neutral10 | Neutral6 / Neutral90 | Legacy roles, same values as surface/on surface |
| Outline | NeutralVariant50 | NeutralVariant60 | Important boundaries, e.g. text-field outline |
| Outline variant | NeutralVariant80 | NeutralVariant30 | Decorative elements, e.g. dividers |
| Inverse surface | Neutral20 | Neutral90 | Background contrasting against surrounding surface |
| Inverse on surface | Neutral95 | Neutral20 | Text/icons against inverse surface |
| Inverse primary | Primary80 | Primary40 | Actionable elements (e.g. text buttons) against inverse surface |
| Scrim | Neutral0 | Neutral0 | Scrim behind modals, at 32% opacity |
| Surface tint | (=Primary) | (=Primary) | Deprecated — use elevation level tokens instead |
Surface container family (5 levels, named by emphasis — not tied to elevation, replaced "surface +1..+5"
in Feb 2023):
| Role | Light tone | Dark tone |
| --- | --- | --- |
| Surface container lowest | Neutral100 | Neutral4 |
| Surface container low | Neutral96 | Neutral10 |
| Surface container | Neutral94 | Neutral12 |
| Surface container high | Neutral92 | Neutral17 |
| Surface container highest | Neutral90 | Neutral22 |
Bright/dim add-on surface roles (keep relative brightness across both themes, unlike default surface
which inverts):
| Role | Light tone | Dark tone |
| --- | --- | --- |
| Surface dim | Neutral87 | Neutral6 |
| Surface bright | Neutral98 | Neutral24 |
Fixed accent add-on roles (same tone in light **and** dark theme; added Feb 2023):
| Role | Tone (both themes) | Purpose |
| --- | --- | --- |
| Primary fixed | Primary90 | Fill color that stays constant across themes |
| Primary fixed dim | Primary80 | Stronger/more emphasized fixed tone |
| On primary fixed | Primary10 | Text/icons on primary fixed |
| On primary fixed variant | Primary30 | Lower-emphasis text/icons on primary fixed |
| Secondary fixed | Secondary90 | ” |
| Secondary fixed dim | Secondary80 | ” |
| On secondary fixed | Secondary10 | ” |
| On secondary fixed variant | Secondary30 | ” |
| Tertiary fixed | Tertiary90 | ” |
| Tertiary fixed dim | Tertiary80 | ” |
| On tertiary fixed | Tertiary10 | ” |
| On tertiary fixed variant | Tertiary30 | ” |
#### Baseline tonal palettes (hex; from androidx `PaletteTokens.kt`, since the site renders these only in
an interactive, non-text widget)
| Tone | Primary | Secondary | Tertiary | Neutral | Neutral Variant | Error |
| --- | --- | --- | --- | --- | --- | --- |
| 0 | #000000 | #000000 | #000000 | #000000 | #000000 | #000000 |
| 4 | — | — | — | #0F0D13 | — | — |
| 6 | — | — | — | #141218 | — | — |
| 10 | #21005D | #1D192B | #31111D | #1D1B20 | #1D1A22 | #410E0B |
| 12 | — | — | — | #211F26 | — | — |
| 17 | — | — | — | #2B2930 | — | — |
| 20 | #381E72 | #332D41 | #492532 | #322F35 | #322F37 | #601410 |
| 22 | — | — | — | #36343B | — | — |
| 24 | — | — | — | #3B383E | — | — |
| 30 | #4F378B | #4A4458 | #633B48 | #48464C | #49454F | #8C1D18 |
| 40 | #6750A4 | #625B71 | #7D5260 | #605D64 | #605D66 | #B3261E |
| 50 | #7F67BE | #7A7289 | #986977 | #79767D | #79747E | #DC362E |
| 60 | #9A82DB | #958DA5 | #B58392 | #938F96 | #938F99 | #E46962 |
| 70 | #B69DF8 | #B0A7C0 | #D29DAC | #AEA9B1 | #AEA9B4 | #EC928E |
| 80 | #D0BCFF | #CCC2DC | #EFB8C8 | #CAC5CD | #CAC4D0 | #F2B8B5 |
| 87 | — | — | — | #DED8E1 | — | — |
| 90 | #EADDFF | #E8DEF8 | #FFD8E4 | #E6E0E9 | #E7E0EC | #F9DEDC |
| 92 | — | — | — | #ECE6F0 | — | — |
| 94 | — | — | — | #F3EDF7 | — | — |
| 95 | #F6EDFF | #F6EDFF | #FFECF1 | #F5EFF7 | #F5EEFA | #FCEEEE |
| 96 | — | — | — | #F7F2FA | — | — |
| 98 | — | — | — | #FEF7FF | — | — |
| 99 | #FFFBFE | #FFFBFE | #FFFBFA | #FFFBFF | #FFFBFE | #FFFBF9 |
| 100 | #FFFFFF | #FFFFFF | #FFFFFF | #FFFFFF | #FFFFFF | #FFFFFF |
(Source: androidx Compose Material3 `PaletteTokens.kt` — the purple/violet "baseline" scheme. Any product
using a different source color will generate a different palette via the HCT algorithm, but role→tone
assignments are the same.)
#### Contrast levels
| Level | Ratio (approx.) | Notes |
| --- | --- | --- |
| Standard (default) | mixed | Emphasizes hierarchy using high- and low-contrast elements together |
| Medium | 3:1 minimum | For users needing more contrast without high-contrast side effects like halation |
| High | 7:1 | Further emphasizes essential elements (e.g. card content, not the card container) |
Contrast settings apply automatically in both light and dark theme. Custom components support contrast
levels simply by using Material color roles (e.g. primary container / on primary container) — the role's
resolved value changes per contrast level automatically.
### M3 vs M3 Expressive differences
The color pages do not explicitly label any change "M3 Expressive" the way shape/motion/typography pages
do, but the "What's new" timeline on `styles/color` lists (with the May 2025 entry coinciding with the
Expressive launch):
- **May 2025 — Three levels of contrast.** Color roles now support standard/medium/high contrast, each
tokenized (see Values above).
- **Aug 2024 — More colorful text & icons.** On-primary-container, on-secondary-container,
on-tertiary-container, on-error-container were updated in light theme to be more colorful while
remaining accessible; affects badges, buttons (all variants), chips, lists, menus, navigation bar,
navigation drawer, navigation rail, switches, toolbars.
- **Feb 2023 — Tone-based surface colors.** Replaced the old "surface +1 to +5 elevation overlay" model
with the surface/surface-container role family (not tied to elevation). Also: default light-theme
surface moved from tone 99 to tone 98; neutral palette chroma increased 4→6; dark-theme surfaces
slightly darkened (alignment with Android SysUI).
- **Feb 2023 — Additional accent colors.** Added fixed / fixed-dim roles for primary, secondary, tertiary.
### Accessibility requirements
- Color pairs provide an accessible **minimum 3:1 contrast** by design (color-role pairing system).
- Text contrast targets (also stated on `foundations/designing/color-contrast`, W3C-based):
large text (≥14pt bold / ≥18pt regular) and graphics: **≥3:1** against background; small text: **≥4.5:1**.
Disabled states do not need to meet contrast requirements.
- Non-text clustered elements (e.g. a group of buttons) should meet **3:1** contrast between container
color and background; a standalone prominent element (e.g. a FAB) does not need this because its
prominence already distinguishes it.
- High contrast mode targets **7:1**.
- Default typography color is **on surface** (or **on surface variant** as a strong alternative).
- **Color-alone rule**: hyperlinked text must use primary (or tertiary, for a less prominent link) *and*
must be underlined — color alone must not be the only signal.
### Source URLs
- https://m3.material.io/styles
- https://m3.material.io/styles/color
- https://m3.material.io/styles/color/system (identical render to /styles/color)
- https://m3.material.io/styles/color/roles
- https://m3.material.io/styles/color/system/how-the-system-works
- https://m3.material.io/styles/color/choosing-a-scheme
- https://m3.material.io/styles/color/static (= /styles/color/static/baseline)
- https://m3.material.io/styles/color/dynamic
- https://m3.material.io/styles/color/advanced
- https://m3.material.io/styles/color/advanced/apply-colors
- https://m3.material.io/styles/color/advanced/define-new-colors
- https://m3.material.io/styles/color/advanced/adjust-existing-colors
- https://m3.material.io/styles/color/resources
- https://m3.material.io/foundations/designing/color-contrast (contrast-ratio table)
- https://raw.githubusercontent.com/androidx/androidx/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/ColorSchemeKeyTokens.kt
- .../tokens/ColorLightTokens.kt
- .../tokens/ColorDarkTokens.kt
- .../tokens/PaletteTokens.kt
---
## Elevation
### Principles
- Elevation is applied to all surfaces and components.
- Tokens codify the distance on the z-axis so components appear consistently relative to each other.
- Tokens carry no shadow or color themselves — each platform determines the specific shadow/value used
at each elevation level.
- Elevation can be shown as tonal surface colors **or** shadows.
- Avoid changing the default elevation of Material 3 components.
- Stick to using a small number of elevation levels.
- Elevation is measured as the distance between components along the z-axis in density-independent
pixels (dp).
- To successfully depict elevation, a surface must show: surface edges (contrast with surroundings),
overlap with other surfaces (at rest or in motion), and distance from other surfaces.
### Rules (do/don't)
- **Do** ensure floating elements (e.g. a FAB) have sufficient contrast with the surface beneath them.
**Don't** use colors with insufficient contrast — the relationship between surfaces must stay clear.
- By default, Material 3 surfaces use **tonal difference** to indicate separation; shadows and scrims
are alternative/additional methods.
- Any overlapping containment areas or components should use **different surface/surface-container color
roles** to communicate separation — these color roles are not tied to elevation.
- Shadow size and softness both express distance: small, sharp shadows = close proximity; larger, softer
shadows = more distance.
- "When it comes to applying shadows, less is more. The fewer levels in your UI, the more power they have
to direct attention and action."
- Use **visible shadows** to: (1) protect elements against a patterned/busy background (cards, chips,
buttons), or (2) encourage interaction (elements can temporarily lift on focus/selection/hover; a
raised element can lower when a higher element appears).
- Use a **scrim** to bring focus to elements over a large layered surface (e.g. modals, expanded
navigation menus).
### Values
#### Elevation levels (`ElevationTokens.kt`; matches the site's dp values)
| Level | dp |
| --- | --- |
| 0 | 0dp |
| 1 | 1dp |
| 2 | 3dp |
| 3 | 6dp |
| 4 | 8dp |
| 5 | 12dp |
An element's **resting state** is on levels 03; levels 45 are reserved for user-interacted states
(hover, dragged) — e.g. hovering a FAB increases elevation by 1 level (level 3 → 4), consistently across
all Material buttons.
#### Component resting elevation (from `styles/elevation/tokens`)
| Resting level | dp | Components |
| --- | --- | --- |
| 5 | 12dp | (not assigned as a resting level) |
| 4 | 8dp | (not assigned as a resting level) |
| 3 | 6dp | Date pickers, Dialogs (modal), Extended FAB, FAB, FAB menu (close button), Search, Time pickers |
| 2 | 3dp | App bar (scrolled), Menu, Navigation bar, Rich tooltip, Toolbar |
| 1 | 1dp | Banner, Bottom sheet (modal), Button (elevated), Card (elevated), Chips (elevated), Navigation drawer (modal), Side sheet (modal) |
| 0 | 0dp | App bar (not scrolled), Buttons (filled/tonal/outlined), Button groups, Cards (filled/outlined), Carousel, Chips, Dialog (full-screen), Extended FAB (in nav rail), FAB (in nav rail), FAB menu (list items), Icon buttons, List, Navigation rail, Segmented button, Side sheet (docked), Slider, Split button, Tabs |
#### Scrim
Scrims use the **scrim** color role at **32% opacity**.
### Differences from M2
- **Shadows**: instead of applying shadows by default at all levels, use shadows only when needed for
protection against a background or to encourage interaction.
- **Color**: new color mappings and compatibility with dynamic color.
- **Levels**: elevation is now described in terms of discrete levels (05) rather than continuous dp.
### M3 vs M3 Expressive differences
No "M3 Expressive" update is documented on the fetched elevation pages (`styles/elevation`,
`/applying-elevation`, `/tokens`) — the elevation model appears unchanged by the Expressive update.
### Accessibility requirements
- Edges (surface separation) must create sufficient contrast between surfaces — meeting or exceeding
accessible contrast ratios — to be perceived as separate. Elevation/shadow alone is treated as a visual
cue that must be reinforced with adequate color/tonal contrast, not relied on in isolation.
### Source URLs
- https://m3.material.io/styles/elevation (= /styles/elevation/overview)
- https://m3.material.io/styles/elevation/applying-elevation
- https://m3.material.io/styles/elevation/tokens
- https://raw.githubusercontent.com/androidx/androidx/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/ElevationTokens.kt
---
## Icons
### Principles
- Icons are small symbols to easily identify actions and categories.
- Material Symbols are the new default: a variable icon font set in three styles — **outlined, rounded,
sharp** — across seven weights. (Legacy "Material Icons" remain available but lack the variable-font axes.)
- Design principles (do/don't): simplify icons for clarity/legibility, don't be overly literal or complex;
use geometric, consistent, bold shapes, don't use delicate/loose organic shapes; maintain one consistent
visual style per icon set, don't mix styles within a set.
### Rules (do/don't)
- **Don't** use the lightest weight (100) for standard-size (24dp) icons — minimum weight for that size
should be **200**. Use caution with excessive weight at 24dp too.
- **Do** apply weight consistently across a group (e.g. a navigation rail); **don't** mix different weights.
- **Do** use the same size for a Material Symbol and adjacent text; **don't** mix symbol/text sizes.
- **Do** use the same optical weight for a symbol and adjacent text; **don't** mismatch optical weights.
- **Do** shift a symbol's baseline down ~11.5% of the text size when pairing with text; **don't** use the
same baseline for symbol and text.
- **Do** position icons "on pixel" within the icon grid; **don't** place on non-integer (sub-pixel)
coordinates.
- Corners: default corner radius is **2dp**. For the **outlined** style, interior corners are square (not
rounded); for shapes ≤2dp wide, stroke corners shouldn't be rounded. **Rounded** style: both exterior
and interior corners are rounded. **Sharp** style: both exterior and interior corners reduce from 2dp
to **0dp**. **Caution**: overly round corners reduce legibility. **Don't** use inconsistent corner radii.
- Stroke: recommended stroke weight is **2dp** (regular weight, 400) with consistent weights and squared
stroke terminals; **don't** use inconsistent stroke weights or rounded stroke terminals. Complex icons
may use an optically-corrected **1.5dp** stroke to fit multiple curves in the 24×24dp space.
- **Do** make icons face forward (flat); **don't** tilt, rotate, or render icons as dimensional/isometric.
- Icon content must stay inside the **live area**; it may extend into padding toward the **trim area** if
extra visual weight is needed, but **no part may extend past the trim area**.
### Values
#### Sizes
| Size | Use |
| --- | --- |
| 20dp | Desktop, dense layouts, small-scale visuals |
| 24dp | Standard (baseline) size |
| 40dp | Larger — pairs with display/headline type, larger screens |
| 48dp | Larger — pairs with display/headline type, larger screens |
#### Layout (24dp standard icon)
| Region | Size |
| --- | --- |
| Trim area (total canvas) | 24dp × 24dp |
| Live area | 20dp × 20dp |
| Padding (live→trim) | 2dp around the perimeter |
#### Grid and keyline shapes (within the 24dp grid)
| Keyline | Dimensions |
| --- | --- |
| Square | 18dp × 18dp |
| Circle | 20dp diameter |
| Vertical rectangle | 20dp height × 16dp width |
| Horizontal rectangle | 16dp height × 20dp width |
#### Corner radius by style
| Style | Exterior corner | Interior corner |
| --- | --- | --- |
| Outlined | 2dp rounded | Square (not rounded) |
| Rounded | 2dp rounded | Rounded |
| Sharp | 0dp | 0dp |
#### Material Symbols variable-font axes
| Axis | Range | Notes |
| --- | --- | --- |
| Weight | 100 (thin) 700 (bold) | Also affects overall symbol size; min 200 recommended at 24dp |
| Fill | 0 1 | 0 = unfilled/outlined, 1 = fully filled; communicates state transitions |
| Grade | negative / 0 / positive (site examples: 25, 0) | Finer-grained than weight; doesn't change width or line breaks. Default grade is 0 for a dark icon on light background, **25** for a light icon on dark background (compensates for visual bleed/halation). Positive grade for emphasis (e.g. active state). |
| Optical size | 20dp 48dp | Auto-adjusts stroke weight so the icon "looks the same" as size scales; use 20dp for dense desktop layouts, 4048dp to highlight primary actions |
#### Icon styles (3): Outlined, Rounded, Sharp — see corner-radius table above.
### M3 vs M3 Expressive differences
No "M3 Expressive" update is documented on the fetched icon pages (`styles/icons`,
`/designing-icons`, `/applying-icons`). Material Symbols (three styles + four variable axes) predate the
Expressive update; no Expressive-specific icon changes were found on the site.
### Accessibility requirements
- Icon meaning must always be unambiguous and accessible; use caution displaying icons without labels.
Label text (short, meaningful) should be used especially for navigation.
- Below **20dp**, symbols that are complex/highly-detailed, have multiple parts, or represent a key
action essential to the product should have an accompanying text label. Simple symbols (e.g. a star for
ratings) can be used alone at any size if they remain identifiable.
- **Target size**: a 24dp symbol should have a default **target size of 48dp**.
### Source URLs
- https://m3.material.io/styles/icons (= /styles/icons/overview)
- https://m3.material.io/styles/icons/designing-icons
- https://m3.material.io/styles/icons/applying-icons
---
## Motion
### Principles
- May 2025: Material introduced the **motion physics system** with M3 Expressive — a spring-based system
replacing the previous easing-and-duration system, intended to feel "more alive, fluid, and natural,"
and to be easier to implement/customize.
- Two preset **motion schemes**: **Expressive** (Material's opinionated default; overshoots final values
to add bounce; use for most situations, hero moments, key interactions) and **Standard** (minimal
bounce, eases into final values; for utilitarian products). Custom schemes can also be created.
- A **spring** is defined by three attributes: **stiffness** (hardness — higher = faster resolve),
**damping** (how fast bounce wears out — higher = faster stop; damping = 1 removes bounce entirely),
and **initial velocity** (starting speed, combines with stiffness/damping to affect total duration).
- Springs are versatile (one spring can drive transitions, button effects, gestures — consistent feel)
and feel natural/predictable, handling gesture interruption and retargeting seamlessly.
- Two spring **styles**: **Spatial** (movement — x/y position, rotation, size, rounded corners; overshoots
and bounces into place) and **Effects** (color, opacity; no overshoot).
- Three **speeds** per style: **default**, **fast**, **slow**. Most motion uses default; smaller elements
may use fast; larger elements may use slow. The exact values differ by device class (wearable/phone/
tablet) even though the *relative* speed ordering (fast < default < slow) is constant.
- Token naming: e.g. `md.sys.motion.spring.fast.spatial` — the scheme (expressive/standard) is applied at
the product level, not baked into the token name, so schemes can be swapped without re-tokenizing.
- On Jetpack Compose, **21 Material components** use the motion physics system by default (Android Views/
MDC-Android: available but not yet wired into components; Web: compatible via curve approximations).
- Legacy **easing and duration** tokens remain available as a fallback and are still used for transition
animation on platforms/paths not yet migrated to springs.
### Rules (do/don't; easing & duration choice)
- Choose the **Emphasized** easing set for most transitions (captures the M3 style); use **Standard**
only for small, utility-focused, quick transitions, and as the fallback on platforms without Emphasized
support (iOS, Web).
- Easing by transition type:
- **Begin and end on screen****Emphasized** (speeds up quickly, gentle rest, emphasizes the end).
- **Enter the screen****Emphasized decelerate** (starts at peak velocity, gentle rest).
- **Exit the screen permanently****Emphasized accelerate** (starts at rest, ends at peak velocity —
implies it can't be retrieved).
- **Exit the screen temporarily****Emphasized** (ends at rest just off-screen — implies it can be
retrieved, e.g. a drawer).
- Duration by transition size: small-area transitions get short durations; large-area transitions get
long durations (consistent sense of speed).
- Duration by direction: **exit/dismiss/collapse** → shorter durations (less attention needed); **enter/
persist** → longer durations (focus attention on what's new).
- Suggested easing+duration pairs (defaults good for most transitions):
| Easing | Duration | Transition type |
| --- | --- | --- |
| Emphasized | 500ms | Begin and end on screen |
| Emphasized decelerate | 400ms | Enter the screen |
| Emphasized accelerate | 200ms | Exit the screen |
| Standard | 300ms | Begin and end on screen |
| Standard decelerate | 250ms | Enter the screen |
| Standard accelerate | 200ms | Exit the screen |
- Motion customization levels: **Level 1** — use a default scheme (expressive/standard) as-is; **Level 2**
— create a custom `MotionScheme` (custom `AnimationSpec` per property) applied product-wide; **Level 3**
— swap the scheme per element/screen (override the `CompositionLocal`) while most of the product uses
the primary scheme.
- Transition patterns documented: container transform (full-screen and within-screen), forward/backward,
lateral, top-level, enter/exit (within screen bounds and beyond screen bounds), skeleton loaders.
### Values
#### Expressive motion scheme — spring tokens (`ExpressiveMotionTokens.kt`)
| Speed | Style | Damping | Stiffness |
| --- | --- | --- | --- |
| Fast | Spatial | 0.6 | 800.0 |
| Fast | Effects | 1.0 | 3800.0 |
| Default | Spatial | 0.8 | 380.0 |
| Default | Effects | 1.0 | 1600.0 |
| Slow | Spatial | 0.8 | 200.0 |
| Slow | Effects | 1.0 | 800.0 |
#### Standard motion scheme — spring tokens (`StandardMotionTokens.kt`)
| Speed | Style | Damping | Stiffness |
| --- | --- | --- | --- |
| Fast | Spatial | 0.9 | 1400.0 |
| Fast | Effects | 1.0 | 3800.0 |
| Default | Spatial | 0.9 | 700.0 |
| Default | Effects | 1.0 | 1600.0 |
| Slow | Spatial | 0.9 | 300.0 |
| Slow | Effects | 1.0 | 800.0 |
#### Web curve equivalents for springs (from `styles/motion/overview/specs`; springs aren't natively
supported on the web, so Web should use these approximated cubic-bezier curves with matching duration)
| Spring | Cubic-bezier | Duration |
| --- | --- | --- |
| Expressive fast spatial | 0.42, 1.67, 0.21, 0.90 | 350ms |
| Expressive default spatial | 0.38, 1.21, 0.22, 1.00 | 500ms |
| Expressive slow spatial | 0.39, 1.29, 0.35, 0.98 | 650ms |
| Expressive fast effects | 0.31, 0.94, 0.34, 1.00 | 150ms |
| Expressive default effects | 0.34, 0.80, 0.34, 1.00 | 200ms |
| Expressive slow effects | 0.34, 0.88, 0.34, 1.00 | 300ms |
| Standard fast spatial | 0.27, 1.06, 0.18, 1.00 | 350ms |
| Standard default spatial | 0.27, 1.06, 0.18, 1.00 | 500ms |
| Standard slow spatial | 0.27, 1.06, 0.18, 1.00 | 750ms |
| Standard fast effects | 0.31, 0.94, 0.34, 1.00 | 150ms |
| Standard default effects | 0.34, 0.80, 0.34, 1.00 | 200ms |
| Standard slow effects | 0.34, 0.88, 0.34, 1.00 | 300ms |
#### Legacy easing tokens (`styles/motion/easing-and-duration/tokens-specs`; curves confirmed in
androidx `MotionTokens.kt`, noted where Compose's single-cubic-bezier approximation differs from the
platform-accurate multi-segment Android `PathInterpolator`)
| Token | Android | CSS | Flutter | iOS |
| --- | --- | --- | --- | --- |
| `md.sys.motion.easing.emphasized` | PathInterpolator(M 0,0 C 0.05,0 0.133333,0.06 0.166666,0.4 C 0.208333,0.82 0.25,1 1,1) | N/A (use Standard) | `easeInOutCubicEmphasized` | N/A (use Standard) |
| `md.sys.motion.easing.emphasized.decelerate` | PathInterpolator(0.05, 0.7, 0.1, 1) | cubic-bezier(0.05,0.7,0.1,1.0) | Cubic(0.05,0.7,0.1,1.0) | ControlPoints 0.05,0.7,0.1,1.0 |
| `md.sys.motion.easing.emphasized.accelerate` | PathInterpolator(0.3, 0, 0.8, 0.15) | cubic-bezier(0.3,0.0,0.8,0.15) | Cubic(0.3,0.0,0.8,0.15) | ControlPoints 0.3,0.0,0.8,0.15 |
| `md.sys.motion.easing.standard` | PathInterpolator(0.2, 0, 0, 1) | cubic-bezier(0.2,0.0,0,1.0) | Cubic(0.2,0.0,0,1.0) | ControlPoints 0.2,0.0,0.0,1.0 |
| `md.sys.motion.easing.standard.decelerate` | PathInterpolator(0, 0, 0, 1) | cubic-bezier(0,0,0,1) | Cubic(0,0,0,1) | ControlPoints 0.0,0.0,0.0,1.0 |
| `md.sys.motion.easing.standard.accelerate` | PathInterpolator(0.3, 0, 1, 1) | cubic-bezier(0.3,0,1,1) | Cubic(0.3,0,1,1) | ControlPoints 0.3,0.0,1.0,1.0 |
Compose `MotionTokens.kt` also defines `EasingLegacyCubicBezier` = CubicBezierEasing(0.4, 0.0, 0.2, 1.0)
(M2-era "standard" curve) and `EasingLinearCubicBezier` = (0,0,1,1) — not shown on the site pages fetched
(not on the site; from androidx `MotionTokens.kt`).
#### Legacy duration tokens (site table; matches androidx `MotionTokens.kt` exactly)
| Token | Value |
| --- | --- |
| `md.sys.motion.duration.short1` | 50ms |
| `md.sys.motion.duration.short2` | 100ms |
| `md.sys.motion.duration.short3` | 150ms |
| `md.sys.motion.duration.short4` | 200ms |
| `md.sys.motion.duration.medium1` | 250ms |
| `md.sys.motion.duration.medium2` | 300ms |
| `md.sys.motion.duration.medium3` | 350ms |
| `md.sys.motion.duration.medium4` | 400ms |
| `md.sys.motion.duration.long1` | 450ms |
| `md.sys.motion.duration.long2` | 500ms |
| `md.sys.motion.duration.long3` | 550ms |
| `md.sys.motion.duration.long4` | 600ms |
| `md.sys.motion.duration.extra-long1` | 700ms |
| `md.sys.motion.duration.extra-long2` | 800ms |
| `md.sys.motion.duration.extra-long3` | 900ms |
| `md.sys.motion.duration.extra-long4` | 1000ms |
### M3 vs M3 Expressive differences
- **Physics-based motion system** (springs: stiffness/damping/initial velocity) replaces the
easing-and-duration system as the primary motion model. Legacy easing/duration tokens remain as a
fallback and still drive some transitions.
- Two schemes, **Expressive** and **Standard**, selectable/customizable at up to 3 levels of depth
(default scheme → custom scheme → per-element scheme override).
- Spring tokens split into **spatial** vs **effects**, each with **fast/default/slow** — a 2×3 matrix per
scheme (12 values total across both schemes).
- 21 Jetpack Compose components use the physics system by default; component motion is largely driven by
just two tokens in practice: **expressive fast spatial** and **expressive fast effects**.
- Shape morph (see Shape section) uses the **expressive** motion scheme by default.
### Accessibility requirements
- No general "prefers-reduced-motion" policy was found stated on `styles/motion` itself. Reduced-motion
handling is documented per component instead — e.g. `components/carousel/accessibility`: "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," and hero carousels should keep the
small item only partially visible rather than animating it. (Component-level source, included here
because it is the only "reduced motion" guidance found in the crawl.)
- `components/menus/guidelines`: "In dense products, such as on desktop, menus can open instantly to
reduce motion."
### Source URLs
- https://m3.material.io/styles/motion (= /styles/motion/overview = /styles/motion/overview/how-it-works)
- https://m3.material.io/styles/motion/overview/specs
- https://m3.material.io/styles/motion/easing-and-duration
- https://m3.material.io/styles/motion/easing-and-duration/tokens-specs
- https://m3.material.io/styles/motion/transitions
- https://m3.material.io/components/carousel/accessibility (reduced motion example)
- https://m3.material.io/components/menus/guidelines (reduced motion example)
- https://raw.githubusercontent.com/androidx/androidx/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/MotionTokens.kt
- .../tokens/ExpressiveMotionTokens.kt
- .../tokens/StandardMotionTokens.kt
---
## Shape
### Principles
- The M3 shape system includes original shapes, a corner radius scale, and built-in shape morphing.
- Use abstract shapes thoughtfully to add emphasis and decorative flair; leverage Material shapes for
built-in shape morphing.
- Rectangular shapes are fully rounded in all corners by default; individual corners can be adjusted to
create asymmetrical shapes.
- Use shape and type "in harmony" — M3 shapes are designed to echo key visual attributes of M3 typography
(e.g. M3 shapes and Google Sans Flex share roundness attributes).
- Shape morph should respond to user interaction and connect function and feeling — use it to communicate
interaction states (e.g. a selected button), actions in progress (e.g. typing, loading), or environment
changes (sound, temperature, time of day).
- "Be bold and dare to embrace tension" — contrast (square + rounded, unconventional shapes) creates more
dynamic, memorable, expressive design; Material historically favored rounded shapes only.
- Shape is **versatile, not semantic** — avoid making a shape literal or assigning it one fixed meaning
(e.g. a wavy loading indicator isn't a literal progress waveform and could be reused elsewhere).
- Use abstract shapes sparingly — don't compromise clarity for visual design; be intentional and consider
how a shape fits the product narrative.
- Emphasize aesthetic moments (imagery, avatar masking) with shape — these are the most flexible,
creative uses.
- Shape (with motion) can create a 2.5D illusion of depth on flat visuals.
### Rules (do/don't)
- Components can have symmetric (all corners equal) or asymmetric ("inner corner") shapes; both use the
same 10-step scale. Asymmetric/inner corners are used in components with closely-grouped items (menus,
split buttons) — inner-corner component tokens always map to individual corner-shape tokens.
- Customization happens at **style** level (changing what a named style like "medium" resolves to —
affects every component mapped to it, unless overridden) or **component** level (remapping one
component to a different named style, e.g. remapping a button's default "full" to "small"/"medium").
- The shape style family can be customized from **rounded** to **cut** (straight line instead of curve);
add extra padding to avoid clipping content, especially in information-dense components. **Caution**:
don't apply large/full corners to information-dense components (e.g. cards).
- **Optical roundness**: when nesting rounded objects, don't reuse the same radius for both. Formula:
**outer radius padding = inner radius** (example given: 48dp 14dp = 34dp). **Do** use different,
proportional radii for nested components; **don't** use the same radius as the container.
- Use the shape library for mostly visual/decorative elements; avoid unconventional shapes on text-heavy
containers; use shapes sparingly for emphasis and delight.
### Values
#### Corner radius scale (10 styles; matches androidx `ShapeTokens.kt` exactly)
| # | Style | Value |
| --- | --- | --- |
| 1 | None | 0dp |
| 2 | Extra small | 4dp |
| 3 | Small | 8dp |
| 4 | Medium | 12dp |
| 5 | Large | 16dp |
| 6 | Large increased | 20dp |
| 7 | Extra large | 28dp |
| 8 | Extra large increased | 32dp |
| 9 | Extra extra large | 48dp |
| 10 | Full | fully rounded (Compose: `CircleShape`, not a fixed dp value) |
(M2 used a 3-level shape scale based on component-container size; M3 uses this 10-level scale based on
corner roundedness instead.)
#### Shape library
35 pre-made shapes in the Material shape library (Figma Design Kit / Jetpack Compose `MaterialShapes`
API), designed to morph seamlessly into one another. Used by the standard **button group** and
**loading indicator** components by default.
### M3 vs M3 Expressive differences (May 2025 "M3 Expressive update")
- Added **35 new shapes** and **shape morphing** to the Material Shape Library (Figma) and Jetpack
Compose (`MaterialShapes` API).
- Added new shape principles and a refreshed art direction.
- Added corner-radius tokens: **Large increased (20dp)**, **Extra large increased (32dp)**,
**Extra extra large (48dp)** — these three styles are new to the 10-step scale.
- Updated "fully rounded" corners to use the dedicated **Full** style; previously this was defined as
50% of the component's size.
- Shape morphing uses the **expressive** motion scheme by default (see Motion section); can be switched
to the standard scheme. Access is via a platform API — Android: Compose `MaterialShapes`; Web: not
currently available.
### Accessibility requirements
No shape-specific accessibility requirements (contrast ratios, color-alone rule, reduced motion) were
found on the fetched shape pages. The only adjacent accessibility-flavored guidance is the "optical
roundness" and "cut corners need extra padding" rules above, which are about legibility/clipping rather
than a formal accessibility requirement.
### Source URLs
- https://m3.material.io/styles/shape (= /styles/shape/overview-principles)
- https://m3.material.io/styles/shape/corner-radius-scale
- https://m3.material.io/styles/shape/shape-morph
- https://raw.githubusercontent.com/androidx/androidx/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/ShapeTokens.kt
---
## Typography
### Principles
- Use typography to make content readable and beautiful.
- M3 has **one type scale** containing two sets of type styles: **15 baseline** and **15 emphasized**
(added in the Expressive update) — 30 styles total, both sets following the same scale from Display
Large to Label Small.
- Five style **roles**: **Display, Headline, Title, Body, Label** — each with Large/Medium/Small (Display/
Headline/Title/Body) — see the type-scale table below for exact members.
- **Display**: largest text on screen; short, important text/numerals; best on large screens; can use
a more expressive/handwritten/script font; set optical size appropriately.
- **Headline**: short, high-emphasis text on smaller screens; can use expressive typefaces if line
height/letter spacing are adjusted to preserve readability.
- **Title**: medium-emphasis, relatively short text (e.g. dividing secondary content); use caution with
expressive/display/handwritten/script fonts here.
- **Body**: longer passages; use typefaces meant for body copy, readable at small size; avoid
expressive/decorative fonts.
- **Label**: small, utilitarian text inside components (e.g. buttons use Label Large) or captions.
- Two typeface roles: **Brand** (larger styles — Display/Headline — focused on expression) and **Plain**
(smaller styles — Body/Label — focused on readability). Roboto is the default for both.
- Typesetting: use **padding + bounding box** method for Web/iOS (line height = bounding box height, text
vertically centered — CSS "half-leading"); use the **baseline** method for Android/platform-agnostic
specs (distances measured to the invisible baseline a line of text rests on).
- **Line height**: recommend a **1.2×** ratio of type size for larger styles (title/headline/display);
recommend **~1.5×** for smaller styles (body/label) — too tight undermines flow, too loose feels
incohesive.
- Use **tabular (monospaced) numbers**, not proportional digits, wherever values change often (tables,
clocks) to prevent layout shift and aid scanning.
- Language script height: line height can auto-adapt by script category so components (with vertical
padding) don't clip. Categories: **Small/base** (~Latin, Cyrillic, Greek, Hebrew, except Vietnamese);
**Medium** (~7% taller: Arabic, Chinese, Hindi, Japanese, Korean, Vietnamese, most others); **Large**
(~30% taller: Burmese, Telugu); **Extra large** (~100% taller: Nastaliq). Default to Medium since most
languages fall there; components with a fixed height are built for Small and may not auto-adapt.
- Material's default type scale follows the **Major Second (1.125)** modular scale, anchored to 14 (the
base size for body text).
### Rules (do/don't)
- Emphasized styles are **not** used by default by Material components — swap the baseline token for the
matching emphasized token (e.g. `md.sys.typescale.display-large` → `md.sys.typescale.emphasized.
display-large`) deliberately.
- Recommended emphasized-style use: badges, buttons (primary actions), extended FAB, selected list items,
selected menu items, and generally selection/actions/headlines/other editorial-treatment moments —
not just for decoration.
- Emphasized can be combined with **weight** (apply to already-bold text) and **context** (apply
selectively to selected/unread/key-interaction states) simultaneously.
- When customizing the scale: change brand/plain typeface tokens if swapping fonts; adjust line height
and letter spacing to refine appearance; **avoid changing type size** (can break component
layout/reflow); keep both baseline and emphasized styles visually consistent (e.g. all wider than
baseline, not just some).
- Customizing the M3 type scale or individual styles may forgo future Material typography token updates.
- Hyperlinks: use **primary** (or **tertiary** for a less prominent link) and the text must be
**underlined** — never color alone.
- **Do** use the same size and same optical weight for Material Symbols paired with text; **don't** mix.
### Values
#### Type scale — baseline set (`TypeScaleTokens.kt`)
| Style | Size | Line height | Tracking | Weight | Family |
| --- | --- | --- | --- | --- | --- |
| Display Large | 57sp | 64sp | 0.2sp | Regular | Brand |
| Display Medium | 45sp | 52sp | 0sp | Regular | Brand |
| Display Small | 36sp | 44sp | 0sp | Regular | Brand |
| Headline Large | 32sp | 40sp | 0sp | Regular | Brand |
| Headline Medium | 28sp | 36sp | 0sp | Regular | Brand |
| Headline Small | 24sp | 32sp | 0sp | Regular | Brand |
| Title Large | 22sp | 28sp | 0sp | Regular | Brand |
| Title Medium | 16sp | 24sp | 0.2sp | Medium | Plain |
| Title Small | 14sp | 20sp | 0.1sp | Medium | Plain |
| Body Large | 16sp | 24sp | 0.5sp | Regular | Plain |
| Body Medium | 14sp | 20sp | 0.2sp | Regular | Plain |
| Body Small | 12sp | 16sp | 0.4sp | Regular | Plain |
| Label Large | 14sp | 20sp | 0.1sp | Medium | Plain |
| Label Medium | 12sp | 16sp | 0.5sp | Medium | Plain |
| Label Small | 11sp | 16sp | 0.5sp | Medium | Plain |
#### Type scale — emphasized set (Expressive; same size/line-height/family as baseline, heavier weight
and slightly different tracking on some styles)
| Style | Size | Line height | Tracking | Weight | Family |
| --- | --- | --- | --- | --- | --- |
| Display Large Emphasized | 57sp | 64sp | 0sp | Medium | Brand |
| Display Medium Emphasized | 45sp | 52sp | 0sp | Medium | Brand |
| Display Small Emphasized | 36sp | 44sp | 0sp | Medium | Brand |
| Headline Large Emphasized | 32sp | 40sp | 0sp | Medium | Brand |
| Headline Medium Emphasized | 28sp | 36sp | 0sp | Medium | Brand |
| Headline Small Emphasized | 24sp | 32sp | 0sp | Medium | Brand |
| Title Large Emphasized | 22sp | 28sp | 0sp | Medium | Brand |
| Title Medium Emphasized | 16sp | 24sp | 0.15sp | Bold | Plain |
| Title Small Emphasized | 14sp | 20sp | 0.1sp | Bold | Plain |
| Body Large Emphasized | 16sp | 24sp | 0.15sp | Medium | Plain |
| Body Medium Emphasized | 14sp | 20sp | 0.25sp | Medium | Plain |
| Body Small Emphasized | 12sp | 16sp | 0.4sp | Medium | Plain |
| Label Large Emphasized | 14sp | 20sp | 0.1sp | Bold | Plain |
| Label Medium Emphasized | 12sp | 16sp | 0.5sp | Bold | Plain |
| Label Small Emphasized | 11sp | 16sp | 0.5sp | Bold | Plain |
(Size, line height and font family are unchanged between a baseline style and its emphasized counterpart;
weight steps up — Regular→Medium or Medium→Bold — and a few styles gain slightly wider tracking. Source:
`TypeScaleTokens.kt`; the site's `styles/typography/type-scale-tokens` page describes these sets in prose
and confirms "30 styles: 15 baseline, 15 emphasized" and the naming convention, but renders the actual
numeric table only inside an interactive, non-text widget.)
`TypefaceTokens.kt` weight mapping used above: Regular = `FontWeight.Normal`, Medium = `FontWeight.
Medium`, Bold = `FontWeight.Bold`; Brand/Plain both default to the platform sans-serif (Compose fallback;
the actual M3 web/design spec default typeface is **Roboto** for both Brand and Plain, per
`styles/typography/fonts`).
#### Fonts
| Font | Role | Notes |
| --- | --- | --- |
| Roboto | Default (Brand + Plain) | Static; used in the M3 type scale by default; 3,300+ glyphs |
| Roboto Flex | Variable, not yet part of the type scale | Axes: Slant, Width, Weight, Grade, Optical Size, plus advanced axes (thick stroke XOPQ, thin stroke YOPQ, counter width XTRA, uppercase height YTUC, lowercase height YTLC, ascender height YTAS, descender depth YTDE, figure height YTFI); width range 25150 (example) |
| Roboto Serif | Variable | Reading-focused; wide weight/width range |
| Roboto Mono | Variable | Axes: Weight, Italic; monospaced, good for code/tabular numbers |
| Noto Sans | Fallback | Axes: Width, Weight, Italic; 150+ scripts, used when a language is unsupported |
Font fallback order (e.g. for Roboto Flex): **Roboto Flex → Roboto → Noto Sans**.
#### Units
| Platform | Font size unit | Conversion | Letter spacing unit | Conversion |
| --- | --- | --- | --- | --- |
| Android | sp | 1.0 (base) | em | tracking(px) / font size(sp) |
| Web | rem | sp / 16 = rem (16px root default) | rem | tracking(px) / font size(sp) |
Example: 16sp = 1rem; 24sp = 1.5rem; 60sp = 3.75rem. 0.2 tracking / 16sp = 0.0125 em (or rem on web).
### M3 vs M3 Expressive differences (May 2025 "M3 Expressive update")
- Added **15 emphasized type styles** alongside the existing 15 baseline styles — 30 total. Emphasized
styles have higher weight (and minor tracking adjustments) and are meant for bold/selection/emphasis
moments; baseline and emphasized are meant to be used together, not as a full replacement.
- New **emphasized token namespace**: `md.sys.typescale.emphasized.<style>` alongside
`md.sys.typescale.<style>`.
- (Aug 2026, post-Expressive) **Language script height support**: line height can automatically adapt by
language script category (small/medium/large/extra large) — see Principles above.
- Roboto Flex / variable-font axis guidance (weight, grade, width, optical size — "editorial treatments")
is presented as an Expressive-era authoring technique, though Roboto Flex itself is "not yet part of
the M3 type scale."
### Accessibility requirements
- Text contrast: **≥3:1** for large text (≥14pt bold / ≥18pt regular) and graphics; **≥4.5:1** for small
text (restated from `foundations/designing/color-contrast`).
- Default typography color is **on surface** (or **on surface variant**).
- **Color-alone rule**: hyperlinked text must be **underlined**, in addition to using primary/tertiary color.
- Avoid very light/thin weights for body text — low-resolution displays can struggle to render thin type,
especially at small sizes; reserve very light weights for larger sizes such as display type.
- Avoid excessive weight/bold at small label sizes — it can reduce readability (navigation-bar example).
- Tabular numbers should be used where digits change frequently, to avoid disorienting layout shifts.
### Source URLs
- https://m3.material.io/styles/typography (= /styles/typography/overview)
- https://m3.material.io/styles/typography/type-scale-tokens
- https://m3.material.io/styles/typography/fonts
- https://m3.material.io/styles/typography/applying-type
- https://m3.material.io/styles/typography/editorial-treatments
- https://raw.githubusercontent.com/androidx/androidx/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/TypeScaleTokens.kt
- .../tokens/TypefaceTokens.kt
+119 -656
View File
@@ -6,11 +6,10 @@
"": {
"devDependencies": {
"@material/material-color-utilities": "^0.4.0",
"@tailwindcss/vite": "^4.3.3",
"esbuild": "^0.28.2",
"fontkit": "^2.0.4",
"laravel-vite-plugin": "^3.2.0",
"playwright": "^1.62.1",
"tailwindcss": "^4.3.3",
"vite": "^8.2.2"
}
},
@@ -456,56 +455,6 @@
"node": ">=18"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@material/material-color-utilities": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@material/material-color-utilities/-/material-color-utilities-0.4.0.tgz",
@@ -803,288 +752,55 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
"integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.24.1",
"jiti": "^2.7.0",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.3.3"
"base64-js": "^1.1.2"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
"integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.3.3",
"@tailwindcss/oxide-darwin-arm64": "4.3.3",
"@tailwindcss/oxide-darwin-x64": "4.3.3",
"@tailwindcss/oxide-freebsd-x64": "4.3.3",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
"@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
"@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
"@tailwindcss/oxide-linux-x64-musl": "4.3.3",
"@tailwindcss/oxide-wasm32-wasi": "4.3.3",
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
"@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
"integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
"integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
"integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
"integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
"integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
"integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
"integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
"integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
"integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
"integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util",
"@emnapi/wasi-threads",
"tslib"
],
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.11.1",
"@emnapi/runtime": "^1.11.1",
"@emnapi/wasi-threads": "^1.2.2",
"@napi-rs/wasm-runtime": "^1.1.4",
"@tybys/wasm-util": "^0.10.2",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
"integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
"integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
"integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tailwindcss/node": "4.3.3",
"@tailwindcss/oxide": "4.3.3",
"tailwindcss": "4.3.3"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7 || ^8"
"node": ">=0.8"
}
},
"node_modules/detect-libc": {
@@ -1097,19 +813,12 @@
"node": ">=8"
}
},
"node_modules/enhanced-resolve": {
"version": "5.25.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.0.tgz",
"integrity": "sha512-ghq3mhs649mvbarTCAlZn2wRhbfHmzAFiKxoWA14B3VtqnxtZt+wz8BroKXU0tF3GiJsnUldjVncQGZ8qk4rdA==",
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.3.3"
},
"engines": {
"node": ">=10.13.0"
}
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.28.2",
@@ -1153,6 +862,13 @@
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1171,6 +887,24 @@
}
}
},
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1186,23 +920,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/laravel-vite-plugin": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.2.0.tgz",
@@ -1230,289 +947,6 @@
}
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/nanoid": {
"version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
@@ -1532,6 +966,13 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1610,6 +1051,13 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"dev": true,
"license": "MIT"
},
"node_modules/rolldown": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz",
@@ -1654,27 +1102,13 @@
"node": ">=0.10.0"
}
},
"node_modules/tailwindcss": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"dev": true,
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1692,6 +1126,35 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"dev": true,
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/vite": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
+4 -3
View File
@@ -7,15 +7,16 @@
"dev": "vite",
"build:scheme": "esbuild bin/scheme.mjs --bundle --platform=node --format=esm --target=node20 --minify --legal-comments=eof --outfile=resources/node/scheme.mjs",
"build:shapes": "node bin/shapes.mjs",
"build:loading": "node bin/loading-indicator.mjs"
"build:loading": "node bin/loading-indicator.mjs",
"build:springs": "node bin/springs.mjs",
"check:font": "node bin/check-font.mjs"
},
"devDependencies": {
"@material/material-color-utilities": "^0.4.0",
"@tailwindcss/vite": "^4.3.3",
"esbuild": "^0.28.2",
"fontkit": "^2.0.4",
"laravel-vite-plugin": "^3.2.0",
"playwright": "^1.62.1",
"tailwindcss": "^4.3.3",
"vite": "^8.2.2"
}
}
+3 -2
View File
@@ -1,10 +1,11 @@
@verbatim
## Livewire Material
This application uses `nonameweb/livewire-material`: Material 3 Expressive components for Laravel and Livewire, built on Tailwind CSS. It replaces UI kits such as maryUI, daisyUI and Flux in this application.
This application uses `nonameweb/livewire-material`: Material 3 Expressive components for Laravel and Livewire, in plain CSS. It replaces UI kits such as maryUI, daisyUI and Flux, and Tailwind CSS, in this application.
- Components are anonymous Blade components, unprefixed unless `config/livewire-material.php` sets a `prefix`. Before writing or changing a view that uses them, activate the `livewire-material-development` skill for the props, slots and traps of each component.
- Never write maryUI tags (`<x-mary-*>`) or daisyUI classes (`btn`, `card`, `badge`, `bg-base-200`, `text-base-content`). They compile to nothing and fail silently.
- The CSS entry imports `foundation.css` first, then the stylesheet of each component the views render (or `all.css` for all of them). A component whose stylesheet is not imported renders unstyled; `DesignGuard::missingStylesheets()` names each missing `@import`.
- Never write a utility class Tailwind's, the library's 1.x ones or daisyUI's — or a maryUI tag. Nothing defines them, so they compile to nothing and fail silently. Layout is the layout components (`<x-row>`, `<x-stack>`, `<x-grid>`, `<x-surface>`, `<x-pane>`), text is `md-type-*` and `md-ink-*`, and everything else is the application's own CSS on `--md-sys-*` custom properties.
- Every layout includes `<x-theme-script />` in `<head>` before `@vite`. The colour scheme is generated with `php artisan material:scheme` never edit `resources/css/material-scheme.css` by hand. With colour profiles (`livewire-material.profiles`), run it without a seed after changing them; the active profile comes from `Scheme::resolveProfileUsing()`.
- While the application runs locally, every token and component renders in the application's own scheme at `/material` (the showcase).
- HTTP error pages and the Markdown mail theme come from the package. Change error wording by publishing `--tag=livewire-material-errors`; select the mail theme with `MAIL_MARKDOWN_THEME=livewire-material::mail.theme`.
@@ -0,0 +1,63 @@
@verbatim
## Material 3
Every view in this application is Material 3 Expressive (m3.material.io), through `nonameweb/livewire-material`. These rules decide what to write; the `material-3-design` skill carries the tables, the numbers and Google's source pages behind each one activate it before designing a screen.
The library is plain CSS on M3's tokens, and there are no utility classes: a Tailwind class, or one of the library's 1.x utilities (bg-primary, type-body-md, medium:hidden), compiles to nothing. A view is written three ways:
- Components and their props: `<x-button variant="filled">`, and the layout components `<x-row>`, `<x-stack>`, `<x-grid>`, `<x-feed>`, `<x-surface>` and `<x-pane>`, whose `gap` and `padding` take a spacing token (`space200`) and whose `hide-below`, `hide-from` and `stack-below` take a window size class.
- A fixed set of classes for text and interaction on plain elements: `md-type-*`, `md-ink-*`, `md-text-*`, `md-truncate`, `md-tabular`, `md-visually-hidden`, `md-state-layer`, `md-focus-ring`, `md-touch-target` and `md-link`.
- The application's own CSS, named by the application, whose values are `--md-sys-*` custom properties.
### Colour
- A colour is always a role: `md-ink-variant` on text, `var(--md-sys-color-outline-variant)` in the application's CSS, `color="error"` on a component. Never a hex, a palette tone or an opacity.
- Pair a role only with its `on-` partner: a `primary` fill takes `on-primary` text, a `secondary-container` fill takes `on-secondary-container`. That pair is the one whose contrast is guaranteed at every contrast level; mixing pairs (`primary-container` under `on-surface`) is not.
- `primary` is the one key action on a screen (a filled button; the FAB in `primary-container`). `secondary-container` is the quiet fill (tonal buttons, selected navigation, selected chips). `tertiary` is a contrasting accent, used rarely. `error`, `success`, `warning`, `info` mean state and nothing else: the `-container` for a tinted panel, the role itself for its text and icon.
- Ink is `on-surface` (`md-ink`); lower emphasis is `on-surface-variant` (`md-ink-variant`); decoration is `outline` (`md-ink-quiet`). Never dim ink with an opacity: 38% means disabled.
- `outline` is a boundary that must be read (a text field, the edge of a target). `outline-variant` is a divider or a card edge (`<x-divider>`, `<x-surface outlined>`). Never `outline` on a divider.
- Fixed and dim roles (`primary-fixed`, `surface-dim`, ) are for a colour that must not change with the theme; if unsure, don't. Inverse roles only on an inverse surface (the snackbar).
- A link in running text is underlined (`md-link`, with `md-ink-primary`); colour alone signals nothing.
- Contrast: 4.5:1 for text, 3:1 for large text, icons and grouped controls; disabled is exempt. Three contrast levels exist (`<html data-contrast>`: standard, medium, high) and every role changes with them which is why only roles are allowed.
### Surfaces and elevation
- The page is `surface`. Panels separate by tone first: `surface-container-lowest` `surface-container-highest` is a hierarchy of emphasis, not of height (`<x-surface level="surface-container-high">`). Navigation chrome is `surface-container`; a dialog, a menu, the search bar are `surface-container-high`; a modal sheet is `surface-container-low`; a filled card is `surface-container-highest`. A region keeps its role at every width.
- Shadows (`var(--md-sys-elevation-1)` `-5`) are for what floats or lifts: 1 for elevated cards, buttons and modal sheets; 2 for menus, the navigation bar, a scrolled app bar; 3 for the FAB, dialogs, pickers and search; one level more on hover; nothing rests above 3. Fewer shadows carry more meaning.
- A scrim is `scrim` at 32%: `color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent)`.
### Shape
- Corners come from the scale `var(--md-sys-shape-corner-{none|xs|sm|md|lg|lg-increased|xl|xl-increased|xxl|full})`, or `<x-surface corner="md">`; never a length of your own.
- By family: `full` buttons, icon buttons, chips' avatars, badges, switches, sliders, the search bar, navigation indicators; `xs` text fields, menus, snackbars, plain tooltips; `sm` chips; `md` cards, rich tooltips; `lg` the FAB and a side sheet's inner corners; `xl` dialogs, bottom sheets, the search view, pickers, carousel items; `xxl` large hero containers.
- Nested shapes: inner radius = outer radius padding; never the same radius inside and out.
- A press squares a round shape (the components do it; nothing morphs on hover). The 35 `<x-shape>`s are decoration, never meaning, used sparingly.
### Type
- Every text element carries one `md-type-*` class: `display` for hero figures and short marketing lines; `headline` for page and section titles; `title` for card, dialog and list-section titles; `body` for paragraphs (`md-type-body-lg` for reading); `label` inside components (buttons, chips, tabs, captions). In the application's CSS a style is `font: var(--md-sys-typescale-body-md)` with its `-tracking`; never a size, weight, line height or letter spacing of your own.
- `md-type-emphasized-*` is opt-in: a selected item, a primary action, a headline, a badge not decoration.
- 4060 characters per line; `md-tabular` on figures that change; text must scale to 200% without loss (containers grow, rows wrap, no fixed heights on text, no ellipsis without a way to read the rest).
### Motion
- Position, size and shape move on the spatial springs (they overshoot): `transition: transform var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default)` `fast` for small elements, `slow` for large ones. Colour and opacity move on the effects springs (`--md-sys-motion-effects-*`), which never overshoot. Always pair an easing with its duration.
- Entering decelerates, a permanent exit accelerates, a temporary exit (a sheet, a drawer) takes the emphasized curve; exits are shorter than entrances.
- Everything that moves goes through these tokens, so reduced motion makes it instant; a literal duration is a bug.
### States and targets
- Interactive elements carry `md-state-layer md-focus-ring`: hover 8%, focus 10%, pressed 10%, dragged 16% (`data-md-dragged`) of the content colour. Disabled is content at 38% and a container at 12% of `on-surface` (`--md-sys-state-disabled-content-opacity`, `--md-sys-state-disabled-container-opacity`, through `color-mix()`), with no state layer. Every state shows two indicators: colour plus a shape, an outline, an icon or a word.
- Every target is at least 48×48px with 8px between targets (`md-touch-target` on anything drawn smaller); a denser layout is an opt-in prop, never a default.
- Keyboard: Tab between components, arrows within one, Enter and Space activate, Escape dismisses; a dialog takes focus and gives it back to what opened it.
### Layout and breakpoints
- Widths are M3's window size classes and only those: compact below 600px (the default), medium 600, expanded 840, large 1200, extra-large 1600. A layout component takes them as props (`<x-row stack-below="medium">`, `<x-stack hide-from="expanded">`, `<x-grid :columns="['compact' => 1, 'expanded' => 2]">`); the application's CSS writes `@media (width >= 840px)`; a script asks `from()` and `upTo()` from `resources/js/breakpoints.js`.
- What changes per class: compact navigation bar, one pane, full-screen dialogs, a bottom sheet for choices; medium collapsed rail, one pane; expanded rail (collapsible), two panes, menus and basic dialogs; large and extra-large the rail expanded, two panes, a third only at extra-large as a side sheet. `<x-scaffold>` does this; content lives in panes (`<x-pane>`, `<x-list-detail>` for a list's second pane), never beside the rail by hand.
- Margins are 16px below medium and 24px from it (`<x-pane>` draws them); spacing sits on the 4px grid as `space25` `space900`, as padding and gaps on the parent, with margins only between layout regions. A fixed pane is 360px (expanded) or 412px (large); a side sheet at most 400px.
- Write logical properties (`padding-inline-start`, `inset-inline-end`, `md-text-start`); directional icons mirror in RTL; charts and media controls stay LTR. Keep controls inside the safe area (`--material-safe-*`).
### Accessibility
- Native elements first (`<button>`, `<dialog>`, `<input>`), then ARIA. One `main`, one `banner`, one `contentinfo`; every repeated `nav` labelled, without the word "navigation".
- Headings in order from a single H1; the level is structure, the `md-type-*` class is appearance.
- An icon-only control has an accessible name that does not include its role; decorative icons are hidden; an error is announced and tied to its field (`aria-describedby`); a toast uses a polite live region and never takes focus. A single-key shortcut needs a modifier or a focused component.
### Icons
- `<x-icon name="home">` is a Material Symbol Rounded: `filled` means active or selected, `optical="20"` when drawn at 20px or less, one weight per group, the size and colour of the text beside it.
### Don'ts
- No icon in a snackbar; no disabled FAB (hide it); no horizontal radio rows; no hover morph on cards; no `outline` on dividers; no hex colours; no utility classes, and no breakpoint, radius, shadow, type size or duration off M3's scales; no segmented buttons, navigation drawer or bottom app bar use `<x-button-group connected>`, the expanded rail and `<x-toolbar>`.
@endverbatim
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,303 @@
---
name: material-3-design
description: Material 3 Expressive's design system as Livewire Material implements it — colour roles and surface containers, elevation, shape, type, motion, states and targets, window size classes, spacing, icons, accessibility — each M3 name beside the class, prop or token that draws it and Google's source page, for deciding how a screen should look and behave before writing it.
---
# Material 3 design
## When to use this skill
Use this skill when deciding how a screen, panel or control should look or behave — which colour, container, corner, type style, motion, breakpoint or spacing — in an application that requires `nonameweb/livewire-material`, and when reviewing a view against Material 3. The props and slots of each component are in the `livewire-material-development` skill; this one is the design language they implement. The rules an agent must always follow are in the `material-3` guideline; the tables here are what those rules compress.
Every table pairs the M3 name with what the library gives for it. The library is plain CSS with no utility classes, so that is one of three things: a component or layout component prop (`color="error"`, `<x-surface level="surface-container">`, `gap="space200"`), one of the fixed text and interaction classes (`md-type-*`, `md-ink-*`, `md-state-layer`, `md-focus-ring`, `md-touch-target`, `md-link`), or a token the application's own CSS reads with `var()`. The tokens are CSS custom properties (`--md-sys-color-*`, `--md-sys-typescale-*`, `--md-sys-shape-*`, `--md-sys-elevation-*`, `--md-sys-motion-*`, `--md-sys-state-*`, `--md-sys-measurement-*`, `--md-ref-typeface-*`), so a stylesheet names a token and never a value.
## Colour
A colour scheme is generated from one seed by Google's colour science (`php artisan material:scheme`); every role below is a slot in that scheme, light and dark, at three contrast levels. A view names a role and nothing else — never a hex, a palette tone or an opacity — because only a role follows the theme, the contrast level and a colour profile.
### Roles
| Role | Purpose | Its `on-` pair | In this library |
| --- | --- | --- | --- |
| primary | High-emphasis fills, text and icons: the key action on a screen | on-primary | `var(--md-sys-color-primary)`, `md-ink-primary`, `<x-button variant="filled">` |
| primary-container | A standout fill for key components (FAB, an emphasised panel) | on-primary-container | `var(--md-sys-color-primary-container)` |
| primary-dim | A darker primary for a pressed or contrasting fill (2025 spec) | on-primary | `var(--md-sys-color-primary-dim)` |
| secondary | Less prominent fills, text and icons | on-secondary | `var(--md-sys-color-secondary)` |
| secondary-container | The recessive fill: tonal buttons, selected navigation, selected chips | on-secondary-container | `var(--md-sys-color-secondary-container)`, `<x-button variant="tonal">` |
| tertiary | A complementary accent, used sparingly for contrast | on-tertiary | `var(--md-sys-color-tertiary)`, `color="tertiary"` |
| tertiary-container | The complementary fill | on-tertiary-container | `var(--md-sys-color-tertiary-container)` |
| error | Urgency and errors; static, does not follow dynamic colour | on-error | `md-ink-error`, `var(--md-sys-color-error)`, `color="error"` |
| error-container | An error panel | on-error-container | `var(--md-sys-color-error-container)`, `<x-alert color="error">` |
| success, warning, info | This library's custom state colours, built like error on the 2025 spec, with `-container` and `on-` pairs | on-success … | `md-ink-success`, `var(--md-sys-color-warning-container)`, `color="info"` |
| surface | The page background | on-surface | the page itself (the foundation paints it), `<x-surface level="surface">` |
| on-surface-variant | Lower-emphasis text and icons on any surface | — | `md-ink-variant` |
| outline | A boundary that must be read: a text field, a target's edge (3:1 against surface) | — | `md-ink-quiet`, `var(--md-sys-color-outline)` |
| outline-variant | Decorative lines: dividers, card edges | — | `<x-divider>`, `<x-surface outlined>`, `var(--md-sys-color-outline-variant)` |
| inverse-surface | A surface that contrasts with its surroundings (the snackbar) | inverse-on-surface | `var(--md-sys-color-inverse-surface)` with `md-ink-inverse` |
| inverse-primary | An action on an inverse surface (the snackbar's action) | — | `var(--md-sys-color-inverse-primary)` |
| scrim | Behind a modal, at 32% | — | `color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent)` |
| shadow | The shadow colour, inside every `--md-sys-elevation-*` | — | — |
| surface-dim, surface-bright | Add-on surfaces that keep their relative brightness in both themes | on-surface | `<x-surface level="surface-dim">`, `<x-surface level="surface-bright">` |
| primary-fixed, primary-fixed-dim, on-primary-fixed, on-primary-fixed-variant (and secondary, tertiary) | Add-on roles with the same tone in light and dark; for a colour that must not change with the theme; never where contrast matters | — | `var(--md-sys-color-primary-fixed)` with `var(--md-sys-color-on-primary-fixed)` |
Pairing: a role's `on-` pair is the only combination whose contrast is guaranteed at every contrast level. A `primary` fill under `on-primary` text and a `secondary-container` fill under `on-secondary-container` are right; `primary-container` under `on-surface`, or `secondary-container` under `primary`, are not, and break as the contrast level rises. A component sets its own pair; the application's CSS writes both halves (`background-color: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary)`). Google: "Pair and layer color roles only as intended … Don't mix roles improperly."
### Surface containers
A hierarchy of emphasis, not of height: the tone separates panels before any shadow does, and a region keeps its role at every breakpoint (body always `surface`, navigation always `surface-container`).
| Role | Use | In this library |
| --- | --- | --- |
| surface | The page | the page itself, `<x-surface level="surface">` |
| surface-container-lowest | The most recessed panel; an elevated card's body in dark themes | `<x-surface level="surface-container-lowest">` |
| surface-container-low | An elevated card, a modal bottom or side sheet, the full-screen search view | `<x-surface level="surface-container-low">` |
| surface-container | Navigation bar and rail, docked and floating toolbars, menus, the segmented list | `<x-surface>` (the default level) |
| surface-container-high | Dialogs, the search bar, date and time pickers, a rich tooltip | `<x-surface level="surface-container-high">` |
| surface-container-highest | A filled card, a filled text field, a filled chip's selected state | `<x-surface level="surface-container-highest">` |
In the application's CSS each is `var(--md-sys-color-surface-container-low)` and so on; the ink on every one of them is `on-surface`.
### Emphasis and lines
- Default ink is `on-surface` (`md-ink`); lower emphasis is `on-surface-variant` (`md-ink-variant`); decoration is `outline` (`md-ink-quiet`). Emphasis is never an opacity: M3 reserves 38% (`--md-sys-state-disabled-content-opacity`) for disabled content and 12% (`--md-sys-state-disabled-container-opacity`) for a disabled container.
- `outline` for a boundary that has to be perceived (a text field's edge, a target's edge — 3:1 against the surface); `outline-variant` for dividers and the edge of a card or any component holding several elements. Google: "Don't use the outline color for dividers … use outline variant instead." `outline-variant` may edge a chip or a button only because the content inside already carries the contrast.
- A hyperlink in running text is `primary` (or `tertiary` for a quieter link) **and** underlined: `md-link` with `md-ink-primary`.
### Contrast
| Level | Target | How |
| --- | --- | --- |
| Standard | Hierarchy from high- and low-contrast elements together; text 4.5:1, large text and icons 3:1, grouped non-text controls 3:1 | the default scheme |
| Medium | 3:1 minimum everywhere, without halation | `<html data-contrast="medium">` |
| High | 7:1 | `<html data-contrast="high">`, or the visitor's OS setting (`theme.contrast.default` = `system`) |
Every role changes with the level automatically; a component built from roles needs nothing else. Disabled states are exempt from contrast. A colour outside the roles (a hex, white, black) does not change and is the one thing that breaks a contrast level.
Sources: https://m3.material.io/styles/color/roles · https://m3.material.io/styles/color/system/how-the-system-works · https://m3.material.io/styles/color/advanced/apply-colors · https://m3.material.io/foundations/designing/color-contrast
## Surfaces and elevation
M3 separates surfaces by tone first; a shadow says that something floats over the content or is being interacted with. "When it comes to applying shadows, less is more."
| Level | Shadow | Rests here | In this library |
| --- | --- | --- | --- |
| 0 | none | The page, cards (filled, outlined), buttons (filled, tonal, outlined), button groups, icon buttons, lists, chips, tabs, sliders, the rail, a docked side sheet, a carousel, a full-screen dialog, a FAB inside the rail, an app bar at rest | — |
| 1 | 1dp | Elevated cards, elevated buttons and chips, modal bottom and side sheets, a banner | `box-shadow: var(--md-sys-elevation-1)` |
| 2 | 3dp | Menus, the navigation bar, a scrolled app bar, toolbars, rich tooltips | `var(--md-sys-elevation-2)` |
| 3 | 6dp | FAB and extended FAB, the FAB menu's close button, dialogs, date and time pickers, the search bar | `var(--md-sys-elevation-3)` |
| 4 | 8dp | Interaction only: a level-3 element on hover or while dragged | `var(--md-sys-elevation-4)` |
| 5 | 12dp | Interaction only | `var(--md-sys-elevation-5)` |
- Hover lifts an element one level (a FAB 3 → 4, an elevated card 1 → 2); focus and selection may too; a raised element lowers when something higher appears.
- Overlapping panels take different surface-container roles to show separation; the roles are not tied to the levels.
- A scrim (`scrim` at 32%) brings focus to a modal over a large surface; it is never a substitute for a shadow on a small floating element.
- On a dark surface a shadow is nearly invisible, so the tone does the work there.
Sources: https://m3.material.io/styles/elevation/overview · https://m3.material.io/styles/elevation/applying-elevation · https://m3.material.io/styles/elevation/tokens
## Shape
### The corner scale
| Style | Value | In this library |
| --- | --- | --- |
| None | 0 | `var(--md-sys-shape-corner-none)`, `corner="none"` |
| Extra small | 4px | `var(--md-sys-shape-corner-xs)`, `corner="xs"` |
| Small | 8px | `var(--md-sys-shape-corner-sm)`, `corner="sm"` |
| Medium | 12px | `var(--md-sys-shape-corner-md)`, `corner="md"` |
| Large | 16px | `var(--md-sys-shape-corner-lg)`, `corner="lg"` |
| Large increased | 20px | `var(--md-sys-shape-corner-lg-increased)`, `corner="lg-increased"` |
| Extra large | 28px | `var(--md-sys-shape-corner-xl)`, `corner="xl"` |
| Extra large increased | 32px | `var(--md-sys-shape-corner-xl-increased)`, `corner="xl-increased"` |
| Extra extra large | 48px | `var(--md-sys-shape-corner-xxl)`, `corner="xxl"` |
| Full | a stadium or circle | `var(--md-sys-shape-corner-full)`, `corner="full"` |
`corner` is `<x-surface>`'s prop. In the application's CSS a corner is `border-radius` on a token, and one side at a time a logical longhand (`border-start-start-radius` and `border-start-end-radius` for a bottom sheet's top); a length of your own is off the scale.
### Corner by component
| Component | Corner | Note |
| --- | --- | --- |
| Buttons, icon buttons, split button (outer), FAB menu items | full | a press morphs to `md` (xs/sm sizes), `lg` (md), `xl` (lg/xl); a selected toggle swaps round ↔ square |
| Connected button group | full outside, `sm` between segments | segments press to `xs` |
| FAB | `lg` 16 (baseline 56px), `lg-increased` 20 (medium 80px), `xl` 28 (large 96px) | extended FAB `lg` |
| Chips | `sm` 8 | an avatar in a chip `md` 12 |
| Cards | `md` 12 | no change on hover |
| Text fields | `xs` 4 (outlined: all corners; filled: top corners only) | |
| Menus, snackbar, plain tooltip | `xs` 4 | the Expressive vertical menu rounds the focused item |
| Rich tooltip | `md` 12 | |
| Dialogs | `xl` 28 | full-screen dialog `none` |
| Bottom sheet | `xl` 28 on top | |
| Side sheet | `lg` 16 on the inner side | |
| Search bar | full | search view `xl` 28 when docked, `none` full-screen |
| Date and time pickers | `xl` 28 | date cells full |
| Carousel items | `xl` 28 | |
| Navigation indicator, badges, switch, slider handle, checkbox state layer | full | checkbox box 2px, tab indicator 3px on top |
| Navigation bar, app bar, docked toolbar, tabs | none | floating toolbar full |
| Segmented list rows | `xs` inner, `lg` outer; a selected row `lg` | |
### Rules
- Optical roundness: a shape nested in a rounded container takes inner radius = outer radius padding (48 14 = 34), never the container's own radius.
- Large and full corners do not belong on information-dense containers (cards, tables, text fields).
- A press squares a round shape and rounds a square one (the components carry the morph on the fast spatial spring); nothing morphs on hover.
- The 35 Expressive shapes (`<x-shape name="cookie-9">`, also the loading indicator and the standard button group's press shape) are decoration for emphasis and delight — never a carrier of meaning, never behind text-heavy content, and used sparingly.
Sources: https://m3.material.io/styles/shape/corner-radius-scale · https://m3.material.io/styles/shape/shape-morph · https://m3.material.io/styles/shape/overview-principles
## Type
The typeface is Google Sans Flex for brand and plain styles (`--md-ref-typeface-brand`, `--md-ref-typeface-plain`); an application may replace it after importing the stylesheet. Each style is one class that sets size, line height, weight, family and tracking together — or, in the application's CSS, `font: var(--md-sys-typescale-body-md)` with `letter-spacing: var(--md-sys-typescale-body-md-tracking)`. A size, weight, line height or letter spacing of your own is off the scale.
| Role | Style | Size / line | Weight | In this library | Use for |
| --- | --- | --- | --- | --- | --- |
| Display | large / medium / small | 57/64 · 45/52 · 36/44 | 400 | `md-type-display-lg` … | hero figures, one short marketing line; never running text |
| Headline | large / medium / small | 32/40 · 28/36 · 24/32 | 400 | `md-type-headline-lg` … | page titles, section titles, a dialog's headline (`headline-sm`) |
| Title | large / medium / small | 22/28 · 16/24 · 14/20 | 400 / 500 / 500 | `md-type-title-lg` … | app bar title (`lg`), card and list-section titles (`md`), dense headers (`sm`) |
| Body | large / medium / small | 16/24 · 14/20 · 12/16 | 400 | `md-type-body-lg` … | paragraphs (`lg` for reading, `md` in components), supporting text (`sm`) |
| Label | large / medium / small | 14/20 · 12/16 · 11/16 | 500 | `md-type-label-lg` … | buttons and tabs (`lg`), chips and navigation (`md`), captions and badges (`sm`) |
- `md-type-emphasized-*` (`--md-sys-typescale-emphasized-*`) is the same size and line height one weight step heavier (400 → 500, 500 → 700), fully rounded in Google Sans Flex, with its own tracking. M3 uses it deliberately, never by default: a selected list or menu item, a button's label on a primary action, an extended FAB, a badge, a headline given editorial weight.
- Tracking follows Compose's `TypeScaleTokens`: display-large 0.2, title-medium 0.2, title-small 0.1, body-large 0.5, body-medium 0.2, body-small 0.4, label-large 0.1, label-medium and small 0.5 (sp; rem = sp/16); the emphasized set tightens a few (display-large 0, title-medium 0.15, body-large 0.15, body-medium 0.25).
- Line length 4060 characters (`max-inline-size: 60ch` in the application's CSS). Figures that change take `md-tabular`.
- Text must scale to 200%: containers grow, side-by-side controls stack, padding stays; components without text (progress, checkboxes) do not scale. Truncate to an ellipsis (`md-truncate`) only when the full text is one tooltip or link away.
- When customising, change the typeface or tracking, never the sizes: component layout depends on them.
Sources: https://m3.material.io/styles/typography/type-scale-tokens · https://m3.material.io/styles/typography/applying-type · https://m3.material.io/styles/typography/fonts · https://m3.material.io/foundations/writing/text-resizing · https://m3.material.io/foundations/writing/text-truncation
## Motion
M3 Expressive moves on physics: every transition is a spring, and the library samples each spring into a CSS `linear()` easing paired with a duration. Use the pair together, or the curve is stretched over the wrong time.
| Spring | Damping / stiffness | Duration | In this library | For |
| --- | --- | --- | --- | --- |
| Spatial fast | 0.6 / 800 | 350ms | `var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast)` | small elements: a button's press morph, a switch, a chip |
| Spatial default | 0.8 / 380 | 500ms | `var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default)` | most position, size and shape changes |
| Spatial slow | 0.8 / 200 | 650ms | `var(--md-sys-motion-spatial-slow-duration) var(--md-sys-motion-spatial-slow)` | large surfaces: a sheet, a pane, a full-screen transition |
| Effects fast | 1.0 / 3800 | 150ms | `var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast)` | state layers, small fades |
| Effects default | 1.0 / 1600 | 200ms | `var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default)` | most colour and opacity changes |
| Effects slow | 1.0 / 800 | 300ms | `var(--md-sys-motion-effects-slow-duration) var(--md-sys-motion-effects-slow)` | large fades, a scrim |
A transition names the property, then the pair: `transition: transform var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default), opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast)`.
- Spatial springs are underdamped and overshoot — that bounce is what reads as Expressive — so they carry only position, size and shape. Effects springs are critically damped and carry colour and opacity, which must never overshoot. A transition on `all` mixes the two and is wrong.
- The Standard motion scheme (`<html data-motion="standard">`, config `motion.scheme`) swaps the spatial springs for stiffer ones with almost no bounce (0.9 / 1400, 700, 300; 350, 500, 750ms) for utilitarian products; effects are shared.
- Direction: something entering decelerates (`--md-sys-motion-easing-emphasized-decelerate`, or a spatial spring from off-screen), a permanent exit accelerates (`--md-sys-motion-easing-emphasized-accelerate`), a temporary exit that can be recalled (a drawer, a sheet) takes `--md-sys-motion-easing-emphasized`; exits are shorter than entrances, and larger areas move longer.
- The cubic-bezier set (`--md-sys-motion-easing-standard`, `-emphasized`, `-emphasized-decelerate`, `-emphasized-accelerate`, with `--md-sys-motion-duration-short|medium|long`) is for the few transitions whose duration is fixed from outside: a view transition, an animated scroll.
- Reduced motion zeroes every duration token, so anything animated through them turns instant; a literal `300ms`, or a keyframe animation with its own timing, ignores the visitor's setting and is a bug. Container transforms, parallax and expansions are removed, not slowed.
Sources: https://m3.material.io/styles/motion/overview · https://m3.material.io/styles/motion/overview/specs · https://m3.material.io/styles/motion/easing-and-duration/tokens-specs · https://m3.material.io/styles/motion/transitions/transition-patterns
## States and targets
| State | Layer | Class or hook | Also |
| --- | --- | --- | --- |
| Enabled | none | — | |
| Hover | 8% of the content colour | `md-state-layer` (pointer devices only) | one level of elevation on floating elements |
| Focused | 10% | `md-state-layer md-focus-ring` (keyboard focus: a 3px `secondary` ring, 2px out) | only one focused element at a time |
| Pressed | 10% | `md-state-layer` (`:active`) | the shape morph on buttons |
| Dragged | 16% | `md-state-layer` with `data-md-dragged` | one level of elevation |
| Disabled | content 38%, container 12%, no state layer, not focusable | `color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent)`, and the container likewise with `--md-sys-state-disabled-container-opacity` | exempt from contrast; a FAB is hidden rather than disabled |
| Selected | the `secondary-container` pair, a filled icon, the emphasized style | component props (`selected`, `aria-selected`, `aria-pressed`) | combines with hover, focus and press |
- The state layer takes the content's `on-` colour (on `secondary-container` it is `on-secondary-container`), is 40px on a 48px target, and only one shows at a time. `md-state-layer` draws it in `currentColor` as a `::before`, so the element becomes `position: relative`.
- Every state shows two indicators, so a colour change alone is never a state: add a shape, an outline, an icon, a weight or a word (`aria-selected` plus the container, an error colour plus an icon and a message).
- Targets: 48×48px minimum, 8px between targets, on every device; `md-touch-target` extends a smaller drawing to 48px. Density is an opt-in prop (`dense`) that steps padding by 4px and never applies to menus, snackbars, dialogs or settings controls, and never takes a target below 48px.
- Keyboard: Tab and Shift+Tab between components in DOM order, arrows within a component (menu, tabs, grid, radio group), Enter and Space activate, Escape dismisses; a dialog moves focus in on open and back to its opener on close.
Sources: https://m3.material.io/foundations/interaction/states/state-layers · https://m3.material.io/foundations/interaction/states/applying-states · https://m3.material.io/foundations/designing/structure · https://m3.material.io/foundations/layout/grids-spacing/density
## Layout and breakpoints
Layout keys on the width of the window, in M3's five window size classes and only those. A layout component names the class in a prop (`hide-below`, `hide-from`, `stack-below`, `<x-grid>`'s `columns` map); the application's CSS writes the width as a range media query; a script asks `resources/js/breakpoints.js` (`from('expanded')`, `upTo('medium')`) for the same numbers.
| Class | Width | Prop value · CSS | Navigation | Panes | Dialogs and choices | Margins |
| --- | --- | --- | --- | --- | --- | --- |
| Compact | below 600px | the default; `hide-from="medium"` for "only here" · `@media (width < 600px)` | navigation bar; the rail opens as a modal | 1 | full-screen or basic dialog; a bottom sheet for choices | 16px |
| Medium | 600839px | `medium` · `@media (width >= 600px)` | collapsed rail (96px) | 1, or 2 for low-density content at 50% each | basic dialog; a menu for choices | 24px |
| Expanded | 8401199px | `expanded` · `@media (width >= 840px)` | rail, collapsed or expanded, collapsible | 2 recommended; a fixed pane 360px | basic dialog; menu | 24px |
| Large | 12001599px | `large` · `@media (width >= 1200px)` | rail expanded | 2; a fixed pane 412px | basic dialog; menu | 24px |
| Extra-large | 1600px and up | `extra-large` · `@media (width >= 1600px)` | rail expanded | 2, or 3 with a standard side sheet (at most 400px) | basic dialog; menu | 24px |
- `<x-scaffold>` implements the navigation column; `<x-pane>` is a content region with the margins above; `<x-list-detail>` is the second pane of a list-detail layout from expanded, `<x-supporting-pane>` puts a supporting pane (360px, beside the focus pane) from expanded and below it before that. Moving up a class, ask what to reveal, divide into panes, resize, reposition or swap — never swap a component for one that does not do the same job.
- Scaffold: bars (app bar at the top, navigation bar at the bottom: 35 destinations), rails (the navigation rail, toolbars, the FAB, on the leading edge), panes (all content), around a safety region that stays clear of the device's own chrome (`--material-safe-top|bottom|left|right`).
- Canonical layouts: feed (`<x-feed>`, a grid of cards that gains columns as the room grows), list-detail (one pane on compact, two from expanded; a back button only in single-pane mode, a selected row only in two-pane mode), supporting pane (two thirds focus, one third support).
- Bidirectionality: write logical properties (`padding-inline-start`, `margin-inline-end`, `inset-inline-start`, `border-inline-start`, `md-text-start`); `<x-row>` runs in the inline direction and mirrors by itself; leading and trailing icons swap, directional icons (back, send) mirror, the rail moves to the right; charts, media controls, clocks and Hebrew progress bars stay left-to-right.
Sources: https://m3.material.io/foundations/layout/breakpoints/overview · https://m3.material.io/foundations/layout/breakpoints/compact (medium, expanded, large-extra-large) · https://m3.material.io/foundations/layout/scaffold/overview · https://m3.material.io/foundations/layout/canonical-examples/overview · https://m3.material.io/foundations/layout/bidirectionality-rtl
## Spacing
M3's spacing tokens are multiples of an 8px base on a 4px grid. A layout component takes the token's name (`gap="space200"`, `<x-surface padding="space300">`); the application's CSS reads it (`var(--md-sys-measurement-space200)`).
| Token | Value | In this library |
| --- | --- | --- |
| space25 | 2px | `space25` |
| space50 | 4px | `space50` |
| space75 | 6px | `space75` |
| space100 | 8px (the base) | `space100` |
| space125 | 10px | `space125` |
| space200 | 16px | `space200` — a component's padding, compact margins |
| space300 | 24px | `space300` — a dialog's padding, margins from medium |
| space400 | 32px | `space400` |
| space500 | 40px | `space500` |
| space600 | 48px | `space600` — a target |
| space700 | 56px | `space700` |
| space800 | 64px | `space800` |
| space900 | 72px | `space900` |
- Padding and gaps live on the parent (`<x-surface padding="space200">` around `<x-stack gap="space100">`), never as margins on children; a margin is for space beyond a container's padding or between layout regions.
- Spacing does not scale with text: at 200% text size the same padding and gaps stay.
- Name a gap by what it separates when a component has several (iconlabel 8px, labelsupporting text 4px).
Sources: https://m3.material.io/styles/spacing/overview · https://m3.material.io/styles/spacing/tokens · https://m3.material.io/styles/spacing/applying-spacing
## Icons
`<x-icon name="lock">` draws a Material Symbol Rounded (weight 400, grade 0), outlined or `filled`, at optical size 24 or 20.
| Axis | Values | In this library |
| --- | --- | --- |
| Fill | 0 outlined, 1 filled | `filled` — active, selected or on state (a selected navigation item, a FAB's icon, a checked filter chip) |
| Weight | 100700; never below 200 at 24px | 400 for every icon; one weight per group |
| Grade | 25 on dark backgrounds, 0 otherwise, positive for emphasis | 0 |
| Optical size | 20 dense, 24 standard, 4048 with display type | `size="20"` and below pick the 20 cut (small buttons, chips, dense lists); `optical="20"` for an icon sized by the application's own CSS |
- An icon beside text takes the text's size and colour (`size="20"` beside `md-type-label-lg`, 24 beside body) and the same optical weight; its baseline sits about 11.5% of the text size below the text's.
- Icons stay flat and forward-facing, on the pixel grid, inside their 20px live area of the 24px canvas.
- An icon-only control has an accessible name (`aria-label`, or a tooltip that names it); a decorative icon is `aria-hidden`; a complex icon drawn below 20px needs a label beside it.
Sources: https://m3.material.io/styles/icons/overview · https://m3.material.io/styles/icons/designing-icons · https://m3.material.io/styles/icons/applying-icons
## Accessibility
- Native elements before ARIA: `<button>`, `<a href>`, `<dialog>`, `<input>`, `<select>`; a styled `div` that fakes one needs everything re-implemented and tested.
- Landmarks: one `main`, one `banner`, one `contentinfo` per page; `nav`, `search`, `complementary`, `form`, `region` labelled when they repeat, never with their own role in the label ("Primary", not "Primary navigation").
- Headings: one H1 for the page, then H2H6 in order without skipping; the level is the document's structure, the `md-type-*` class is the appearance, and they need not match.
- Names: an interactive icon, image or ambiguous button ("Save", "Learn more") has a name that says what it does, without the word "button"; decorative images are `alt=""` or `aria-hidden`; text only a screen reader needs is `md-visually-hidden`.
- Focus: DOM order is reading order; a dialog moves focus to its first meaningful control and returns it to the opener; grouped controls are one Tab stop with arrows inside; a keyboard shortcut is two keys, or a single key only while its component is focused.
- Announcements: an error is tied to its field (`aria-invalid`, `aria-describedby`) and announced; a snackbar is a polite live region that never steals focus and stays while it carries an action; a loading state has a name.
- Contrast and states: 4.5:1 text, 3:1 large text and icons and grouped controls, disabled exempt; every state has two indicators; targets 48px with 8px between; text scales to 200%; motion honours reduced motion.
Sources: https://m3.material.io/foundations/overview/principles · https://m3.material.io/foundations/designing/structure · https://m3.material.io/foundations/designing/flow · https://m3.material.io/foundations/designing/elements · https://m3.material.io/foundations/overview/assistive-technology
## Don'ts
What Google's pages say not to do, and this library follows:
| Don't | Because | Instead |
| --- | --- | --- |
| Put an icon in a snackbar | a snackbar is a short message with at most one action | `<x-toast>` types choose the announcement, not a picture |
| Disable a FAB | "if the action is unavailable, the FAB shouldn't appear" | hide it |
| Lay radio buttons in a row | a row reads as one control | a vertical group; chips or a connected button group for a horizontal choice |
| Morph a card's corners on hover | shape morphs mark a press or a selection | the state layer and one level of elevation |
| Use `outline` on a divider | dividers carry no contrast requirement and read too heavy | `<x-divider>` (`outline-variant`) |
| Use a hex, white, black or an opacity for ink | it ignores theme, contrast level and profile | a role |
| Write a utility class, or a breakpoint, radius, shadow, type size or easing of your own | nothing defines utility classes, and other values are not M3's | the layout components' props, `md-type-*` and `md-ink-*`, and `--md-sys-*` tokens at 600/840/1200/1600px in your own CSS |
| Use segmented buttons, a navigation drawer or a bottom app bar | deprecated in M3 Expressive | `<x-button-group connected>`, the expanded rail, `<x-toolbar>` |
| Truncate without a way to read the rest | an ellipsis alone is not accessible | wrap, grow the container, or a tooltip |
| Animate with a literal duration | it ignores reduced motion | the paired tokens |
## Attribution
The rules, tables and wording here are Google's, condensed from the Material Design 3 documentation at https://m3.material.io (Foundations, Styles and Components), which Google publishes under the Creative Commons Attribution 4.0 License except as otherwise noted; the numeric token values are from the Android Open Source Project's Material 3 token files in androidx Compose (Apache License 2.0). Copyright Google LLC; Copyright The Android Open Source Project. The library's `NOTICE` records the same. Dates and page names are those of the site as read on 2026-09-13; the full extracted references, with every source page, are kept in the package repository under `docs/reference/m3/`.
+113
View File
@@ -0,0 +1,113 @@
/*
* Livewire Material for an application that wants everything: the foundation, every layout
* stylesheet and every component stylesheet, each imported once. The blocks below are the same
* groups `components.css` and `layout.css` used before this file replaced both of them (plan step
* 37) one block per audit stream, so a stylesheet still lands in a predictable place plus the
* Layout block those two files didn't share.
*
* This is not the recommended way to bring the package's CSS in: importing `all.css` pulls in
* every component's rules whether an application's views render them or not. The recommended path
* is `foundation.css` (the one required import) followed by only the component stylesheets a
* view actually renders each component file already imports the stylesheets of the components
* it draws, so the list an application writes stays short. `all.css` is for the showcase, for an
* error page rendered without a build, and for an application that would rather not track
* per-component imports at all.
*
* Same shape as every package stylesheet: the layer statement first, then plain imports, nothing
* else at the top level an `@import` cannot sit inside a `@layer` block, and `layer()` on it
* would nest the layer a second time inside a file that already declares it.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './foundation.css';
/* Layout */
@import './layout/spacing.css';
@import './layout/visibility.css';
@import './layout/stack.css';
@import './layout/row.css';
@import './layout/grid.css';
@import './layout/surface.css';
@import './layout/pane.css';
@import './layout/list-detail.css';
@import './layout/supporting-pane.css';
@import './layout/feed.css';
@import './layout/scaffold.css';
/* Foundation components */
@import './components/icon.css';
@import './components/shape.css';
/* Actions and communication */
@import './components/loading.css';
@import './components/tooltip.css';
@import './components/badge.css';
@import './components/button.css';
@import './components/group.css';
@import './components/button-group.css';
@import './components/split-button.css';
@import './components/fab.css';
@import './components/menu-separator.css';
@import './components/menu-group.css';
@import './components/menu-item.css';
@import './components/menu.css';
@import './components/fab-menu-item.css';
@import './components/fab-menu.css';
@import './components/rich-tooltip.css';
@import './components/toast.css';
@import './components/progress.css';
@import './components/alert.css';
@import './components/stat.css';
@import './components/empty-state.css';
/* Inputs, selection and data */
@import './components/form.css';
@import './components/field.css';
@import './components/input.css';
@import './components/password.css';
@import './components/textarea.css';
@import './components/select.css';
@import './components/file.css';
@import './components/selection.css';
@import './components/checkbox.css';
@import './components/radio.css';
@import './components/toggle.css';
@import './components/chip.css';
@import './components/chip-set.css';
@import './components/choices.css';
@import './components/slider.css';
@import './components/search.css';
@import './components/table.css';
@import './components/sort-header.css';
@import './components/pagination.css';
@import './components/datepicker.css';
@import './components/timepicker.css';
/* Containment */
@import './components/divider.css';
@import './components/collapse.css';
@import './components/card.css';
@import './components/list-item.css';
@import './components/list.css';
@import './components/modal.css';
@import './components/drawer.css';
@import './components/bottom-sheet.css';
@import './components/carousel-item.css';
@import './components/carousel.css';
@import './components/error-page.css';
/* Navigation */
@import './components/app-bar.css';
@import './components/toolbar.css';
@import './components/tabs.css';
@import './components/navigation-item.css';
@import './components/navigation-bar.css';
@import './components/navigation-bar-item.css';
@import './components/navigation-rail.css';
@import './components/navigation-rail-item.css';
@import './components/navigation-rail-section.css';
@import './components/section-nav.css';
@import './components/account-menu.css';
@import './components/theme-toggle.css';
@import './components/scheme-picker.css';
+53
View File
@@ -0,0 +1,53 @@
/*
* <x-account-menu>: the avatar trigger of an `<x-menu>`, its name-and-email header and its theme
* item an M3 "extra" the audit found already inside the foundations (docs/audits/m3-alignment/
* navigation.md): a 40px trigger reaching M3's 48px target through `md-touch-target` (N-01), with
* `md-state-layer` for the hover and pressed feedback every other trigger in the library already
* has (N-15) and `md-focus-ring` for its keyboard ring. The avatar image sits behind the state
* layer's `::before` (z-index -1, foundation/interaction.css) at z-index -2, so a hover or a press
* still washes over the picture instead of under it; nothing clips the pseudo-target the touch
* class draws past the 40px circle.
*
* `[data-md-account-menu-header]` is the name-and-email block above the separator; its own type and
* ink are the fixed text classes on the view (`md-type-title-sm md-ink`, `md-type-body-sm
* md-ink-variant`), since this is prose the caller supplies, not the component's own part.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './menu.css';
@import './menu-separator.css';
@import './menu-item.css';
@import './icon.css';
@layer material.components {
[data-md-account-menu] {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
inline-size: var(--md-sys-measurement-space500);
block-size: var(--md-sys-measurement-space500);
border-radius: var(--md-sys-shape-corner-full);
cursor: pointer;
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
}
[data-md-account-menu-avatar] {
position: relative;
z-index: -2;
inline-size: 100%;
block-size: 100%;
border-radius: var(--md-sys-shape-corner-full);
object-fit: cover;
}
[data-md-account-menu-header] {
padding-inline: 12px;
padding-block: var(--md-sys-measurement-space100) 12px;
}
}
+106
View File
@@ -0,0 +1,106 @@
/*
* <x-alert>: a notice in the page not an M3 component (M3's banner is gone), drawn in M3's own
* terms as the plan's deliberate-deviations note records: the state's container colour (tinted,
* never filled), a medium corner, the state's icon, title-small/body-medium text and an actions
* row of text buttons pulled back 12px to the text's edge, as the rich tooltip's actions row is
* (rich-tooltip.css).
*
* `data-md-color`: info (the default), success, warning, error, primary, secondary, tertiary or
* neutral (surface-container-high, on-surface it has no state colour of its own). The dismiss
* button is drawn at 40px, pulled 8px out into the alert's padding, and renders the foundation's
* `md-state-layer`, `md-focus-ring` and `md-touch-target` (foundation/interaction.css) to reach
* M3's 48px target (ACT-18); nothing here copies their rules.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@layer material.components {
[data-md-alert] {
display: flex;
align-items: flex-start;
gap: 12px;
border-radius: var(--md-sys-shape-corner-md);
padding: var(--md-sys-measurement-space200);
background-color: var(--md-sys-color-info-container);
color: var(--md-sys-color-on-info-container);
}
[data-md-alert][data-md-color='success'] {
background-color: var(--md-sys-color-success-container);
color: var(--md-sys-color-on-success-container);
}
[data-md-alert][data-md-color='warning'] {
background-color: var(--md-sys-color-warning-container);
color: var(--md-sys-color-on-warning-container);
}
[data-md-alert][data-md-color='error'] {
background-color: var(--md-sys-color-error-container);
color: var(--md-sys-color-on-error-container);
}
[data-md-alert][data-md-color='primary'] {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}
[data-md-alert][data-md-color='secondary'] {
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
[data-md-alert][data-md-color='tertiary'] {
background-color: var(--md-sys-color-tertiary-container);
color: var(--md-sys-color-on-tertiary-container);
}
[data-md-alert][data-md-color='neutral'] {
background-color: var(--md-sys-color-surface-container-high);
color: var(--md-sys-color-on-surface);
}
[data-md-alert-content] {
min-width: 0;
flex: 1;
align-self: center;
}
[data-md-alert-content] > * + * {
margin-top: var(--md-sys-measurement-space50);
}
[data-md-alert-title] {
font: var(--md-sys-typescale-title-sm);
letter-spacing: var(--md-sys-typescale-title-sm-tracking);
font-variation-settings: normal;
}
[data-md-alert-description] {
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
font-variation-settings: normal;
}
[data-md-alert-actions] {
display: flex;
flex-wrap: wrap;
gap: var(--md-sys-measurement-space100);
margin-inline-start: -12px;
padding-top: var(--md-sys-measurement-space50);
}
[data-md-alert-dismiss] {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: var(--md-sys-measurement-space500);
height: var(--md-sys-measurement-space500);
margin: -8px;
border-radius: var(--md-sys-shape-corner-full);
cursor: pointer;
}
}
+131 -58
View File
@@ -1,66 +1,93 @@
/*
* M3 Expressive's top app bars (AppBarTokens, AppBarSmallTokens, AppBarMediumFlexibleTokens,
* AppBarLargeFlexibleTokens, androidx Compose Material 3, Apache-2.0).
* `<x-app-bar>`: M3 Expressive's top app bars (AppBarTokens.kt, AppBarSmallTokens.kt,
* AppBarMediumFlexibleTokens.kt, AppBarLargeFlexibleTokens.kt, androidx Compose Material 3,
* Apache-2.0; docs/reference/m3/components-navigation-selection-inputs.md § Top App Bar).
*
* [data-md-app-bar] the bar; data-md-variant, data-md-sticky, data-md-subtitled,
* data-md-scrolled/data-md-collapsed (resources/js/app-bar.js)
* [data-md-app-bar-row] 64px, sticky at the top of the bar
* [data-md-app-bar-leading] the `navigation` slot
* [data-md-app-bar-headline] [data-md-app-bar-title], [data-md-app-bar-subtitle]
* [data-md-app-bar-search] the `search` variant's slot
* [data-md-app-bar-trailing] [data-md-app-bar-action], [data-md-app-bar-overflow]
* [data-md-app-bar-expanded] medium/large's big title, under the row
*
* small 64px, title-large title (label-medium subtitle), 4px from the edges, the title 16px
* from the start or straight after the navigation icon
* center the same, the title centred
* center the same, the title centred a three-column grid, so it shrinks against whatever is
* beside it rather than ellipsising underneath it (N-11: a fixed inset fit exactly one
* of M3's two trailing buttons)
* medium 112px (136px with a subtitle): the 64px row of icons over a headline-medium title
* large 120px (152px with a subtitle): a display-small title
* search the row holds a search bar
* search the row holds a search bar, full width to 312px and then half of what is left (N-09)
*
* The container is the surface, and surface-container once content scrolls under it. A medium or
* large bar collapses into the small one without script moving anything: the bar is sticky at a
* negative top, so it scrolls up until only 64px of it remain, while its row of icons is sticky at
* 0 inside it and stays put the big title scrolls away under the row. Script only says when that
* has happened (resources/js/app-bar.js), so the small title can fade in and the colour change.
*
* Trailing actions given as a list collapse into an overflow menu below `medium` and come back
* from it, by media query alone (the site's "trailing actions collapse into an overflow menu at
* smaller breakpoints" and "become visible again at larger sizes", read together with "up to two
* icon buttons" and, for a wider window, "up to four trailing icons"): both forms of every action
* are in the page, and each width hides the one it does not use, so nothing flashes and no script
* counts.
*/
@layer components {
[data-app-bar] {
--app-bar-row: 4rem;
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './button.css';
@import './menu.css';
@import './menu-item.css';
@layer material.components {
[data-md-app-bar] {
--app-bar-row: 64px;
--app-bar-height: var(--app-bar-row);
z-index: 20;
display: block;
min-height: var(--app-bar-height);
padding-top: env(safe-area-inset-top);
padding-top: var(--material-safe-top, env(safe-area-inset-top));
background-color: var(--md-sys-color-surface);
color: var(--md-sys-color-on-surface);
transition: background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
transition-property: background-color;
transition-duration: var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-effects-default);
}
[data-app-bar][data-variant="medium"] {
--app-bar-height: 7rem;
[data-md-app-bar][data-md-variant='medium'] {
--app-bar-height: 112px;
}
[data-app-bar][data-variant="medium"][data-subtitled] {
--app-bar-height: 8.5rem;
[data-md-app-bar][data-md-variant='medium'][data-md-subtitled] {
--app-bar-height: 136px;
}
[data-app-bar][data-variant="large"] {
--app-bar-height: 7.5rem;
[data-md-app-bar][data-md-variant='large'] {
--app-bar-height: 120px;
}
[data-app-bar][data-variant="large"][data-subtitled] {
--app-bar-height: 9.5rem;
[data-md-app-bar][data-md-variant='large'][data-md-subtitled] {
--app-bar-height: 152px;
}
[data-app-bar][data-sticky] {
[data-md-app-bar][data-md-sticky] {
position: sticky;
top: 0;
}
/* `--app-bar-measured` is the bar's real height (a title can wrap), set by app-bar.js. */
[data-app-bar][data-sticky]:is([data-variant="medium"], [data-variant="large"]) {
[data-md-app-bar][data-md-sticky]:is([data-md-variant='medium'], [data-md-variant='large']) {
top: calc(var(--app-bar-row) - var(--app-bar-measured, var(--app-bar-height)));
}
[data-app-bar][data-scrolled] {
[data-md-app-bar][data-md-scrolled] {
background-color: var(--md-sys-color-surface-container);
}
[data-app-bar-row] {
[data-md-app-bar-row] {
position: sticky;
top: 0;
z-index: 1;
@@ -68,40 +95,62 @@
align-items: center;
gap: 0;
min-height: var(--app-bar-row);
padding-inline: 0.25rem;
padding-inline: var(--md-sys-measurement-space50);
background-color: inherit;
}
[data-app-bar-leading],
[data-app-bar-trailing] {
[data-md-app-bar-leading],
[data-md-app-bar-trailing] {
display: flex;
flex: none;
align-items: center;
color: var(--md-sys-color-on-surface-variant);
}
[data-app-bar-leading] {
[data-md-app-bar-leading] {
color: var(--md-sys-color-on-surface);
}
[data-app-bar-trailing] {
[data-md-app-bar-trailing] {
margin-inline-start: auto;
}
[data-app-bar-headline] {
/* `actions` as a list: every action is both an icon button and a menu item, and the width of
the window says which one is drawn up to two trailing icon buttons below `medium` and four
from it, an overflow button counted among them. The wrappers stay out of the row's layout
until a width hides them. */
[data-md-app-bar-action],
[data-md-app-bar-overflow] {
display: contents;
}
@media (width < 600px) {
[data-md-app-bar-action][data-md-overflows-compact],
[data-md-app-bar-overflow='medium'] {
display: none;
}
}
@media (width >= 600px) {
[data-md-app-bar-overflow='compact'] {
display: none;
}
}
[data-md-app-bar-headline] {
display: flex;
min-width: 0;
flex: 1 1 0%;
flex-direction: column;
justify-content: center;
padding-inline: 0.75rem;
padding-inline: 12px;
}
[data-app-bar-row]:has([data-app-bar-leading]) [data-app-bar-headline] {
padding-inline-start: 0.25rem;
[data-md-app-bar-row]:has([data-md-app-bar-leading]) [data-md-app-bar-headline] {
padding-inline-start: var(--md-sys-measurement-space50);
}
[data-app-bar-title] {
[data-md-app-bar-title] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -109,7 +158,7 @@
letter-spacing: var(--md-sys-typescale-title-lg-tracking);
}
[data-app-bar-subtitle] {
[data-md-app-bar-subtitle] {
overflow: hidden;
color: var(--md-sys-color-on-surface-variant);
text-overflow: ellipsis;
@@ -118,70 +167,94 @@
letter-spacing: var(--md-sys-typescale-label-md-tracking);
}
/* Centred: the headline spans the row and centres its text between whatever is at either end. */
[data-app-bar][data-variant="center"] [data-app-bar-headline] {
position: absolute;
inset-inline: 3.5rem;
top: 0;
bottom: 0;
align-items: center;
text-align: center;
pointer-events: none;
/* Centred: a three-column row leading, headline, trailing so the headline is centred in the
window and shrinks against whatever is actually beside it. M3 allows two trailing icon
buttons, which a fixed inset for one would have let a long title ellipsise underneath (N-11). */
[data-md-app-bar][data-md-variant='center'] [data-md-app-bar-row] {
display: grid;
grid-template-columns: 1fr auto 1fr;
}
/* The search bar fills the row. */
[data-app-bar-search] {
[data-md-app-bar][data-md-variant='center'] [data-md-app-bar-leading] {
grid-column: 1;
justify-self: start;
}
[data-md-app-bar][data-md-variant='center'] [data-md-app-bar-headline] {
grid-column: 2;
min-width: 0;
align-items: center;
justify-self: center;
padding-inline: 12px;
text-align: center;
}
[data-md-app-bar][data-md-variant='center'] [data-md-app-bar-trailing] {
grid-column: 3;
justify-self: end;
margin-inline-start: 0;
}
/* The search bar fills the space between the leading and trailing elements until it is 312dp
wide, and then grows into only half of what is left (N-09). The percentage is of the row
rather than of that space, which CSS cannot name without measuring it; below 312px the cap
never binds, so a phone still gets the whole row. */
[data-md-app-bar-search] {
min-width: 0;
max-width: calc(312px + (100% - 312px) / 2);
flex: 1 1 0%;
padding-inline: 0.25rem;
margin-inline: auto;
padding-inline: var(--md-sys-measurement-space50);
}
/* Medium and large: the row's title is the collapsed one, shown once the big one has gone. */
[data-app-bar]:is([data-variant="medium"], [data-variant="large"]) [data-app-bar-row] [data-app-bar-headline] {
[data-md-app-bar]:is([data-md-variant='medium'], [data-md-variant='large']) [data-md-app-bar-row] [data-md-app-bar-headline] {
opacity: 0;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
transition-property: opacity;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
}
[data-app-bar][data-collapsed] [data-app-bar-row] [data-app-bar-headline] {
[data-md-app-bar][data-md-collapsed] [data-md-app-bar-row] [data-md-app-bar-headline] {
opacity: 1;
}
/* Heights from the tokens: the title (and subtitle) lines and the space under them fill what the
row leaves 36 + 12px medium, 36 + 20 + 16px with a subtitle; 44 + 12px large, 44 + 24 + 20px. */
[data-app-bar-expanded] {
[data-md-app-bar-expanded] {
display: flex;
min-height: calc(var(--app-bar-height) - var(--app-bar-row));
flex-direction: column;
justify-content: flex-end;
padding-inline: 1rem;
padding-bottom: 0.75rem;
padding-inline: var(--md-sys-measurement-space200);
padding-bottom: 12px;
}
[data-app-bar][data-subtitled] [data-app-bar-expanded] {
padding-bottom: 1rem;
[data-md-app-bar][data-md-subtitled] [data-md-app-bar-expanded] {
padding-bottom: var(--md-sys-measurement-space200);
}
[data-app-bar][data-variant="large"][data-subtitled] [data-app-bar-expanded] {
padding-bottom: 1.25rem;
[data-md-app-bar][data-md-variant='large'][data-md-subtitled] [data-md-app-bar-expanded] {
padding-bottom: 20px;
}
[data-app-bar-expanded] [data-app-bar-title] {
[data-md-app-bar-expanded] [data-md-app-bar-title] {
white-space: normal;
font: var(--md-sys-typescale-headline-md);
letter-spacing: var(--md-sys-typescale-headline-md-tracking);
}
[data-app-bar-expanded] [data-app-bar-subtitle] {
[data-md-app-bar-expanded] [data-md-app-bar-subtitle] {
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
}
[data-app-bar][data-variant="large"] [data-app-bar-expanded] [data-app-bar-title] {
[data-md-app-bar][data-md-variant='large'] [data-md-app-bar-expanded] [data-md-app-bar-title] {
font: var(--md-sys-typescale-display-sm);
letter-spacing: var(--md-sys-typescale-display-sm-tracking);
}
[data-app-bar][data-variant="large"] [data-app-bar-expanded] [data-app-bar-subtitle] {
[data-md-app-bar][data-md-variant='large'] [data-md-app-bar-expanded] [data-md-app-bar-subtitle] {
font: var(--md-sys-typescale-title-md);
letter-spacing: var(--md-sys-typescale-title-md-tracking);
}
+143
View File
@@ -0,0 +1,143 @@
/*
* <x-badge>: M3's small and large badges, and the library's status label.
*
* BadgeTokens (androidx Compose Material 3, Apache-2.0): the small badge is a 6px dot
* (`data-md-dot`), the large badge 16px tall and at least 16px wide with 4px either side of a
* `label-small` count in tabular figures, both fully round and `error` by default. A floating
* badge sits on M3's anchor geometry at the top-end corner of its `position: relative` parent: the
* dot flush in the corner, the count 2px above it with its start 12px in from the parent's end.
*
* The status label (`data-md-variant`) is not an M3 badge: 24px tall, small corner, 8px either
* side, 4px between an icon and its word, `label-medium`. `tonal` draws it in the colour's
* container, `solid` in the colour itself, `outline` as a 1px `outline` edge around
* `on-surface-variant` text `outline` rather than `outline-variant`, because M3 asks a badge for
* 3:1 against what is behind it and `outline-variant` is the decorative role dividers use.
*
* `data-md-color` picks the pair of roles; `neutral` has no hue of its own (`on-surface-variant`
* and `surface`, or `surface-container-high` behind `on-surface-variant` text), and `plain` sets
* no colour at all, so the caller's CSS paints it.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-badge] {
--md-badge-color: var(--md-sys-color-error);
--md-badge-on-color: var(--md-sys-color-on-error);
--md-badge-container: var(--md-sys-color-error-container);
--md-badge-on-container: var(--md-sys-color-on-error-container);
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
white-space: nowrap;
}
[data-md-badge][data-md-dot] {
inline-size: 6px;
block-size: 6px;
border-radius: var(--md-sys-shape-corner-full);
}
[data-md-badge]:not([data-md-dot], [data-md-variant]) {
min-inline-size: 16px;
block-size: 16px;
border-radius: var(--md-sys-shape-corner-full);
padding-inline: var(--md-sys-measurement-space50);
font: var(--md-sys-typescale-label-sm);
letter-spacing: var(--md-sys-typescale-label-sm-tracking);
font-variation-settings: normal;
font-variant-numeric: tabular-nums;
}
[data-md-badge][data-md-variant] {
gap: var(--md-sys-measurement-space50);
block-size: 24px;
border-radius: var(--md-sys-shape-corner-sm);
padding-inline: var(--md-sys-measurement-space100);
font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking);
font-variation-settings: normal;
}
[data-md-badge][data-md-variant='outline'] {
border: 1px solid;
}
[data-md-badge][data-md-color='primary'] {
--md-badge-color: var(--md-sys-color-primary);
--md-badge-on-color: var(--md-sys-color-on-primary);
--md-badge-container: var(--md-sys-color-primary-container);
--md-badge-on-container: var(--md-sys-color-on-primary-container);
}
[data-md-badge][data-md-color='secondary'] {
--md-badge-color: var(--md-sys-color-secondary);
--md-badge-on-color: var(--md-sys-color-on-secondary);
--md-badge-container: var(--md-sys-color-secondary-container);
--md-badge-on-container: var(--md-sys-color-on-secondary-container);
}
[data-md-badge][data-md-color='tertiary'] {
--md-badge-color: var(--md-sys-color-tertiary);
--md-badge-on-color: var(--md-sys-color-on-tertiary);
--md-badge-container: var(--md-sys-color-tertiary-container);
--md-badge-on-container: var(--md-sys-color-on-tertiary-container);
}
[data-md-badge][data-md-color='success'] {
--md-badge-color: var(--md-sys-color-success);
--md-badge-on-color: var(--md-sys-color-on-success);
--md-badge-container: var(--md-sys-color-success-container);
--md-badge-on-container: var(--md-sys-color-on-success-container);
}
[data-md-badge][data-md-color='warning'] {
--md-badge-color: var(--md-sys-color-warning);
--md-badge-on-color: var(--md-sys-color-on-warning);
--md-badge-container: var(--md-sys-color-warning-container);
--md-badge-on-container: var(--md-sys-color-on-warning-container);
}
[data-md-badge][data-md-color='info'] {
--md-badge-color: var(--md-sys-color-info);
--md-badge-on-color: var(--md-sys-color-on-info);
--md-badge-container: var(--md-sys-color-info-container);
--md-badge-on-container: var(--md-sys-color-on-info-container);
}
[data-md-badge][data-md-color='neutral'] {
--md-badge-color: var(--md-sys-color-on-surface-variant);
--md-badge-on-color: var(--md-sys-color-surface);
--md-badge-container: var(--md-sys-color-surface-container-high);
--md-badge-on-container: var(--md-sys-color-on-surface-variant);
}
[data-md-badge]:not([data-md-color='plain']) {
background-color: var(--md-badge-color);
color: var(--md-badge-on-color);
&[data-md-variant='tonal'] {
background-color: var(--md-badge-container);
color: var(--md-badge-on-container);
}
&[data-md-variant='outline'] {
border-color: var(--md-sys-color-outline);
background-color: transparent;
color: var(--md-sys-color-on-surface-variant);
}
}
[data-md-badge][data-md-floating] {
position: absolute;
inset-block-start: calc(-1 * var(--md-sys-measurement-space25));
inset-inline-start: calc(100% - 12px);
}
[data-md-badge][data-md-floating][data-md-dot] {
inset-block-start: 0;
inset-inline: auto 0;
}
}
+169
View File
@@ -0,0 +1,169 @@
/*
* `<x-bottom-sheet>`: M3's modal and standard bottom sheets (SheetBottomTokens.kt, androidx
* Compose Material 3, Apache-2.0; docs/reference/m3/
* components-actions-communication-containment.md § Bottom sheets Specs).
*
* [data-md-bottom-sheet] the wrapper; data-md-open, data-md-standard
* [data-md-bottom-sheet-scrim] modal only
* [data-md-bottom-sheet-probe] hidden, measures a stop's px height for a drag to settle on
* [data-md-bottom-sheet-panel] surface-container-low, extra-large top corners, elevation 1;
* data-md-preset while `heights`/`snap` gives it stops
* [data-md-bottom-sheet-handle] the drag target and M3's 48dp hit area (C-01)
* [data-md-bottom-sheet-grip] the 32×4px bar, the shared classes' touch target
* [data-md-bottom-sheet-announce] a live region, only while stops exist
* [data-md-bottom-sheet-body], [data-md-bottom-sheet-title], [data-md-bottom-sheet-actions]
*
* 640px max width, centred; 28px top corners only (`DockedContainerShape` = CornerExtraLargeTop);
* 50dvh default open height under a ceiling of the screen less M3's 72dp top margin (C-19). It
* rises on emphasized decelerate and only falls on emphasized accelerate the asymmetric timing a
* more specific selector for the open state supplies, since a CSS transition always takes its
* duration and easing from the *after-change* style (the technique resources/css/components/
* drawer.css also uses). `data-md-preset` adds the height-between-stops transition on the spatial
* spring, independent of the open/close slide.
*
* The handle's own 22px top/bottom padding (`SheetDefaults.kt`'s `DragHandleVerticalPadding`) is a
* literal 22 is not on the measurement scale matching alert.css's 12px for the same reason.
* The grip button draws the shared `md-focus-ring` and `md-touch-target` classes rather than a
* hand-rolled ring: its own visible bar is not a smaller indicator drawn inside a bigger box those
* classes cannot reach (unlike the datepicker's day) — the box they draw *is* the button's whole
* hit area. The one refinement is the ring's offset, 4px rather than 2px, as the old handle drew
* it: 2px from a 4px-tall bar leaves the ring hard to tell from the bar.
*
* The scrim fades out as it fades in, and the state rules match the root's own scrim and panel,
* so a sheet nested in an open one (a menu's sheet at compact inside a sheet) keeps its own state.
* Both keep `x-show`, and the view's `x-transition` holds their `display` through the exit rather
* than `allow-discrete`, which Firefox does not honour for `display` (see drawer.css); Alpine holds
* for the first `transition-duration` listed, so the closing slide is listed first.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
/* The scrim fades out while the view's `x-transition` holds its `display` (see drawer.css). */
[data-md-bottom-sheet-scrim] {
position: fixed;
inset: 0;
z-index: 40;
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
opacity: 0;
transition-property: opacity;
transition-duration: var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-effects-default);
}
[data-md-bottom-sheet][data-md-open] > [data-md-bottom-sheet-scrim] {
opacity: 1;
}
@starting-style {
[data-md-bottom-sheet][data-md-open] > [data-md-bottom-sheet-scrim] {
opacity: 0;
}
}
[data-md-bottom-sheet-probe] {
position: fixed;
inset-inline-start: 0;
top: 0;
width: 0;
visibility: hidden;
pointer-events: none;
}
[data-md-bottom-sheet-panel] {
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 50;
display: flex;
width: 100%;
max-width: 640px;
margin-inline: auto;
flex-direction: column;
max-height: var(--sheet-max-height);
touch-action: pan-y;
border-radius: var(--md-sys-shape-corner-xl) var(--md-sys-shape-corner-xl) var(--md-sys-shape-corner-none) var(--md-sys-shape-corner-none);
background-color: var(--md-sys-color-surface-container-low);
color: var(--md-sys-color-on-surface);
box-shadow: var(--md-sys-elevation-1);
padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
translate: 0 100%;
transition-property: translate;
transition-duration: var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate);
}
[data-md-bottom-sheet][data-md-open] > [data-md-bottom-sheet-panel] {
translate: 0 0;
transition-duration: var(--md-sys-motion-spatial-default-duration);
transition-timing-function: var(--md-sys-motion-easing-emphasized-decelerate);
}
@starting-style {
[data-md-bottom-sheet][data-md-open] > [data-md-bottom-sheet-panel] {
translate: 0 100%;
}
}
/* With stops the panel is the height of its current one, moving between them on the spatial
spring, independent of the translate that opens and closes the sheet. */
[data-md-bottom-sheet-panel][data-md-preset] {
height: var(--sheet-max-height);
transition-property: translate, height;
transition-duration: var(--md-sys-motion-effects-default-duration), var(--md-sys-motion-spatial-default-duration);
transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate), var(--md-sys-motion-spatial-default);
}
[data-md-bottom-sheet][data-md-open] > [data-md-bottom-sheet-panel][data-md-preset] {
transition-duration: var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-spatial-default-duration);
transition-timing-function: var(--md-sys-motion-easing-emphasized-decelerate), var(--md-sys-motion-spatial-default);
}
[data-md-bottom-sheet-handle] {
display: flex;
flex-shrink: 0;
justify-content: center;
padding-block: 22px;
cursor: grab;
}
[data-md-bottom-sheet-handle]:active {
cursor: grabbing;
}
[data-md-bottom-sheet-grip] {
width: 32px;
height: 4px;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-on-surface-variant);
}
[data-md-bottom-sheet-grip]:focus-visible {
outline-offset: 4px;
}
[data-md-bottom-sheet-body] {
min-height: 0;
flex: 1 1 0%;
overflow-y: auto;
padding-inline: var(--md-sys-measurement-space300);
padding-bottom: var(--md-sys-measurement-space300);
}
[data-md-bottom-sheet-title] {
margin-bottom: var(--md-sys-measurement-space200);
font: var(--md-sys-typescale-title-lg);
letter-spacing: var(--md-sys-typescale-title-lg-tracking);
}
[data-md-bottom-sheet-actions] {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--md-sys-measurement-space100);
padding-inline: var(--md-sys-measurement-space300);
padding-bottom: var(--md-sys-measurement-space300);
}
}
+169
View File
@@ -0,0 +1,169 @@
/*
* <x-button-group>: related buttons in a row, and the shapes a button takes from its place among
* its siblings. The connected form is also what `<x-group>` draws its segments with, which is why
* its stylesheet imports this one. A group never wraps: M3's rule, and the press expansion only
* reaches a neighbour on the same line anyway.
*
* Standard (`data-md-button-group="standard"`): the buttons stand apart 18/12/8/8/8px by size
* (ButtonGroupXSmallXLargeTokens.BetweenSpace) and pressing a label button widens it and
* narrows its neighbours by the same amount, so the row keeps its length: Expressive's press
* expansion (ButtonGroupDefaults.ExpandedRatio is 15%; this uses a fixed step per size, 4/6/8/16/
* 20px, on the button's own padding of 16/16/24/48/64px). Icon buttons keep their width. A square
* standard group has no inner edges, so it hands its buttons the square corner scale
* `<x-button shape="square">` draws (12/12/16/28/28px, 8/8/12/16/16px under the finger), and a
* selected button in it turns round.
*
* Connected (`data-md-button-group="connected"`): 2px apart, the outer corners full, the inner
* corners small, smaller still while pressed, and a selected segment (`aria-pressed`, or a checked
* input inside it) fully round ConnectedButtonGroupXSmallXLargeTokens and the M3 Expressive
* connected button group spec: inner corners 4/8/8/16/20px, pressed 2/4/4/12/16px. At the two
* smallest sizes a segment is 32px or 40px tall, and M3 names both numbers it must not go below:
* "XS and S connected button groups have a 48dp target area and a 48dp minimum width".
*
* Shape (`data-md-shape="square"`, M3's "Default shape | Round, square" configuration):
* `--md-button-group-outer` is what the two ends of a group round to half the height for a round
* group, and for a square one the corner its inner edges take, which is the square table M3
* publishes for connected groups (4/8/8/16/20dp by size). Selection morphs the other way: M3 has a
* toggle inside a group "swap shape square/round on selection", so a selected segment in a square
* group turns round, which is why the selected rule keeps `--md-button-group-full`.
*
* "Full" is half the size's height (16/20/28/48/68px), never `--md-sys-shape-corner-full`'s
* 9999px. One element mixes full outer corners with small inner ones, and when a box's radii add
* up to more than its side, CSS scales every radius by the same factor: a 9999px corner beside an
* 8px one shrank the 8px one to a hundredth of a pixel, so the inner corners drew square.
*
* <x-button>'s own corner rules carry no specificity, so these reshape the buttons in a group
* whatever selector they use; a pressed or selected segment changes one custom property and the
* outer corners stay put.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-button-group] {
--md-button-group-inner: var(--md-sys-shape-corner-sm);
--md-button-group-inner-pressed: var(--md-sys-shape-corner-xs);
--md-button-group-full: 20px;
--md-button-group-square: var(--md-sys-shape-corner-md);
--md-button-group-square-pressed: var(--md-sys-shape-corner-sm);
--md-button-group-outer: var(--md-button-group-full);
--md-button-group-pad: var(--md-sys-measurement-space200);
--md-button-group-grow: 6px;
display: inline-flex;
align-items: center;
}
[data-md-button-group][data-md-size='xs'] {
--md-button-group-inner: var(--md-sys-shape-corner-xs);
--md-button-group-inner-pressed: 2px;
--md-button-group-full: 16px;
--md-button-group-grow: 4px;
}
[data-md-button-group][data-md-size='md'] {
--md-button-group-full: 28px;
--md-button-group-square: var(--md-sys-shape-corner-lg);
--md-button-group-square-pressed: var(--md-sys-shape-corner-md);
--md-button-group-pad: var(--md-sys-measurement-space300);
--md-button-group-grow: 8px;
}
[data-md-button-group][data-md-size='lg'] {
--md-button-group-inner: var(--md-sys-shape-corner-lg);
--md-button-group-inner-pressed: var(--md-sys-shape-corner-md);
--md-button-group-full: 48px;
--md-button-group-square: var(--md-sys-shape-corner-xl);
--md-button-group-square-pressed: var(--md-sys-shape-corner-lg);
--md-button-group-pad: var(--md-sys-measurement-space600);
--md-button-group-grow: 16px;
}
[data-md-button-group][data-md-size='xl'] {
--md-button-group-inner: var(--md-sys-shape-corner-lg-increased);
--md-button-group-inner-pressed: var(--md-sys-shape-corner-lg);
--md-button-group-full: 68px;
--md-button-group-square: var(--md-sys-shape-corner-xl);
--md-button-group-square-pressed: var(--md-sys-shape-corner-lg);
--md-button-group-pad: var(--md-sys-measurement-space800);
--md-button-group-grow: 20px;
}
[data-md-button-group='standard'] {
gap: var(--md-sys-measurement-space100);
}
[data-md-button-group='standard'][data-md-size='xs'] {
gap: 18px;
}
[data-md-button-group='standard'][data-md-size='sm'] {
gap: 12px;
}
/* A square standard group hands its buttons the square corner scale; a selected one rounds. */
[data-md-button-group='standard'][data-md-shape='square'] > * {
border-radius: var(--md-button-group-square);
}
[data-md-button-group='standard'][data-md-shape='square'] > :active:not(:disabled, [aria-disabled='true']) {
border-radius: var(--md-button-group-square-pressed);
}
[data-md-button-group='standard'][data-md-shape='square'] > [aria-pressed='true'] {
border-radius: var(--md-button-group-full);
}
/* Press expansion: the pressed label button grows, the label buttons beside it give way. */
[data-md-button-group='standard'] > :not([data-md-icon-button]):active:not(:disabled, [aria-disabled='true']) {
padding-inline: calc(var(--md-button-group-pad) + var(--md-button-group-grow));
}
[data-md-button-group='standard'] > :not([data-md-icon-button]):has(+ :not([data-md-icon-button]):active:not(:disabled, [aria-disabled='true'])) {
padding-inline-end: calc(var(--md-button-group-pad) - var(--md-button-group-grow));
}
[data-md-button-group='standard'] > :not([data-md-icon-button]):active:not(:disabled, [aria-disabled='true']) + :not([data-md-icon-button]) {
padding-inline-start: calc(var(--md-button-group-pad) - var(--md-button-group-grow));
}
/* A square group's ends take the corner its inner edges take. */
[data-md-button-group][data-md-shape='square'] {
--md-button-group-outer: var(--md-button-group-inner);
}
[data-md-button-group='connected'] {
gap: var(--md-sys-measurement-space25);
}
[data-md-button-group='connected']:is([data-md-size='xs'], [data-md-size='sm']) > * {
min-inline-size: var(--md-sys-measurement-space600);
}
[data-md-button-group='connected'] > * {
--md-button-group-corner: var(--md-button-group-inner);
border-start-start-radius: var(--md-button-group-corner);
border-end-start-radius: var(--md-button-group-corner);
border-start-end-radius: var(--md-button-group-corner);
border-end-end-radius: var(--md-button-group-corner);
}
[data-md-button-group='connected'] > :active {
--md-button-group-corner: var(--md-button-group-inner-pressed);
}
[data-md-button-group='connected'] > :is([aria-pressed='true'], :has(:checked)) {
--md-button-group-corner: var(--md-button-group-full);
}
[data-md-button-group='connected'] > :first-child {
border-start-start-radius: var(--md-button-group-outer);
border-end-start-radius: var(--md-button-group-outer);
}
[data-md-button-group='connected'] > :last-child {
border-start-end-radius: var(--md-button-group-outer);
border-end-end-radius: var(--md-button-group-outer);
}
}
+401
View File
@@ -0,0 +1,401 @@
/*
* <x-button>: M3 Expressive's buttons — label button, icon button and toggle — and the page's
* create action that is a FAB on a compact window.
*
* Geometry from androidx Compose Material 3's tokens (Apache-2.0), per `data-md-size`:
*
* size height label padding gap type icon (label / icon-only) outline square pressed
* xs 32px 16px 8px label-lg 20 / 20px 1px md sm
* sm 40px 16px 8px label-lg 20 / 24px 1px md sm
* md 56px 24px 8px title-md 24 / 24px 1px lg md
* lg 96px 48px 12px headline-sm 32 / 32px 2px xl lg
* xl 136px 64px 16px headline-lg 40 / 40px 3px xl lg
*
* (ButtonXSmallXLargeTokens; the xs padding is the 16px Phase D settled on, ACT-09.) An icon button
* (`data-md-icon-button`) is as tall and `narrow`, `default` or `wide` across by
* XSmallXLargeIconButtonTokens: 28/32/40, 32/40/52, 48/56/72, 64/96/128 and 104/136/184px. The
* views size the glyphs through `<x-icon size>`; `--md-button-icon` carries the same size for the
* loading indicator that stands in for the glyph while the button's action runs.
*
* Shape: round (`--md-sys-shape-corner-full`) by default; `data-md-shape="square"` and a selected
* button take the size's square corner, a selected square icon button turns round, and every one
* squares off to the pressed corner under the finger. Those corner rules are wrapped in `:where()`,
* so they carry no specificity at all: a group's stylesheet (button-group.css, split-button.css)
* reshapes the buttons inside it with any selector, without a specificity fight.
*
* Colour: `data-md-color` picks the roles a variant draws with; the variants set
* `--md-button-container`, `--md-button-label`, `--md-button-outline` and `--md-button-elevation`,
* which one rule paints, so the disabled treatment (on-surface at 10% behind 38% text,
* FilledButtonTokens.DisabledContainerOpacity and DisabledLabelTextOpacity) replaces them with a
* plain declaration. Primary's tonal button is secondary-container and its standard icon button
* on-surface-variant, as M3 draws them; the text button's label is primary, as Compose draws it,
* not the token's on-surface-variant, which its own source marks as wrong; a selected text button
* takes the tonal container (the library's deliberate deviations, docs/audits/m3-alignment).
*
* Motion: corners, padding, margins and `bottom` on the fast spatial spring, colour and shadow on
* the fast effects spring beside it a colour must never overshoot (tokens/motion.css).
*
* The button renders the foundation's `md-state-layer` and `md-focus-ring`
* (foundation/interaction.css), and, at `xs` and `sm` where it draws under 48px, `md-touch-target`
* too; nothing here copies their rules.
*
* `data-md-compact-fab`: below `medium` (600px) the button is an extended FAB in the thumb zone
* fixed 16px from the end, 56px tall with 16px padding, the large corner, `title-md`,
* primary-container at elevation 3 (ExtendedFabPrimaryTokens) lifted clear of a bottom bar and
* of a snackbar on screen (`--material-bottom-bar`, `--material-snackbar-height`), because M3 puts a
* snackbar above a FAB and never over one. From `medium` it is the button it was written as.
* `data-md-responsive` on the label hides it below `expanded` (840px).
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@import './loading.css';
@import './tooltip.css';
@layer material.components {
[data-md-button] {
--md-button-color: var(--md-sys-color-primary);
--md-button-on-color: var(--md-sys-color-on-primary);
--md-button-tone: var(--md-sys-color-secondary-container);
--md-button-on-tone: var(--md-sys-color-on-secondary-container);
--md-button-tone-selected: var(--md-sys-color-secondary);
--md-button-on-tone-selected: var(--md-sys-color-on-secondary);
--md-button-quiet: var(--md-sys-color-on-surface-variant);
--md-button-container: transparent;
--md-button-label: var(--md-button-color);
--md-button-outline: transparent;
--md-button-elevation: none;
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
block-size: var(--md-button-height);
background-color: var(--md-button-container);
color: var(--md-button-label);
box-shadow: var(--md-button-elevation);
white-space: nowrap;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
transition-property: border-radius, padding, margin, bottom, background-color, color, box-shadow;
transition-duration:
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration),
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration),
var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration);
transition-timing-function:
var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast),
var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast),
var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast);
}
/* Sizes. */
[data-md-button][data-md-size='xs'] {
--md-button-height: 32px;
--md-button-padding: var(--md-sys-measurement-space200);
--md-button-gap: var(--md-sys-measurement-space100);
--md-button-icon: 20px;
--md-button-outline-width: 1px;
--md-button-square: var(--md-sys-shape-corner-md);
--md-button-pressed: var(--md-sys-shape-corner-sm);
--md-button-narrow: 28px;
--md-button-default: 32px;
--md-button-wide: 40px;
}
[data-md-button][data-md-size='sm'] {
--md-button-height: 40px;
--md-button-padding: var(--md-sys-measurement-space200);
--md-button-gap: var(--md-sys-measurement-space100);
--md-button-icon: 20px;
--md-button-outline-width: 1px;
--md-button-square: var(--md-sys-shape-corner-md);
--md-button-pressed: var(--md-sys-shape-corner-sm);
--md-button-narrow: 32px;
--md-button-default: 40px;
--md-button-wide: 52px;
}
[data-md-button][data-md-size='sm'][data-md-icon-button] {
--md-button-icon: 24px;
}
[data-md-button][data-md-size='md'] {
--md-button-height: 56px;
--md-button-padding: var(--md-sys-measurement-space300);
--md-button-gap: var(--md-sys-measurement-space100);
--md-button-icon: 24px;
--md-button-outline-width: 1px;
--md-button-square: var(--md-sys-shape-corner-lg);
--md-button-pressed: var(--md-sys-shape-corner-md);
--md-button-narrow: 48px;
--md-button-default: 56px;
--md-button-wide: 72px;
}
[data-md-button][data-md-size='lg'] {
--md-button-height: 96px;
--md-button-padding: var(--md-sys-measurement-space600);
--md-button-gap: 12px;
--md-button-icon: 32px;
--md-button-outline-width: 2px;
--md-button-square: var(--md-sys-shape-corner-xl);
--md-button-pressed: var(--md-sys-shape-corner-lg);
--md-button-narrow: 64px;
--md-button-default: 96px;
--md-button-wide: 128px;
}
[data-md-button][data-md-size='xl'] {
--md-button-height: 136px;
--md-button-padding: var(--md-sys-measurement-space800);
--md-button-gap: var(--md-sys-measurement-space200);
--md-button-icon: 40px;
--md-button-outline-width: 3px;
--md-button-square: var(--md-sys-shape-corner-xl);
--md-button-pressed: var(--md-sys-shape-corner-lg);
--md-button-narrow: 104px;
--md-button-default: 136px;
--md-button-wide: 184px;
}
[data-md-button]:not([data-md-icon-button]) {
gap: var(--md-button-gap);
padding-inline: var(--md-button-padding);
}
[data-md-button]:not([data-md-icon-button]):is([data-md-size='xs'], [data-md-size='sm']) {
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
}
[data-md-button]:not([data-md-icon-button])[data-md-size='md'] {
font: var(--md-sys-typescale-title-md);
letter-spacing: var(--md-sys-typescale-title-md-tracking);
font-variation-settings: normal;
}
[data-md-button]:not([data-md-icon-button])[data-md-size='lg'] {
font: var(--md-sys-typescale-headline-sm);
letter-spacing: var(--md-sys-typescale-headline-sm-tracking);
font-variation-settings: normal;
}
[data-md-button]:not([data-md-icon-button])[data-md-size='xl'] {
font: var(--md-sys-typescale-headline-lg);
letter-spacing: var(--md-sys-typescale-headline-lg-tracking);
font-variation-settings: normal;
}
[data-md-button][data-md-icon-button] {
--md-button-width: var(--md-button-default);
inline-size: var(--md-button-width);
&[data-md-width='narrow'] {
--md-button-width: var(--md-button-narrow);
}
&[data-md-width='wide'] {
--md-button-width: var(--md-button-wide);
}
}
/* Shape, without specificity: a group reshapes the buttons in it. */
:where([data-md-button]) {
border-radius: var(--md-sys-shape-corner-full);
}
:where([data-md-button]):where([data-md-shape='square'], [data-md-selected='true']) {
border-radius: var(--md-button-square);
}
:where([data-md-button]):where([data-md-icon-button][data-md-shape='square'][data-md-selected='true']) {
border-radius: var(--md-sys-shape-corner-full);
}
:where([data-md-button]):where(:active) {
border-radius: var(--md-button-pressed);
}
/* Colour roles. */
[data-md-button][data-md-color='secondary'] {
--md-button-color: var(--md-sys-color-secondary);
--md-button-on-color: var(--md-sys-color-on-secondary);
--md-button-tone: var(--md-sys-color-secondary-container);
--md-button-on-tone: var(--md-sys-color-on-secondary-container);
}
[data-md-button][data-md-color='tertiary'] {
--md-button-color: var(--md-sys-color-tertiary);
--md-button-on-color: var(--md-sys-color-on-tertiary);
--md-button-tone: var(--md-sys-color-tertiary-container);
--md-button-on-tone: var(--md-sys-color-on-tertiary-container);
}
[data-md-button][data-md-color='error'] {
--md-button-color: var(--md-sys-color-error);
--md-button-on-color: var(--md-sys-color-on-error);
--md-button-tone: var(--md-sys-color-error-container);
--md-button-on-tone: var(--md-sys-color-on-error-container);
}
[data-md-button][data-md-color='success'] {
--md-button-color: var(--md-sys-color-success);
--md-button-on-color: var(--md-sys-color-on-success);
--md-button-tone: var(--md-sys-color-success-container);
--md-button-on-tone: var(--md-sys-color-on-success-container);
}
[data-md-button][data-md-color='warning'] {
--md-button-color: var(--md-sys-color-warning);
--md-button-on-color: var(--md-sys-color-on-warning);
--md-button-tone: var(--md-sys-color-warning-container);
--md-button-on-tone: var(--md-sys-color-on-warning-container);
}
[data-md-button][data-md-color='info'] {
--md-button-color: var(--md-sys-color-info);
--md-button-on-color: var(--md-sys-color-on-info);
--md-button-tone: var(--md-sys-color-info-container);
--md-button-on-tone: var(--md-sys-color-on-info-container);
}
/* Every colour but primary selects into the colour itself and is quiet in it. */
[data-md-button]:not([data-md-color='primary']) {
--md-button-tone-selected: var(--md-button-color);
--md-button-on-tone-selected: var(--md-button-on-color);
--md-button-quiet: var(--md-button-color);
}
/* Variants. */
[data-md-button][data-md-variant='filled'] {
--md-button-container: var(--md-button-color);
--md-button-label: var(--md-button-on-color);
&[data-md-selected='false'] {
--md-button-container: var(--md-sys-color-surface-container);
--md-button-label: var(--md-sys-color-on-surface-variant);
}
}
[data-md-button][data-md-variant='tonal'] {
--md-button-container: var(--md-button-tone);
--md-button-label: var(--md-button-on-tone);
&[data-md-selected='true'] {
--md-button-container: var(--md-button-tone-selected);
--md-button-label: var(--md-button-on-tone-selected);
}
}
[data-md-button][data-md-variant='outlined'] {
--md-button-label: var(--md-button-quiet);
--md-button-outline: var(--md-sys-color-outline-variant);
border: var(--md-button-outline-width) solid var(--md-button-outline);
&[data-md-selected='true'] {
--md-button-container: var(--md-sys-color-inverse-surface);
--md-button-label: var(--md-sys-color-inverse-on-surface);
--md-button-outline: transparent;
}
}
[data-md-button][data-md-variant='elevated'] {
--md-button-container: var(--md-sys-color-surface-container-low);
--md-button-elevation: var(--md-sys-elevation-1);
&[data-md-selected='true'] {
--md-button-container: var(--md-button-color);
--md-button-label: var(--md-button-on-color);
}
}
[data-md-button][data-md-variant='text'][data-md-icon-button]:not([data-md-selected='true']) {
--md-button-label: var(--md-button-quiet);
}
[data-md-button][data-md-variant='text']:not([data-md-icon-button])[data-md-selected='true'] {
--md-button-container: var(--md-button-tone);
--md-button-label: var(--md-button-on-tone);
}
@media (hover: hover) {
[data-md-button]:is([data-md-variant='filled'], [data-md-variant='tonal']):hover {
--md-button-elevation: var(--md-sys-elevation-1);
}
[data-md-button][data-md-variant='elevated']:hover {
--md-button-elevation: var(--md-sys-elevation-2);
}
}
/* Disabled: no state layer, no shadow, M3's disabled colours over whatever the variant set. */
[data-md-button]:is(:disabled, [aria-disabled='true']) {
box-shadow: none;
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
}
[data-md-button]:is([data-md-variant='filled'], [data-md-variant='tonal'], [data-md-variant='elevated'], [data-md-variant='outlined'][data-md-selected='true']):is(:disabled, [aria-disabled='true']) {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
}
[data-md-button]:disabled {
cursor: not-allowed;
}
[data-md-button][aria-disabled='true'] {
pointer-events: none;
}
/* The glyph's place, and the loading indicator that stands in for it. */
[data-md-button-icon] {
display: contents;
}
[data-md-button-spinner] {
align-items: center;
justify-content: center;
}
[data-md-button-spinner] > [data-md-loading] {
inline-size: var(--md-button-icon);
block-size: var(--md-button-icon);
color: currentColor;
}
@media (width < 840px) {
[data-md-button] > [data-md-responsive] {
display: none;
}
}
@media (width < 600px) {
[data-md-button][data-md-compact-fab] {
--md-button-height: 56px;
--md-button-padding: var(--md-sys-measurement-space200);
--md-button-gap: var(--md-sys-measurement-space100);
position: fixed;
inset-inline-end: var(--md-sys-measurement-space200);
bottom: calc(var(--material-bottom-bar, 0px) + var(--material-snackbar-height, 0px) + var(--md-sys-measurement-space200));
z-index: 30;
border-radius: var(--md-sys-shape-corner-lg);
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
box-shadow: var(--md-sys-elevation-3);
font: var(--md-sys-typescale-title-md);
letter-spacing: var(--md-sys-typescale-title-md-tracking);
font-variation-settings: normal;
}
[data-md-button][data-md-compact-fab] [data-md-icon] {
inline-size: 24px;
block-size: 24px;
}
}
}
+184
View File
@@ -0,0 +1,184 @@
/*
* <x-card>: M3's three card variants (ElevatedCardTokens.kt, FilledCardTokens.kt,
* OutlinedCardTokens.kt, androidx Compose Material 3, Apache-2.0; docs/reference/m3
* § Cards Specs) filled (surface-container-highest), elevated (surface-container-low at
* elevation 1) and outlined (surface, an outline-variant edge); one 12dp corner for all three,
* which never morphs (M3 specifies shape morph for buttons, FABs and list items, never cards
* docs/audits/m3-alignment/containment.md, C-23).
*
* Every card renders the shared `md-state-layer` class, and a row (`data-md-list-row`,
* `resources/js/list-rows.js`) `md-focus-ring` too (foundation/interaction.css), for its tint and
* its ring; this file adds only what a card needs beyond them:
*
* - *no layer* on a card that is neither a row nor dragged: a card that does not open anything
* answers no pointer, but any card can be carried, and the application sets `data-md-dragged`
* from script long after the view rendered;
* - per-state *elevation*, which the shared class has no opinion on: elevated 1dp rest 2dp
* hover 1dp focus/pressed; filled and outlined 0dp rest 1dp hover 0dp focus/pressed
* (the cross-check table's Hover/Focus/Pressed rows). Pressed and focus fall back to the rest
* level, so the `:active` rule (declared after `:hover`, same specificity) resets it even
* while still hovering;
* - the *press/hover exclusion*: a card's own nested button (the "Copy link" in a card that is
* also a row) should light only itself, never the row's tint or elevation. `material.components`
* out-layers `material.base`, so these two rules win over the shared class's plain `:hover`/
* `:active` without needing extra specificity;
* - the *opener-focused ring*: a non-actionable row's focus never lands on the card itself (its
* opener carries the tab stop), so `md-focus-ring`'s own `:focus-visible` never fires for it;
* `:has([data-md-list-open]:focus-visible)` draws the same ring from here instead. An
* actionable card (`tabindex="0"`) is focused directly, which `md-focus-ring` already covers.
*
* `data-md-dragged` (an application sets it while its drag is in progress) is the top of the
* elevation scale Level4 (8dp) elevated, Level3 (6dp) filled and outlined under the shared
* class's own 16% dragged tint, which already reads `data-md-dragged` without any help from here.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './divider.css';
@layer material.components {
[data-md-card] {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: var(--md-sys-shape-corner-md);
color: var(--md-sys-color-on-surface);
}
[data-md-card='filled'] {
background-color: var(--md-sys-color-surface-container-highest);
}
[data-md-card='elevated'] {
background-color: var(--md-sys-color-surface-container-low);
box-shadow: var(--md-sys-elevation-1);
}
[data-md-card='outlined'] {
border: 1px solid var(--md-sys-color-outline-variant);
background-color: var(--md-sys-color-surface);
}
[data-md-card]:not([data-md-list-row], [data-md-dragged])::before {
display: none;
}
[data-md-card][data-md-list-row] {
cursor: pointer;
transition-property: box-shadow;
transition-duration: var(--md-sys-motion-spatial-default-duration);
transition-timing-function: var(--md-sys-motion-spatial-default);
}
@media (hover: hover) {
[data-md-card][data-md-list-row]:not([data-md-dragged]):hover:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):hover)) {
box-shadow: var(--md-sys-elevation-1);
}
[data-md-card='elevated'][data-md-list-row]:not([data-md-dragged]):hover:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):hover)) {
box-shadow: var(--md-sys-elevation-2);
}
[data-md-card][data-md-list-row]:not([data-md-dragged]):hover:has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):hover)::before {
opacity: 0;
}
}
[data-md-card][data-md-list-row]:not([data-md-dragged]):active:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):active)) {
box-shadow: none;
}
[data-md-card='elevated'][data-md-list-row]:not([data-md-dragged]):active:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):active)) {
box-shadow: var(--md-sys-elevation-1);
}
[data-md-card][data-md-list-row]:not([data-md-dragged]):has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):active)::before {
opacity: 0;
}
[data-md-card][data-md-dragged] {
box-shadow: var(--md-sys-elevation-3);
}
[data-md-card='elevated'][data-md-dragged] {
box-shadow: var(--md-sys-elevation-4);
}
[data-md-card][data-md-list-row]:has([data-md-list-open]:focus-visible) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
[data-md-card][data-md-list-row] [data-md-list-open]:focus-visible {
outline: none;
}
[data-md-card-figure] {
flex-shrink: 0;
overflow: hidden;
& > img {
width: 100%;
object-fit: cover;
}
}
[data-md-card-body] {
display: flex;
flex: 1 1 0%;
flex-direction: column;
gap: var(--md-sys-measurement-space200);
padding: var(--md-sys-measurement-space200);
}
[data-md-card-header-row] {
display: flex;
align-items: flex-start;
gap: var(--md-sys-measurement-space100);
}
[data-md-card-heading] {
min-width: 0;
flex: 1 1 0%;
}
[data-md-card-title] {
font: var(--md-sys-typescale-title-md);
letter-spacing: var(--md-sys-typescale-title-md-tracking);
}
[data-md-card-subtitle] {
margin-top: var(--md-sys-measurement-space25);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
}
[data-md-card-menu] {
display: flex;
flex-shrink: 0;
align-items: center;
gap: var(--md-sys-measurement-space50);
margin-block-start: calc(-1 * var(--md-sys-measurement-space100));
margin-inline-end: calc(-1 * var(--md-sys-measurement-space100));
}
[data-md-card-header] > [data-md-divider] {
margin-top: var(--md-sys-measurement-space200);
}
[data-md-card-content] {
flex: 1 1 0%;
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
}
[data-md-card-actions] {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--md-sys-measurement-space100);
}
}
+112
View File
@@ -0,0 +1,112 @@
/*
* <x-carousel-item>: one slide of an <x-carousel>, masked and shifted by the keyline maths
* carousel.css's header describes, unless the row is full-screen or multi-aspect, where nothing
* masks it at all (docs/reference/m3/components-actions-communication-containment.md § Carousel;
* docs/audits/m3-alignment/containment.md, C-11, C-12, C-25).
*
* [data-md-carousel-item] the tab stop (C-18), a focusable `group`, `md-focus-ring`
* [data-md-carousel-surface] the masked, shifted, extra-large-corner (28px) box
* [data-md-carousel-content] the slot (an <img>, cropped)
* [data-md-carousel-label] the `label` prop's words, over a scrim gradient
* [data-md-carousel-label-text]
*
* The item renders the shared `md-focus-ring` class (foundation/interaction.css) rather than a
* hand-rolled ring the box the class draws *is* the item's own whole hit area refined here to
* an inset ring, 3px in, its own width (the old `focus-visible:-outline-offset-3`): items sit 8px
* apart with the row clipping them, so the class's own 2px *outward* offset would be cut off at the
* row's edges and crowd the next item.
*
* Full-screen: edge to edge, no corner, no mask `--material-carousel-shift`/`-inset` go unread.
* Multi-aspect: the view writes the item's own `aspect-ratio` inline (`aspect`, a prop, held
* inside M3's 9:1616:9 range); the extra-large corner still applies but nothing masks it, since
* an Arrangement describes items of one size and a multi-aspect row has none. Selectors use `>`
* throughout, matching carousel.css, so a carousel nested inside an item never inherits its
* parent's layout rules.
*
* The label's ink is a literal `white`, not a role: it sits over `--md-sys-color-scrim`, which is
* black in every scheme, theme and contrast level, so white is the one ink that always has the
* scrim's contrast a role (`inverse-on-surface`) is dark ink in a dark scheme and fails there
* (C-25, deliberately kept, as the component's original header explained).
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-carousel-item] {
position: relative;
height: 100%;
flex-shrink: 0;
scroll-snap-align: start;
scroll-snap-stop: always;
}
[data-md-carousel-item]:focus-visible {
outline-offset: -3px;
}
[data-md-carousel='full-screen'] > [data-md-carousel-scroller] > [data-md-carousel-item] {
width: 100%;
}
[data-md-carousel='multi-aspect'] > [data-md-carousel-scroller] > [data-md-carousel-item] {
width: auto;
border-radius: var(--md-sys-shape-corner-xl);
}
[data-md-carousel]:not([data-md-carousel='full-screen']):not([data-md-carousel='multi-aspect']) > [data-md-carousel-scroller] > [data-md-carousel-item] {
width: var(--material-carousel-slot);
max-width: 100%;
border-radius: var(--md-sys-shape-corner-xl);
}
[data-md-carousel-item] > [data-md-carousel-surface] {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background-color: var(--md-sys-color-surface-container-highest);
color: var(--md-sys-color-on-surface);
}
[data-md-carousel='multi-aspect'] > [data-md-carousel-scroller] > [data-md-carousel-item] > [data-md-carousel-surface] {
border-radius: var(--md-sys-shape-corner-xl);
}
[data-md-carousel]:not([data-md-carousel='full-screen']):not([data-md-carousel='multi-aspect']) > [data-md-carousel-scroller] > [data-md-carousel-item] > [data-md-carousel-surface] {
border-radius: var(--md-sys-shape-corner-xl);
translate: var(--material-carousel-shift) 0;
clip-path: inset(0 var(--material-carousel-inset, 0px) round var(--md-sys-shape-corner-xl));
}
[data-md-carousel-surface] > [data-md-carousel-content] {
width: 100%;
height: 100%;
& > img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
[data-md-carousel-surface] > [data-md-carousel-label] {
pointer-events: none;
position: absolute;
inset-inline: 0;
bottom: 0;
display: flex;
padding: var(--md-sys-measurement-space500) var(--md-sys-measurement-space200) var(--md-sys-measurement-space200);
background: linear-gradient(to top, color-mix(in srgb, var(--md-sys-color-scrim) 60%, transparent), transparent);
opacity: var(--material-carousel-label);
}
[data-md-carousel-label] > [data-md-carousel-label-text] {
overflow: hidden;
color: white;
font: var(--md-sys-typescale-title-md);
letter-spacing: var(--md-sys-typescale-title-md-tracking);
text-overflow: ellipsis;
white-space: nowrap;
translate: var(--material-carousel-label-shift) 0;
}
}
+113
View File
@@ -0,0 +1,113 @@
/*
* <x-carousel>: M3 Expressive's carousel multi-browse, hero, uncontained, the uncontained
* multi-aspect-ratio layout (added November 2025) and full-screen, on a native scroll container
* with CSS scroll snap. resources/js/carousel.js ports Compose's keyline maths (Arrangement.kt,
* Keylines.kt, KeylineList.kt, Strategy.kt, KeylineSnapPosition.kt, Carousel.kt, androidx Compose
* Material 3 @ commit 7ac433e44e797de53af85226797862687f37735f, Apache-2.0) and writes the masks
* and sizes as inline custom properties every scroll frame; this file draws everything the script
* does not the row's layout per layout, and the controls and carousel-item.css draws the
* item and its mask (docs/reference/m3/components-actions-communication-containment.md
* § Carousel; docs/audits/m3-alignment/containment.md).
*
* [data-md-carousel] the row wrapper; its value is the layout
* [data-md-carousel-probe] hidden, measures the preferred item width for the script
* [data-md-carousel-scroller] the scroll container (role="region")
* [data-md-carousel-item] one slide carousel-item.css
* [data-md-carousel-controls] previous/next, "auto" (pointer: fine only) or "always"
* [data-md-carousel-previous], [data-md-carousel-next]
*
* Specs table: 16dp leading/trailing padding, 8dp top/bottom and between items, for every layout
* but uncontained (leading only) and full-screen (0, edge to edge C-12); a `>` combinator
* throughout, never a bare descendant one, because a carousel item can itself hold a nested
* carousel (a `multi-aspect` gallery inside a `hero` slide) whose own root would otherwise match
* its parent's rules too. Full-screen scrolls vertically, one edge-to-edge item at a time, capped
* at the 840px medium window it is meant for (C-11); reduced motion needs no rule here at all
* the script writes zero inset, shift and full opacity itself for every layout (C-05).
*
* The previous/next icons mirror in RTL by scaling the whole button, as the row's arrow keys and
* scroll direction already do (`resources/js/carousel.js`'s own `state.rtl`), rather than through
* `<x-icon mirror-rtl>`: `<x-button icon>` has no prop to reach it, and flipping a round icon
* button whole reads the same as flipping only its glyph.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './button.css';
@import './carousel-item.css';
@layer material.components {
[data-md-carousel] {
position: relative;
}
[data-md-carousel] > [data-md-carousel-probe] {
position: absolute;
inset-inline-start: 0;
top: 0;
height: 0;
width: var(--material-carousel-item-width);
visibility: hidden;
pointer-events: none;
}
[data-md-carousel] > [data-md-carousel-scroller] {
display: flex;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
[data-md-carousel='full-screen'] > [data-md-carousel-scroller] {
margin-inline: auto;
height: var(--material-carousel-height);
max-width: 840px;
flex-direction: column;
gap: var(--md-sys-measurement-space200);
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior-y: contain;
scroll-snap-type: y mandatory;
}
[data-md-carousel]:not([data-md-carousel='full-screen']) > [data-md-carousel-scroller] {
height: calc(var(--material-carousel-height) + 16px);
gap: var(--md-sys-measurement-space100);
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
padding-block: var(--md-sys-measurement-space100);
}
[data-md-carousel='multi-aspect'] > [data-md-carousel-scroller] {
padding-inline-start: var(--material-carousel-pad);
}
[data-md-carousel='multi-browse'] > [data-md-carousel-scroller],
[data-md-carousel='hero'] > [data-md-carousel-scroller] {
scroll-snap-type: x mandatory;
}
[data-md-carousel] > [data-md-carousel-controls] {
display: none;
margin-top: 12px;
justify-content: flex-end;
gap: var(--md-sys-measurement-space100);
}
[data-md-carousel] > [data-md-carousel-controls='always'] {
display: flex;
}
@media (pointer: fine) {
[data-md-carousel] > [data-md-carousel-controls='auto'] {
display: flex;
}
}
[data-md-carousel]:not([data-md-carousel='full-screen']) > [data-md-carousel-controls] > [data-md-carousel-previous]:is([dir='rtl'], [dir='rtl'] *),
[data-md-carousel]:not([data-md-carousel='full-screen']) > [data-md-carousel-controls] > [data-md-carousel-next]:is([dir='rtl'], [dir='rtl'] *) {
transform: scaleX(-1);
}
}
+114
View File
@@ -0,0 +1,114 @@
/*
* <x-checkbox>: M3's checkbox (resources/views/components/checkbox.blade.php), on the row the
* selection controls share (components/selection.css).
*
* CheckboxTokens (androidx Compose Material 3, Apache-2.0): an 18px box with a 2px on-surface-variant
* outline and 2px corners, filled with primary and holding an on-primary tick (or dash, for the
* mixed state) at the 18px icon size once it is ticked; the state layer a 40px circle around it,
* 11px past each edge. The tick grows in and fades in on the fast spatial spring. With the server's
* error the outline, the fill and the state layer are error and the tick on-error. Disabled, the
* outline and the fill are on-surface at 38% and the tick is surface. Beside a hint the box drops
* 3px, to sit on the label's first line.
*
* [data-md-checkbox] the root: the row and its errors; `class` and `style` land here
* [data-md-selection-row]
* [data-md-checkbox-box] the box: input, [data-md-checkbox-check], [data-md-checkbox-mixed]
* [data-md-selection-support] the errors, 4px under the row
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './selection.css';
@import './icon.css';
@layer material.components {
[data-md-checkbox] {
min-width: 0;
}
[data-md-checkbox-box] {
position: relative;
display: grid;
flex: none;
place-items: center;
width: 18px;
height: 18px;
}
[data-md-checkbox-box] > * {
grid-area: 1 / 1;
}
[data-md-selection-row]:has([data-md-selection-hint]) [data-md-checkbox-box] {
margin-top: 3px;
}
[data-md-checkbox-box]::before {
inset: -11px;
}
[data-md-checkbox-box]:has(input:focus-visible)::before {
outline: 3px solid var(--md-sys-color-secondary);
}
[data-md-checkbox-box] input {
width: 18px;
height: 18px;
border: 2px solid var(--md-sys-color-on-surface-variant);
border-radius: 2px;
}
[data-md-checkbox-box] input:is(:checked, :indeterminate) {
border-color: var(--md-sys-color-primary);
background-color: var(--md-sys-color-primary);
}
[data-md-checkbox-box] :is([data-md-checkbox-check], [data-md-checkbox-mixed]) {
color: var(--md-sys-color-on-primary);
opacity: 0;
scale: 0.5;
pointer-events: none;
transition-property: opacity, scale;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
[data-md-checkbox-box] input:checked:not(:indeterminate) ~ [data-md-checkbox-check],
[data-md-checkbox-box] input:indeterminate ~ [data-md-checkbox-mixed] {
opacity: 1;
scale: 1;
}
[data-md-selection-row][data-md-invalid] [data-md-checkbox-box] input {
border-color: var(--md-sys-color-error);
}
[data-md-selection-row][data-md-invalid] [data-md-checkbox-box] input:is(:checked, :indeterminate) {
background-color: var(--md-sys-color-error);
}
[data-md-selection-row][data-md-invalid] [data-md-checkbox-box] :is([data-md-checkbox-check], [data-md-checkbox-mixed]) {
color: var(--md-sys-color-on-error);
}
[data-md-checkbox-box] input:disabled {
border-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
}
[data-md-checkbox-box] input:disabled:is(:checked, :indeterminate) {
border-color: transparent;
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
}
[data-md-checkbox-box] input:disabled ~ :is([data-md-checkbox-check], [data-md-checkbox-mixed]) {
color: var(--md-sys-color-surface);
}
[data-md-checkbox] [data-md-selection-support] {
margin-top: var(--md-sys-measurement-space50);
color: var(--md-sys-color-error);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
font-variation-settings: normal;
}
}
+128
View File
@@ -0,0 +1,128 @@
/*
* <x-chip-set>: a set of M3 chips (resources/views/components/chip-set.blade.php), named for screen
* readers as a group.
*
* The chips sit 8px apart (M3's chip spacing), in a row that wraps. Above it the label in
* label-large on-surface-variant, 8px over the row; under it the hint, or the errors in its place,
* body-small, 4px under the row.
*
* `data-md-scroll` keeps the chips on one line that scrolls sideways, as M3 lays chips out on a
* narrow screen. M3's chips accessibility page asks that a row which overflows say so: the edge the
* row can still scroll towards (`data-md-scroll-start`, `data-md-scroll-end`, set by
* resources/js/chips.js) fades over 24px through a mask, black meaning only "opaque" there, and in a
* right-to-left document the fade runs the other way. Where the pointer is fine, and so there is no
* swipe, a 32px round button in surface-container-high at elevation 1 sits over each fading edge;
* the row's scroll padding grows from 24px to 40px there, so a chip the keyboard reaches scrolls
* clear of the button as well as the fade. The row reaches 6px and 8px past its box so a focused
* chip's ring and an elevated chip's shadow are not clipped by the scroller.
*
* [data-md-chip-set] the group; data-md-scroll
* [data-md-chip-set-label]
* [data-md-chip-set-scroller] (scrolling) the row and its buttons
* [data-md-chip-set-row] the chips; data-md-scroll-start, data-md-scroll-end
* [data-md-chip-scroll="start|end"]
* [data-md-chip-set-hint] | [data-md-chip-set-errors]
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './chip.css';
@import './icon.css';
@layer material.components {
[data-md-chip-set] {
min-width: 0;
}
[data-md-chip-set-label] {
margin-bottom: var(--md-sys-measurement-space100);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
}
[data-md-chip-set-row] {
display: flex;
flex-wrap: wrap;
gap: var(--md-sys-measurement-space100);
}
[data-md-chip-set-hint],
[data-md-chip-set-errors] > p {
margin-top: var(--md-sys-measurement-space50);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
font-variation-settings: normal;
}
[data-md-chip-set-errors] > p {
color: var(--md-sys-color-error);
}
/* ---- Scrolling --------------------------------------------------------------------------- */
[data-md-chip-set-scroller] {
position: relative;
}
[data-md-chip-set-scroller] > [data-md-chip-set-row] {
--chip-fade-start: 0px;
--chip-fade-end: 0px;
flex-wrap: nowrap;
margin: calc(-1 * var(--md-sys-measurement-space100)) calc(-1 * var(--md-sys-measurement-space75));
padding: var(--md-sys-measurement-space100) var(--md-sys-measurement-space75);
overflow-x: auto;
scroll-padding-inline: var(--md-sys-measurement-space300);
scrollbar-width: none;
mask-image: linear-gradient(to right, transparent, black var(--chip-fade-start), black calc(100% - var(--chip-fade-end)), transparent);
&:is([dir='rtl'], [dir='rtl'] *) {
mask-image: linear-gradient(to left, transparent, black var(--chip-fade-start), black calc(100% - var(--chip-fade-end)), transparent);
}
&[data-md-scroll-start] {
--chip-fade-start: var(--md-sys-measurement-space300);
}
&[data-md-scroll-end] {
--chip-fade-end: var(--md-sys-measurement-space300);
}
@media (pointer: fine) {
scroll-padding-inline: var(--md-sys-measurement-space500);
}
}
[data-md-chip-scroll] {
position: absolute;
inset-block: 0;
display: none;
place-items: center;
width: 32px;
height: 32px;
margin-block: auto;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-surface-container-high);
color: var(--md-sys-color-on-surface-variant);
box-shadow: var(--md-sys-elevation-1);
}
[data-md-chip-scroll='start'] {
inset-inline-start: 0;
}
[data-md-chip-scroll='end'] {
inset-inline-end: 0;
}
/* Only while the row can still scroll that way, and only where the pointer cannot swipe. */
@media (pointer: fine) {
[data-md-chip-set-row][data-md-scroll-start] ~ [data-md-chip-scroll='start'],
[data-md-chip-set-row][data-md-scroll-end] ~ [data-md-chip-scroll='end'] {
display: grid;
}
}
}
+444
View File
@@ -0,0 +1,444 @@
/*
* <x-chip>: M3's assist, filter, input and suggestion chips (resources/views/components/chip.blade.php).
*
* From androidx Compose Material 3 (Chip.kt, AssistChipTokens, FilterChipTokens, InputChipTokens,
* SuggestionChipTokens at androidx/androidx 27cf9a7, Apache-2.0): 32px tall, the small corner,
* label-large, 18px icons and a 24px avatar; 16px padding beside a label and 8px beside an icon,
* 8px between (an input chip: 12px, 8px, and 4px beside an avatar). Compose draws the 1px border
* inside the chip; a CSS border takes room, so every padding beside it is 1px short of its token
* (7px for 8, 11px for 12, 15px for 16).
*
* Colours by type and state, each one role (never an opacity but M3's disabled 12% and 38%,
* tokens/state.css):
*
* flat outline-variant border; on-surface ink (assist) or on-surface-variant (the rest);
* the border turns on-surface (assist) or on-surface-variant while keyboard-focused
* elevated surface-container-low at elevation 1, 2 on hover, no border (not for input chips)
* selected a filter chip that is checked or pressed, an input chip marked `data-md-selected`:
* secondary-container with on-secondary-container ink, no border; a flat selected
* filter chip rises to elevation 1 on hover
* disabled on-surface ink at 38%; the border (flat) or the container (elevated, selected) at 12%
*
* Leading icons are primary on assist, suggestion and filter chips; on an input chip they are
* on-surface-variant, primary on a selected chip and while a chip with its own action is hovered,
* focused or pressed (`data-md-actionable`). A trailing icon is primary on assist and suggestion
* chips. A disabled chip's icons take its ink.
*
* The state layer is the chip's own ::before in the chip's ink (an input chip draws it on its
* action, since the remove button is a second control), at M3's hover, focus and press opacities;
* the focus indicator is the package's secondary ring outside the chip. Both the chip (or an input
* chip's action) and the remove button catch presses over 48×48 M3 lets the target extend beyond
* the visible container. The filter chip's check grows in on the fast spatial spring and fades in
* on the slow effects one; it shrinks on default effects and fades on fast effects, as
* AnimatingChipContent does.
*
* [data-md-chip="assist|filter|input|suggestion"] the chip; data-md-elevated, data-md-disabled,
* data-md-selected, data-md-actionable,
* data-md-icon-start, data-md-icon-end,
* data-md-avatar, data-md-removable
* [data-md-chip-check-slot] a filter chip's check, over its icon when it has one
* [data-md-chip-icon], [data-md-chip-check]
* [data-md-chip-action] an input chip's own button or link (a <span> without one)
* [data-md-chip-avatar], [data-md-chip-icon], [data-md-chip-label]
* [data-md-chip-remove] an input chip's remove button
* [data-md-chip-label], [data-md-chip-icon], [data-md-chip-trailing]
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@import './tooltip.css';
@layer material.components {
[data-md-chip] {
--chip-disabled-container: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-container-opacity) * 100%), transparent);
--chip-disabled-ink: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
position: relative;
display: inline-flex;
flex-shrink: 0;
align-items: center;
max-width: 100%;
height: 32px;
border: 1px solid var(--md-sys-color-outline-variant);
border-radius: var(--md-sys-shape-corner-sm);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
white-space: nowrap;
-webkit-user-select: none;
user-select: none;
transition-property: background-color, border-color, color, box-shadow;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
}
/* ---- Assist, filter and suggestion: the chip is the control ------------------------------ */
[data-md-chip]:not([data-md-chip='input']) {
isolation: isolate;
padding-inline: 15px;
outline: none;
cursor: pointer;
/* The 48px target, past the chip's 32px. */
&::after {
content: '';
position: absolute;
inset-inline: 0;
top: 50%;
height: var(--md-sys-measurement-space600);
translate: 0 -50%;
}
&::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background-color: currentColor;
opacity: 0;
pointer-events: none;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
&:hover::before {
opacity: var(--md-sys-state-hover-state-layer-opacity);
}
}
&:focus-visible::before,
&:has(:focus-visible)::before {
opacity: var(--md-sys-state-focus-state-layer-opacity);
}
&:active::before {
opacity: var(--md-sys-state-pressed-state-layer-opacity);
}
&:is(:focus-visible, :has(:focus-visible)) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
&:is(:disabled, [aria-disabled='true'], [data-md-disabled])::before {
display: none;
}
}
[data-md-chip]:is([data-md-chip='filter'], [data-md-icon-start]):not([data-md-chip='input']) {
padding-inline-start: 7px;
}
[data-md-chip][data-md-icon-end] {
padding-inline-end: 7px;
}
[data-md-chip='assist'] {
color: var(--md-sys-color-on-surface);
}
[data-md-chip='assist']:not([data-md-elevated], [data-md-disabled]):focus-visible {
border-color: var(--md-sys-color-on-surface);
}
:is([data-md-chip='suggestion'], [data-md-chip='filter']):not([data-md-elevated], [data-md-disabled], :has(:checked), [aria-pressed='true']):is(:focus-visible, :has(:focus-visible)) {
border-color: var(--md-sys-color-on-surface-variant);
}
[data-md-chip][data-md-elevated] {
border-color: transparent;
background-color: var(--md-sys-color-surface-container-low);
box-shadow: var(--md-sys-elevation-1);
}
@media (hover: hover) {
[data-md-chip][data-md-elevated]:not([data-md-disabled]):hover {
box-shadow: var(--md-sys-elevation-2);
}
}
/* Selected: a checked filter chip, or a pressed one. */
[data-md-chip='filter']:is(:has(:checked), [aria-pressed='true']),
[data-md-chip='input'][data-md-selected] {
border-color: transparent;
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
@media (hover: hover) {
[data-md-chip='filter']:not([data-md-elevated], [data-md-disabled]):is(:has(:checked), [aria-pressed='true']):hover {
box-shadow: var(--md-sys-elevation-1);
}
}
[data-md-chip][data-md-disabled] {
border-color: var(--chip-disabled-container);
color: var(--chip-disabled-ink);
box-shadow: none;
}
[data-md-chip][data-md-disabled]:is([data-md-elevated], [data-md-selected], :has(:checked), [aria-pressed='true']) {
border-color: transparent;
background-color: var(--chip-disabled-container);
}
[data-md-chip][data-md-disabled]:not(a) {
cursor: not-allowed;
}
/* A disabled link leaves the pointer too. */
a[data-md-chip][aria-disabled='true'] {
pointer-events: none;
}
/* ---- Contents ------------------------------------------------------------------------------ */
[data-md-chip-label] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:is([data-md-chip], [data-md-chip-action]) > [data-md-chip-icon] {
margin-inline-end: var(--md-sys-measurement-space100);
}
[data-md-chip-trailing] {
margin-inline-start: var(--md-sys-measurement-space100);
}
[data-md-chip]:not([data-md-chip='input'], [data-md-disabled]) [data-md-chip-icon],
[data-md-chip='input'][data-md-selected]:not([data-md-disabled]) [data-md-chip-icon],
:is([data-md-chip='assist'], [data-md-chip='suggestion']):not([data-md-disabled]) [data-md-chip-trailing] {
color: var(--md-sys-color-primary);
}
[data-md-chip][data-md-actionable] [data-md-chip-icon] {
transition: color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-md-chip][data-md-actionable]:is(:active, :has(:focus-visible)) [data-md-chip-icon] {
color: var(--md-sys-color-primary);
}
@media (hover: hover) {
[data-md-chip][data-md-actionable]:hover [data-md-chip-icon] {
color: var(--md-sys-color-primary);
}
}
/* The filter chip's check: from nothing to 18px as it is selected, or over the icon it replaces. */
[data-md-chip-check-slot] {
display: grid;
flex-shrink: 0;
width: 0;
height: 18px;
margin-inline-end: var(--md-sys-measurement-space100);
overflow: hidden;
transition: width var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
}
[data-md-chip-check-slot] > * {
grid-area: 1 / 1;
}
[data-md-chip-check-slot]:has([data-md-chip-icon]) {
width: 18px;
transition: none;
}
[data-md-chip]:is(:has(:checked), [aria-pressed='true']) [data-md-chip-check-slot]:not(:has([data-md-chip-icon])) {
width: 18px;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
[data-md-chip-check-slot] [data-md-chip-icon] {
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-md-chip-check] {
opacity: 0;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-md-chip]:is(:has(:checked), [aria-pressed='true']) [data-md-chip-check-slot] [data-md-chip-icon] {
opacity: 0;
}
[data-md-chip]:is(:has(:checked), [aria-pressed='true']) [data-md-chip-check] {
opacity: 1;
transition-duration: var(--md-sys-motion-effects-slow-duration);
transition-timing-function: var(--md-sys-motion-effects-slow);
}
/* ---- Input chips: an action of their own, and a remove button ---------------------------- */
[data-md-chip='input'] {
isolation: isolate;
}
[data-md-chip='input']:not([data-md-selected], [data-md-disabled]):has([data-md-chip-action]:focus-visible) {
border-color: var(--md-sys-color-on-surface-variant);
}
[data-md-chip='input']:has([data-md-chip-action]:focus-visible) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
[data-md-chip-action] {
display: flex;
align-items: center;
min-width: 0;
height: 100%;
padding-inline: 11px;
outline: none;
}
[data-md-chip][data-md-icon-start] [data-md-chip-action] {
padding-inline-start: 7px;
}
[data-md-chip][data-md-avatar] [data-md-chip-action] {
padding-inline-start: 3px;
}
[data-md-chip][data-md-removable] [data-md-chip-action] {
padding-inline-end: var(--md-sys-measurement-space100);
}
:is(a, button)[data-md-chip-action] {
cursor: pointer;
/* The 48px target and the state layer, across the whole chip. */
&::after {
content: '';
position: absolute;
inset-inline: 0;
top: 50%;
height: var(--md-sys-measurement-space600);
translate: 0 -50%;
}
}
[data-md-chip]:not([data-md-disabled]) :is(a, button)[data-md-chip-action] {
&::before {
content: '';
position: absolute;
inset: -1px;
z-index: -1;
border-radius: var(--md-sys-shape-corner-sm);
background-color: currentColor;
opacity: 0;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
&:hover::before {
opacity: var(--md-sys-state-hover-state-layer-opacity);
}
}
&:focus-visible::before {
opacity: var(--md-sys-state-focus-state-layer-opacity);
}
&:active::before {
opacity: var(--md-sys-state-pressed-state-layer-opacity);
}
}
[data-md-chip][data-md-disabled] :is(a, button)[data-md-chip-action] {
cursor: not-allowed;
}
[data-md-chip][data-md-disabled] a[data-md-chip-action] {
pointer-events: none;
}
[data-md-chip-avatar] {
flex-shrink: 0;
width: 24px;
height: 24px;
margin-inline-end: var(--md-sys-measurement-space100);
border-radius: var(--md-sys-shape-corner-full);
}
span[data-md-chip-avatar] {
display: grid;
place-items: center;
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
font: var(--md-sys-typescale-label-sm);
letter-spacing: var(--md-sys-typescale-label-sm-tracking);
font-variation-settings: normal;
}
img[data-md-chip-avatar] {
object-fit: cover;
}
[data-md-chip][data-md-disabled] [data-md-chip-avatar] {
opacity: var(--md-sys-state-disabled-content-opacity);
}
/* An 18px icon in a 24px state layer, catching presses 15px out on every side: M3's 48px. */
[data-md-chip-remove] {
position: relative;
display: grid;
flex-shrink: 0;
place-items: center;
width: 18px;
height: 18px;
margin-inline-end: 7px;
border-radius: var(--md-sys-shape-corner-full);
outline: none;
cursor: pointer;
&::after {
content: '';
position: absolute;
inset: -15px;
}
&:disabled {
cursor: not-allowed;
}
&:not(:disabled) {
&::before {
content: '';
position: absolute;
inset: -3px;
border-radius: var(--md-sys-shape-corner-full);
background-color: currentColor;
opacity: 0;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
&:hover::before {
opacity: var(--md-sys-state-hover-state-layer-opacity);
}
}
&:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
&:focus-visible::before {
opacity: var(--md-sys-state-focus-state-layer-opacity);
}
&:active::before {
opacity: var(--md-sys-state-pressed-state-layer-opacity);
}
}
}
}
+31
View File
@@ -0,0 +1,31 @@
/*
* <x-choices>: choosing from a list, as M3's filter chips or as a searchable field with a menu
* (resources/views/components/choices.blade.php).
*
* Almost nothing of its own to draw: the chips are <x-chip-set>'s and <x-chip>'s, the searchable
* field is <x-field>'s with its arrow turning over while the list is open (components/field.css),
* and the open list is M3's menu, drawn by components/menu.css on `data-md-field-menu` and
* `data-md-field-option`. What is left is the row that says nothing matches: body-medium in
* on-surface-variant, padded like a menu row (16px across, 12px down).
*
* [data-md-choices] the root; data-md-searchable
* [data-md-choices-empty] the list's "Nothing matches" row
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './field.css';
@import './icon.css';
@import './chip-set.css';
@import './chip.css';
@import './menu.css';
@layer material.components {
[data-md-choices-empty] {
padding: 12px var(--md-sys-measurement-space200);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
font-variation-settings: normal;
}
}
+84
View File
@@ -0,0 +1,84 @@
/*
* <x-collapse>: not an M3 component M3 has expandable list items and menu expansion but no
* standalone disclosure built on the native `<details>` so it opens without script, keeps its
* state through a Livewire morph, and is announced as a disclosure by the browser. Judged against
* foundations rather than any M3 component spec (docs/audits/m3-alignment/containment.md, C-24).
*
* The height eases open and shut on the fast spatial spring, the same one that turns the chevron,
* from resources/js/collapse.js: it animates the `<details>`' own `block-size`. This file cannot
* `interpolate-size` (for `0 -> auto`) is Chrome's alone, and Firefox does not hold the content's
* `content-visibility` through a close so Firefox and Safari used to snap open and shut. While a
* close runs, `open` is still set and `data-md-collapse-closing` turns the chevron back at its
* start. Under reduced motion the duration token is zero (tokens/motion.css), and the section
* snaps, as the browser draws it.
*
* `data-md-variant="filled"` is a surface-container tile with a large corner; the summary inherits
* it (`border-radius: inherit`) so the shared `md-state-layer`'s own `::before`, which also
* inherits, draws inside the same shape. `md-state-layer` and `md-focus-ring`
* (foundation/interaction.css) sit on the summary; this file only refines their target the
* 48px summary height, the icon and chevron colour never their opacities or ring.
*
* Icon-to-title and title-to-chevron gaps are a literal 12px: the measurement scale has no 12
* (`space125` is 10px), as alert.css's gap. Every part is matched as its own `<details>`' child, so
* a collapse nested in a filled one takes neither its padding nor its turned chevron.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@layer material.components {
[data-md-collapse][data-md-variant='filled'] {
border-radius: var(--md-sys-shape-corner-lg);
background-color: var(--md-sys-color-surface-container);
}
[data-md-collapse-summary] {
display: flex;
min-block-size: var(--md-sys-measurement-space600);
align-items: center;
gap: 12px;
border-radius: inherit;
cursor: pointer;
list-style-type: none;
font: var(--md-sys-typescale-title-md);
letter-spacing: var(--md-sys-typescale-title-md-tracking);
&::-webkit-details-marker {
display: none;
}
}
[data-md-collapse][data-md-variant='filled'] > [data-md-collapse-summary] {
padding-inline: var(--md-sys-measurement-space200);
}
[data-md-collapse-icon] {
color: var(--md-sys-color-on-surface-variant);
}
[data-md-collapse-title] {
min-width: 0;
flex: 1 1 0%;
}
[data-md-collapse-chevron] {
color: var(--md-sys-color-on-surface-variant);
transition: rotate var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast);
}
[data-md-collapse][open]:not([data-md-collapse-closing]) > [data-md-collapse-summary] > [data-md-collapse-chevron] {
rotate: 180deg;
}
[data-md-collapse-body] {
padding-block: var(--md-sys-measurement-space50) var(--md-sys-measurement-space200);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
}
[data-md-collapse][data-md-variant='filled'] > [data-md-collapse-body] {
padding-inline: var(--md-sys-measurement-space200);
}
}
+279 -200
View File
@@ -1,71 +1,92 @@
/*
* M3's date pickers docked, modal and modal input (resources/views/components/datepicker.blade.php).
*
* One `<dialog>` wears both: docked it is a popover hung under the field by CSS anchor positioning
* (large corner), modal it is opened with `showModal()` over a scrim (extra-large corner, with M3's
* header: a title, the chosen date as the headline, and the switch between calendar and typing).
* Both are 360px of surface-container-high at elevation 3.
* One `<dialog>` wears all three: docked it is a popover hung under the field by CSS anchor
* positioning (large corner), modal it is opened with `showModal()` over a scrim (extra-large
* corner, with M3's header: a title, the chosen date as the headline, and the switch between
* calendar and typing), and full a `range` picker on a compact window is that modal dialog
* grown to the whole screen, M3's full-screen range picker. The first two are 360px of
* surface-container-high at elevation 3; the third keeps the colour and drops the corner.
*
* Values from DatePickerModalTokens and DateInputModalTokens (androidx Compose Material 3, commit
* 27cf9a7d5788aa0f5f2d8b6699ce279560daf326) and the layout of DatePicker.kt, DateRangePicker.kt and
* DateInput.kt; the docked picker's from material-web's md-comp-date-picker-docked tokens (v0_192).
* Apache-2.0.
*
* [data-datepicker] the root: the field, its support line, the dialog
* [data-datepicker-support] the hint, a server error, or what is wrong with the typed date
* [data-datepicker-picker] the <dialog>; data-presentation="docked" | "modal"
* [data-datepicker-surface]
* [data-datepicker-header] modal only: title, headline, switch
* [data-datepicker-calendar]
* [data-datepicker-nav] month and year: one menu button and arrows (modal),
* or a month and a year stepper (docked, data-docked)
* [data-datepicker-grid] the weekdays and six weeks of [data-datepicker-day] cells:
* data-today, data-selected, data-start, data-end,
* data-between, data-outside, data-blank, aria-disabled
* [data-datepicker-years] the modal year grid
* [data-datepicker-menu] a docked month or year list of [data-datepicker-option]s
* [data-datepicker-entry] the text field(s) of the modal input
* [data-datepicker-actions] Cancel and OK
* [data-md-datepicker] the root: the field, its support line, the dialog
* [data-md-datepicker-support] the hint, a server error, or what is wrong with the typed date
* [data-md-datepicker-picker] the <dialog>; data-md-presentation="docked" | "modal" | "full"
* [data-md-datepicker-surface]
* [data-md-datepicker-header] modal only: title, headline, switch
* [data-md-datepicker-full] full only: [data-md-datepicker-app-bar] (close, switch, Save)
* and [data-md-datepicker-full-headline] (title, headline)
* [data-md-datepicker-calendar] the scroller, full screen
* [data-md-datepicker-nav] month and year: one menu button and arrows (modal),
* or a month and a year stepper (docked, data-md-docked)
* [data-md-datepicker-grid] the weekdays and six weeks of [data-md-datepicker-day]
* cells: data-md-today, data-md-selected, data-md-start,
* data-md-end, data-md-between, data-md-outside,
* data-md-blank, aria-disabled full screen, every month
* of the window under a [data-md-datepicker-month-label] row
* [data-md-datepicker-years] the modal year grid
* [data-md-datepicker-menu] a docked month or year list of [data-md-datepicker-option]s
* [data-md-datepicker-entry] the text field(s) of the modal input
* [data-md-datepicker-actions] Cancel and OK
*
* Every state layer is the content colour at 8% on hover (only where a pointer can hover) and 10% on
* focus and press; keyboard focus adds the package's 3px secondary ring.
* The year, the menu buttons and the docked lists' options render the foundation's `md-state-layer`
* and `md-focus-ring` (foundation/interaction.css): each draws its layer in its own ink and its
* ring round itself, so the classes need one refinement only an option's ring sits inside its
* full-width row. Cancel, OK and the icon buttons are <x-button>, which renders the classes too.
* The day alone keeps a state layer and ring of its own: the element focused and pressed is the
* 48px cell, while both are drawn on the 40px round indicator inside it, which a class drawn on the
* element it sits on cannot do, and `:focus-visible` never matches that child. Its layer takes
* M3's state opacities (tokens/state.css) and is gone from a disabled or blank day.
*
* Lengths are px, and spacing is the measurement tokens: M3 gives every size here in dp. A
* disabled day or option is on-surface at M3's 38% content opacity.
*/
@layer components {
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './field.css';
@import './icon.css';
@import './button.css';
@layer material.components {
/* ---- The support line under the field ------------------------------------------------------ */
/* The padding is on the lines, not the box, so a box with nothing to say takes no room. */
[data-datepicker-support] {
--datepicker-pad: 1rem;
[data-md-datepicker-support] {
--datepicker-pad: var(--md-sys-measurement-space200);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
}
[data-datepicker-support] > * {
padding: 0.25rem var(--datepicker-pad) 0;
[data-md-datepicker-support] > * {
padding: var(--md-sys-measurement-space50) var(--datepicker-pad) 0;
}
[data-datepicker-support][data-size="sm"] {
--datepicker-pad: 0.75rem;
[data-md-datepicker-support][data-md-size='sm'] {
--datepicker-pad: 12px;
}
[data-datepicker-support][data-size="xs"] {
--datepicker-pad: 0.625rem;
[data-md-datepicker-support][data-md-size='xs'] {
--datepicker-pad: var(--md-sys-measurement-space125);
}
[data-datepicker-error] {
[data-md-datepicker-error] {
color: var(--md-sys-color-error);
}
/* ---- The dialog ---------------------------------------------------------------------------- */
[data-datepicker-picker] {
[data-md-datepicker-picker] {
inset: auto;
width: 22.5rem;
max-width: calc(100vw - 1rem);
max-height: calc(100dvh - 1rem);
width: 360px;
max-width: calc(100vw - var(--md-sys-measurement-space200));
max-height: calc(100dvh - var(--md-sys-measurement-space200));
margin: 0;
padding: 0;
overflow: visible;
@@ -74,10 +95,10 @@
color: var(--md-sys-color-on-surface);
}
[data-datepicker-picker]:popover-open {
[data-md-datepicker-picker]:popover-open {
top: anchor(bottom);
left: anchor(left);
margin-block: 0.25rem;
margin-block: var(--md-sys-measurement-space50);
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
opacity: 1;
transition-property: opacity;
@@ -86,12 +107,12 @@
}
@starting-style {
[data-datepicker-picker]:popover-open {
[data-md-datepicker-picker]:popover-open {
opacity: 0;
}
}
[data-datepicker-picker]:modal {
[data-md-datepicker-picker]:modal {
inset: 0;
margin: auto;
opacity: 1;
@@ -102,17 +123,17 @@
}
@starting-style {
[data-datepicker-picker]:modal {
[data-md-datepicker-picker]:modal {
opacity: 0;
scale: 0.95;
}
}
[data-datepicker-picker]:modal::backdrop {
[data-md-datepicker-picker]:modal::backdrop {
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
}
[data-datepicker-surface] {
[data-md-datepicker-surface] {
display: flex;
flex-direction: column;
max-height: inherit;
@@ -124,41 +145,122 @@
outline: none;
}
[data-datepicker-picker][data-presentation="modal"] [data-datepicker-surface] {
[data-md-datepicker-picker][data-md-presentation='modal'] [data-md-datepicker-surface] {
border-radius: var(--md-sys-shape-corner-xl);
}
/* ---- The modal header ---------------------------------------------------------------------- */
/* ---- The full-screen range picker (compact) ------------------------------------------------- */
[data-datepicker-header] {
display: flex;
flex-direction: column;
justify-content: space-between;
/* M3 § Date Pickers: a range picker at a compact breakpoint is a full-screen dialog, "covering
* the whole screen for readability and touch-target size", with a close (×) icon button and a
* Save confirmation instead of Cancel and OK. Its anatomy is the modal picker's parts in a
* different order: an app bar, the supporting text and the range as the headline, a divider, the
* day-of-week labels, and a vertically scrolling list of months under them, each with its label.
* Only the months scroll; everything above them stays where it is. */
[data-md-datepicker-picker][data-md-presentation='full'] {
inset: 0;
width: 100vw;
max-width: none;
height: 100dvh;
max-height: none;
margin: 0;
}
[data-md-datepicker-picker][data-md-presentation='full'] [data-md-datepicker-surface] {
height: 100%;
max-height: none;
padding-block: var(--material-safe-top, env(safe-area-inset-top)) var(--material-safe-bottom, env(safe-area-inset-bottom));
overflow: hidden;
border-radius: 0;
box-shadow: none;
}
[data-md-datepicker-full] {
flex: none;
min-height: 7.5rem;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
color: var(--md-sys-color-on-surface-variant);
}
[data-datepicker-header][data-range] {
min-height: 8rem;
[data-md-datepicker-app-bar] {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--md-sys-measurement-space100);
height: 64px;
padding-inline: var(--md-sys-measurement-space50) 12px;
}
[data-datepicker-title] {
padding: 1rem 0.75rem 0 1.5rem;
[data-md-datepicker-app-bar-actions] {
display: flex;
align-items: center;
gap: var(--md-sys-measurement-space50);
}
[data-md-datepicker-full-headline] {
display: flex;
flex-direction: column;
gap: var(--md-sys-measurement-space50);
padding: 0 var(--md-sys-measurement-space300) var(--md-sys-measurement-space200);
}
/* The calendar is the only part that scrolls, with the weekday header held at its top. */
[data-md-datepicker-picker][data-md-presentation='full'] [data-md-datepicker-calendar] {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
[data-md-datepicker-picker][data-md-presentation='full'] [data-md-datepicker-grid] thead th {
position: sticky;
top: 0;
z-index: 1;
background-color: var(--md-sys-color-surface-container-high);
}
[data-md-datepicker-picker][data-md-presentation='full'] [data-md-datepicker-day] {
scroll-margin-block: 48px;
}
[data-md-datepicker-month-label] {
padding: 20px 0 var(--md-sys-measurement-space100);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-title-sm);
letter-spacing: var(--md-sys-typescale-title-sm-tracking);
text-align: start;
}
/* ---- The modal header ---------------------------------------------------------------------- */
[data-md-datepicker-header] {
display: flex;
flex-direction: column;
justify-content: space-between;
flex: none;
min-height: 120px;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
color: var(--md-sys-color-on-surface-variant);
}
[data-md-datepicker-header][data-md-range] {
min-height: 128px;
}
[data-md-datepicker-title] {
padding: var(--md-sys-measurement-space200) 12px 0 var(--md-sys-measurement-space300);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
}
[data-datepicker-headline-row] {
[data-md-datepicker-headline-row] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0 0.75rem 0.75rem 1.5rem;
gap: var(--md-sys-measurement-space100);
padding: 0 12px 12px var(--md-sys-measurement-space300);
}
[data-datepicker-headline] {
[data-md-datepicker-headline] {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -167,129 +269,77 @@
letter-spacing: var(--md-sys-typescale-headline-lg-tracking);
}
[data-datepicker-header][data-range] [data-datepicker-headline] {
[data-md-datepicker-header][data-md-range] [data-md-datepicker-headline] {
font: var(--md-sys-typescale-title-lg);
letter-spacing: var(--md-sys-typescale-title-lg-tracking);
}
/* ---- Month and year navigation -------------------------------------------------------------- */
[data-datepicker-nav] {
[data-md-datepicker-nav] {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
height: 3.5rem;
padding-inline: 0.75rem;
height: 56px;
padding-inline: 12px;
color: var(--md-sys-color-on-surface-variant);
}
[data-datepicker-nav][data-docked] {
height: 4rem;
padding-inline: 0.25rem;
[data-md-datepicker-nav][data-md-docked] {
height: 64px;
padding-inline: var(--md-sys-measurement-space50);
}
[data-datepicker-stepper],
[data-datepicker-arrows] {
[data-md-datepicker-stepper],
[data-md-datepicker-arrows] {
display: flex;
align-items: center;
}
/* While a year or month list is open the arrows keep their place but are gone, as androidx hides them. */
[data-datepicker-arrows][data-concealed] {
[data-md-datepicker-arrows][data-md-concealed] {
visibility: hidden;
}
[data-datepicker-menu-button] {
position: relative;
[data-md-datepicker-menu-button] {
display: inline-flex;
align-items: center;
gap: 0.5rem;
height: 2.5rem;
padding-inline: 0.75rem 0.5rem;
gap: var(--md-sys-measurement-space100);
height: 40px;
padding-inline: 12px var(--md-sys-measurement-space100);
border-radius: var(--md-sys-shape-corner-full);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
white-space: nowrap;
cursor: pointer;
outline: none;
isolation: isolate;
}
[data-datepicker-nav][data-docked] [data-datepicker-menu-button] {
padding-inline: 0.5rem 0.25rem;
gap: 0.25rem;
[data-md-datepicker-nav][data-md-docked] [data-md-datepicker-menu-button] {
padding-inline: var(--md-sys-measurement-space100) var(--md-sys-measurement-space50);
gap: var(--md-sys-measurement-space50);
}
[data-datepicker-menu-arrow] {
[data-md-datepicker-menu-arrow] {
transition: rotate var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-datepicker-menu-button][aria-expanded="true"] [data-datepicker-menu-arrow] {
[data-md-datepicker-menu-button][aria-expanded='true'] [data-md-datepicker-menu-arrow] {
rotate: 180deg;
}
/* ---- State layers shared by days, years, menu buttons and list rows ------------------------ */
[data-datepicker-menu-button]::before,
[data-datepicker-day] > span::before,
[data-datepicker-year]::before,
[data-datepicker-option]::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background-color: var(--datepicker-layer, currentColor);
opacity: 0;
pointer-events: none;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
[data-datepicker-menu-button]:hover::before,
[data-datepicker-day]:not([aria-disabled="true"], [data-blank]):hover > span::before,
[data-datepicker-year]:hover::before,
[data-datepicker-option]:not([aria-disabled="true"]):hover::before {
opacity: 0.08;
}
}
[data-datepicker-menu-button]:focus-visible::before,
[data-datepicker-menu-button]:active::before,
[data-datepicker-day]:focus-visible > span::before,
[data-datepicker-day]:not([aria-disabled="true"], [data-blank]):active > span::before,
[data-datepicker-year]:focus-visible::before,
[data-datepicker-year]:active::before,
[data-datepicker-option]:focus-visible::before,
[data-datepicker-option]:not([aria-disabled="true"]):active::before {
opacity: 0.1;
}
[data-datepicker-menu-button]:focus-visible,
[data-datepicker-day]:focus-visible > span,
[data-datepicker-year]:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
[data-datepicker-option]:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: -3px;
}
/* ---- The days ----------------------------------------------------------------------------- */
[data-datepicker-grid] {
width: calc(100% - 1.5rem);
margin-inline: 0.75rem;
[data-md-datepicker-grid] {
width: calc(100% - 24px);
margin-inline: 12px;
border-collapse: collapse;
table-layout: fixed;
}
[data-datepicker-grid] th {
height: 3rem;
[data-md-datepicker-grid] th {
height: 48px;
padding: 0;
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-body-lg);
@@ -297,11 +347,11 @@
text-align: center;
}
[data-datepicker-day] {
[data-md-datepicker-day] {
--datepicker-layer: var(--md-sys-color-on-surface-variant);
position: relative;
height: 3rem;
height: 48px;
padding: 0;
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-body-lg);
@@ -313,123 +363,153 @@
-webkit-tap-highlight-color: transparent;
}
[data-datepicker-day] > span {
/* The 40px indicator, its own stacking context so the state layer lies over a chosen day's
primary fill rather than under it. */
[data-md-datepicker-day] > span {
position: relative;
display: grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
width: 40px;
height: 40px;
margin: auto;
border-radius: var(--md-sys-shape-corner-full);
isolation: isolate;
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-datepicker-day][data-blank] {
/* The day's state layer and focus ring, on the indicator (see the header). */
[data-md-datepicker-day] > span::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background-color: var(--datepicker-layer);
opacity: 0;
pointer-events: none;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
[data-md-datepicker-day]:hover > span::before {
opacity: var(--md-sys-state-hover-state-layer-opacity);
}
}
[data-md-datepicker-day]:focus-visible > span::before {
opacity: var(--md-sys-state-focus-state-layer-opacity);
}
[data-md-datepicker-day]:active > span::before {
opacity: var(--md-sys-state-pressed-state-layer-opacity);
}
[data-md-datepicker-day]:is([aria-disabled='true'], [data-md-blank]) > span::before {
display: none;
}
[data-md-datepicker-day]:focus-visible > span {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
[data-md-datepicker-day][data-md-blank] {
cursor: default;
}
/* A docked picker shows the neighbouring months' days, quieter. */
[data-datepicker-day][data-outside] {
[data-md-datepicker-day][data-md-outside] {
color: var(--md-sys-color-on-surface-variant);
}
[data-datepicker-day][data-today] {
[data-md-datepicker-day][data-md-today] {
--datepicker-layer: var(--md-sys-color-primary);
color: var(--md-sys-color-primary);
}
[data-datepicker-day][data-today] > span {
[data-md-datepicker-day][data-md-today] > span {
box-shadow: inset 0 0 0 1px var(--md-sys-color-primary);
}
/* A range's band: secondary-container behind the days between its ends, from the middle of each end. */
[data-datepicker-day][data-between]::before,
[data-datepicker-day][data-start]::before,
[data-datepicker-day][data-end]::before {
content: "";
[data-md-datepicker-day][data-md-between]::before,
[data-md-datepicker-day][data-md-start]::before,
[data-md-datepicker-day][data-md-end]::before {
content: '';
position: absolute;
inset-block: 0.25rem;
inset-block: var(--md-sys-measurement-space50);
inset-inline: 0;
z-index: -1;
background-color: var(--md-sys-color-secondary-container);
}
[data-datepicker-day][data-start]::before {
[data-md-datepicker-day][data-md-start]::before {
inset-inline-start: 50%;
}
[data-datepicker-day][data-end]::before {
[data-md-datepicker-day][data-md-end]::before {
inset-inline-end: 50%;
}
[data-datepicker-day][data-between] {
[data-md-datepicker-day][data-md-between] {
--datepicker-layer: var(--md-sys-color-on-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
[data-datepicker-day][data-selected] {
[data-md-datepicker-day][data-md-selected] {
--datepicker-layer: var(--md-sys-color-on-primary);
color: var(--md-sys-color-on-primary);
}
[data-datepicker-day][data-selected] > span {
[data-md-datepicker-day][data-md-selected] > span {
background-color: var(--md-sys-color-primary);
box-shadow: none;
}
[data-datepicker-day][aria-disabled="true"] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
[data-md-datepicker-day][aria-disabled='true'] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
cursor: default;
}
[data-datepicker-day][aria-disabled="true"][data-today] > span {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
[data-md-datepicker-day][aria-disabled='true'][data-md-today] > span {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
}
/* ---- The modal year grid ----------------------------------------------------------------- */
[data-datepicker-years] {
[data-md-datepicker-years] {
display: grid;
grid-template-columns: repeat(3, 1fr);
align-content: start;
justify-items: center;
row-gap: 1rem;
height: 20.9375rem;
padding: 0.5rem 0.75rem;
row-gap: var(--md-sys-measurement-space200);
height: 335px;
padding: var(--md-sys-measurement-space100) 12px;
overflow-y: auto;
overscroll-behavior: contain;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
[data-datepicker-year] {
--datepicker-layer: var(--md-sys-color-on-surface-variant);
position: relative;
width: 4.5rem;
height: 2.25rem;
[data-md-datepicker-year] {
width: 72px;
height: 36px;
border-radius: var(--md-sys-shape-corner-full);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
cursor: pointer;
outline: none;
isolation: isolate;
scroll-margin-block: 0.5rem;
scroll-margin-block: var(--md-sys-measurement-space100);
}
[data-datepicker-year][data-current] {
--datepicker-layer: var(--md-sys-color-primary);
[data-md-datepicker-year][data-md-current] {
color: var(--md-sys-color-primary);
box-shadow: inset 0 0 0 1px var(--md-sys-color-primary);
}
[data-datepicker-year][aria-selected="true"] {
--datepicker-layer: var(--md-sys-color-on-primary);
[data-md-datepicker-year][aria-selected='true'] {
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
box-shadow: none;
@@ -437,72 +517,71 @@
/* ---- The docked month and year lists ------------------------------------------------------ */
[data-datepicker-menu] {
height: 21.5rem;
padding-block: 0.5rem;
[data-md-datepicker-menu] {
height: 344px;
padding-block: var(--md-sys-measurement-space100);
overflow-y: auto;
overscroll-behavior: contain;
border-top: 1px solid var(--md-sys-color-outline-variant);
}
[data-datepicker-option] {
--datepicker-layer: var(--md-sys-color-on-surface);
position: relative;
[data-md-datepicker-option] {
display: flex;
align-items: center;
gap: 1rem;
gap: var(--md-sys-measurement-space200);
width: 100%;
height: 3rem;
padding-inline: 1rem;
height: 48px;
padding-inline: var(--md-sys-measurement-space200);
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
text-align: start;
cursor: pointer;
outline: none;
isolation: isolate;
}
[data-datepicker-option][aria-selected="true"] {
/* The foundation's ring, drawn inside the full-width row rather than round it. */
[data-md-datepicker-option]:focus-visible {
outline-offset: -3px;
}
[data-md-datepicker-option][aria-selected='true'] {
background-color: var(--md-sys-color-surface-container-highest);
}
[data-datepicker-option][aria-disabled="true"] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
[data-md-datepicker-option][aria-disabled='true'] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
cursor: default;
}
[data-datepicker-check] {
[data-md-datepicker-check] {
color: var(--md-sys-color-on-surface);
}
[data-datepicker-option]:not([aria-selected="true"]) [data-datepicker-check] {
[data-md-datepicker-option]:not([aria-selected='true']) [data-md-datepicker-check] {
visibility: hidden;
}
/* ---- The modal input ------------------------------------------------------------------------ */
[data-datepicker-entry] {
padding: 0.625rem 1.5rem 1rem;
[data-md-datepicker-entry] {
padding: var(--md-sys-measurement-space125) var(--md-sys-measurement-space300) var(--md-sys-measurement-space200);
}
[data-datepicker-entry-fields][data-range] {
[data-md-datepicker-entry-fields][data-md-range] {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
gap: var(--md-sys-measurement-space100);
}
/* ---- Cancel and OK ---------------------------------------------------------------------------- */
[data-datepicker-actions] {
[data-md-datepicker-actions] {
display: flex;
flex: none;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.25rem 0.375rem 0.5rem 0.75rem;
gap: var(--md-sys-measurement-space100);
padding: var(--md-sys-measurement-space50) var(--md-sys-measurement-space75) var(--md-sys-measurement-space100) 12px;
}
}
+75
View File
@@ -0,0 +1,75 @@
/*
* <x-divider>: M3's divider (DividerTokens.kt, androidx Compose Material 3, Apache-2.0) a single
* 1px outline-variant line, full-width by default, `data-md-inset` 16px from the start or
* `data-md-middle` 16px from both ends (docs/reference/m3/components-actions-communication-containment.md
* § Divider Specs: "Divider inset left margin 16dp", "Divider middle-inset left/right margin
* 16dp"). A vertical divider (`data-md-orientation="vertical"`) stretches the height of its row
* instead of drawing a width.
*
* `data-md-divider-heading` draws the divider with a subheader, M3's way to head a group in a list
* or a menu: the words lead, 4px of gap to the rule that runs on from them, 8px short of the end
* and 8px above the next row (the same section's "Space between divider & supporting text 4dp",
* "Divider right margin 8dp", "Divider bottom margin 8dp"). The label, `data-md-divider-text`, is
* the rich tooltip's subhead type — title-small in on-surface-variant — since the divider's own
* page names no type role for it.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-divider] {
flex-shrink: 0;
background-color: var(--md-sys-color-outline-variant);
}
[data-md-divider][data-md-orientation='horizontal'] {
width: auto;
height: 1px;
}
[data-md-divider][data-md-orientation='vertical'] {
width: 1px;
align-self: stretch;
}
[data-md-divider][data-md-orientation='horizontal'][data-md-inset] {
margin-inline-start: var(--md-sys-measurement-space200);
}
[data-md-divider][data-md-orientation='horizontal'][data-md-middle] {
margin-inline: var(--md-sys-measurement-space200);
}
[data-md-divider][data-md-orientation='vertical'][data-md-middle] {
margin-block: var(--md-sys-measurement-space100);
}
[data-md-divider-heading] {
display: flex;
flex-shrink: 0;
align-items: center;
gap: var(--md-sys-measurement-space50);
padding-bottom: var(--md-sys-measurement-space100);
}
[data-md-divider-heading][data-md-inset] {
margin-inline-start: var(--md-sys-measurement-space200);
}
[data-md-divider-heading][data-md-middle] {
margin-inline: var(--md-sys-measurement-space200);
}
[data-md-divider-text] {
flex-shrink: 0;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-title-sm);
letter-spacing: var(--md-sys-typescale-title-sm-tracking);
}
[data-md-divider-heading] [data-md-divider] {
flex: 1 1 0%;
min-width: 0;
margin-inline-end: var(--md-sys-measurement-space100);
}
}
+300
View File
@@ -0,0 +1,300 @@
/*
* `<x-drawer>`: M3's side sheet, modal by default and co-planar as `standard` from 840px
* (docs/reference/m3/components-actions-communication-containment.md § Side sheets Specs; no
* androidx Compose token file exists for it Compose support is "Unavailable" per the site so
* the numbers come from the specs page and, for the standard variant's 0dp elevation,
* material-components-android's `SideSheet.md`).
*
* [data-md-drawer] the wrapper; data-md-open, data-md-standard
* [data-md-drawer-scrim] modal only (a standard sheet's is hidden from 840px)
* [data-md-drawer-sheet] surface-container-low, elevation 1; data-md-side
* [data-md-drawer-head] title/subtitle/close, and the separator's divider
* [data-md-drawer-head-row], [data-md-drawer-heading], [data-md-drawer-title],
* [data-md-drawer-subtitle], [data-md-drawer-close]
* [data-md-drawer-body] a size container, so its contents lay out by the sheet's own
* width (`@md:`), never the viewport's
* [data-md-drawer-actions] left-aligned, 72dp (C-10 a dialog's are trailing-aligned)
*
* 400px cap, 24dp start/end padding, a large corner on the inner edge only from the specs
* table. It enters on the emphasized-decelerate easing rather than a spring, because a sheet
* anchored to an edge that overshot would open a gap, and leaves on emphasized accelerate: the
* view keeps `x-show`, so `@starting-style` gives the entry its start and the view's `x-transition`
* holds `display` through the exit, whose timing comes from the closed state's own rule (a
* transition takes the after-change style's). Not `allow-discrete` on `display`: Firefox does not
* transition `display`, and in the other engines it would hold a second time after Alpine's hold.
* Alpine holds for the first `transition-duration` listed, so each exit's longest transition is
* listed first. Reduced motion zeroes both duration tokens (tokens/motion.css).
* Below `expanded` it slides, from whichever edge `data-md-side` names in the document's direction;
* once `standard` makes it co-planar it only fades (an in-flow box does not need to travel). Every
* state rule matches the sheet and scrim as the root's children, so a sheet nested in an open one
* keeps its own.
*
* A `standard` sheet drops the scrim, the trap and the elevation, and swaps surface-container-low
* for `surface` with an outline-variant rule down its inner edge, in place of the scrim
* (its anatomy's "Divider (optional)"). Below `expanded` (840px) it is the modal sheet M3 caps
* a side sheet at 400dp, and a 600px window has too little room left beside one, so the switch
* sits at `expanded` rather than `medium`. Opening it shrinks the body beside it and closing it
* gives the room back (M3's side sheets, "Adaptive"): the root's `inline-size` springs from none to
* the sheet's width — the sheet itself keeps its width, sits at the root's far edge and is clipped,
* so it is uncovered from its inner edge together with a negative margin as wide as the flex
* parent's gap (`--md-drawer-gap`, which the view measures), so the content beside does not jump by
* the gap when the root leaves the layout. `data-md-drawer-collapsed` (the view, while the window is
* 840px and the sheet is closed) then takes the root out of the layout entirely, but only once the
* exit has run: the view's `closing` holds it, where it once cut the fade and the resize off on
* their first frame.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './button.css';
@import './divider.css';
@layer material.components {
/* The scrim fades in from `@starting-style` and back out while the view's `x-transition` holds
its `display` for this duration. Not `allow-discrete` on `display`: Firefox does not transition
`display`, so the scrim vanished there, and in the other engines it would hold a second time
after Alpine's hold. */
[data-md-drawer-scrim] {
position: fixed;
inset: 0;
z-index: 40;
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
opacity: 0;
transition-property: opacity;
transition-duration: var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-effects-default);
}
[data-md-drawer][data-md-open] > [data-md-drawer-scrim] {
opacity: 1;
}
@starting-style {
[data-md-drawer][data-md-open] > [data-md-drawer-scrim] {
opacity: 0;
}
}
/* Closed, the sheet waits one width past its own edge (`--sheet-offset`, mirrored in RTL) and
slides there on emphasized accelerate; open, it comes back on emphasized decelerate. */
[data-md-drawer-sheet] {
--sheet-offset: 100%;
position: fixed;
top: var(--material-safe-top, env(safe-area-inset-top));
bottom: 0;
z-index: 50;
display: flex;
width: 100%;
flex-direction: column;
overflow-y: auto;
padding: var(--md-sys-measurement-space300);
background-color: var(--md-sys-color-surface-container-low);
color: var(--md-sys-color-on-surface);
box-shadow: var(--md-sys-elevation-1);
translate: var(--sheet-offset) 0;
transition-property: translate;
transition-duration: var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate);
}
[data-md-drawer-sheet][data-md-side='end'] {
inset-inline-end: 0;
}
[data-md-drawer-sheet][data-md-side='start'] {
--sheet-offset: -100%;
inset-inline-start: 0;
}
[data-md-drawer-sheet][data-md-side='end']:is([dir='rtl'], [dir='rtl'] *) {
--sheet-offset: -100%;
}
[data-md-drawer-sheet][data-md-side='start']:is([dir='rtl'], [dir='rtl'] *) {
--sheet-offset: 100%;
}
[data-md-drawer][data-md-open] > [data-md-drawer-sheet] {
translate: 0 0;
transition-duration: var(--md-sys-motion-spatial-default-duration);
transition-timing-function: var(--md-sys-motion-easing-emphasized-decelerate);
}
@starting-style {
[data-md-drawer][data-md-open] > [data-md-drawer-sheet] {
translate: var(--sheet-offset) 0;
}
}
@media (width >= 600px) {
[data-md-drawer-sheet] {
width: var(--sheet-width);
max-width: calc(100vw - 64px);
}
[data-md-drawer-sheet][data-md-side='end'] {
border-start-start-radius: var(--md-sys-shape-corner-lg);
border-end-start-radius: var(--md-sys-shape-corner-lg);
}
[data-md-drawer-sheet][data-md-side='start'] {
border-start-end-radius: var(--md-sys-shape-corner-lg);
border-end-end-radius: var(--md-sys-shape-corner-lg);
}
}
/* Co-planar from expanded: sticky beside the content, no scrim, no elevation, flat on
`surface` with an outline-variant rule down the inner edge instead. It only fades here
an in-flow box does not travel like an overlay does. */
@media (width >= 840px) {
[data-md-drawer][data-md-standard] {
position: sticky;
top: 0;
display: flex;
justify-content: flex-end;
height: 100dvh;
flex-shrink: 0;
align-self: flex-start;
overflow: clip;
inline-size: 0;
margin-inline-start: calc(-1 * var(--md-drawer-gap, 0px));
transition-property: inline-size, margin-inline;
transition-duration: var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate);
}
/* A start sheet sits at the start edge, with the gap after it. */
[data-md-drawer][data-md-standard]:where(:has(> [data-md-drawer-sheet][data-md-side='start'])) {
justify-content: flex-start;
margin-inline-start: 0;
margin-inline-end: calc(-1 * var(--md-drawer-gap, 0px));
}
[data-md-drawer][data-md-standard][data-md-open] {
inline-size: min(var(--sheet-width), calc(100vw - 64px));
margin-inline: 0;
transition-duration: var(--md-sys-motion-spatial-default-duration);
transition-timing-function: var(--md-sys-motion-easing-emphasized-decelerate);
}
@starting-style {
[data-md-drawer][data-md-standard][data-md-open] {
inline-size: 0;
margin-inline-start: calc(-1 * var(--md-drawer-gap, 0px));
}
[data-md-drawer][data-md-standard][data-md-open]:where(:has(> [data-md-drawer-sheet][data-md-side='start'])) {
margin-inline-start: 0;
margin-inline-end: calc(-1 * var(--md-drawer-gap, 0px));
}
}
[data-md-drawer][data-md-standard][data-md-drawer-collapsed] {
display: none;
}
/* Until the view settles (`data-md-drawer-settled`, two frames after Alpine starts), the
state the page loads with is drawn at once: a sheet that starts open does not grow in. */
[data-md-drawer][data-md-standard]:not([data-md-drawer-settled]),
[data-md-drawer][data-md-standard]:not([data-md-drawer-settled]) > [data-md-drawer-sheet] {
transition: none;
}
[data-md-drawer][data-md-standard] > [data-md-drawer-scrim] {
display: none;
}
[data-md-drawer][data-md-standard] > [data-md-drawer-sheet] {
position: relative;
flex-shrink: 0;
top: 0;
z-index: auto;
height: 100%;
translate: 0 0;
border-radius: var(--md-sys-shape-corner-none);
background-color: var(--md-sys-color-surface);
box-shadow: none;
opacity: 0;
transition-property: opacity;
transition-duration: var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate);
}
[data-md-drawer][data-md-standard][data-md-open] > [data-md-drawer-sheet] {
opacity: 1;
transition-duration: var(--md-sys-motion-spatial-default-duration);
transition-timing-function: var(--md-sys-motion-easing-emphasized-decelerate);
}
@starting-style {
[data-md-drawer][data-md-standard][data-md-open] > [data-md-drawer-sheet] {
translate: 0 0;
opacity: 0;
}
}
[data-md-drawer][data-md-standard] > [data-md-drawer-sheet][data-md-side='end'] {
border-inline-start: 1px solid var(--md-sys-color-outline-variant);
}
[data-md-drawer][data-md-standard] > [data-md-drawer-sheet][data-md-side='start'] {
border-inline-end: 1px solid var(--md-sys-color-outline-variant);
}
}
[data-md-drawer-head] {
margin-bottom: var(--md-sys-measurement-space200);
}
[data-md-drawer-head-row] {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
[data-md-drawer-heading] {
min-width: 0;
}
[data-md-drawer-title] {
font: var(--md-sys-typescale-title-lg);
letter-spacing: var(--md-sys-typescale-title-lg-tracking);
}
[data-md-drawer-subtitle] {
margin-top: var(--md-sys-measurement-space50);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
}
[data-md-drawer-close] {
display: inline-flex;
flex-shrink: 0;
margin-top: calc(-1 * var(--md-sys-measurement-space100));
margin-inline-end: -12px;
}
[data-md-drawer-head] > [data-md-divider] {
margin-top: var(--md-sys-measurement-space200);
}
[data-md-drawer-body] {
container-type: inline-size;
flex: 1 1 0%;
}
[data-md-drawer-actions] {
display: flex;
min-height: var(--md-sys-measurement-space900);
flex-shrink: 0;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: var(--md-sys-measurement-space100);
padding-top: var(--md-sys-measurement-space200);
}
}
+69
View File
@@ -0,0 +1,69 @@
/*
* <x-empty-state>: "you have nothing here yet" not an M3 component; built from M3 Expressive's
* parts, a shape in secondary-container behind an icon in on-secondary-container, a title-large
* title, body-medium description at 448px and a centred row of actions.
*
* The shape is `position: absolute`, so the icon needs its own stacking context
* (`position: relative`) to paint above it in DOM order rather than losing to the shape's own
* an application's illustration slot replaces both and sizes itself.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@import './shape.css';
@layer material.components {
[data-md-empty-state] {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--md-sys-measurement-space200);
padding-inline: var(--md-sys-measurement-space200);
padding-block: var(--md-sys-measurement-space500);
text-align: center;
}
[data-md-empty-state-illustration] {
position: relative;
display: grid;
width: 112px;
height: 112px;
place-items: center;
}
[data-md-empty-state-shape] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
color: var(--md-sys-color-secondary-container);
}
[data-md-empty-state-icon] {
position: relative;
color: var(--md-sys-color-on-secondary-container);
}
[data-md-empty-state-title] {
font: var(--md-sys-typescale-title-lg);
letter-spacing: var(--md-sys-typescale-title-lg-tracking);
font-variation-settings: normal;
}
[data-md-empty-state-description] {
max-width: 448px;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
font-variation-settings: normal;
}
[data-md-empty-state-actions] {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
padding-top: var(--md-sys-measurement-space100);
}
}
+147
View File
@@ -0,0 +1,147 @@
/*
* `errors::minimal`: the layout the package's 403/404/419/429/500/503 pages and the framework's
* own 401/402 (which extend this layout, `src/Support/ErrorPage.php`'s docblock) render through.
* A page's own `@section`s fill `title`, `code`, `headline`, `message`, `shape` and `actions`
* (`resources/views/error-pages/errors/minimal.blade.php`'s header) — none of that is this file's
* concern, only the geometry and colour of the parts that show them:
*
* body:has(> [data-md-error-page])
* [data-md-error-page]
* [data-md-error-art]
* [data-md-error-shape] an <x-shape>, turning once a minute unless reduced motion asks
* otherwise (its `svg` fills the art box and is coloured here,
* since <x-shape size> only takes a fixed px, not "fill parent")
* [data-md-error-code] the status, in display type over the shape
* [data-md-error-headline] what happened
* [data-md-error-message] one sentence on what to do, only when `headline` is its own
* section (otherwise `message` doubles as the headline)
* [data-md-error-actions]
*
* The page carries this file itself: `src/Support/ErrorPage.php` inlines it, with its imports, into
* every error page (`Stylesheets::bundle()`) beside the application's Vite tags when there is a
* build (`layoutStyles()`), inside the whole fallback stylesheet when there is none
* (`fallbackStyles()`, plan step 40) so an application imports nothing for its error pages
* (plan step 46). The body is still matched only when it holds the layout
* (`body:has(> [data-md-error-page])`): an application that imports `all.css` has this file in
* its bundle beside every other page, where a bare `body` rule would restyle them all.
*
* The page's font is the foundation's, `--md-ref-typeface-brand` on `html` (foundation/base.css),
* as the old `font-sans` was; only the fallback, which has no `@font-face`, uses a system stack.
*
* Colours from the roles (`docs/reference/m3/styles.md` § Typography, "Accessibility
* requirements": surface/on-surface); the display code over its primary-container shape is the
* pairing `docs/audits/m3-alignment/containment.md`'s "Aligned" section confirms; 24px page
* gutters and the 600px growth of the art and headline are the same numbers the audit found
* clean. `--md-sys-typescale-emphasized-display-lg` is the code's weight and roundedness; the
* shape's 60s linear turn has no motion token (`--md-sys-motion-*` pairs a duration with a spring
* for a state change, not an ambient loop) and stays a literal, gated the way the audit's
* "Aligned" section already found it: behind `prefers-reduced-motion: no-preference`.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './button.css';
@import './shape.css';
@layer material.components {
body:has(> [data-md-error-page]) {
margin: 0;
min-height: 100dvh;
background-color: var(--md-sys-color-surface);
color: var(--md-sys-color-on-surface);
}
[data-md-error-page] {
display: flex;
min-height: 100dvh;
max-width: 576px;
margin-inline: auto;
flex-direction: column;
align-items: center;
justify-content: center;
padding-inline: var(--md-sys-measurement-space300);
padding-block: var(--md-sys-measurement-space600);
text-align: center;
}
[data-md-error-art] {
position: relative;
display: grid;
flex-shrink: 0;
place-items: center;
width: 192px;
height: 192px;
}
@media (width >= 600px) {
[data-md-error-art] {
width: 240px;
height: 240px;
}
}
[data-md-error-shape] {
position: absolute;
inset: 0;
}
[data-md-error-shape] svg {
width: 100%;
height: 100%;
color: var(--md-sys-color-primary-container);
}
@media (prefers-reduced-motion: no-preference) {
[data-md-error-shape] {
animation: material-error-turn 60s linear infinite;
}
}
@keyframes material-error-turn {
to {
transform: rotate(1turn);
}
}
[data-md-error-code] {
position: relative;
margin: 0;
color: var(--md-sys-color-on-primary-container);
font: var(--md-sys-typescale-emphasized-display-lg);
letter-spacing: var(--md-sys-typescale-emphasized-display-lg-tracking);
font-variation-settings: 'ROND' 100;
font-variant-numeric: tabular-nums;
}
[data-md-error-headline] {
margin: var(--md-sys-measurement-space500) 0 0;
font: var(--md-sys-typescale-headline-md);
letter-spacing: var(--md-sys-typescale-headline-md-tracking);
text-wrap: balance;
}
@media (width >= 600px) {
[data-md-error-headline] {
font: var(--md-sys-typescale-headline-lg);
letter-spacing: var(--md-sys-typescale-headline-lg-tracking);
}
}
[data-md-error-message] {
margin: 12px 0 0;
max-width: 448px;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
text-wrap: balance;
}
[data-md-error-actions] {
display: flex;
margin-top: var(--md-sys-measurement-space500);
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 12px;
}
}
@@ -0,0 +1,63 @@
/*
* <x-fab-menu-item>: one action in an `<x-fab-menu>`, a pill that rises into place as the menu
* opens and sinks back as it closes.
*
* FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): 56px tall, 24px either side,
* 8px between the icon and the label, 24px icons, full corner, elevation 3, `data-md-color`'s
* container. It renders the foundation's `md-state-layer` and `md-focus-ring`
* (foundation/interaction.css); its 56px already meets M3's target, so it needs no
* `md-touch-target`.
*
* The entry is `@starting-style` on the item itself; the exit is the popover's exit copy
* (resources/js/popover-exit.js), whose items fab-menu.css sets to the closed values, so the item
* has somewhere to animate to (ACT-33).
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@layer material.components {
[data-md-fab-menu-item] {
--md-fab-menu-item-container: var(--md-sys-color-primary-container);
--md-fab-menu-item-on-container: var(--md-sys-color-on-primary-container);
display: inline-flex;
flex-shrink: 0;
align-items: center;
gap: var(--md-sys-measurement-space100);
block-size: var(--md-sys-measurement-space700);
padding-inline: var(--md-sys-measurement-space300);
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-fab-menu-item-container);
color: var(--md-fab-menu-item-on-container);
box-shadow: var(--md-sys-elevation-3);
white-space: nowrap;
font: var(--md-sys-typescale-title-md);
letter-spacing: var(--md-sys-typescale-title-md-tracking);
font-variation-settings: normal;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
translate: 0 0;
opacity: 1;
transition-property: translate, opacity;
transition-duration: var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast), var(--md-sys-motion-effects-default);
@starting-style {
translate: 0 var(--md-sys-measurement-space100);
opacity: 0;
}
}
[data-md-fab-menu-item][data-md-color='secondary'] {
--md-fab-menu-item-container: var(--md-sys-color-secondary-container);
--md-fab-menu-item-on-container: var(--md-sys-color-on-secondary-container);
}
[data-md-fab-menu-item][data-md-color='tertiary'] {
--md-fab-menu-item-container: var(--md-sys-color-tertiary-container);
--md-fab-menu-item-on-container: var(--md-sys-color-on-tertiary-container);
}
}
+154
View File
@@ -0,0 +1,154 @@
/*
* <x-fab-menu>: an M3 Expressive FAB that opens into a short list of related actions.
*
* FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): the trigger is a 56px FAB,
* `data-md-color`'s container, large corner, elevation 3, rising to elevation 4 under a hovering
* pointer; while its list is open (`aria-expanded`) it turns fully round and paints the colour
* itself, on the default spatial and effects springs (ACT-19 splits the two). It renders the
* foundation's `md-state-layer` and `md-focus-ring` (foundation/interaction.css); its 56px already
* meets M3's target, so it needs no `md-touch-target`. The list sits
* `data-md-position`'s corner of the trigger, 8px padding the room the close button keeps below
* the first item and scrolls past `calc(100dvh - 128px)` on a short window, behind the close
* button, which stays fixed (ACT-32).
*
* The items (fab-menu-item.css) rise into place as the list opens and sink back as it closes: the
* entry is `@starting-style` on the item itself; the exit is the popover's exit copy
* (`data-md-popover-exit`, resources/js/popover-exit.js), which stays on screen after the popover
* has closed, so the items have somewhere to animate to (ACT-33) in every engine, Firefox included.
* `[data-md-fab-menu-popover]:not(:popover-open) > *` and the copy's `[data-md-popover-closing] > *`
* are the closed values; inside the layer they beat fab-menu-item.css's resting `translate: 0 0`
* and `opacity: 1` on specificity two attributes, or an attribute and a pseudo-class, against the
* item's one attribute whichever order the two files are bundled in.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@layer material.components {
[data-md-fab-menu] {
position: relative;
display: inline-flex;
}
[data-md-fab-menu-anchor] {
display: inline-flex;
}
[data-md-fab-menu-trigger] {
--md-fab-menu-container: var(--md-sys-color-primary-container);
--md-fab-menu-on-container: var(--md-sys-color-on-primary-container);
--md-fab-menu-color: var(--md-sys-color-primary);
--md-fab-menu-on-color: var(--md-sys-color-on-primary);
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
inline-size: var(--md-sys-measurement-space700);
block-size: var(--md-sys-measurement-space700);
border-radius: var(--md-sys-shape-corner-lg);
background-color: var(--md-fab-menu-container);
color: var(--md-fab-menu-on-container);
box-shadow: var(--md-sys-elevation-3);
cursor: pointer;
-webkit-user-select: none;
user-select: none;
transition-property: border-radius, background-color, color, box-shadow;
transition-duration:
var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-effects-default-duration),
var(--md-sys-motion-effects-default-duration), var(--md-sys-motion-effects-default-duration);
transition-timing-function:
var(--md-sys-motion-spatial-default), var(--md-sys-motion-effects-default),
var(--md-sys-motion-effects-default), var(--md-sys-motion-effects-default);
@media (hover: hover) {
&:hover {
box-shadow: var(--md-sys-elevation-4);
}
}
&[aria-expanded='true'] {
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-fab-menu-color);
color: var(--md-fab-menu-on-color);
}
}
[data-md-fab-menu-trigger][data-md-color='secondary'] {
--md-fab-menu-container: var(--md-sys-color-secondary-container);
--md-fab-menu-on-container: var(--md-sys-color-on-secondary-container);
--md-fab-menu-color: var(--md-sys-color-secondary);
--md-fab-menu-on-color: var(--md-sys-color-on-secondary);
}
[data-md-fab-menu-trigger][data-md-color='tertiary'] {
--md-fab-menu-container: var(--md-sys-color-tertiary-container);
--md-fab-menu-on-container: var(--md-sys-color-on-tertiary-container);
--md-fab-menu-color: var(--md-sys-color-tertiary);
--md-fab-menu-on-color: var(--md-sys-color-on-tertiary);
}
[data-md-fab-menu-close-icon] {
display: none;
}
[data-md-fab-menu-trigger][aria-expanded='true'] [data-md-fab-menu-icon] {
display: none;
}
[data-md-fab-menu-trigger][aria-expanded='true'] [data-md-fab-menu-close-icon] {
display: inline-flex;
}
[data-md-fab-menu-popover] {
inset: auto;
margin: 0;
display: none;
flex-direction: column;
gap: var(--md-sys-measurement-space50);
max-block-size: calc(100dvh - 128px);
overflow-y: auto;
border-width: 0;
background-color: transparent;
padding: var(--md-sys-measurement-space100);
&:popover-open {
display: flex;
}
}
[data-md-fab-menu-popover][data-md-position='top-end'] {
margin-inline: calc(-1 * var(--md-sys-measurement-space100));
margin-block-start: calc(-1 * var(--md-sys-measurement-space100));
align-items: flex-end;
position-area: top span-left;
}
[data-md-fab-menu-popover][data-md-position='top-start'] {
margin-inline: calc(-1 * var(--md-sys-measurement-space100));
margin-block-start: calc(-1 * var(--md-sys-measurement-space100));
align-items: flex-start;
position-area: top span-right;
}
[data-md-fab-menu-popover][data-md-position='bottom-end'] {
margin-inline: calc(-1 * var(--md-sys-measurement-space100));
margin-block-end: calc(-1 * var(--md-sys-measurement-space100));
align-items: flex-end;
position-area: bottom span-left;
}
[data-md-fab-menu-popover][data-md-position='bottom-start'] {
margin-inline: calc(-1 * var(--md-sys-measurement-space100));
margin-block-end: calc(-1 * var(--md-sys-measurement-space100));
align-items: flex-start;
position-area: bottom span-right;
}
[data-md-fab-menu-popover]:not(:popover-open) > *,
[data-md-fab-menu-popover][data-md-popover-closing] > * {
translate: 0 var(--md-sys-measurement-space100);
opacity: 0;
}
}
+176
View File
@@ -0,0 +1,176 @@
/*
* <x-fab>: M3 Expressive's floating action button and extended FAB.
*
* FabBaseline/Medium/LargeTokens and ExtendedFab*Tokens (androidx Compose Material 3, Apache-2.0),
* per `data-md-size`:
*
* size FAB corner glyph extended: min width gap padding type
* sm 56px lg (16px) 24px 80px 8px 16px title-md
* md 80px lg-increased 28px 80px 16px 26px title-lg
* lg 96px xl (28px) 32px 96px 20px 28px headline-sm
*
* A FAB and its extended form share the height and the corner. `data-md-color` is `primary`,
* `secondary` or `tertiary`, drawn in its container (`data-md-variant="container"`) or in the
* colour itself (`filled`), at elevation 3, 4 under a hovering pointer, the shadow and colours on
* the fast effects spring. It renders the foundation's `md-state-layer` and `md-focus-ring`
* (foundation/interaction.css); every size draws at 56px or more, so it needs no `md-touch-target`.
* `data-md-fab` on the root is also the hook a place uses to draw a nested FAB its own way: a
* navigation rail or a docked toolbar flattens it to elevation 0.
*
* `data-md-collapse-on-scroll` is M3's extended FAB that "can collapse to a FAB on scroll-down and
* re-expand to extended on scroll-up when switching between FABextended FAB, shape changes, the
* icon moves left, and the text label fades in/out" (resources/js/fab.js sets
* `data-md-collapsed`). `width: auto` cannot transition, so the label sits in a one-column grid
* whose track closes to zero; that track is what animates, and the button lays itself out around
* it every frame. The gap and the minimum width go with it, all on the default spatial spring,
* while the label fades on the effects spring. The collapsed width is the FAB's own size. The
* extended padding already centres the glyph at `sm` (16 + 24 + 16) and `md` (26 + 28 + 26); at
* `lg` its 28px is 4px short of the 32px that centres a 32px glyph in 96px, so that one moves. The
* label stays in the accessibility tree clipped and transparent, never `display: none` so the
* collapsed FAB keeps the name the extended one had. Under reduced motion tokens/motion.css takes
* every duration to zero, so it swaps.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@import './tooltip.css';
@layer material.components {
[data-md-fab] {
--md-fab-color: var(--md-sys-color-primary);
--md-fab-on-color: var(--md-sys-color-on-primary);
--md-fab-container: var(--md-sys-color-primary-container);
--md-fab-on-container: var(--md-sys-color-on-primary-container);
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
block-size: var(--md-fab-size);
border-radius: var(--md-fab-corner);
background-color: var(--md-fab-container);
color: var(--md-fab-on-container);
box-shadow: var(--md-sys-elevation-3);
white-space: nowrap;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
transition-property: box-shadow, background-color, color;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
@media (hover: hover) {
&:hover {
box-shadow: var(--md-sys-elevation-4);
}
}
}
[data-md-fab][data-md-size='sm'] {
--md-fab-size: 56px;
--md-fab-corner: var(--md-sys-shape-corner-lg);
--md-fab-extended-min: 80px;
}
[data-md-fab][data-md-size='md'] {
--md-fab-size: 80px;
--md-fab-corner: var(--md-sys-shape-corner-lg-increased);
--md-fab-extended-min: 80px;
}
[data-md-fab][data-md-size='lg'] {
--md-fab-size: 96px;
--md-fab-corner: var(--md-sys-shape-corner-xl);
--md-fab-extended-min: 96px;
}
[data-md-fab][data-md-color='secondary'] {
--md-fab-color: var(--md-sys-color-secondary);
--md-fab-on-color: var(--md-sys-color-on-secondary);
--md-fab-container: var(--md-sys-color-secondary-container);
--md-fab-on-container: var(--md-sys-color-on-secondary-container);
}
[data-md-fab][data-md-color='tertiary'] {
--md-fab-color: var(--md-sys-color-tertiary);
--md-fab-on-color: var(--md-sys-color-on-tertiary);
--md-fab-container: var(--md-sys-color-tertiary-container);
--md-fab-on-container: var(--md-sys-color-on-tertiary-container);
}
[data-md-fab][data-md-variant='filled'] {
background-color: var(--md-fab-color);
color: var(--md-fab-on-color);
}
[data-md-fab]:not([data-md-extended]) {
inline-size: var(--md-fab-size);
}
[data-md-fab][data-md-extended] {
min-inline-size: var(--md-fab-extended-min);
}
[data-md-fab][data-md-extended][data-md-size='sm'] {
gap: var(--md-sys-measurement-space100);
padding-inline: var(--md-sys-measurement-space200);
font: var(--md-sys-typescale-title-md);
letter-spacing: var(--md-sys-typescale-title-md-tracking);
font-variation-settings: normal;
}
[data-md-fab][data-md-extended][data-md-size='md'] {
gap: var(--md-sys-measurement-space200);
padding-inline: 26px;
font: var(--md-sys-typescale-title-lg);
letter-spacing: var(--md-sys-typescale-title-lg-tracking);
font-variation-settings: normal;
}
[data-md-fab][data-md-extended][data-md-size='lg'] {
gap: 20px;
padding-inline: 28px;
font: var(--md-sys-typescale-headline-sm);
letter-spacing: var(--md-sys-typescale-headline-sm-tracking);
font-variation-settings: normal;
}
/* The extended FAB that collapses to a FAB while the page scrolls down. */
[data-md-fab][data-md-collapse-on-scroll] {
transition-property: min-inline-size, padding-inline, gap, box-shadow, background-color, color;
transition-duration:
var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-spatial-default-duration),
var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration);
transition-timing-function:
var(--md-sys-motion-spatial-default), var(--md-sys-motion-spatial-default), var(--md-sys-motion-spatial-default),
var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast);
}
[data-md-fab-label] {
display: grid;
grid-template-columns: 1fr;
transition-property: grid-template-columns, opacity;
transition-duration: var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-spatial-default), var(--md-sys-motion-effects-default);
& > span {
overflow: hidden;
white-space: nowrap;
}
}
[data-md-fab][data-md-collapse-on-scroll][data-md-collapsed] {
min-inline-size: var(--md-fab-size);
gap: 0;
& [data-md-fab-label] {
grid-template-columns: 0fr;
opacity: 0;
}
}
[data-md-fab][data-md-collapse-on-scroll][data-md-size='lg'][data-md-collapsed] {
padding-inline: var(--md-sys-measurement-space400);
}
}
+192 -248
View File
@@ -9,26 +9,44 @@
* `<legend>`, carrying the label at its floated size, cuts the gap the label sits in, so the gap is
* right on any background. The filled field has no outline: a surface-container-highest box with
* extra-small top corners and an active indicator line, the label floating inside it
* (FilledTextFieldTokens / OutlinedTextFieldTokens, androidx Compose Material 3, Apache-2.0).
* (FilledTextFieldTokens / OutlinedTextFieldTokens, androidx Compose Material 3, Apache-2.0): 56px
* tall, 16px padding, 24px icons 16px from the text, the label in body-small once it floats.
* The `sm` (40px) and `xs` (32px) sizes are this package's, for an unlabelled field in a toolbar.
* Disabled is on-surface at M3's 38% content and 12% container opacities (tokens/state.css); the
* filled field's disabled container is on-surface at 4%, FilledTextFieldTokens'
* DisabledContainerOpacity, which has no system token.
*
* Anatomy (resources/views/components/field.blade.php):
*
* .field the whole thing; `class` from the call site lands here; data-variant, data-size
* .field-box the 56px row: icon, prefix, control, suffix, trailing
* .field-control the <input>, <select> or <textarea>
* .field-outline the fieldset and its legend (the filled field's indicator line)
* .field-label the visible label
* .field-support the hint, or the error in its place
* [data-md-field] the whole thing; `class` from the call site lands here;
* data-md-variant, data-md-size, data-md-invalid, data-md-floated,
* data-md-mono, data-md-full, data-md-readonly
* [data-md-field-box] the 56px row: icon, prefix, control, suffix, trailing
* [data-md-field-icon] the leading icon
* [data-md-field-affix] a prefix or suffix beside the value
* [data-md-field-control] the <input>, <select> or <textarea>
* [data-md-field-trailing] what ends the row; [data-md-field-error] is the error icon M3 asks
* for as the error state's second indicator; [data-md-field-button]
* a trailing icon button (clear, copy, reveal, open a picker)
* [data-md-field-outline] the fieldset and its legend (the filled field's indicator line)
* [data-md-field-label] the visible label
* [data-md-field-support-row] the row under the field
* [data-md-field-support] the hint, or the error in its place
* [data-md-field-counter] M3's character counter, `n/max`, at the end of that row
*
* A select's open list is not part of the field: it is the dropdown menu in components/menu.css.
*/
@layer components {
.field {
--field-height: 3.5rem;
--field-pad: 1rem;
--field-icon: 1.5rem;
--field-gap: 1rem;
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@layer material.components {
[data-md-field] {
--field-height: 56px;
--field-pad: var(--md-sys-measurement-space200);
--field-icon: 24px;
--field-gap: var(--md-sys-measurement-space200);
--field-start: var(--field-pad);
--field-edge: var(--md-sys-color-outline);
--field-ink: var(--md-sys-color-on-surface-variant);
@@ -38,30 +56,45 @@
min-width: 0;
}
/* M3 § Text Fields, Behaviour: "compact breakpoints can let a text field span full width;
* medium/expanded should bound it with flexible margins/other containers never let it span the
* full width of a large screen." The site names no number, so 40rem is this package's: it is
* under half the `large` breakpoint's 1200px, a little over half the `expanded` one's, and holds
* about the 70 characters body-large reads best at a measure of text, so it is in rem and grows
* with the text. It is a ceiling, not a width a narrower pane still gets a narrower field. A
* width rule from the call site beats it, because an application's CSS outranks this layer, and
* `full` takes it off for a field that really is the width of its pane (a search-and-filter row,
* an editor). Below `medium` nothing is bounded. */
@media (width >= 600px) {
[data-md-field]:not([data-md-full]) {
max-width: 40rem;
}
}
/* A filled field's resting indicator line is on-surface-variant, where an outline is outline. */
.field[data-variant="filled"] {
[data-md-field][data-md-variant='filled'] {
--field-edge: var(--md-sys-color-on-surface-variant);
}
.field[data-size="sm"] {
--field-height: 2.5rem;
--field-pad: 0.75rem;
--field-icon: 1.25rem;
--field-gap: 0.5rem;
[data-md-field][data-md-size='sm'] {
--field-height: 40px;
--field-pad: 12px;
--field-icon: 20px;
--field-gap: var(--md-sys-measurement-space100);
}
.field[data-size="xs"] {
--field-height: 2rem;
--field-pad: 0.625rem;
--field-icon: 1rem;
--field-gap: 0.375rem;
[data-md-field][data-md-size='xs'] {
--field-height: 32px;
--field-pad: var(--md-sys-measurement-space125);
--field-icon: 16px;
--field-gap: var(--md-sys-measurement-space75);
}
.field:has(.field-icon) {
[data-md-field]:has([data-md-field-icon]) {
--field-start: calc(var(--field-pad) + var(--field-icon) + var(--field-gap));
}
.field-box {
[data-md-field-box] {
position: relative;
display: flex;
align-items: center;
@@ -72,7 +105,7 @@
cursor: text;
}
.field-control {
[data-md-field-control] {
flex: 1 1 0%;
min-width: 0;
align-self: stretch;
@@ -84,219 +117,120 @@
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
}
.field[data-size="xs"] .field-control {
[data-md-field][data-md-size='xs'] [data-md-field-control] {
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
}
.field[data-mono] .field-control {
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
[data-md-field][data-md-mono] [data-md-field-control] {
font-family: var(--md-ref-typeface-mono);
}
.field-control::placeholder {
[data-md-field-control]::placeholder {
color: var(--md-sys-color-on-surface-variant);
opacity: 1;
}
/* Autofill paints its own background, which on a card is a pale block in the
wrong colour. Delaying the transition for a week keeps the field's own. */
.field-control:-webkit-autofill {
[data-md-field-control]:-webkit-autofill {
-webkit-text-fill-color: var(--md-sys-color-on-surface);
transition: background-color 604800s, color 604800s;
}
/* The browser draws the resize grip in the textarea's own corner, so the
textarea reaches to just inside the outline's end and bottom the grip sits
in the field's corner, not on the bottom outline a padding in from it and
its padding puts the text back where it was. */
textarea.field-control {
--field-grip: 0.25rem;
/* The first half-line of the top offset is a margin, not padding: text scrolled up in a full
textarea disappears under the edge instead of running through the label. */
--textarea-clear: 0.5rem;
--textarea-pad-top: calc((var(--field-height) - 1.5rem) / 2 - var(--textarea-clear));
--textarea-pad-bottom: calc((var(--field-height) - 1.5rem) / 2 - var(--field-grip));
margin-block: var(--textarea-clear) var(--field-grip);
margin-inline-end: calc(var(--field-grip) - var(--field-pad));
padding-block: var(--textarea-pad-top) var(--textarea-pad-bottom);
padding-inline-end: calc(var(--field-pad) - var(--field-grip));
resize: vertical;
}
/* `<x-textarea>` grows with what is typed, from `rows` lines up to `max-rows`. Where the browser
cannot size a field to its content, resources/js/field.js sets the height instead. */
textarea.field-control[data-autogrow] {
field-sizing: content;
min-block-size: calc(var(--field-rows, 3) * 1.5rem + var(--textarea-pad-top) + var(--textarea-pad-bottom));
max-block-size: calc(var(--field-max-rows, 1000) * 1.5rem + var(--textarea-pad-top) + var(--textarea-pad-bottom));
resize: none;
}
/* A select covers its whole field out over the padding and the leading icon at
the start, under the arrow to the end and its own padding puts the value back
where it was. So a press anywhere on the field opens it, and the list, which
the browser anchors to the select, is the field's width (components/menu.css). */
select.field-control {
--field-end: calc(var(--field-icon) + var(--field-gap) + var(--field-pad));
appearance: none;
cursor: pointer;
margin-inline: calc(-1 * var(--field-start)) calc(-1 * var(--field-end));
padding-inline: var(--field-start) var(--field-end);
}
select.field-control:disabled {
cursor: default;
}
/* Where the browser has a customizable select, the closed select is its own
button: without the border, padding and arrow that button brings, it is the
same field as before. While its list is open the field reads as focused
said from `:open`, because focus inside the list is in the top layer and
the arrow turns over, as the searchable choices' does. */
@supports (appearance: base-select) {
select.field-control {
display: flex;
align-items: center;
border: 0;
padding-block: 0;
border-radius: 0;
background: transparent;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
select.field-control::picker-icon {
display: none;
}
.field-box:has(select.field-control:open) {
--field-edge: var(--md-sys-color-primary);
--field-ink: var(--md-sys-color-primary);
.field-outline {
border-width: 2px;
}
}
.field:has(select.field-control:open) .field-arrow {
rotate: 180deg;
}
}
/* `<x-file>`: the browser's own "No file chosen" line is transparent (the caller shows what was
chosen), and its button is a tonal pill the thing to press. */
input[type="file"].field-control {
align-self: center;
color: transparent;
cursor: pointer;
}
input[type="file"].field-control::file-selector-button {
height: 2rem;
margin-inline-end: 0.75rem;
padding-inline: 1rem;
border: 0;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
cursor: pointer;
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
input[type="file"].field-control:hover::file-selector-button {
background-color: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 8%, var(--md-sys-color-secondary-container));
}
}
input[type="file"].field-control:disabled::file-selector-button {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 12%, transparent);
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
cursor: default;
}
/* A combobox's arrow turns over while its list is open, as a select's does. */
.field:has(.field-control[aria-expanded="true"]) .field-arrow {
[data-md-field]:has([data-md-field-control][aria-expanded='true']) [data-md-field-arrow] {
rotate: 180deg;
}
.field-arrow {
[data-md-field-arrow] {
transition: rotate var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
.field-icon,
.field-trailing {
[data-md-field-icon],
[data-md-field-trailing] {
flex: none;
width: var(--field-icon);
height: var(--field-icon);
color: var(--md-sys-color-on-surface-variant);
}
.field-icon,
.field-arrow {
/* The error's second indicator: M3 asks that the colour change never stand alone. */
[data-md-field-error] {
color: var(--md-sys-color-error);
}
[data-md-field-icon],
[data-md-field-arrow] {
pointer-events: none;
}
/* A trailing button — clear, copy, reveal — is an icon button: the icon in a 40px state layer. */
.field-button {
/* A trailing button clear, copy, reveal, the datepicker's calendar and the timepicker's clock
is an icon button: the icon in a state layer that scales with the field's own icon size (40px
at `md`, smaller at `sm`/`xs`), which the foundation's fixed-size classes cannot draw; the
layer doubles as the touch target, since it already reaches 40px+ without a second pseudo. The
hook stays `data-md-field-button` rather than a class for that reason alone now: every other
component that draws one input, password, datepicker, timepicker selects it from here. */
[data-md-field-button] {
position: relative;
display: grid;
place-items: center;
margin-inline-end: -0.25rem;
margin-inline-end: calc(-1 * var(--md-sys-measurement-space50));
border-radius: var(--md-sys-shape-corner-full);
cursor: pointer;
outline: none;
}
.field-button::before {
content: "";
[data-md-field-button]::before {
content: '';
position: absolute;
inset: 50% auto auto 50%;
width: calc(var(--field-icon) + 1rem);
height: calc(var(--field-icon) + 1rem);
width: calc(var(--field-icon) + var(--md-sys-measurement-space200));
height: calc(var(--field-icon) + var(--md-sys-measurement-space200));
translate: -50% -50%;
border-radius: inherit;
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
.field-button:hover::before {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 8%, transparent);
[data-md-field-button]:hover::before {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-hover-state-layer-opacity) * 100%), transparent);
}
}
.field-button:focus-visible::before {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, transparent);
[data-md-field-button]:focus-visible::before {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-focus-state-layer-opacity) * 100%), transparent);
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
.field-button:active::before {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, transparent);
[data-md-field-button]:active::before {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-pressed-state-layer-opacity) * 100%), transparent);
}
.field-affix {
[data-md-field-button]:disabled::before,
[data-md-field-button][aria-disabled='true']::before {
display: none;
}
[data-md-field-affix] {
flex: none;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
}
.field[data-size="xs"] .field-affix {
[data-md-field][data-md-size='xs'] [data-md-field-affix] {
font: var(--md-sys-typescale-body-md);
}
.field-outline {
/* The legend is body-small's 16px line less its 5px above the outline: the fieldset starts
5px up so the notch's edge runs through the middle of the floated label. */
[data-md-field-outline] {
position: absolute;
inset: -0.3125rem 0 0;
margin: 0;
padding: 0 calc(var(--field-pad) - 0.25rem);
padding: 0 calc(var(--field-pad) - var(--md-sys-measurement-space50));
border: 1px solid var(--field-edge);
border-radius: inherit;
pointer-events: none;
@@ -304,7 +238,7 @@
transition: border-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
.field-outline > legend {
[data-md-field-outline] > legend {
display: block;
width: auto;
max-width: 100%;
@@ -318,12 +252,12 @@
transition: max-width var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
.field-outline > legend > span {
[data-md-field-outline] > legend > span {
display: inline-block;
padding-inline: 0.25rem;
padding-inline: var(--md-sys-measurement-space50);
}
.field-label {
[data-md-field-label] {
position: absolute;
inset-inline-start: var(--field-pad);
top: 0;
@@ -343,126 +277,153 @@
/* `required` is marked from the control itself, in the label and in the notch,
so the gap is cut for the asterisk too. */
.field:has(.field-control:required) .field-label::after,
.field:has(.field-control:required) .field-outline > legend > span::after {
content: " *";
[data-md-field]:has([data-md-field-control]:required) [data-md-field-label]::after,
[data-md-field]:has([data-md-field-control]:required) [data-md-field-outline] > legend > span::after {
content: ' *';
}
/* Resting: a label, an empty control, and no focus. The label sits where the
text will go, at the text's size; the notch closes; the placeholder and the
prefix wait, because the label is standing where they would be. A select and
a date always hold a value, so they mark themselves `data-floated`. */
.field:not([data-floated]):has(.field-label):has(.field-control:placeholder-shown):not(:focus-within) {
.field-label {
a date always hold a value, so they mark themselves `data-md-floated`. */
[data-md-field]:not([data-md-floated]):has([data-md-field-label]):has([data-md-field-control]:placeholder-shown):not(:focus-within) {
[data-md-field-label] {
top: 50%;
inset-inline-start: var(--field-start);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
}
.field-outline > legend {
[data-md-field-outline] > legend {
max-width: 0.01px;
}
.field-affix {
[data-md-field-affix] {
visibility: hidden;
}
}
.field[data-size="xs"]:not([data-floated]):has(.field-label):has(.field-control:placeholder-shown):not(:focus-within) .field-label {
[data-md-field][data-md-size='xs']:not([data-md-floated]):has([data-md-field-label]):has([data-md-field-control]:placeholder-shown):not(:focus-within) [data-md-field-label] {
font: var(--md-sys-typescale-body-md);
}
/* A textarea's label rests on its first line, not in the middle of the box. */
.field:not([data-floated]):has(.field-label):has(textarea.field-control:placeholder-shown):not(:focus-within) .field-label {
top: calc(var(--field-height) / 2);
}
.field:has(.field-label):not(:focus-within) .field-control::placeholder {
[data-md-field]:has([data-md-field-label]):not(:focus-within) [data-md-field-control]::placeholder {
color: transparent;
}
/* Clearing is offered only once there is something to clear. */
.field:has(.field-control:placeholder-shown) .field-clear {
[data-md-field]:has([data-md-field-control]:placeholder-shown) [data-md-field-clear] {
display: none;
}
.field-box:hover {
--field-edge: var(--md-sys-color-on-surface);
/* Hover only where a pointer can hover, and never on a disabled field: M3 gives a disabled
control no state layer at all, and the disabled edge below is inherited, so a declaration
here would beat it whatever the selector weighs. */
@media (hover: hover) {
[data-md-field]:not(:has([data-md-field-control]:disabled)) [data-md-field-box]:hover {
--field-edge: var(--md-sys-color-on-surface);
}
}
.field-box:focus-within {
[data-md-field-box]:focus-within {
--field-edge: var(--md-sys-color-primary);
--field-ink: var(--md-sys-color-primary);
}
.field-box:focus-within .field-outline {
[data-md-field-box]:focus-within [data-md-field-outline] {
border-width: 2px;
}
.field[data-invalid] {
[data-md-field][data-md-invalid] {
--field-edge: var(--md-sys-color-error);
--field-ink: var(--md-sys-color-error);
}
.field[data-invalid] .field-box:hover {
--field-edge: var(--md-sys-color-on-error-container);
@media (hover: hover) {
[data-md-field][data-md-invalid]:not(:has([data-md-field-control]:disabled)) [data-md-field-box]:hover {
--field-edge: var(--md-sys-color-on-error-container);
}
}
.field[data-invalid] .field-box:focus-within {
[data-md-field][data-md-invalid] [data-md-field-box]:focus-within {
--field-edge: var(--md-sys-color-error);
--field-ink: var(--md-sys-color-error);
}
/* Dashed for a field the caller made read-only marked on the field, not read off the control,
because a date picker makes its own input read-only too. */
.field[data-readonly] .field-outline {
border-style: dashed;
}
/* `data-md-readonly` marks a field the caller made read-only on the field, not read off the
control, because a date picker makes its own input read-only too. Nothing here draws it
differently: M3 says a read-only field keeps "the same visual style as an editable field, but
clearly labeled read-only", and the native `readonly` attribute is what carries that. The hook
stays for an application that wants a mark of its own. */
.field:has(.field-control:disabled) {
--field-edge: color-mix(in srgb, var(--md-sys-color-on-surface) 12%, transparent);
--field-ink: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
[data-md-field]:has([data-md-field-control]:disabled) {
--field-edge: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-container-opacity) * 100%), transparent);
--field-ink: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
.field-box {
[data-md-field-box] {
cursor: default;
}
.field-control,
.field-icon,
.field-affix,
.field-trailing {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
[data-md-field-control],
[data-md-field-icon],
[data-md-field-affix],
[data-md-field-trailing] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
}
}
.field-support {
padding: 0.25rem var(--field-pad) 0;
/* The hint or the error, and when the field has a maximum M3's character counter at the far
end of the same row (specs: "Padding between supporting text and counter | 16dp", which the
two paddings between them already make). Supporting text sits 4px under the field. */
[data-md-field-support-row] {
display: flex;
align-items: baseline;
}
[data-md-field-support] {
min-width: 0;
padding: var(--md-sys-measurement-space50) var(--field-pad) 0;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
}
.field[data-invalid] .field-support {
[data-md-field][data-md-invalid] [data-md-field-support] {
color: var(--md-sys-color-error);
}
[data-md-field-counter] {
flex: none;
margin-inline-start: auto;
padding-block-start: var(--md-sys-measurement-space50);
padding-inline-end: var(--field-pad);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
font-variant-numeric: tabular-nums;
}
/* Past the maximum the count is an error, and says so in the error colour. */
[data-md-field-counter][data-md-over] {
color: var(--md-sys-color-error);
}
/* ---- The filled text field ---------------------------------------------------------------- */
.field[data-variant="filled"] .field-box {
[data-md-field][data-md-variant='filled'] [data-md-field-box] {
border-radius: var(--md-sys-shape-corner-xs) var(--md-sys-shape-corner-xs) 0 0;
background-color: var(--md-sys-color-surface-container-highest);
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
.field[data-variant="filled"] .field-box:hover {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, var(--md-sys-color-surface-container-highest));
[data-md-field][data-md-variant='filled'] [data-md-field-box]:hover {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-hover-state-layer-opacity) * 100%), var(--md-sys-color-surface-container-highest));
}
}
/* The outline becomes the active indicator: one line along the bottom. */
.field[data-variant="filled"] .field-outline {
[data-md-field][data-md-variant='filled'] [data-md-field-outline] {
inset: auto 0 0;
padding: 0;
border: 0;
@@ -470,51 +431,34 @@
border-radius: 0;
}
.field[data-variant="filled"] .field-outline > legend {
[data-md-field][data-md-variant='filled'] [data-md-field-outline] > legend {
display: none;
}
.field[data-variant="filled"] .field-box:focus-within .field-outline {
[data-md-field][data-md-variant='filled'] [data-md-field-box]:focus-within [data-md-field-outline] {
border-block-end-width: 2px;
}
/* The label floats inside the box, above the value, and the value sits below it. */
.field[data-variant="filled"] .field-label {
top: 1rem;
[data-md-field][data-md-variant='filled'] [data-md-field-label] {
top: var(--md-sys-measurement-space200);
inset-inline-start: var(--field-start);
max-width: calc(100% - var(--field-start) - var(--field-pad));
}
.field[data-variant="filled"]:has(.field-label) .field-control {
padding-block: 1.5rem 0.5rem;
[data-md-field][data-md-variant='filled']:has([data-md-field-label]) [data-md-field-control] {
padding-block: var(--md-sys-measurement-space300) var(--md-sys-measurement-space100);
}
.field[data-variant="filled"]:has(.field-label) textarea.field-control {
--textarea-clear: 1.5rem;
--textarea-pad-top: 0rem;
--textarea-pad-bottom: calc(0.5rem - var(--field-grip));
padding-block: var(--textarea-pad-top) var(--textarea-pad-bottom);
}
/* A file picker's button is taller than a line of text: it sits lower, and a little smaller. */
.field[data-variant="filled"]:has(.field-label) input[type="file"].field-control {
padding-block: 1.375rem 0.375rem;
}
.field[data-variant="filled"] input[type="file"].field-control::file-selector-button {
height: 1.75rem;
}
.field[data-variant="filled"]:has(.field-label) .field-affix {
[data-md-field][data-md-variant='filled']:has([data-md-field-label]) [data-md-field-affix] {
align-self: stretch;
padding-block: 1.5rem 0.5rem;
padding-block: var(--md-sys-measurement-space300) var(--md-sys-measurement-space100);
}
.field[data-variant="filled"]:has(.field-control:disabled) {
--field-edge: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
[data-md-field][data-md-variant='filled']:has([data-md-field-control]:disabled) {
--field-edge: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
.field-box {
[data-md-field-box] {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 4%, transparent);
}
}
+62
View File
@@ -0,0 +1,62 @@
/*
* <x-file>: the browser's file input in the text field's chrome
* (resources/views/components/file.blade.php).
*
* The chrome is the field's (components/field.css); this file restyles only what the browser
* draws. Its "No file chosen" line is transparent, because it can be neither truncated nor wrapped
* and the caller shows what was chosen; its `::file-selector-button` is a tonal pill a 32px
* button in secondary-container with label-large, 16px padding, 12px before the line so it reads
* as the thing to press, with M3's hover state layer and disabled opacities (tokens/state.css). In
* a filled field the button sits under the floating label, 28px tall.
*
* The field's root carries `data-md-file`.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './field.css';
@layer material.components {
/* `<x-file>`: the browser's own "No file chosen" line is transparent (the caller shows what was
chosen), and its button is a tonal pill the thing to press. */
input[type='file'][data-md-field-control] {
align-self: center;
color: transparent;
cursor: pointer;
}
input[type='file'][data-md-field-control]::file-selector-button {
height: 32px;
margin-inline-end: 12px;
padding-inline: var(--md-sys-measurement-space200);
border: 0;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
cursor: pointer;
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
input[type='file'][data-md-field-control]:hover::file-selector-button {
background-color: color-mix(in srgb, var(--md-sys-color-on-secondary-container) calc(var(--md-sys-state-hover-state-layer-opacity) * 100%), var(--md-sys-color-secondary-container));
}
}
input[type='file'][data-md-field-control]:disabled::file-selector-button {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-container-opacity) * 100%), transparent);
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
cursor: default;
}
/* A file picker's button is taller than a line of text: it sits lower, and a little smaller. */
[data-md-field][data-md-variant='filled']:has([data-md-field-label]) input[type='file'][data-md-field-control] {
padding-block: 22px var(--md-sys-measurement-space75);
}
[data-md-field][data-md-variant='filled'] input[type='file'][data-md-field-control]::file-selector-button {
height: 28px;
}
}
+44
View File
@@ -0,0 +1,44 @@
/*
* <x-form>: its fields in one column, and its actions at the foot
* (resources/views/components/form.blade.php).
*
* The column is a one-track grid whose track is `minmax(0, 1fr)`: a bare grid's implicit column
* floors at its content's min-content, so on a phone a wide field would push the form past its
* pane. Rows take their content's height. 16px (`space200`) between fields, because a text field
* floats its label half above its outline and any less lets the hint under one field run into the
* label of the next. A button written among the fields keeps its label's width at the start edge,
* where the grid would stretch it across: M3 keeps a button's width "dynamic to fit label" and
* says not to "stretch buttons into long flat shapes"
* (docs/reference/m3/components-actions-communication-containment.md § Buttons).
*
* The actions wrap, end-aligned, 8px (`space100`) apart M3's gap between buttons in a row. A
* caller's class on the `actions` slot lands on the row and outranks this layer.
*
* [data-md-form] the <form>
* [data-md-form-actions] the row of actions, under the divider when `separator` asks for one
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './divider.css';
@layer material.components {
[data-md-form] {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-auto-rows: min-content;
gap: var(--md-sys-measurement-space200);
}
[data-md-form] > [data-md-button] {
justify-self: start;
}
[data-md-form-actions] {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--md-sys-measurement-space100);
}
}
+223
View File
@@ -0,0 +1,223 @@
/*
* <x-group>: a choice of a few options drawn as M3 Expressive's connected button group, the
* successor of the segmented button native radios (checkboxes with `data-md-multiple`) under
* segments that share the row, or hug their labels with `data-md-inline`.
*
* The segments are M3's buttons at the group's `data-md-size` (Button*Tokens: 32/40/56/96/136px
* tall, 16/16/24/48/64px either side, 8/8/8/12/16px between icon and label, label-large, label-
* large, title-medium, headline-small, headline-large), with the connected shape from
* button-group.css: 2px apart, small inner corners, the chosen segment fully round. At `xs` and
* `sm` a segment carries M3's 48px target and a 48px minimum width, which M3 asks for by name.
*
* `data-md-variant` is the toggle button's colours: `tonal` (secondary-container, chosen in
* secondary), `filled` (surface-container behind on-surface-variant, chosen in primary) or
* `outlined` (an outline-variant edge, chosen in inverse-surface). A checked input picks the
* chosen colours (`:has(:checked)`), a disabled one the disabled treatment on-surface at 10%
* behind 38% text. Corners move on the fast spatial spring and colours on the fast effects spring
* beside it.
*
* A segment renders the foundation's `md-state-layer` (foundation/interaction.css) on the label
* itself, which reaches the same `:hover`/`:active` as the class expects; below `medium`'s smallest
* two sizes it renders `md-touch-target` too. The real control is the `<input>` inside, not the
* label, so keyboard focus and the disabled state are read off it with `:has()` the one
* difference the class cannot draw, kept here as a refinement.
*
* The legend is label-large and the hint body-small, both in on-surface-variant; a validation
* message takes the hint's place in error. `hint-class` lands on the hint, where a caller's class
* outranks the colour here.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@import './button-group.css';
@layer material.components {
[data-md-group] {
min-inline-size: 0;
}
[data-md-group-legend] {
margin-block-end: var(--md-sys-measurement-space100);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
}
[data-md-group] > [data-md-button-group] {
display: flex;
align-items: normal;
inline-size: 100%;
}
[data-md-group][data-md-inline] > [data-md-button-group] {
inline-size: fit-content;
}
[data-md-group-segment] {
--md-group-container: var(--md-sys-color-secondary-container);
--md-group-label: var(--md-sys-color-on-secondary-container);
display: flex;
flex: 1 1 0%;
align-items: center;
justify-content: center;
min-inline-size: 0;
background-color: var(--md-group-container);
color: var(--md-group-label);
white-space: nowrap;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
transition-property: border-radius, padding, margin, bottom, background-color, color, box-shadow;
transition-duration:
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration),
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration),
var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration);
transition-timing-function:
var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast),
var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast),
var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast);
/* Focus lands on the input inside, not the label, so it is read with :has(); the state
layer's own opacity joins the ring, as the foundation class draws for a focused control. */
&:has(:focus-visible) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
&::before {
opacity: var(--md-sys-state-focus-state-layer-opacity);
}
}
/* Disabled is read off the input too: the class's own :disabled/aria-disabled rule never
fires on the label it never reaches. */
&:has(:disabled)::before {
display: none;
}
/* The radio or checkbox stays in the page, for the form, the keyboard and a screen reader. */
& > input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border-width: 0;
}
}
[data-md-group][data-md-inline] [data-md-group-segment] {
flex: initial;
}
[data-md-group]:is([data-md-size='xs'], [data-md-size='sm']) [data-md-group-segment] {
min-inline-size: var(--md-sys-measurement-space600);
}
[data-md-group][data-md-size='xs'] [data-md-group-segment] {
gap: var(--md-sys-measurement-space100);
block-size: 32px;
padding-inline: var(--md-sys-measurement-space200);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
}
[data-md-group][data-md-size='sm'] [data-md-group-segment] {
gap: var(--md-sys-measurement-space100);
block-size: 40px;
padding-inline: var(--md-sys-measurement-space200);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
}
[data-md-group][data-md-size='md'] [data-md-group-segment] {
gap: var(--md-sys-measurement-space100);
block-size: 56px;
padding-inline: var(--md-sys-measurement-space300);
font: var(--md-sys-typescale-title-md);
letter-spacing: var(--md-sys-typescale-title-md-tracking);
font-variation-settings: normal;
}
[data-md-group][data-md-size='lg'] [data-md-group-segment] {
gap: 12px;
block-size: 96px;
padding-inline: var(--md-sys-measurement-space600);
font: var(--md-sys-typescale-headline-sm);
letter-spacing: var(--md-sys-typescale-headline-sm-tracking);
font-variation-settings: normal;
}
[data-md-group][data-md-size='xl'] [data-md-group-segment] {
gap: var(--md-sys-measurement-space200);
block-size: 136px;
padding-inline: var(--md-sys-measurement-space800);
font: var(--md-sys-typescale-headline-lg);
letter-spacing: var(--md-sys-typescale-headline-lg-tracking);
font-variation-settings: normal;
}
[data-md-group-segment]:has(:checked) {
--md-group-container: var(--md-sys-color-secondary);
--md-group-label: var(--md-sys-color-on-secondary);
}
[data-md-group][data-md-variant='filled'] [data-md-group-segment] {
--md-group-container: var(--md-sys-color-surface-container);
--md-group-label: var(--md-sys-color-on-surface-variant);
&:has(:checked) {
--md-group-container: var(--md-sys-color-primary);
--md-group-label: var(--md-sys-color-on-primary);
}
}
[data-md-group][data-md-variant='outlined'] [data-md-group-segment] {
--md-group-container: transparent;
--md-group-label: var(--md-sys-color-on-surface-variant);
border: 1px solid var(--md-sys-color-outline-variant);
&:has(:checked) {
--md-group-container: var(--md-sys-color-inverse-surface);
--md-group-label: var(--md-sys-color-inverse-on-surface);
border-color: transparent;
}
}
[data-md-group-segment]:has(:disabled) {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
cursor: not-allowed;
}
[data-md-group-label] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-md-group-hint],
[data-md-group-error] {
margin-block-start: var(--md-sys-measurement-space50);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
font-variation-settings: normal;
}
[data-md-group-hint] {
color: var(--md-sys-color-on-surface-variant);
}
[data-md-group-error] {
color: var(--md-sys-color-error);
}
}
-84
View File
@@ -1,84 +0,0 @@
/*
* Button groups and split buttons: shapes that depend on a button's place among its siblings.
*
* Unlayered on purpose. Each <x-button> draws its own corners and padding as utilities, and a
* rule in any @layer loses to a utility whatever its specificity; the group has to win.
*
* Standard group (`<x-button-group>`): pressing a label button widens it and narrows its
* neighbours by the same amount, so the row keeps its length Expressive's press expansion
* (ButtonGroupDefaults.ExpandedRatio is 15%; this uses a fixed step per size). Icon buttons keep
* their width.
*
* Connected group (`<x-button-group connected>`, `<x-group>`): 2px apart, the outer corners
* full, the inner corners small, smaller still while pressed, and a selected segment fully round
* (ConnectedButtonGroupSmallTokens and the M3 Expressive connected button group spec).
*
* Split button (`<x-split-button>`): the same idea for two halves; the trailing half turns
* round and its chevron turns over while its menu is open (SplitButton*Tokens).
*/
[data-button-group] {
--group-inner: var(--md-sys-shape-corner-sm);
--group-inner-pressed: var(--md-sys-shape-corner-xs);
}
[data-button-group][data-size='xs'] { --group-pad: 0.75rem; --group-grow: 4px; --group-inner: var(--md-sys-shape-corner-xs); --group-inner-pressed: 2px; }
[data-button-group][data-size='sm'] { --group-pad: 1rem; --group-grow: 6px; }
[data-button-group][data-size='md'] { --group-pad: 1.5rem; --group-grow: 8px; }
[data-button-group][data-size='lg'] { --group-pad: 3rem; --group-grow: 16px; --group-inner: var(--md-sys-shape-corner-lg); --group-inner-pressed: var(--md-sys-shape-corner-md); }
[data-button-group][data-size='xl'] { --group-pad: 4rem; --group-grow: 20px; --group-inner: var(--md-sys-shape-corner-lg-increased); --group-inner-pressed: var(--md-sys-shape-corner-lg); }
[data-button-group='standard'] > :not([data-icon-button]):active:not(:disabled, [aria-disabled='true']) {
padding-inline: calc(var(--group-pad) + var(--group-grow));
}
[data-button-group='standard'] > :not([data-icon-button]):has(+ :not([data-icon-button]):active:not(:disabled, [aria-disabled='true'])) {
padding-inline-end: calc(var(--group-pad) - var(--group-grow));
}
[data-button-group='standard'] > :not([data-icon-button]):active:not(:disabled, [aria-disabled='true']) + :not([data-icon-button]) {
padding-inline-start: calc(var(--group-pad) - var(--group-grow));
}
/*
* Connected segments and split halves take their inner corner from `--group-corner`, so pressing
* or selecting changes one variable and the rounded outer corners stay put.
*/
[data-button-group='connected'] > *,
[data-split] {
--group-corner: var(--group-inner);
border-start-start-radius: var(--group-corner);
border-end-start-radius: var(--group-corner);
border-start-end-radius: var(--group-corner);
border-end-end-radius: var(--group-corner);
}
[data-button-group='connected'] > :active,
[data-split]:active {
--group-corner: var(--group-inner-pressed);
}
[data-button-group='connected'] > :is([aria-pressed='true'], :has(:checked)),
[data-split='trailing'][aria-expanded='true'] {
--group-corner: var(--md-sys-shape-corner-full);
}
[data-button-group='connected'] > :first-child,
[data-split='leading'] {
border-start-start-radius: var(--md-sys-shape-corner-full);
border-end-start-radius: var(--md-sys-shape-corner-full);
}
[data-button-group='connected'] > :last-child,
[data-split='trailing'] {
border-start-end-radius: var(--md-sys-shape-corner-full);
border-end-end-radius: var(--md-sys-shape-corner-full);
}
[data-split='trailing'] svg {
transition: rotate var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast);
}
[data-split='trailing'][aria-expanded='true'] svg {
rotate: 180deg;
}
+22
View File
@@ -0,0 +1,22 @@
/*
* <x-icon>: a Material Symbol at the size it is drawn.
*
* 24px unless `size` sets `--md-icon-size` (resources/views/components/icon.blade.php), which is
* M3's default icon size (docs/reference/m3/styles.md § Icons). The symbol never shrinks in a flex
* row, so a long label beside it cannot squeeze it. `data-md-mirror-rtl` flips a directional
* symbol in a right-to-left document (docs/reference/m3/foundations.md § Layout bidirectionality).
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-icon] {
flex-shrink: 0;
inline-size: var(--md-icon-size, 24px);
block-size: var(--md-icon-size, 24px);
}
[data-md-icon][data-md-mirror-rtl]:is([dir='rtl'], [dir='rtl'] *) {
transform: scaleX(-1);
}
}
+13
View File
@@ -0,0 +1,13 @@
/*
* <x-input>: M3's one-line text field (resources/views/components/input.blade.php).
*
* Nothing of its own to draw: the outlined and filled chrome, the floating label, the prefix and
* suffix, the clear and copy buttons and the character counter are the field's
* (components/field.css), and its icons are <x-icon>s. The field's root carries `data-md-input`
* for an application that wants to tell a one-line field from the others.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './field.css';
@import './icon.css';
+259
View File
@@ -0,0 +1,259 @@
/*
* <x-list-item>: a headline with what leads it, what supports it and what trails it, one- two- or
* three-line by `data-md-lines` (56/72/88px, `ListTokens.kt:180,323,347`, androidx Compose
* Material 3, Apache-2.0) the tallest element sets the height, and a three-line item top-aligns
* rather than centring (docs/reference/m3/components-actions-communication-containment.md
* § Lists Specs). Leading/trailing gap and container padding are both `space200` (16px,
* `ItemLeadingSpace`/`ItemTrailingSpace`/container padding, C-14).
*
* `data-md-list-row` (`resources/js/list-rows.js`, shared with `<x-card>` and an application's own
* table rows) and `[data-md-list-open]:focus-visible` answer with the state layer the row's ink at
* the hover 8% and focus/press 10% state tokens and a 3px secondary inset ring; excluded on
* `[data-md-card]`, which draws its own tint through the shared `md-state-layer` class instead
* (card.css). The layer is mixed over the row's own fill, `--md-list-row-fill` (a segmented tile's
* surface, a selected item's secondary-container, table.css's selected row), so a hovered, pressed
* or focused row keeps its fill under the tint instead of trading it for a translucent one (C-08's
* second fix). `data-md-selected` is M3's selected item, secondary-container filled, which also
* lifts the description/overline/trailing/icon colour rules below since they exclude it the
* container's own colour takes over by inheritance. A selected option draws a second cue, a
* trailing check, so selection is never colour alone (C-03). `aria-disabled` inks the whole item
* on-surface at the disabled-content opacity (38%) and blocks the pointer; the view drops the
* item's link entirely rather than leaving a focusable, activatable control behind it (C-02).
*
* `data-md-list="segmented"` items (M3 Expressive) are `surface-container` tiles (one tone up from
* `ItemSegmentedContainerColor`, so they read against the package's own `surface` page;
* C-21), 4px corners that open to 16px at the list's own ends and while hovered (12px), pressed,
* focused or selected (16px) the specs page's interaction-state expressive shapes (C-22). Leading
* icons there are drawn at 20px, not scaled down from 24 list-item.blade.php passes `size` to
* `<x-icon>` from the parent's `@aware(['segmented'])`, so no CSS override is needed here (C-26).
*
* `data-md-dividers` on the list insets its rule 16px from both ends (`DividerLeadingSpace`/
* `DividerTrailingSpace`, C-16) rather than full-bleed.
*
* Leading media: `data-md-list-item-avatar` is 40px, full corner, initials in primary-container
* when it is a `<span>`, an image otherwise; `data-md-list-item-image` is a 56px small-corner
* thumbnail; `data-md-list-item-video` is M3's leading video in its two sizes,
* `data-md-size="sm"` 100×56px (a two-line item) or `"lg"` 114×64px (a three-line item),
* `LeadingVideoSmall`/`LeadingVideoLarge`.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@layer material.components {
[data-md-list-item] {
position: relative;
display: flex;
align-items: center;
gap: var(--md-sys-measurement-space200);
padding-inline: var(--md-sys-measurement-space200);
color: var(--md-sys-color-on-surface);
}
[data-md-list-item][data-md-lines='1'] {
min-height: 56px;
padding-block: var(--md-sys-measurement-space100);
}
[data-md-list-item][data-md-lines='2'] {
min-height: 72px;
padding-block: var(--md-sys-measurement-space100);
}
[data-md-list-item][data-md-lines='3'] {
min-height: 88px;
padding-block: 12px;
align-items: flex-start;
}
/* A row's fill, under its state layer. Declared on every row at no weight, so a row nested in a
filled one starts from nothing rather than inheriting its parent's fill. */
:where([data-md-list-row]) {
--md-list-row-fill: transparent;
}
/* M3 Expressive's segmented list gives each item its own container: ListTokens.kt:201,
`ItemSegmentedContainerColor get() = ColorSchemeKeyTokens.Surface`. One tone up from that,
because the token reads against a Compose scaffold and this package paints the page
`surface` itself (foundation/base.css): a surface tile on a surface page is a list nobody
can see. `surface-container` is the step M3 names for a container that has to read against
the page, and what 1.x drew. Before the selected rule, which outranks it on the same
weight. */
[data-md-list='segmented'] > [data-md-list-item] {
--md-list-row-fill: var(--md-sys-color-surface-container);
background-color: var(--md-list-row-fill);
}
[data-md-list-item][data-md-selected] {
--md-list-row-fill: var(--md-sys-color-secondary-container);
background-color: var(--md-list-row-fill);
color: var(--md-sys-color-on-secondary-container);
}
[data-md-list-item][aria-disabled='true'] {
pointer-events: none;
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
}
[data-md-list-item]:not([data-md-selected], [aria-disabled='true']) :is([data-md-list-item-overline], [data-md-list-item-description], [data-md-list-item-trailing], [data-md-list-item-icon]) {
color: var(--md-sys-color-on-surface-variant);
}
[data-md-list-item-leading],
[data-md-list-item-end] {
display: flex;
flex-shrink: 0;
align-items: center;
}
[data-md-list-item-end] {
gap: var(--md-sys-measurement-space50);
}
[data-md-list-item-video] {
flex-shrink: 0;
overflow: hidden;
border-radius: var(--md-sys-shape-corner-sm);
background-color: var(--md-sys-color-surface-container-highest);
& > * {
width: 100%;
height: 100%;
object-fit: cover;
}
}
[data-md-list-item-video][data-md-size='sm'] {
width: 100px;
height: 56px;
}
[data-md-list-item-video][data-md-size='lg'] {
width: 114px;
height: 64px;
}
[data-md-list-item-avatar] {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: var(--md-sys-shape-corner-full);
object-fit: cover;
}
span[data-md-list-item-avatar] {
display: grid;
place-items: center;
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
font: var(--md-sys-typescale-title-md);
letter-spacing: var(--md-sys-typescale-title-md-tracking);
}
[data-md-list-item-image] {
flex-shrink: 0;
width: 56px;
height: 56px;
border-radius: var(--md-sys-shape-corner-sm);
object-fit: cover;
}
[data-md-list-item-content] {
min-width: 0;
flex: 1 1 0%;
}
[data-md-list-item-overline] {
font: var(--md-sys-typescale-label-sm);
letter-spacing: var(--md-sys-typescale-label-sm-tracking);
}
[data-md-list-item-title] {
display: block;
overflow: hidden;
outline: none;
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
text-overflow: ellipsis;
white-space: nowrap;
}
[data-md-list-item-description] {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
}
[data-md-list-item-trailing] {
flex-shrink: 0;
font: var(--md-sys-typescale-label-sm);
letter-spacing: var(--md-sys-typescale-label-sm-tracking);
}
[data-md-list][data-md-dividers] > [data-md-list-item]:not(:last-child)::after {
content: '';
position: absolute;
inset-inline: var(--md-sys-measurement-space200);
bottom: 0;
height: 1px;
background-color: var(--md-sys-color-outline-variant);
pointer-events: none;
}
[data-md-list-row] {
cursor: pointer;
transition-property: background-color, border-radius;
transition-duration: var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast), var(--md-sys-motion-spatial-fast);
}
@media (hover: hover) {
[data-md-list-row]:not([data-md-card]):hover:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):hover)) {
background-color: color-mix(in srgb, currentColor calc(var(--md-sys-state-hover-state-layer-opacity) * 100%), var(--md-list-row-fill, transparent));
}
}
[data-md-list-row]:not([data-md-card]):has([data-md-list-open]:focus-visible) {
background-color: color-mix(in srgb, currentColor calc(var(--md-sys-state-focus-state-layer-opacity) * 100%), var(--md-list-row-fill, transparent));
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: -3px;
}
[data-md-list-row]:not([data-md-card]):active:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):active)) {
background-color: color-mix(in srgb, currentColor calc(var(--md-sys-state-pressed-state-layer-opacity) * 100%), var(--md-list-row-fill, transparent));
}
[data-md-list-row] [data-md-list-open]:focus-visible {
outline: none;
}
[data-md-list='segmented'] > [data-md-list-item] {
border-radius: var(--md-sys-shape-corner-xs);
}
[data-md-list='segmented'] > [data-md-list-item]:first-child {
border-start-start-radius: var(--md-sys-shape-corner-lg);
border-start-end-radius: var(--md-sys-shape-corner-lg);
}
[data-md-list='segmented'] > [data-md-list-item]:last-child {
border-end-start-radius: var(--md-sys-shape-corner-lg);
border-end-end-radius: var(--md-sys-shape-corner-lg);
}
@media (hover: hover) {
[data-md-list='segmented'] > [data-md-list-item][data-md-list-row]:hover {
border-radius: var(--md-sys-shape-corner-md);
}
}
[data-md-list='segmented'] > [data-md-list-item]:is([data-md-selected], [data-md-list-row]:active, [data-md-list-row]:has([data-md-list-open]:focus-visible)) {
border-radius: var(--md-sys-shape-corner-lg);
}
}
+18 -92
View File
@@ -1,101 +1,27 @@
/*
* Rows a person can go into (`data-list-row`, resources/js/list-rows.js), and M3 Expressive lists.
* <x-list>: `<x-list-item>`s, one under another.
*
* A row answers a pointer with M3's state layer hover 8%, press and focus 10% but not while the
* pointer is on one of its own controls, which light only themselves. Hover only under
* `(hover: hover)`, because a touch screen keeps the last hover after a tap. The opener draws no
* focus ring inside a row; the row draws it, inset, so keyboard focus shows which row Enter opens.
* `data-selected` is M3's selected list item, in secondary-container.
* Plain by default; `data-md-list="segmented"` is M3 Expressive's list, each item its own surface
* tile `space25` (2px) apart (`SegmentedGap`, `ListTokens.kt`, androidx Compose Material 3,
* Apache-2.0) the item's own background, corner and interaction states are list-item.css's,
* since they are the item's to draw, not the list's.
*
* Unlayered where a component paints its fill as a utility (`<x-card>`): anything in a @layer loses
* to a utility whatever its specificity.
* `selectable`/`selection` switch the container's role between `list` and `listbox`
* (list.blade.php; docs/reference/m3/components-actions-communication-containment.md
* § Lists Accessibility, C-03) a role takes no CSS of its own.
*/
@layer components {
:where([data-list-row]) {
cursor: pointer;
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast),
border-radius var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast);
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './list-item.css';
@layer material.components {
[data-md-list] {
display: flex;
flex-direction: column;
}
@media (hover: hover) {
:where([data-list-row]:not([data-card]):hover:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-list-open]):hover))) {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
}
}
:where([data-list-row]:not([data-card]):has([data-list-open]:focus-visible)) {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: -3px;
}
:where([data-list-row]:not([data-card]):active:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-list-open]):active))) {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
[data-md-list='segmented'] {
gap: var(--md-sys-measurement-space25);
}
}
[data-list-row] [data-list-open]:focus-visible {
outline: none;
}
[data-list='segmented'] > [data-list-item] {
background-color: var(--md-sys-color-surface-container);
}
:is([data-list-row], [data-list-item])[data-selected]:not([data-card]) {
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
/* A card that opens answers with its container one tone up and its corner opening a step. */
[data-card][data-list-row] {
transition-property: border-radius, background-color, box-shadow;
transition-duration: var(--md-sys-motion-spatial-default-duration);
transition-timing-function: var(--md-sys-motion-spatial-default);
}
@media (hover: hover) {
[data-card][data-list-row]:hover:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-list-open]):hover)) {
border-radius: var(--md-sys-shape-corner-lg);
box-shadow: inset 0 0 0 100vmax color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
}
}
[data-card][data-list-row]:active:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-list-open]):active)) {
box-shadow: inset 0 0 0 100vmax color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
}
[data-card][data-list-row]:has([data-list-open]:focus-visible) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
/*
* M3 Expressive's segmented list (`<x-list segmented>`): each item its own surface, 2px apart,
* extra-small corners that open to large at the ends, and to large while hovered, pressed or
* selected (ListTokens, androidx Compose Material 3, Apache-2.0).
*/
[data-list='segmented'] > [data-list-item] {
border-radius: var(--md-sys-shape-corner-xs);
}
[data-list='segmented'] > [data-list-item]:first-child {
border-start-start-radius: var(--md-sys-shape-corner-lg);
border-start-end-radius: var(--md-sys-shape-corner-lg);
}
[data-list='segmented'] > [data-list-item]:last-child {
border-end-start-radius: var(--md-sys-shape-corner-lg);
border-end-end-radius: var(--md-sys-shape-corner-lg);
}
@media (hover: hover) {
[data-list='segmented'] > [data-list-item][data-list-row]:hover {
border-radius: var(--md-sys-shape-corner-md);
}
}
[data-list='segmented'] > [data-list-item]:is([data-selected], [data-list-row]:active) {
border-radius: var(--md-sys-shape-corner-lg);
}
+55
View File
@@ -0,0 +1,55 @@
/*
* <x-loading>: M3 Expressive's loading indicator, a shape that morphs through seven Expressive
* shapes while it turns, for a wait with no known length.
*
* The drawing is resources/svg/loading-indicator/, ported from androidx Compose Material 3's
* LoadingIndicator in bin/loading-indicator.mjs (Apache-2.0): LoadingIndicatorTokens' 38px
* indicator in a 48px container, which is the size here unless `size` (`--md-loading-size`, 24 to
* 240px, M3's responsive range) or the caller's CSS sizes it; the SVG scales with its box, so the
* container and the shape keep their ratio at every size. It is
* drawn in the text colour, `primary` (ActiveIndicatorColor) unless the caller colours it;
* `contained` puts it on a `primary-container` circle in `on-primary-container`
* (ContainedContainerColor, ContainedIndicatorColor), for a spinner over content.
*
* The animated drawing is SMIL, so it runs without script. Under `prefers-reduced-motion` the
* still drawing of the same shape takes its place: the view renders both, and this file shows one.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-loading] {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
inline-size: var(--md-loading-size, 48px);
block-size: var(--md-loading-size, 48px);
color: var(--md-sys-color-primary);
& > svg {
inline-size: 100%;
block-size: 100%;
}
& > [data-md-loading-still] {
display: none;
}
@media (prefers-reduced-motion: reduce) {
& > [data-md-loading-animated] {
display: none;
}
& > [data-md-loading-still] {
display: block;
}
}
}
[data-md-loading][data-md-contained] {
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}
}
+49
View File
@@ -0,0 +1,49 @@
/*
* <x-menu-group>: a labelled or gapped cluster of items in an `<x-menu>`
* (menu-group.blade.php).
*
* Without `gap`, a plain 4px of vertical padding around the cluster, 0 at either end of the list
* (the popover's own padding already gives that room). `data-md-gap` draws M3 Expressive's
* "Grouped" layout instead: items 2px apart (`SegmentedMenuTokens.SegmentedGap`, androidx Compose
* Material 3, Apache-2.0), clusters themselves 8px apart the same 8px `<x-menu-separator>`
* keeps above and below its line, so a menu is the same height whichever it uses
* (docs/reference/m3/components-actions-communication-containment.md § Menus Specification).
*
* The label is label-large in on-surface-variant, `aria-hidden` beside the `role="group"`
* `aria-label` a screen reader reads instead (`SegmentedMenuTokens.GroupLabelTextFont`).
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-menu-group]:not([data-md-gap]) {
padding-block: var(--md-sys-measurement-space50);
}
[data-md-menu-group]:not([data-md-gap]):first-child {
padding-block-start: 0;
}
[data-md-menu-group]:not([data-md-gap]):last-child {
padding-block-end: 0;
}
[data-md-menu-group][data-md-gap]:not(:first-child) {
margin-block-start: var(--md-sys-measurement-space100);
}
[data-md-menu-group-label] {
padding-inline: var(--md-sys-measurement-space200);
padding-block: var(--md-sys-measurement-space100) var(--md-sys-measurement-space50);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
}
[data-md-menu-group-items] {
display: flex;
flex-direction: column;
gap: var(--md-sys-measurement-space25);
}
}
+184
View File
@@ -0,0 +1,184 @@
/*
* <x-menu-item>: one row in an `<x-menu>` an action, a link, or a choice and, with `submenu`,
* the trigger and popover of a nested menu beside it.
*
* SegmentedMenuTokens (androidx Compose Material 3, Apache-2.0): 48px row (M3's published "List
* item height" wins over the token's own 44dp `Item`, docs/audits/m3-alignment/actions.md §
* ACT-28), 16px either side (`ItemLeadingSpace`/`ItemTrailingSpace`, § ACT-11), 12px between the
* icon, the label and the trailing content (`ItemBetweenSpace`, off the 8dp grid), 4px corners
* (`ItemShape`) that open to 12px at either end of the list (`ItemFirstChildShape`/
* `ItemLastChildShape` the library's own list ends, 12px, win over `GroupShape`'s 8dp so a
* cluster's ends and a list's ends match, menu-group.css). A selected row
* (`role="menuitemcheckbox"`, the tick beside the colour and shape § ACT-27) takes
* `ItemSelectedShape`'s 12px corner in tertiary-container; `current` a menu of places rather
* than choices takes the same 12px corner in secondary-container, the navigation-indicator
* role. `description` (`ItemSupportingTextFont`) grows the row by 8px top and bottom; `shortcut`
* (`ItemTrailingSupportingTextFont`) sits at the end. Every quiet part of the row the icon, the
* description, the trailing text inks together as `--md-menu-item-ink`; corner and colour are
* two springs, not one (§ ACT-19), and `icon-class` paints only the leading icon, over that ink
* but never over disabled. The row renders the foundation's `md-state-layer` and `md-focus-ring`
* (foundation/interaction.css) no `md-touch-target`, since the row is already 48px tall and
* keeps only one refinement: the ring reads inward (-3px), because a row sits edge to edge in the
* list and an outward one would run past it. A disabled item stays focusable and keeps its ring,
* because M3 keeps it reachable, but the class already withholds the layer from `aria-disabled`.
*
* `submenu` turns the row into the WAI-ARIA menu button for a second `role="menu"` popover beside
* it, anchored to its end and flipping to the start where the window has no room
* (resources/js/menu.js). The popover shares its container colour with the menu it opens from
* through `--material-menu-surface`/`--material-menu-ink` (menu.css defines them on
* `[data-md-menu-popover]`; the fallbacks here are the standard menu's, for a submenu inside some
* other list, e.g. a FAB menu's). Inside an `<x-menu sheet-at-compact>` sheet the same markup
* opens in place instead menu.css restyles `[data-md-submenu]` and `[data-md-submenu-chevron]`
* there.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@import './badge.css';
@layer material.components {
[data-md-menu-item] {
--md-menu-item-ink: var(--md-sys-color-on-surface-variant);
display: flex;
inline-size: 100%;
min-block-size: var(--md-sys-measurement-space600);
align-items: center;
gap: 12px;
padding-inline: var(--md-sys-measurement-space200);
border-radius: var(--md-sys-shape-corner-xs);
background-color: transparent;
color: var(--md-sys-color-on-surface);
text-align: start;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
transition-property: border-radius, background-color, color;
transition-duration:
var(--md-sys-motion-spatial-fast-duration),
var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration);
transition-timing-function:
var(--md-sys-motion-spatial-fast),
var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast);
/* The row sits edge to edge in the list, so the ring reads inward, not past the item. */
&:focus-visible {
outline-offset: -3px;
}
}
[data-md-menu-item]:first-child {
border-start-start-radius: var(--md-sys-shape-corner-md);
border-start-end-radius: var(--md-sys-shape-corner-md);
}
[data-md-menu-item]:last-child {
border-end-start-radius: var(--md-sys-shape-corner-md);
border-end-end-radius: var(--md-sys-shape-corner-md);
}
[data-md-menu-item][data-md-description] {
padding-block: var(--md-sys-measurement-space100);
}
[data-md-menu-item][aria-checked='true'] {
--md-menu-item-ink: var(--md-sys-color-on-tertiary-container);
border-radius: var(--md-sys-shape-corner-md);
background-color: var(--md-sys-color-tertiary-container);
color: var(--md-sys-color-on-tertiary-container);
}
[data-md-menu-item][aria-current='page']:not([aria-checked='true']) {
--md-menu-item-ink: var(--md-sys-color-on-secondary-container);
border-radius: var(--md-sys-shape-corner-md);
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
[data-md-menu-item][aria-disabled='true'] {
--md-menu-item-ink: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
pointer-events: none;
color: var(--md-menu-item-ink);
}
[data-md-menu-item] [data-md-icon] {
color: var(--md-menu-item-ink);
}
[data-md-menu-item][aria-disabled='true'] [data-md-icon] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent) !important;
}
[data-md-menu-item-text] {
min-inline-size: 0;
flex: 1;
}
[data-md-menu-item-label] {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
font-variation-settings: normal;
}
[data-md-menu-item-description] {
display: block;
color: var(--md-menu-item-ink);
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
font-variation-settings: normal;
}
[data-md-menu-item-shortcut] {
flex-shrink: 0;
color: var(--md-menu-item-ink);
font: var(--md-sys-typescale-label-sm);
letter-spacing: var(--md-sys-typescale-label-sm-tracking);
font-variation-settings: normal;
}
/* The submenu popover: a menu of its own, sharing the container colour of the list it opens
from. The fallbacks are the standard menu's, for a submenu inside some other list. */
[data-md-submenu] {
inset: auto;
margin: 0;
margin-inline: var(--md-sys-measurement-space50);
min-inline-size: 112px;
max-inline-size: 280px;
max-block-size: min(288px, calc(100dvh - 32px));
overflow-y: auto;
border-width: 0;
border-radius: var(--md-sys-shape-corner-lg);
padding: var(--md-sys-measurement-space50);
background-color: var(--material-menu-surface, var(--md-sys-color-surface-container-low));
color: var(--material-menu-ink, var(--md-sys-color-on-surface));
box-shadow: var(--md-sys-elevation-2);
transform-origin: top;
position-area: inline-end span-block-end;
position-try-fallbacks: flip-inline;
opacity: 0;
scale: 0.95;
transition-property: opacity, scale;
transition-duration: var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast), var(--md-sys-motion-spatial-fast);
&:popover-open:not([data-md-popover-closing]) {
opacity: 1;
scale: 1;
}
@starting-style {
&:popover-open {
opacity: 0;
scale: 0.95;
}
}
}
}
@@ -0,0 +1,18 @@
/*
* <x-menu-separator>: a line between groups of items in a menu 1px in `outline-variant`, with
* M3's 8px above and below it, inset to the 16px the items keep either side (M3's menu page:
* "dividers are more subtle and are the right choice for scrollable menus"; SegmentedMenuTokens'
* divider spacing).
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-menu-separator] {
block-size: 1px;
margin-block: var(--md-sys-measurement-space100);
margin-inline: var(--md-sys-measurement-space200);
border-width: 0;
background-color: var(--md-sys-color-outline-variant);
}
}
+381 -143
View File
@@ -1,189 +1,427 @@
/*
* The exposed dropdown menu the list a field drops open.
* The menus: `<x-menu>` and what a form's own dropdowns wear too, so a menu and a form read as
* one family.
*
* Two lists wear it, so a form reads as one family:
* Three lists wear it:
*
* - `<x-menu>`'s own popover (`[data-md-menu-popover]`) and, inside it, a submenu
* (`[data-md-submenu]`, menu-item.css).
* - `<x-select>`'s: the native <select>, opted into the browser's customizable select
* (`appearance: base-select`). `::picker(select)` is the menu and each <option> a row, while the
* keyboard, type-to-find, the screen reader and the form value stay the browser's own.
* - `<x-choices searchable>`'s listbox, which Alpine draws, as `.field-menu` and `.field-option`.
* (`appearance: base-select`). `::picker(select)` is the menu and each <option> a row, while
* the keyboard, type-to-find, the screen reader and the form value stay the browser's own.
* - `<x-choices searchable>`'s listbox, which Alpine draws, as `[data-md-field-menu]` and
* `[data-md-field-option]`.
*
* M3's menu as its tokens have it: surface-container at elevation 2, extra-small corner, 48px rows in
* body-large, the chosen row in secondary-container with a tick at its end (so it is not told by colour
* alone), and the state layer on hover (only where a pointer can hover) and focus.
* M3's menu as its tokens have it (StandardMenuTokens/VibrantMenuTokens/SegmentedMenuTokens,
* androidx Compose Material 3, Apache-2.0): surface-container-low at elevation 2, large corner,
* 48px rows in body-large, the chosen row in secondary-container with a tick at its end (so it is
* not told by colour alone), and the state layer on hover (only where a pointer can hover) and
* focus.
*
* Unlayered, as the rest of the package's component CSS, so a utility on the call site cannot half-undo
* a row.
* `[data-md-menu]` is `<x-menu>`'s root, holding the trigger and the popover; the popover itself
* is `[data-md-menu-popover]`, capped at 288px so a long menu scrolls instead of running off the
* top layer's edge (ACT-04), and transitions in by growing from its trigger's corner while it
* fades, on the spatial and effects springs respectively (ACT-26). `data-md-vibrant` swaps its
* container for tertiary-container; a nested submenu inherits the colour through
* `--material-menu-surface`/`--material-menu-ink`, which the sheet's own copy of the list falls
* back to instead (it is not a descendant of the popover).
*
* `sheet-at-compact`'s bottom sheet is `<x-bottom-sheet>` (bottom-sheet.css): `[data-md-menu-sheet]`
* cancels its 24px padding and restyles a submenu to open in place under its item instead of
* beside it (M3 calls submenus "best suited to large screens").
*
* Where the browser has no customizable select, the select keeps its native list, which
* `color-scheme` themes. Every rule for the select's own rows is inside `@supports`: a browser that
* paints option colours into its native list would otherwise show a half-painted one.
* `color-scheme` themes. Every rule for the select's own rows is inside `@supports`: a browser
* that paints option colours into its native list would otherwise show a half-painted one.
*/
.field-menu {
max-block-size: 18rem;
overflow-y: auto;
padding-block: 0.5rem;
border-radius: var(--md-sys-shape-corner-xs);
background: var(--md-sys-color-surface-container);
box-shadow: var(--md-sys-elevation-2);
}
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
/* As a popover (`<x-choices searchable>`), it hangs under its field, as wide, and goes above only
when there is no room below. */
.field-menu[popover] {
inset: auto;
top: anchor(bottom);
left: anchor(left);
width: anchor-size(width);
margin: 0.25rem 0;
border: 0;
padding-inline: 0;
color: var(--md-sys-color-on-surface);
position-try-fallbacks: flip-block;
}
@import './icon.css';
@import './bottom-sheet.css';
.field-option[aria-disabled="true"] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
cursor: default;
}
.field-option {
display: flex;
align-items: center;
gap: 0.75rem;
min-block-size: 3rem;
padding-inline: 1rem;
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
cursor: pointer;
}
/* `data-active` is the row the arrow keys or the pointer are on. */
.field-option[data-active] {
background: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
}
.field-option[aria-selected="true"] {
background: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
.field-option[aria-selected="true"][data-active] {
background: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 8%, var(--md-sys-color-secondary-container));
}
.field-check {
margin-inline-start: auto;
}
.field-option:not([aria-selected="true"]) .field-check {
visibility: hidden;
}
@supports (appearance: base-select) {
select.field-control,
select.field-control::picker(select) {
appearance: base-select;
@layer material.components {
[data-md-menu] {
position: relative;
display: inline-flex;
}
/* The menu. The browser anchors it to the select; the select covers its whole
field (field.css), so the menu is at least the field's width. It hangs under
the field and goes above only when it does not fit there the browser's own
order tries the roomier side first, which opened a menu upwards with room to
spare under it. It opens with a fade and a grow on the spatial spring and
closes with a fade, as `<x-menu>` does. */
select.field-control::picker(select) {
position-try-order: normal;
max-block-size: 18rem;
max-inline-size: calc(100vw - 2rem);
margin-block: 0.25rem;
padding-block: 0.5rem;
border: 0;
border-radius: var(--md-sys-shape-corner-xs);
background: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface);
[data-md-menu-trigger] {
display: inline-flex;
}
[data-md-menu-popover] {
--material-menu-surface: var(--md-sys-color-surface-container-low);
--material-menu-ink: var(--md-sys-color-on-surface);
inset: auto;
margin: 0;
margin-block: var(--md-sys-measurement-space50);
min-inline-size: 112px;
max-inline-size: 280px;
max-block-size: min(288px, calc(100dvh - 32px));
overflow-y: auto;
border-width: 0;
border-radius: var(--md-sys-shape-corner-lg);
padding: var(--md-sys-measurement-space50);
background-color: var(--material-menu-surface);
color: var(--material-menu-ink);
box-shadow: var(--md-sys-elevation-2);
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
opacity: 0;
transform-origin: top;
transition-property: opacity, display, overlay;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
transition-behavior: allow-discrete;
}
scale: 0.95;
transition-property: opacity, scale;
transition-duration: var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast), var(--md-sys-motion-spatial-fast);
select.field-control:open::picker(select) {
opacity: 1;
transition-property: opacity, scale, display, overlay;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
&:popover-open:not([data-md-popover-closing]) {
opacity: 1;
scale: 1;
}
@starting-style {
select.field-control:open::picker(select) {
opacity: 0;
scale: 0.95;
@starting-style {
&:popover-open {
opacity: 0;
scale: 0.95;
}
}
}
select.field-control option {
[data-md-menu-popover]:has(> [data-md-menu-filter]) {
padding: 0;
}
[data-md-menu-popover][data-md-vibrant] {
--material-menu-surface: var(--md-sys-color-tertiary-container);
--material-menu-ink: var(--md-sys-color-on-tertiary-container);
}
[data-md-menu-popover][data-md-position='bottom-start'] {
transform-origin: top;
position-area: bottom span-right;
}
[data-md-menu-popover][data-md-position='bottom-end'] {
transform-origin: top;
position-area: bottom span-left;
}
[data-md-menu-popover][data-md-position='top-start'] {
transform-origin: bottom;
position-area: top span-right;
}
[data-md-menu-popover][data-md-position='top-end'] {
transform-origin: bottom;
position-area: top span-left;
}
/*
* `<x-menu filter>`: M3's menu as a filtering surface. The field stays put while the list
* scrolls under it and takes the menu's own container, so a vibrant menu's field is vibrant
* too. It is a plain <input> with no field chrome M3's menus embed a text field, not a text
* field component.
*/
[data-md-menu-filter] {
position: sticky;
inset-block-start: 0;
z-index: 1;
display: flex;
align-items: center;
gap: 0.75rem;
min-block-size: 3rem;
padding-block: 0;
padding-inline: 1rem;
background: transparent;
color: var(--md-sys-color-on-surface);
gap: 12px;
min-block-size: var(--md-sys-measurement-space600);
padding-inline: var(--md-sys-measurement-space200);
border-block-end: 1px solid var(--md-sys-color-outline-variant);
background-color: var(--material-menu-surface, var(--md-sys-color-surface-container-low));
}
[data-md-menu-filter] input {
flex: 1;
min-inline-size: 0;
border-width: 0;
background-color: transparent;
padding: 0;
color: inherit;
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
font-variation-settings: normal;
outline: none;
cursor: pointer;
}
@media (hover: hover) {
select.field-control option:hover {
background: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
[data-md-menu-filter] input::placeholder {
color: color-mix(in srgb, var(--material-menu-ink, var(--md-sys-color-on-surface)) 70%, transparent);
}
[data-md-menu-list] {
padding: var(--md-sys-measurement-space50);
}
[data-md-menu-empty] {
padding-inline: var(--md-sys-measurement-space200);
padding-block: 12px;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
font-variation-settings: normal;
}
/* The filter hides what the query leaves out with the `hidden` attribute; a row's own
`display: flex` would otherwise beat the user agent's `[hidden] { display: none }`. */
:is([data-md-menu-popover], [data-md-menu-sheet]) [hidden] {
display: none;
}
/* `data-md-active` is the row the arrow keys are on while the focus stays in the field the
state layer the roving focus would have drawn, without taking the focus off the field. A
chosen or current row keeps its own colour. */
:is([data-md-menu-popover], [data-md-menu-sheet]) [role^="menuitem"][data-md-active]:not([aria-checked="true"], [aria-current="page"]) {
background-color: color-mix(in srgb, var(--material-menu-ink, var(--md-sys-color-on-surface)) 8%, transparent);
}
/*
* `<x-menu sheet-at-compact>`: the copy of the list in the bottom sheet a compact window opens
* instead of the popover. The sheet is surface-container-low, as the standard menu is, so the
* fallbacks above already paint its field; the field reaches both edges of the sheet and holds
* its icon where the rows hold theirs, 24px in (the list is 8px in, a row's own padding 16px).
* `-24px` cancels the bottom sheet's own padding.
*/
[data-md-menu-sheet] {
margin-inline: calc(-1 * var(--md-sys-measurement-space300));
padding-inline: var(--md-sys-measurement-space100);
}
[data-md-menu-sheet]:has(> [data-md-menu-filter]) {
padding-inline: 0;
}
[data-md-menu-sheet] [data-md-menu-filter] {
padding-inline: var(--md-sys-measurement-space300);
}
[data-md-menu-sheet] [data-md-menu-list] {
padding-inline: var(--md-sys-measurement-space100);
padding-block: var(--md-sys-measurement-space50) 0;
}
/*
* A submenu there opens in place: its list stands under the item, inset by 16px, instead of in
* a popover beside it, which a sheet has no room for (M3 calls submenus "best suited to large
* screens", docs/reference/m3/components-actions-communication-containment.md § Menus). The
* markup is the popover's, never shown as one: the user agent's `[popover]` box is undone, the
* list is shown while its item says `aria-expanded="true"`, and it fades in on the effects
* track.
*/
[data-md-menu-sheet] [data-md-submenu] {
position: static;
inset: auto;
inline-size: auto;
max-inline-size: none;
block-size: auto;
max-block-size: none;
margin: 0;
padding-block: var(--md-sys-measurement-space25);
padding-inline: var(--md-sys-measurement-space200) 0;
overflow: visible;
border-radius: 0;
background-color: transparent;
color: inherit;
box-shadow: none;
opacity: 1;
scale: none;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-md-menu-sheet] [aria-expanded="true"] + [data-md-submenu] {
display: block;
@starting-style {
opacity: 0;
}
}
select.field-control option:focus-visible {
background: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
/* The chevron turns from the way a popover would open to the way this list did: down, which
is a quarter turn the other way on the chevron `mirror-rtl` mirrors too. */
[data-md-menu-sheet] [data-md-submenu-chevron] {
transition: rotate var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast);
}
select.field-control option:checked {
background: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
[data-md-menu-sheet] [aria-expanded="true"] > [data-md-submenu-chevron] {
rotate: 90deg;
@media (hover: hover) {
select.field-control option:checked:hover {
background: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 8%, var(--md-sys-color-secondary-container));
&:is([dir='rtl'], [dir='rtl'] *) {
rotate: -90deg;
}
}
select.field-control option:checked:focus-visible {
background: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 10%, var(--md-sys-color-secondary-container));
[data-md-field-menu] {
max-block-size: 18rem;
overflow-y: auto;
padding-block: var(--md-sys-measurement-space100);
border-radius: var(--md-sys-shape-corner-xs);
background-color: var(--md-sys-color-surface-container);
box-shadow: var(--md-sys-elevation-2);
}
select.field-control option:disabled {
background: transparent;
/* As a popover (`<x-choices searchable>`), it hangs under its field, as wide, and goes above
only when there is no room below. */
[data-md-field-menu][popover] {
inset: auto;
top: anchor(bottom);
left: anchor(left);
width: anchor-size(width);
margin: var(--md-sys-measurement-space50) 0;
border-width: 0;
padding-inline: 0;
color: var(--md-sys-color-on-surface);
position-try-fallbacks: flip-block;
}
[data-md-field-option][aria-disabled="true"] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
cursor: default;
}
/* The tick, at the row's end: the browser draws it before the label, and hides
it on every row but the chosen one. The Material Symbol, as a mask, so it
takes the row's ink. */
select.field-control option::checkmark {
content: "";
order: 1;
flex: none;
inline-size: 1.5rem;
block-size: 1.5rem;
[data-md-field-option] {
display: flex;
align-items: center;
gap: 12px;
min-block-size: var(--md-sys-measurement-space600);
padding-inline: var(--md-sys-measurement-space200);
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
font-variation-settings: normal;
cursor: pointer;
}
/* `data-md-active` is the row the arrow keys or the pointer are on. */
[data-md-field-option][data-md-active] {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
}
[data-md-field-option][aria-selected="true"] {
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
[data-md-field-option][aria-selected="true"][data-md-active] {
background-color: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 8%, var(--md-sys-color-secondary-container));
}
[data-md-field-check] {
margin-inline-start: auto;
background-color: currentColor;
mask: url("../../svg/symbols/outlined/check.svg") center / contain no-repeat;
}
[data-md-field-option]:not([aria-selected="true"]) [data-md-field-check] {
visibility: hidden;
}
@supports (appearance: base-select) {
/* The select itself opts in from select.css, after its own `appearance: none`: both sit in
`material.components` with the same specificity, and select.css's block always comes
after this file, which it imports. */
select[data-md-field-control]::picker(select) {
appearance: base-select;
}
/* The menu. The browser anchors it to the select; the select covers its whole
field (field.css), so the menu is at least the field's width. It hangs under
the field and goes above only when it does not fit there the browser's own
order tries the roomier side first, which opened a menu upwards with room to
spare under it. It opens with a fade and a grow on the spatial spring and
closes with a fade, as `<x-menu>` does. */
select[data-md-field-control]::picker(select) {
position-try-order: normal;
max-block-size: 18rem;
max-inline-size: calc(100vw - 32px);
margin-block: var(--md-sys-measurement-space50);
padding-block: var(--md-sys-measurement-space100);
border-width: 0;
border-radius: var(--md-sys-shape-corner-xs);
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface);
box-shadow: var(--md-sys-elevation-2);
opacity: 0;
transform-origin: top;
transition-property: opacity, display, overlay;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
transition-behavior: allow-discrete;
}
select[data-md-field-control]:open::picker(select) {
opacity: 1;
transition-property: opacity, scale, display, overlay;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
@starting-style {
select[data-md-field-control]:open::picker(select) {
opacity: 0;
scale: 0.95;
}
}
select[data-md-field-control] option {
display: flex;
align-items: center;
gap: 12px;
min-block-size: var(--md-sys-measurement-space600);
padding-block: 0;
padding-inline: var(--md-sys-measurement-space200);
background-color: transparent;
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
font-variation-settings: normal;
outline: none;
cursor: pointer;
}
@media (hover: hover) {
select[data-md-field-control] option:hover {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
}
}
select[data-md-field-control] option:focus-visible {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
}
select[data-md-field-control] option:checked {
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
@media (hover: hover) {
select[data-md-field-control] option:checked:hover {
background-color: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 8%, var(--md-sys-color-secondary-container));
}
}
select[data-md-field-control] option:checked:focus-visible {
background-color: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 10%, var(--md-sys-color-secondary-container));
}
select[data-md-field-control] option:disabled {
background-color: transparent;
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
cursor: default;
}
/* The tick, at the row's end: the browser draws it before the label, and hides
it on every row but the chosen one. The Material Symbol, as a mask, so it
takes the row's ink. */
select[data-md-field-control] option::checkmark {
content: "";
order: 1;
flex: none;
inline-size: var(--md-sys-measurement-space300);
block-size: var(--md-sys-measurement-space300);
margin-inline-start: auto;
background-color: currentColor;
mask: url("../../svg/symbols/outlined/check.svg") center / contain no-repeat;
}
}
}
+277
View File
@@ -0,0 +1,277 @@
/*
* `<x-modal>`: M3's basic and full-screen dialogs (DialogTokens.kt, FullScreenDialogTokens.kt,
* androidx Compose Material 3, Apache-2.0; docs/reference/m3/
* components-actions-communication-containment.md § Dialogs Specs).
*
* [data-md-modal] the <dialog>; data-md-fullscreen while `fullscreen` holds
* [data-md-modal-box] surface-container-high, extra-large corner, elevation 3
* [data-md-modal-bar] the 56px phone-only header bar (fullscreen, below 600px)
* [data-md-modal-head] title/subtitle/icon, pinned
* [data-md-modal-body] the only part that scrolls; [data-md-modal-content] wraps the slot
* [data-md-modal-actions] pinned, trailing-aligned
*
* 560/280px width (max/min), 48px scrim margin, 28px corner and 24dp padding all match the specs
* page; a compact window's full-screen dialog swaps the box for the whole screen with a 56px bar
* (C-17: this used to be 64px) instead of the headline block. It opens on the fast spatial spring
* and closes at once a native `<dialog>` makes an exit transition awkward, since
* `@starting-style` only ever supplies an entry.
*
* The dividers (§ Dialogs Anatomy, C-06): a 1px outline-variant rule under the pinned head and
* over the pinned actions, each only while the body has more content hidden on its side.
* resources/js/dialog.js marks the `<dialog>` with data-md-overflow-top/-bottom as the body
* scrolls; `data-md-modal-divider` says an element takes part at all the head and the actions
* always do while a body exists (structural: whichever follows or precedes it), the phone bar
* only when it is the row actually sitting above the body there (computed in the view, because a
* subtitle or an icon can keep the head on screen too, in which case the bar must not draw a rule
* the head already owns); `data-md-separator` (the `separator` prop) draws the rule whatever the
* scroll. The rule is a pseudo-element over the row's own edge, so showing it moves nothing.
*
* Every rule that reads the full-screen flag or the head's icon and subtitle goes through
* `> [data-md-modal-box] >`, as the divider marks do: a basic dialog opened from inside a
* full-screen dialog's body is a descendant of it, and must keep its own title and padding.
*
* `[data-md-modal-body]:focus-visible` is the scrolling body catching the dialog's first focus when
* nothing inside can take it natively in Chrome, whose scroll containers are focusable, and
* through `materialShowModal()` (resources/js/dialog.js) in Firefox and WebKit M3's 3px secondary
* indicator, drawn inside the edge because the box's rounded, overflow-hidden corner would clip one
* drawn outside.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@import './button.css';
@layer material.components {
[data-md-modal] {
margin: auto;
max-width: 560px;
min-width: 280px;
width: calc(100vw - 48px);
max-height: calc(100dvh - 48px);
overflow: visible;
padding: 0;
background-color: transparent;
color: var(--md-sys-color-on-surface);
opacity: 1;
scale: 1;
transition-property: opacity, scale;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
@starting-style {
[data-md-modal] {
opacity: 0;
scale: 0.95;
}
}
[data-md-modal]::backdrop {
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
}
@media (width < 600px) {
[data-md-modal][data-md-fullscreen] {
margin: 0;
width: 100%;
max-width: none;
min-width: 0;
height: 100dvh;
max-height: none;
}
}
[data-md-modal-box] {
display: flex;
max-height: inherit;
flex-direction: column;
overflow: hidden;
border-radius: var(--md-sys-shape-corner-xl);
background-color: var(--md-sys-color-surface-container-high);
box-shadow: var(--md-sys-elevation-3);
}
@media (width < 600px) {
[data-md-modal][data-md-fullscreen] > [data-md-modal-box] {
height: 100%;
border-radius: var(--md-sys-shape-corner-none);
padding-top: var(--material-safe-top, env(safe-area-inset-top));
}
}
/* The phone-only header bar: M3's full-screen dialog header, 56dp (C-17). */
[data-md-modal-bar] {
display: flex;
height: var(--md-sys-measurement-space700);
flex-shrink: 0;
align-items: center;
gap: var(--md-sys-measurement-space50);
padding-inline: var(--md-sys-measurement-space50);
}
@media (width >= 600px) {
[data-md-modal-bar] {
display: none;
}
}
[data-md-modal-bar-title] {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font: var(--md-sys-typescale-title-lg);
letter-spacing: var(--md-sys-typescale-title-lg-tracking);
}
[data-md-modal-head] {
flex-shrink: 0;
padding-inline: var(--md-sys-measurement-space300);
padding-top: var(--md-sys-measurement-space300);
}
[data-md-modal-head]:has(+ [data-md-modal-body]) {
padding-bottom: var(--md-sys-measurement-space100);
}
[data-md-modal-head]:has(> [data-md-icon]) {
text-align: center;
}
/* On a phone the bar already names a plain dialog; the head hides unless an icon or a
subtitle keeps it there (the bar cannot hold either). */
@media (width < 600px) {
[data-md-modal][data-md-fullscreen] > [data-md-modal-box] > [data-md-modal-head]:not(:has(> [data-md-icon], > [data-md-modal-subtitle])) {
display: none;
}
}
[data-md-modal-head] > [data-md-icon] {
margin-inline: auto;
margin-bottom: var(--md-sys-measurement-space200);
color: var(--md-sys-color-secondary);
}
[data-md-modal-title] {
font: var(--md-sys-typescale-headline-sm);
letter-spacing: var(--md-sys-typescale-headline-sm-tracking);
}
@media (width < 600px) {
[data-md-modal][data-md-fullscreen] > [data-md-modal-box] > [data-md-modal-head]:not(:has(> [data-md-icon])) > [data-md-modal-title] {
display: none;
}
}
[data-md-modal-subtitle] {
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
}
[data-md-modal-title] ~ [data-md-modal-subtitle],
[data-md-modal-head] > [data-md-icon] ~ [data-md-modal-subtitle] {
margin-top: var(--md-sys-measurement-space200);
}
@media (width < 600px) {
[data-md-modal][data-md-fullscreen] > [data-md-modal-box] > [data-md-modal-head]:not(:has(> [data-md-icon])) > [data-md-modal-subtitle] {
margin-top: 0;
}
}
[data-md-modal-body] {
min-height: 0;
flex: 1 1 0%;
overflow-y: auto;
padding-inline: var(--md-sys-measurement-space300);
padding-top: var(--md-sys-measurement-space300);
padding-bottom: var(--md-sys-measurement-space300);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
outline: none;
}
[data-md-modal-body]:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: -3px;
}
[data-md-modal-head] ~ [data-md-modal-body] {
padding-top: var(--md-sys-measurement-space100);
}
@media (width < 600px) {
[data-md-modal][data-md-fullscreen] > [data-md-modal-box] > [data-md-modal-head]:not(:has(> [data-md-icon], > [data-md-modal-subtitle])) ~ [data-md-modal-body] {
padding-top: var(--md-sys-measurement-space200);
}
}
[data-md-modal-body]:has(~ [data-md-modal-actions]) {
padding-bottom: var(--md-sys-measurement-space100);
}
[data-md-modal-actions] {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--md-sys-measurement-space100);
padding-inline: var(--md-sys-measurement-space300);
padding-top: var(--md-sys-measurement-space300);
padding-bottom: var(--md-sys-measurement-space300);
}
[data-md-modal-body] ~ [data-md-modal-actions] {
padding-top: var(--md-sys-measurement-space200);
}
@media (width < 600px) {
[data-md-modal][data-md-fullscreen] > [data-md-modal-box] > [data-md-modal-actions] {
min-height: var(--md-sys-measurement-space700);
padding-top: var(--md-sys-measurement-space100);
padding-bottom: var(--md-sys-measurement-space100);
}
}
/* The pinned-edge dividers: a pseudo-element over the row's own padding, drawn only while
something is scrolled past it (or `separator` says always). */
[data-md-modal-head],
[data-md-modal-bar],
[data-md-modal-actions] {
position: relative;
}
[data-md-modal-head]::after,
[data-md-modal-bar]::after,
[data-md-modal-actions]::before {
content: '';
position: absolute;
inset-inline: 0;
height: 1px;
background-color: var(--md-sys-color-outline-variant);
opacity: 0;
pointer-events: none;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-md-modal-head]::after,
[data-md-modal-bar]::after {
bottom: 0;
}
[data-md-modal-actions]::before {
top: 0;
}
dialog[data-md-overflow-top] > [data-md-modal-box] > [data-md-modal-head][data-md-modal-divider]::after,
dialog[data-md-overflow-top] > [data-md-modal-box] > [data-md-modal-bar][data-md-modal-divider]::after,
dialog[data-md-overflow-bottom] > [data-md-modal-box] > [data-md-modal-actions][data-md-modal-divider]::before,
[data-md-modal-head][data-md-modal-divider][data-md-separator]::after,
[data-md-modal-bar][data-md-modal-divider][data-md-separator]::after,
[data-md-modal-actions][data-md-modal-divider][data-md-separator]::before {
opacity: 1;
}
}
@@ -0,0 +1,151 @@
/*
* `<x-navigation-bar-item>`: one destination in an `<x-navigation-bar>`
* (NavigationBarVerticalItemTokens.kt, NavigationBarHorizontalItemTokens.kt, androidx Compose
* Material 3, Apache-2.0).
*
* [data-md-navigation-bar-item] data-md-active; the link or button itself
* [data-md-navigation-pill] icon and label; becomes the indicator from 600px
* [data-md-navigation-indicator] the 56×32 indicator around the icon below 600px
* [data-md-navigation-icon] the floating badge's anchor (navigation-item.css)
* [data-md-navigation-label]
*
* Below 600px (the bar's own width a container query, so a bar in a narrow column keeps this
* layout) the icon sits in a 56×32 `CornerFull` indicator over a label-medium label, both equally
* wide. From 600px icon and label share a 40px horizontal indicator with 16px leading and
* trailing space; the label stays label-medium `NavigationBarTokens.LabelTextFont` is the bar's
* only label token, and Compose's `ShortNavigationBarItem` passes it for both icon positions (the
* *rail's* horizontal item is label-large, a different component's token; N-08). An active item is
* secondary-container behind on-secondary-container (icon and, from 600px, label); its indicator's
* fill is a background image so it can grow from its centre (navigation-item.css) rather than
* stretch the icon.
*
* The indicator's growing fill and its state layer are shared with the rail's item
* (navigation-item.css); only this file's focus ring and the display swap between the two
* indicator shapes are the bar's own.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@import './badge.css';
@import './navigation-item.css';
@layer material.components {
[data-md-navigation-bar-item] {
--navigation-layer: 0;
position: relative;
display: flex;
flex: 1 1 0%;
min-width: 0;
align-items: center;
justify-content: center;
padding-block: 6px;
color: var(--md-sys-color-on-surface-variant);
text-decoration: none;
cursor: pointer;
outline: none;
-webkit-tap-highlight-color: transparent;
}
[data-md-navigation-bar-item][data-md-active] {
color: var(--md-sys-color-secondary);
}
[data-md-navigation-bar-item] [data-md-navigation-pill] {
display: flex;
min-width: 0;
max-width: 100%;
flex-direction: column;
align-items: center;
gap: 4px;
border-radius: var(--md-sys-shape-corner-full);
font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking);
}
[data-md-navigation-bar-item] [data-md-navigation-label] {
max-width: 100%;
padding-inline: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-md-navigation-bar-item] [data-md-navigation-indicator] {
position: relative;
isolation: isolate;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 56px;
height: 32px;
border-radius: var(--md-sys-shape-corner-full);
}
/* Vertical layout (below 600px, or a tall bar at any width): the indicator fills behind the
icon; the pill draws no layer of its own. */
[data-md-navigation-bar-item][data-md-active] [data-md-navigation-indicator] {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
color: var(--md-sys-color-on-secondary-container);
}
[data-md-navigation-bar-item] [data-md-navigation-pill]::before {
display: none;
}
[data-md-navigation-bar-item]:focus-visible [data-md-navigation-indicator] {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
/* Horizontal layout, from 600px: the pill itself fills and takes the layer; the indicator
stops drawing either. Only the short bar (a tall bar keeps the vertical layout). */
@container (width >= 600px) {
[data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-item] {
min-width: max-content;
}
[data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-item] [data-md-navigation-pill] {
position: relative;
isolation: isolate;
flex-direction: row;
height: 40px;
padding-inline: 16px;
}
[data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-item] [data-md-navigation-label] {
padding-inline: 0;
}
[data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-item][data-md-active] {
color: var(--md-sys-color-on-secondary-container);
}
[data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-item][data-md-active] [data-md-navigation-pill] {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
}
[data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-item][data-md-active] [data-md-navigation-indicator] {
background-image: none;
}
[data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-item] [data-md-navigation-pill]::before {
display: block;
}
[data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-item] [data-md-navigation-indicator]::before {
display: none;
}
[data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-item]:focus-visible [data-md-navigation-indicator] {
outline: none;
}
[data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-item]:focus-visible [data-md-navigation-pill] {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
}
}
@@ -0,0 +1,93 @@
/*
* `<x-navigation-bar>`: M3 Expressive's flexible navigation bar, three to five destinations
* pinned to the bottom of a compact or medium window (NavigationBarTokens.kt, androidx Compose
* Material 3, Apache-2.0, and the layout of ShortNavigationBar.kt;
* docs/reference/m3/components-navigation-selection-inputs.md § Navigation Bar).
*
* [data-md-navigation-bar] 64px in surface-container; data-md-tall (80px),
* data-md-hide-on-scroll and data-md-hidden
* [data-md-navigation-bar-items] equal widths below 600px; centred with Compose's
* calculateCenteredContentHorizontalPadding from it
* [data-md-navigation-bar-item] navigation-bar-item.css
*
* `data-md-tall` is `NavigationBarTokens.TallContainerHeight`, 80px against the short bar's 64
* (`ContainerHeight`) Missing until this rewrite. It keeps the vertical, icon-over-label item
* layout at every width, so every rule below that only the short bar's horizontal layout needs
* says `:not([data-md-tall])` first.
*
* `data-md-hide-on-scroll` is M3's scrolling behaviour, also Missing until now: "hides on
* scroll-down, reappears on scroll-up never hide it while a screen reader is active"
* (§ Navigation Bar/Behaviour). resources/js/navigation.js drives `data-md-hidden` and never hides
* the bar while a snackbar, a bottom sheet or a drawer rests on its edge those are anchored to
* its bottom edge and would slide with it and focus reaching the bar brings it back, the web's
* nearest equivalent of "a screen reader is active". It slides on the default spatial spring,
* which reduced motion zeroes along with every other duration token.
*
* The item's own indicator, pill and state layer (shared with the rail item) are
* navigation-bar-item.css and navigation-item.css.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-navigation-bar] {
container-type: inline-size;
padding-inline: var(--material-safe-left, env(safe-area-inset-left)) var(--material-safe-right, env(safe-area-inset-right));
padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface-variant);
}
[data-md-navigation-bar-items] {
display: flex;
min-height: 64px;
margin-inline: auto;
}
[data-md-navigation-bar][data-md-tall] [data-md-navigation-bar-items] {
min-height: 80px;
}
[data-md-navigation-bar][data-md-hide-on-scroll] {
transition: translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default);
}
[data-md-navigation-bar][data-md-hide-on-scroll][data-md-hidden] {
translate: 0 100%;
}
/* From 600px (M3's medium window, the bar's own width): icon and label side by side in a
40px pill, the items gathered in the middle with Centered arrangement's own padding. The
short bar only a tall bar is the vertical layout everywhere. */
@container (width >= 600px) {
[data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-items] {
width: calc(10% * (var(--navigation-bar-count, 7) + 3));
min-width: fit-content;
max-width: 100%;
}
[data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-items]:has(> :last-child:nth-child(1)) { --navigation-bar-count: 1; }
[data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-items]:has(> :last-child:nth-child(2)) { --navigation-bar-count: 2; }
[data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-items]:has(> :last-child:nth-child(3)) { --navigation-bar-count: 3; }
[data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-items]:has(> :last-child:nth-child(4)) { --navigation-bar-count: 4; }
[data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-items]:has(> :last-child:nth-child(5)) { --navigation-bar-count: 5; }
[data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-items]:has(> :last-child:nth-child(6)) { --navigation-bar-count: 6; }
}
/*
* A bar that has slid off the bottom of the window is no longer there to clear, so the offset
* everything pinned to the bottom reads drops to the bottom safe area and whatever the
* application has docked on the bar a `fab` button, the snackbar and the page's own bottom
* padding all follow it down and come back up with it. Layered now, unlike before this
* rewrite: <x-scaffold> used to publish --material-bottom-bar with a Tailwind utility, which
* no rule in any layer could outrank regardless of specificity; now scaffold.css publishes it
* itself in `material.layout` (layout/scaffold.css), a layer this file's own
* `material.components` always outranks by declaration order alone the same reason
* toolbar.css's and fab.css's overrides only have to beat another `material.components` rule,
* not chase a Tailwind utility out of the cascade. Keyed on `data-md-scaffold`, the hook the
* scaffold renders on its root, not `data-app-shell`, which that rewrite also retired.
*/
[data-md-scaffold]:has([data-md-navigation-bar][data-md-hide-on-scroll][data-md-hidden]) {
--material-bottom-bar: calc(var(--material-safe-bottom, env(safe-area-inset-bottom)) + var(--material-bottom-extra, 0px));
}
}
@@ -0,0 +1,70 @@
/*
* Shared between `<x-navigation-bar-item>` and `<x-navigation-rail-item>` (imported by both
* navigation-bar-item.css and navigation-rail-item.css): the destination indicator's growing fill
* and its state layer, on androidx's one stated colour for a navigation item's layer.
*
* `NavigationRailColorTokens.kt` gives one colour for all six states, active or not:
* `ItemActiveHoveredStateLayer = = ItemInactivePressedStateLayer = OnSecondaryContainer`.
* `NavigationBarTokens.kt` states no state-layer tokens of its own, so the bar's item takes the
* rail's (N-19). Hover only where a pointer can hover, so a touch screen does not keep the last
* hover after a tap; state.css's own opacities (8% hover, 10% focus and press) rather than the
* package's `md-state-layer` class, because the element focused and pressed is the whole item
* while the layer is drawn only on the smaller indicator or pill inside it `md-state-layer`
* cannot draw on a different element than the one it decorates, and `:focus-visible` never
* matches a child (the same reason datepicker.css keeps the day's own layer).
*
* The active indicator itself is a secondary-container fill painted as a background image so it
* can grow out of its centre without stretching the icon: when a page arrives through
* wire:navigate, resources/js/navigation.js starts it at zero width for a moment and it springs
* open on the default spatial spring, as Compose's indicator does when the selection changes
* (NavigationItem.kt). A full page load draws it at once; reduced motion zeroes the transition.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-navigation-bar-item] :is([data-md-navigation-indicator], [data-md-navigation-pill]),
[data-md-navigation-rail-item],
[data-md-navigation-rail-item] [data-md-navigation-indicator] {
background-position: center;
background-repeat: no-repeat;
background-size: 100% 100%;
transition: background-size var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default);
}
@media (hover: hover) {
:is([data-md-navigation-bar-item], [data-md-navigation-rail-item]):hover {
--navigation-layer: var(--md-sys-state-hover-state-layer-opacity);
}
}
:is([data-md-navigation-bar-item], [data-md-navigation-rail-item]):focus-visible {
--navigation-layer: var(--md-sys-state-focus-state-layer-opacity);
}
:is([data-md-navigation-bar-item], [data-md-navigation-rail-item]):active {
--navigation-layer: var(--md-sys-state-pressed-state-layer-opacity);
}
[data-md-navigation-bar-item] [data-md-navigation-indicator]::before,
[data-md-navigation-bar-item] [data-md-navigation-pill]::before,
[data-md-navigation-rail-item]::before,
[data-md-navigation-rail-item] [data-md-navigation-indicator]::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background-color: var(--md-sys-color-on-secondary-container);
opacity: var(--navigation-layer, 0);
pointer-events: none;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
/* The wrapper a floating badge anchors to: tight around the glyph alone, not the wider
indicator or pill around it, so the badge sits at the icon's own corner (badge.css). */
[data-md-navigation-icon] {
position: relative;
display: inline-flex;
}
}
@@ -0,0 +1,645 @@
/*
* `<x-navigation-rail-item>`: one destination in an `<x-navigation-rail>`, drawn in whichever
* shape the rail has (NavigationRailVerticalItemTokens.kt, NavigationRailHorizontalItemTokens.kt,
* NavigationRailBaselineItemTokens.kt, NavigationRailColorTokens.kt, androidx Compose Material 3,
* Apache-2.0).
*
* [data-md-navigation-rail-item] data-md-active; the link or button itself
* [data-md-navigation-indicator] the 56×32 indicator, collapsed only
* [data-md-navigation-icon] the floating badge's anchor (navigation-item.css)
* [data-md-navigation-badge-icon] the small/large badge, collapsed only
* [data-md-navigation-label]
* [data-md-navigation-badge-end] the large badge at the label's end, expanded only
*
* Expanded, a 56px full-width pill: the icon, the label-large label beside it (up to two lines,
* collapsed) and a count at its end. Collapsed, the icon sits in a 56×32 `CornerFull` indicator
* over a label-medium label, with the count on the icon instead badges move from the icon
* (collapsed) to the label end (expanded), M3's stated rule.
*
* The item's own state layer is split in two: while expanded the layer paints the whole item (the
* 56px pill *is* the indicator); while collapsed it paints only the 56×32 box. A CSS variant
* selector cannot follow a pseudo-element, so `--navigation-item-layer`/`--navigation-indicator-layer`
* hand `--navigation-layer` (navigation-item.css) on through variables, and each rule below
* outweighs navigation-item.css's shared one with a doubled `[data-md-navigation-rail-item]`
* attribute, the same specificity trick `navigation-rail.css` uses for the header's FAB.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@import './badge.css';
@import './navigation-item.css';
@layer material.components {
[data-md-navigation-rail-item] {
--navigation-layer: 0;
--navigation-item-layer: var(--navigation-layer);
--navigation-indicator-layer: 0;
position: relative;
isolation: isolate;
display: flex;
flex-shrink: 0;
align-items: center;
gap: 8px;
height: 56px;
margin-inline: 20px;
padding-inline: 16px;
border-radius: var(--md-sys-shape-corner-full);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
text-align: start;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
outline: none;
-webkit-tap-highlight-color: transparent;
}
[data-md-navigation-rail-item][data-md-navigation-rail-item]::before {
background-color: var(--md-sys-color-on-secondary-container);
opacity: var(--navigation-item-layer);
}
[data-md-navigation-rail-item][data-md-navigation-rail-item] [data-md-navigation-indicator]::before {
background-color: var(--md-sys-color-on-secondary-container);
opacity: var(--navigation-indicator-layer);
}
[data-md-navigation-rail-item][data-md-active] {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
color: var(--md-sys-color-on-secondary-container);
}
[data-md-navigation-rail-item] [data-md-navigation-indicator] {
position: relative;
isolation: isolate;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: var(--md-sys-shape-corner-full);
}
[data-md-navigation-rail-item] [data-md-navigation-label] {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
[data-md-navigation-rail-item]:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
/* The large badge at the label's end, expanded only collapsed, the badge moves onto the
icon instead (`[data-md-navigation-badge-icon]`, `display: contents` there). */
[data-md-navigation-badge-end] {
display: flex;
flex-shrink: 0;
}
[data-md-navigation-badge-icon] {
display: none;
}
/* ---- Collapsed: icon over label, the 56×32 indicator, badge back on the icon. ---- */
[data-md-navigation-rail-item] {
&:where(
[data-md-navigation-rail='collapsed'],
[data-md-navigation-rail='collapsed'] *,
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
[data-md-navigation-rail='modal']:not([data-md-open]),
[data-md-navigation-rail='modal']:not([data-md-open]) *
) {
flex-direction: column;
justify-content: center;
gap: 4px;
height: auto;
min-height: 64px;
margin-inline: 0;
padding: 6px 4px;
border-radius: 0;
font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking);
white-space: normal;
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
flex-direction: column;
justify-content: center;
gap: 4px;
height: auto;
min-height: 64px;
margin-inline: 0;
padding: 6px 4px;
border-radius: 0;
font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking);
white-space: normal;
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
flex-direction: column;
justify-content: center;
gap: 4px;
height: auto;
min-height: 64px;
margin-inline: 0;
padding: 6px 4px;
border-radius: 0;
font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking);
white-space: normal;
}
}
@media (840px <= width < 1200px) {
&:where(
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
flex-direction: column;
justify-content: center;
gap: 4px;
height: auto;
min-height: 64px;
margin-inline: 0;
padding: 6px 4px;
border-radius: 0;
font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking);
white-space: normal;
}
}
@media (width >= 1200px) {
&:where(
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
flex-direction: column;
justify-content: center;
gap: 4px;
height: auto;
min-height: 64px;
margin-inline: 0;
padding: 6px 4px;
border-radius: 0;
font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking);
white-space: normal;
}
}
}
[data-md-navigation-rail-item] {
&:where(
[data-md-navigation-rail='collapsed'],
[data-md-navigation-rail='collapsed'] *,
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
[data-md-navigation-rail='modal']:not([data-md-open]),
[data-md-navigation-rail='modal']:not([data-md-open]) *
) {
--navigation-item-layer: 0;
--navigation-indicator-layer: var(--navigation-layer);
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
--navigation-item-layer: 0;
--navigation-indicator-layer: var(--navigation-layer);
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
--navigation-item-layer: 0;
--navigation-indicator-layer: var(--navigation-layer);
}
}
@media (840px <= width < 1200px) {
&:where(
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
--navigation-item-layer: 0;
--navigation-indicator-layer: var(--navigation-layer);
}
}
@media (width >= 1200px) {
&:where(
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
--navigation-item-layer: 0;
--navigation-indicator-layer: var(--navigation-layer);
}
}
}
[data-md-navigation-rail-item] [data-md-navigation-label] {
&:where(
[data-md-navigation-rail='collapsed'],
[data-md-navigation-rail='collapsed'] *,
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
[data-md-navigation-rail='modal']:not([data-md-open]),
[data-md-navigation-rail='modal']:not([data-md-open]) *
) {
flex: none;
display: -webkit-box;
max-width: 100%;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
text-align: center;
overflow-wrap: anywhere;
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
flex: none;
display: -webkit-box;
max-width: 100%;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
text-align: center;
overflow-wrap: anywhere;
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
flex: none;
display: -webkit-box;
max-width: 100%;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
text-align: center;
overflow-wrap: anywhere;
}
}
@media (840px <= width < 1200px) {
&:where(
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
flex: none;
display: -webkit-box;
max-width: 100%;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
text-align: center;
overflow-wrap: anywhere;
}
}
@media (width >= 1200px) {
&:where(
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
flex: none;
display: -webkit-box;
max-width: 100%;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
text-align: center;
overflow-wrap: anywhere;
}
}
}
[data-md-navigation-rail-item][data-md-active] {
&:where(
[data-md-navigation-rail='collapsed'],
[data-md-navigation-rail='collapsed'] *,
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
[data-md-navigation-rail='modal']:not([data-md-open]),
[data-md-navigation-rail='modal']:not([data-md-open]) *
) {
background-image: none;
color: var(--md-sys-color-secondary);
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
background-image: none;
color: var(--md-sys-color-secondary);
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
background-image: none;
color: var(--md-sys-color-secondary);
}
}
@media (840px <= width < 1200px) {
&:where(
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
background-image: none;
color: var(--md-sys-color-secondary);
}
}
@media (width >= 1200px) {
&:where(
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
background-image: none;
color: var(--md-sys-color-secondary);
}
}
}
[data-md-navigation-rail-item] [data-md-navigation-indicator] {
&:where(
[data-md-navigation-rail='collapsed'],
[data-md-navigation-rail='collapsed'] *,
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
[data-md-navigation-rail='modal']:not([data-md-open]),
[data-md-navigation-rail='modal']:not([data-md-open]) *
) {
width: 56px;
height: 32px;
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
width: 56px;
height: 32px;
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
width: 56px;
height: 32px;
}
}
@media (840px <= width < 1200px) {
&:where(
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
width: 56px;
height: 32px;
}
}
@media (width >= 1200px) {
&:where(
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
width: 56px;
height: 32px;
}
}
}
[data-md-navigation-rail-item][data-md-active] [data-md-navigation-indicator] {
&:where(
[data-md-navigation-rail='collapsed'],
[data-md-navigation-rail='collapsed'] *,
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
[data-md-navigation-rail='modal']:not([data-md-open]),
[data-md-navigation-rail='modal']:not([data-md-open]) *
) {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
color: var(--md-sys-color-on-secondary-container);
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
color: var(--md-sys-color-on-secondary-container);
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
color: var(--md-sys-color-on-secondary-container);
}
}
@media (840px <= width < 1200px) {
&:where(
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
color: var(--md-sys-color-on-secondary-container);
}
}
@media (width >= 1200px) {
&:where(
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
color: var(--md-sys-color-on-secondary-container);
}
}
}
[data-md-navigation-rail-item]:focus-visible {
&:where(
[data-md-navigation-rail='collapsed'],
[data-md-navigation-rail='collapsed'] *,
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
[data-md-navigation-rail='modal']:not([data-md-open]),
[data-md-navigation-rail='modal']:not([data-md-open]) *
) {
outline: none;
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
outline: none;
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
outline: none;
}
}
@media (840px <= width < 1200px) {
&:where(
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
outline: none;
}
}
@media (width >= 1200px) {
&:where(
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
outline: none;
}
}
}
[data-md-navigation-rail-item]:focus-visible [data-md-navigation-indicator] {
&:where(
[data-md-navigation-rail='collapsed'],
[data-md-navigation-rail='collapsed'] *,
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
[data-md-navigation-rail='modal']:not([data-md-open]),
[data-md-navigation-rail='modal']:not([data-md-open]) *
) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
}
@media (840px <= width < 1200px) {
&:where(
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
}
@media (width >= 1200px) {
&:where(
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
}
}
[data-md-navigation-badge-end],
[data-md-navigation-badge-icon] {
&:where(
[data-md-navigation-rail='collapsed'],
[data-md-navigation-rail='collapsed'] *,
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
[data-md-navigation-rail='modal']:not([data-md-open]),
[data-md-navigation-rail='modal']:not([data-md-open]) *
) {
display: none;
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
display: none;
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
display: none;
}
}
@media (840px <= width < 1200px) {
&:where(
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
display: none;
}
}
@media (width >= 1200px) {
&:where(
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
display: none;
}
}
}
[data-md-navigation-badge-icon] {
&:where(
[data-md-navigation-rail='collapsed'],
[data-md-navigation-rail='collapsed'] *,
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
[data-md-navigation-rail='modal']:not([data-md-open]),
[data-md-navigation-rail='modal']:not([data-md-open]) *
) {
display: contents;
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
display: contents;
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
display: contents;
}
}
@media (840px <= width < 1200px) {
&:where(
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
display: contents;
}
}
@media (width >= 1200px) {
&:where(
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
display: contents;
}
}
}
}
@@ -0,0 +1,114 @@
/*
* `<x-navigation-rail-section>`: a group of `<x-navigation-rail-item>`s under a heading, in an
* `<x-navigation-rail>`. M3 draws a section header only while the rail is expanded; collapsed, a
* little space sets the group apart instead, and the heading keeps naming the group for a screen
* reader either way (`role="group"`/`aria-labelledby`, rendered by the view).
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-navigation-rail-section] {
display: flex;
flex-direction: column;
}
[data-md-navigation-rail-section]:not(:first-child) {
&:where(
[data-md-navigation-rail='collapsed'],
[data-md-navigation-rail='collapsed'] *,
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
[data-md-navigation-rail='modal']:not([data-md-open]),
[data-md-navigation-rail='modal']:not([data-md-open]) *
) {
margin-top: 12px;
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
margin-top: 12px;
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
margin-top: 12px;
}
}
@media (840px <= width < 1200px) {
&:where(
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
margin-top: 12px;
}
}
@media (width >= 1200px) {
&:where(
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
margin-top: 12px;
}
}
}
[data-md-navigation-rail-heading] {
display: flex;
align-items: center;
min-height: 48px;
padding-inline: 36px 20px;
overflow: hidden;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-title-sm);
letter-spacing: var(--md-sys-typescale-title-sm-tracking);
white-space: nowrap;
text-overflow: ellipsis;
}
[data-md-navigation-rail-heading] {
&:where(
[data-md-navigation-rail='collapsed'],
[data-md-navigation-rail='collapsed'] *,
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
[data-md-navigation-rail='modal']:not([data-md-open]),
[data-md-navigation-rail='modal']:not([data-md-open]) *
) {
display: none;
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
display: none;
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
display: none;
}
}
@media (840px <= width < 1200px) {
&:where(
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
display: none;
}
}
@media (width >= 1200px) {
&:where(
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
display: none;
}
}
}
}
File diff suppressed because it is too large Load Diff
-584
View File
@@ -1,584 +0,0 @@
/*
* M3 Expressive navigation: the flexible navigation bar and the navigation rail collapsed,
* expanded and modal.
*
* Values from androidx Compose Material 3 (Apache-2.0) at androidx-main
* 27cf9a7d5788aa0f5f2d8b6699ce279560daf326: tokens/NavigationBarTokens.kt,
* NavigationBarVerticalItemTokens.kt, NavigationBarHorizontalItemTokens.kt,
* NavigationRailCollapsedTokens.kt, NavigationRailExpandedTokens.kt,
* NavigationRailBaselineItemTokens.kt, NavigationRailVerticalItemTokens.kt,
* NavigationRailHorizontalItemTokens.kt, NavigationRailColorTokens.kt, and the layout in
* ShortNavigationBar.kt, WideNavigationRail.kt and NavigationItem.kt.
*
* [data-navigation-bar] surface-container, 64px, the bottom safe area under it
* [data-navigation-bar-items] equal widths; centred from a 600px-wide bar
* [data-navigation-bar-item] data-active
* [data-navigation-pill] icon and label; the indicator itself from 600px
* [data-navigation-indicator] the 56×32 indicator around the icon below 600px
* [data-navigation-label]
*
* [data-navigation-rail="collapsed|expanded|collapsible|modal|adaptive"] data-open
* [data-navigation-rail-scrim] modal and adaptive rails
* [data-navigation-rail-panel] the <nav>
* [data-navigation-rail-header] menu button, brand, FAB never scrolls
* [data-navigation-rail-destinations] scrolls when the window is too short
* [data-navigation-rail-section] a heading (expanded only) and its items
* [data-navigation-rail-item] data-active; the full-width pill when expanded
* [data-navigation-indicator] the 56×32 indicator when collapsed
* [data-navigation-label]
* [data-navigation-rail-footer] never scrolls
*
* `rail-collapsed:` matches a rail, and everything in it, while it is drawn collapsed whatever
* made it so: its mode, the visitor's choice on <html data-rail> (set before the first paint by
* <x-theme-script>), or a window under `lg` for the adaptive rail. A rail item is written once
* and takes both shapes from it; so can anything an application puts in a rail
* (`<span class="rail-collapsed:hidden">`).
*/
@custom-variant rail-collapsed {
&:where([data-navigation-rail='collapsed'], [data-navigation-rail='collapsed'] *) {
@slot;
}
&:where([data-rail='collapsed'] [data-navigation-rail='collapsible'], [data-rail='collapsed'] [data-navigation-rail='collapsible'] *) {
@slot;
}
&:where([data-navigation-rail='modal']:not([data-open]), [data-navigation-rail='modal']:not([data-open]) *) {
@slot;
}
@media (width < 64rem) {
&:where([data-navigation-rail='adaptive']:not([data-open]), [data-navigation-rail='adaptive']:not([data-open]) *) {
@slot;
}
}
@media (width >= 64rem) {
&:where([data-rail='collapsed'] [data-navigation-rail='adaptive'], [data-rail='collapsed'] [data-navigation-rail='adaptive'] *) {
@slot;
}
}
}
@layer components {
/* ---------------------------------------------------------------- the navigation bar */
[data-navigation-bar] {
container-type: inline-size;
padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);
padding-bottom: env(safe-area-inset-bottom);
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface-variant);
}
[data-navigation-bar-items] {
display: flex;
min-height: 4rem;
margin-inline: auto;
}
[data-navigation-bar-item] {
--navigation-layer: 0;
position: relative;
display: flex;
flex: 1 1 0;
min-width: 0;
align-items: center;
justify-content: center;
padding-block: 0.375rem;
color: var(--md-sys-color-on-surface-variant);
text-decoration: none;
cursor: pointer;
outline: none;
-webkit-tap-highlight-color: transparent;
}
[data-navigation-bar-item] [data-navigation-pill] {
display: flex;
min-width: 0;
max-width: 100%;
flex-direction: column;
align-items: center;
gap: 0.25rem;
border-radius: var(--md-sys-shape-corner-full);
font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking);
}
[data-navigation-bar-item] [data-navigation-label] {
max-width: 100%;
padding-inline: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-navigation-bar-item][data-active] {
color: var(--md-sys-color-secondary);
}
/* From 600dp (M3's medium window), icon and label side by side in a 40px indicator, and the
items centred with the padding ShortNavigationBar's Centered arrangement computes. */
@container (width >= 37.5rem) {
[data-navigation-bar-items] {
width: calc(10% * (var(--navigation-bar-count, 7) + 3));
min-width: fit-content;
max-width: 100%;
}
[data-navigation-bar-items]:has(> :last-child:nth-child(1)) { --navigation-bar-count: 1; }
[data-navigation-bar-items]:has(> :last-child:nth-child(2)) { --navigation-bar-count: 2; }
[data-navigation-bar-items]:has(> :last-child:nth-child(3)) { --navigation-bar-count: 3; }
[data-navigation-bar-items]:has(> :last-child:nth-child(4)) { --navigation-bar-count: 4; }
[data-navigation-bar-items]:has(> :last-child:nth-child(5)) { --navigation-bar-count: 5; }
[data-navigation-bar-items]:has(> :last-child:nth-child(6)) { --navigation-bar-count: 6; }
[data-navigation-bar-item] {
min-width: max-content;
}
[data-navigation-bar-item] [data-navigation-pill] {
position: relative;
isolation: isolate;
flex-direction: row;
height: 2.5rem;
padding-inline: 1rem;
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
}
[data-navigation-bar-item] [data-navigation-label] {
padding-inline: 0;
}
[data-navigation-bar-item][data-active] {
color: var(--md-sys-color-on-secondary-container);
}
[data-navigation-bar-item][data-active] [data-navigation-pill] {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
}
}
/* ---------------------------------------------------------------- the navigation rail */
[data-navigation-rail] {
--navigation-rail-expanded-width: clamp(13.75rem, var(--navigation-rail-width, 16rem), 22.5rem);
position: relative;
flex-shrink: 0;
width: var(--navigation-rail-expanded-width);
transition: width var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default);
@variant rail-collapsed {
width: 6rem;
}
}
/* A modal rail keeps its collapsed width in the layout while it is open over it, as Compose's
ModalWideNavigationRail does; the adaptive rail does below lg, and takes no room below sm. */
[data-navigation-rail='modal'] {
width: 6rem;
}
@media (width < 64rem) {
[data-navigation-rail='adaptive'] {
width: 6rem;
}
}
@media (width < 40rem) {
[data-navigation-rail='adaptive'] {
width: 0;
}
}
[data-navigation-rail-panel] {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
max-height: 100dvh;
/* Clip, not hide: no scroll container, so the destinations below can still scroll and
nothing sticky breaks. What only an expanded rail draws a label, the brand is drawn
at once when the rail expands, while the width is still growing; the clip keeps it
from spilling over the page for those frames. */
overflow-x: clip;
padding-bottom: env(safe-area-inset-bottom);
background-color: var(--md-sys-color-surface);
color: var(--md-sys-color-on-surface);
transition:
width var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast),
background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
}
/* Open: expanded over a scrim, in surface-container with a large corner at its inner edge. */
[data-navigation-rail][data-open] > [data-navigation-rail-panel] {
position: fixed;
inset-block: 0;
inset-inline-start: 0;
z-index: 50;
width: var(--navigation-rail-expanded-width);
max-width: calc(100vw - 3.5rem);
height: 100dvh;
max-height: none;
border-start-end-radius: var(--md-sys-shape-corner-lg);
border-end-end-radius: var(--md-sys-shape-corner-lg);
background-color: var(--md-sys-color-surface-container);
box-shadow: var(--md-sys-elevation-2);
}
/* Below sm there is no collapsed rail to grow out of: the open rail slides in from the edge,
on emphasized decelerate rather than a spring, which would overshoot and open a gap. */
@media (width < 40rem) {
[data-navigation-rail='adaptive'] > [data-navigation-rail-panel] {
position: fixed;
inset-block: 0;
inset-inline-start: 0;
z-index: 50;
display: none;
width: var(--navigation-rail-expanded-width);
max-width: calc(100vw - 3.5rem);
height: 100dvh;
max-height: none;
border-start-end-radius: var(--md-sys-shape-corner-lg);
border-end-end-radius: var(--md-sys-shape-corner-lg);
background-color: var(--md-sys-color-surface-container);
box-shadow: var(--md-sys-elevation-2);
translate: -100% 0;
transition:
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate),
display var(--md-sys-motion-effects-default-duration) allow-discrete;
&:dir(rtl) {
translate: 100% 0;
}
}
[data-navigation-rail='adaptive'][data-open] > [data-navigation-rail-panel] {
display: flex;
translate: 0 0;
transition:
translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-easing-emphasized-decelerate),
display var(--md-sys-motion-spatial-default-duration) allow-discrete;
@starting-style {
translate: -100% 0;
}
&:dir(rtl) {
translate: 0 0;
@starting-style {
translate: 100% 0;
}
}
}
}
[data-navigation-rail-scrim] {
position: fixed;
inset: 0;
z-index: 40;
display: none;
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
opacity: 0;
transition:
opacity var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default),
display var(--md-sys-motion-effects-default-duration) allow-discrete;
}
[data-navigation-rail][data-open] > [data-navigation-rail-scrim] {
display: block;
opacity: 1;
@starting-style {
opacity: 0;
}
}
/* 44px above the header (TopSpace), 40px under it (HeaderSpaceMinimum) 32 here and 8 as
the destinations' own padding, which keeps the first item's focus ring inside the scroller. */
[data-navigation-rail-header] {
display: flex;
flex-shrink: 0;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding-top: calc(env(safe-area-inset-top) + 2.75rem);
padding-bottom: 2rem;
}
[data-navigation-rail-panel] > [data-navigation-rail-destinations]:first-child {
padding-top: calc(env(safe-area-inset-top) + 2.75rem);
}
[data-navigation-rail-destinations] {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding-block: 0.5rem;
scrollbar-width: thin;
}
[data-navigation-rail-destinations],
[data-navigation-rail-section],
[data-navigation-rail-footer] {
display: flex;
flex-direction: column;
@variant rail-collapsed {
gap: 0.25rem;
}
}
[data-navigation-rail-footer] {
flex-shrink: 0;
padding-block: 0.5rem 1rem;
}
[data-navigation-rail-heading] {
display: flex;
align-items: center;
min-height: 3rem;
padding-inline: 2.25rem 1.25rem;
overflow: hidden;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-title-sm);
letter-spacing: var(--md-sys-typescale-title-sm-tracking);
white-space: nowrap;
text-overflow: ellipsis;
@variant rail-collapsed {
display: none;
}
}
[data-navigation-rail-section]:not(:first-child) {
@variant rail-collapsed {
margin-top: 0.75rem;
}
}
/* Expanded: a 56px full-width pill, icon and label 8px apart, label-large. */
[data-navigation-rail-item] {
--navigation-layer: 0;
position: relative;
isolation: isolate;
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.5rem;
height: 3.5rem;
margin-inline: 1.25rem;
padding-inline: 1rem;
border-radius: var(--md-sys-shape-corner-full);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
text-align: start;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
outline: none;
-webkit-tap-highlight-color: transparent;
/* Collapsed: the icon in its 56×32 indicator over a label-medium label, 64px tall. */
@variant rail-collapsed {
flex-direction: column;
justify-content: center;
gap: 0.25rem;
height: auto;
min-height: 4rem;
margin-inline: 0;
padding: 0.375rem 0.25rem;
border-radius: 0;
font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking);
white-space: normal;
}
}
[data-navigation-rail-item] [data-navigation-label] {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@variant rail-collapsed {
flex: none;
display: -webkit-box;
max-width: 100%;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
text-align: center;
overflow-wrap: anywhere;
}
}
[data-navigation-rail-item][data-active] {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
color: var(--md-sys-color-on-secondary-container);
@variant rail-collapsed {
background-image: none;
color: var(--md-sys-color-secondary);
}
}
[data-navigation-rail-item] [data-navigation-indicator] {
position: relative;
isolation: isolate;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: var(--md-sys-shape-corner-full);
@variant rail-collapsed {
width: 3.5rem;
height: 2rem;
}
}
/* ---------------------------------------------------------------- both: indicator, states */
[data-navigation-bar-item] [data-navigation-indicator] {
position: relative;
isolation: isolate;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 2rem;
border-radius: var(--md-sys-shape-corner-full);
}
[data-navigation-bar-item][data-active] [data-navigation-indicator] {
color: var(--md-sys-color-on-secondary-container);
}
@container (width < 37.5rem) {
[data-navigation-bar-item][data-active] [data-navigation-indicator] {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
}
}
[data-navigation-rail-item][data-active] [data-navigation-indicator] {
@variant rail-collapsed {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
color: var(--md-sys-color-on-secondary-container);
}
}
/* The active indicator is a secondary-container fill painted as a background image, so it can
grow out of its centre without stretching the icon: when a page arrives through
wire:navigate, resources/js/navigation.js starts it at zero width for a moment, and it
springs open on the default spatial spring, as Compose's indicator does when the selection
changes (NavigationItem.kt). A full page load draws it at once; reduced motion zeroes it. */
[data-navigation-bar-item] :is([data-navigation-indicator], [data-navigation-pill]),
[data-navigation-rail-item],
[data-navigation-rail-item] [data-navigation-indicator] {
background-position: center;
background-repeat: no-repeat;
background-size: 100% 100%;
transition: background-size var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default);
}
/* The state layer covers only the indicator: the pill when the indicator holds the label
too, the 56×32 shape when it holds the icon alone. Hover only where a pointer can hover. */
@media (hover: hover) {
:is([data-navigation-bar-item], [data-navigation-rail-item]):hover {
--navigation-layer: 0.08;
}
}
:is([data-navigation-bar-item], [data-navigation-rail-item]):is(:focus-visible, :active) {
--navigation-layer: 0.1;
}
[data-navigation-bar-item] [data-navigation-indicator]::before,
[data-navigation-bar-item] [data-navigation-pill]::before,
[data-navigation-rail-item] [data-navigation-indicator]::before,
[data-navigation-rail-item]::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background-color: var(--md-sys-color-on-surface);
opacity: var(--navigation-layer);
pointer-events: none;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@container (width < 37.5rem) {
[data-navigation-bar-item] [data-navigation-pill]::before {
display: none;
}
}
@container (width >= 37.5rem) {
[data-navigation-bar-item] [data-navigation-indicator]::before {
display: none;
}
}
/* The rail item's layer is on the item while expanded and on its indicator while collapsed. A
variant cannot follow a pseudo-element in a selector, so the item hands the layer on through
variables; each rule below outweighs its line in the list above. */
[data-navigation-rail-item] {
--navigation-item-layer: var(--navigation-layer);
--navigation-indicator-layer: 0;
@variant rail-collapsed {
--navigation-item-layer: 0;
--navigation-indicator-layer: var(--navigation-layer);
}
}
[data-navigation-rail-item][data-navigation-rail-item]::before {
background-color: var(--md-sys-color-on-secondary-container);
opacity: var(--navigation-item-layer);
}
[data-navigation-rail-item][data-navigation-rail-item] [data-navigation-indicator]::before {
background-color: var(--md-sys-color-on-secondary-container);
opacity: var(--navigation-indicator-layer);
}
/* M3's focus indicator, 3px of secondary 2px out, around the same shape. */
@container (width < 37.5rem) {
[data-navigation-bar-item]:focus-visible [data-navigation-indicator] {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
}
@container (width >= 37.5rem) {
[data-navigation-bar-item]:focus-visible [data-navigation-pill] {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
}
[data-navigation-rail-item]:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
@variant rail-collapsed {
outline: none;
}
}
[data-navigation-rail-item]:focus-visible [data-navigation-indicator] {
@variant rail-collapsed {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
}
}
+87
View File
@@ -0,0 +1,87 @@
/*
* The paginators, Laravel's and Livewire's, drawn in M3 (resources/views/pagination/**): the package
* puts its views in front of the frameworks' `tailwind` and `simple-tailwind` ones, whose names they
* keep.
*
* The numbered paginator is a row: on its start, what the page holds in body-small on-surface-variant
* "2130 of 95", or "Page 3 of 10" below `medium` (600px), where the page numbers give way and only
* previous and next stay and on its end the steps, 4px apart. A step is a 40px circle in label-large
* with tabular figures, M3's icon-button geometry: a live one (a link or a button) in on-surface,
* rendering the foundation's `md-state-layer`, `md-focus-ring` and `md-touch-target`
* (foundation/interaction.css) to reach the 48px target; the current page is the selected state,
* secondary-container, never the action colour it is where you are, not what to do next; an
* unreachable one (`aria-disabled`) is on-surface at 38%. The chevrons mirror in a right-to-left page.
* The simple paginator is the same row holding two outlined buttons.
*
* [data-md-pagination] the <nav>
* [data-md-pagination-summary] [data-md-pagination-compact], [data-md-pagination-range]
* [data-md-pagination-steps]
* [data-md-pagination-step] aria-current="page", aria-disabled="true";
* [data-md-pagination-page] is a number, hidden below 600px
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './button.css';
@import './icon.css';
@layer material.components {
[data-md-pagination] {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--md-sys-measurement-space200);
}
[data-md-pagination-summary] {
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
font-variation-settings: normal;
}
[data-md-pagination-steps] {
display: flex;
align-items: center;
gap: var(--md-sys-measurement-space50);
}
[data-md-pagination-step] {
display: grid;
place-items: center;
width: var(--md-sys-measurement-space500);
height: var(--md-sys-measurement-space500);
border-radius: var(--md-sys-shape-corner-full);
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
font-variant-numeric: tabular-nums;
}
:is(a, button)[data-md-pagination-step] {
cursor: pointer;
}
[data-md-pagination-step][aria-current='page'] {
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
[data-md-pagination-step][aria-disabled='true'] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
}
@media (width < 600px) {
[data-md-pagination-page],
[data-md-pagination-range] {
display: none;
}
}
@media (width >= 600px) {
[data-md-pagination-compact] {
display: none;
}
}
}
+12
View File
@@ -0,0 +1,12 @@
/*
* <x-password>: a text field with M3's trailing eye (resources/views/components/password.blade.php).
*
* Nothing of its own to draw: the chrome and the eye, a trailing icon button, are the field's
* (components/field.css), and the eye's two glyphs are <x-icon>s. The field's root carries
* `data-md-password`.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './field.css';
@import './icon.css';
+112
View File
@@ -0,0 +1,112 @@
/*
* <x-progress>: M3 Expressive's progress indicator, drawn on the server for its first frame and
* frame by frame in the browser after.
*
* ProgressIndicatorTokens, LinearProgressIndicatorTokens, CircularProgressIndicatorTokens (androidx
* Compose Material 3, Apache-2.0): 4px stroke (`data-md-thick` 8px), linear 4px tall (8px thick,
* 10px `data-md-wavy`, 14px wavy thick the container grows by the extra stroke, keeping the
* wave's own height put), circular 40px (44px thick, 48px wavy, 52px wavy thick). `data-md-color`
* is the active indicator's `color` and the track's `stroke`; the track is secondary-container for
* primary and secondary, and the colour's own container otherwise.
*
* Linear mirrors in a right-to-left document unconditionally M3's own rule for this indicator,
* not the `mirror-rtl` a caller opts an icon into. The sizing here is a default only: an
* application's own unlayered class always outranks `material.components`, so an `inline-size` or
* `block-size` of its own narrows or grows the indicator.
*
* resources/js/progress.js reads and writes `data-md-value`, `data-md-max`, `data-md-circular`,
* `data-md-wavy` and `data-md-thick` ProgressIndicator.kt, WavyProgressIndicator.kt and its
* Linear/CircularWavyProgressModifiers, ported at commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326
* and draws every frame straight onto the SVG's own attributes; nothing here reads a class or
* runs a transition.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-progress] {
color: var(--md-sys-color-primary);
stroke: var(--md-sys-color-secondary-container);
& > svg {
display: block;
width: 100%;
height: 100%;
}
}
[data-md-progress]:not([data-md-circular]) {
display: block;
width: 100%;
height: 4px;
}
[data-md-progress]:not([data-md-circular]):is([dir='rtl'], [dir='rtl'] *) {
rotate: 180deg;
}
[data-md-progress]:not([data-md-circular])[data-md-thick] {
height: 8px;
}
[data-md-progress]:not([data-md-circular])[data-md-wavy] {
height: 10px;
}
[data-md-progress]:not([data-md-circular])[data-md-wavy][data-md-thick] {
height: 14px;
}
[data-md-progress][data-md-circular] {
display: inline-block;
flex-shrink: 0;
vertical-align: middle;
width: 40px;
height: 40px;
}
[data-md-progress][data-md-circular][data-md-thick] {
width: 44px;
height: 44px;
}
[data-md-progress][data-md-circular][data-md-wavy] {
width: 48px;
height: 48px;
}
[data-md-progress][data-md-circular][data-md-wavy][data-md-thick] {
width: 52px;
height: 52px;
}
[data-md-progress][data-md-color='secondary'] {
color: var(--md-sys-color-secondary);
stroke: var(--md-sys-color-secondary-container);
}
[data-md-progress][data-md-color='tertiary'] {
color: var(--md-sys-color-tertiary);
stroke: var(--md-sys-color-tertiary-container);
}
[data-md-progress][data-md-color='error'] {
color: var(--md-sys-color-error);
stroke: var(--md-sys-color-error-container);
}
[data-md-progress][data-md-color='success'] {
color: var(--md-sys-color-success);
stroke: var(--md-sys-color-success-container);
}
[data-md-progress][data-md-color='warning'] {
color: var(--md-sys-color-warning);
stroke: var(--md-sys-color-warning-container);
}
[data-md-progress][data-md-color='info'] {
color: var(--md-sys-color-info);
stroke: var(--md-sys-color-info-container);
}
}
+133
View File
@@ -0,0 +1,133 @@
/*
* <x-radio>: M3's radio buttons (resources/views/components/radio.blade.php), on the row the
* selection controls share (components/selection.css).
*
* RadioButtonTokens (androidx Compose Material 3, Apache-2.0): a 20px ring with a 2px
* on-surface-variant outline, primary once chosen, and a 10px primary dot that grows in on the fast
* spatial spring; the state layer a 40px circle around it. With the server's error the ring and
* the dot are error. Disabled, both are on-surface at 38%. Beside a hint the ring drops 2px, to sit
* on the label's first line.
*
* The group is a <fieldset> whose legend (label-large, on-surface-variant, 12px above the options)
* is the question. The options stack 16px apart, as M3 lays radio buttons out; `data-md-inline`
* lays them in a wrapping row 24px apart from `medium` (600px), and compact always stacks. The
* hint, or the errors in its place, sits 8px under the options.
*
* [data-md-radio] the <fieldset>; `class` and `style` land here; data-md-inline
* [data-md-radio-legend]
* [data-md-radio-options]
* [data-md-selection-row]
* [data-md-radio-button] the ring: input, [data-md-radio-dot]
* [data-md-selection-support] the hint, or the errors
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './selection.css';
@layer material.components {
[data-md-radio] {
min-width: 0;
}
[data-md-radio-legend] {
margin-bottom: 12px;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
}
[data-md-radio-options] {
display: grid;
gap: var(--md-sys-measurement-space200);
}
/* M3 stacks radio buttons and cautions against a row at any width; `inline` is for two or three
short labels, and only from medium. */
@media (width >= 600px) {
[data-md-radio][data-md-inline] [data-md-radio-options] {
display: flex;
flex-wrap: wrap;
column-gap: var(--md-sys-measurement-space300);
}
}
[data-md-radio] [data-md-selection-support] {
margin-top: var(--md-sys-measurement-space100);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
font-variation-settings: normal;
}
[data-md-radio] [data-md-selection-support][role='alert'] {
color: var(--md-sys-color-error);
}
[data-md-radio-button] {
position: relative;
display: grid;
flex: none;
place-items: center;
width: 20px;
height: 20px;
}
[data-md-radio-button] > * {
grid-area: 1 / 1;
}
[data-md-selection-row]:has([data-md-selection-hint]) [data-md-radio-button] {
margin-top: var(--md-sys-measurement-space25);
}
[data-md-radio-button]::before {
inset: -10px;
}
[data-md-radio-button]:has(input:focus-visible)::before {
outline: 3px solid var(--md-sys-color-secondary);
}
[data-md-radio-button] input {
width: 20px;
height: 20px;
border: 2px solid var(--md-sys-color-on-surface-variant);
border-radius: var(--md-sys-shape-corner-full);
}
[data-md-radio-button] [data-md-radio-dot] {
width: 10px;
height: 10px;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-primary);
scale: 0;
pointer-events: none;
transition: scale var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast);
}
[data-md-radio-button] input:checked {
border-color: var(--md-sys-color-primary);
}
[data-md-radio-button] input:checked ~ [data-md-radio-dot] {
scale: 1;
}
[data-md-selection-row][data-md-invalid] [data-md-radio-button] input {
border-color: var(--md-sys-color-error);
}
[data-md-selection-row][data-md-invalid] [data-md-radio-button] [data-md-radio-dot] {
background-color: var(--md-sys-color-error);
}
[data-md-radio-button] input:disabled {
border-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
}
[data-md-radio-button] input:disabled ~ [data-md-radio-dot] {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
}
}
+108
View File
@@ -0,0 +1,108 @@
/*
* <x-rich-tooltip>: an M3 rich tooltip, a few lines of context for a control, with an optional
* subhead and actions.
*
* RichTooltipTokens (androidx Compose Material 3, Apache-2.0): surface-container, the medium
* corner, elevation 2, 312px wide at most, 16px either side, 12px above the subhead (or the text
* when there is none) and 8px below it. Title-small subhead and body-medium text in
* on-surface-variant, label-large actions in primary the actions row pulls itself back 12px to
* the text's edge, as the alert's actions row does (alert.css).
*
* Placed by CSS anchor positioning on `data-md-side`, corner-to-corner so the 312px bubble has
* room to spread past a narrow trigger; `position-try-fallbacks` flips it when the window has no
* room, same shape as tooltip.css's plain tooltip. It fades in and out on the fast effects spring:
* `@starting-style` gives the fade in a start, and the fade out is its exit copy's
* (`data-md-popover-exit`, resources/js/popover-exit.js), which `data-md-popover-closing` turns
* back to the closed opacity.
*
* resources/js/rich-tooltip.js shows and hides the bubble and keeps the trigger described with
* `aria-describedby` and, while `persistent`, `aria-haspopup`/`aria-expanded` (ACT-22) none of
* which this stylesheet draws.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-rich-tooltip] {
display: inline-flex;
}
[data-md-rich-tooltip-bubble] {
inset: auto;
margin: 0;
overflow: visible;
width: max-content;
max-width: 312px;
border-width: 0;
border-radius: var(--md-sys-shape-corner-md);
padding-inline: var(--md-sys-measurement-space200);
padding-block: 12px var(--md-sys-measurement-space100);
background-color: var(--md-sys-color-surface-container);
box-shadow: var(--md-sys-elevation-2);
text-align: start;
white-space: normal;
opacity: 0;
transition-property: opacity;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
&:popover-open:not([data-md-popover-closing]) {
opacity: 1;
@starting-style {
opacity: 0;
}
}
}
[data-md-rich-tooltip-bubble]:is([data-md-side='top'], [data-md-side='bottom']) {
margin-block: var(--md-sys-measurement-space50);
position-try-fallbacks: flip-block, flip-inline;
}
[data-md-rich-tooltip-bubble]:is([data-md-side='left'], [data-md-side='right']) {
margin-inline: var(--md-sys-measurement-space50);
position-try-fallbacks: flip-inline, flip-block;
}
[data-md-rich-tooltip-bubble][data-md-side='top'] {
position-area: top span-right;
}
[data-md-rich-tooltip-bubble][data-md-side='bottom'] {
position-area: bottom span-right;
}
[data-md-rich-tooltip-bubble][data-md-side='left'] {
position-area: left span-bottom;
}
[data-md-rich-tooltip-bubble][data-md-side='right'] {
position-area: right span-bottom;
}
[data-md-rich-tooltip-title] {
display: block;
margin-block-end: var(--md-sys-measurement-space50);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-title-sm);
letter-spacing: var(--md-sys-typescale-title-sm-tracking);
font-variation-settings: normal;
}
[data-md-rich-tooltip-text] {
display: block;
padding-block-end: var(--md-sys-measurement-space100);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
font-variation-settings: normal;
}
[data-md-rich-tooltip-actions] {
display: flex;
flex-wrap: wrap;
gap: var(--md-sys-measurement-space100);
margin-inline-start: -12px;
}
}
+145
View File
@@ -0,0 +1,145 @@
/*
* <x-scheme-picker>: a choice of colour profile as a grid of swatch cards, native radios under
* them so `wire:model`/`x-model` bind and the arrow keys move the choice.
*
* [data-md-scheme-picker] fieldset
* [data-md-scheme-picker-legend] optional; the fixed label-large/on-surface-variant text
* classes carry its type and ink
* [data-md-scheme-picker-options] 2 columns below medium, 4 from it
* [data-md-scheme-picker-option] the card; md-state-layer for hover and press, a hidden
* radio inside read with :has() for focus and choice
* [data-md-scheme-picker-swatches] the overlapping dots
* [data-md-scheme-picker-swatch] one role's colour, light by default, the dark theme's
* custom property from [data-theme='dark']
* [data-md-scheme-picker-label] the profile's name, truncated
* [data-md-scheme-picker-check] the chosen mark, shown with the radio
* [data-md-scheme-picker-hint] the hint or, in error, the validation message; ink and
* type are the fixed text classes on the view
*
* The chosen card takes secondary-container over a transparent edge, on the fast effects spring;
* its focus ring reads off the radio inside with `:has()`, the one thing `md-focus-ring` cannot draw
* since the real control is the input, not the label the same refinement group.css keeps for its
* own segments. `dark:` is `[data-theme='dark']` per an ancestor or the element itself
* (tokens/theme.css's own `dark` custom variant, `&:where([data-theme='dark'], [data-theme='dark']
* *)`, reduces to a plain descendant selector here since the swatch itself never carries
* `data-theme`).
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@layer material.components {
[data-md-scheme-picker] {
min-inline-size: 0;
}
[data-md-scheme-picker-legend] {
margin-block-end: var(--md-sys-measurement-space100);
}
[data-md-scheme-picker-options] {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--md-sys-measurement-space100);
@media (width >= 600px) {
grid-template-columns: repeat(4, 1fr);
}
}
[data-md-scheme-picker-option] {
display: flex;
min-inline-size: 0;
flex-direction: column;
align-items: flex-start;
gap: var(--md-sys-measurement-space100);
cursor: pointer;
-webkit-user-select: none;
user-select: none;
border: 1px solid var(--md-sys-color-outline-variant);
border-radius: var(--md-sys-shape-corner-lg);
padding: 12px;
background-color: var(--md-sys-color-surface-container-low);
color: var(--md-sys-color-on-surface);
transition-property: background-color, border-color;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
&:has(:checked) {
border-color: transparent;
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
/* Focus lands on the input inside, not the label, so it is read with :has() the one
difference md-state-layer's own :focus-visible rule cannot draw (group.css's segments
keep the same refinement, for the same reason). */
&:has(:focus-visible) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
&::before {
opacity: var(--md-sys-state-focus-state-layer-opacity);
}
}
& > input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border-width: 0;
}
}
[data-md-scheme-picker-swatches] {
display: flex;
flex-shrink: 0;
}
[data-md-scheme-picker-swatches] > * + * {
margin-inline-start: -6px;
}
[data-md-scheme-picker-swatch] {
inline-size: 20px;
block-size: 20px;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--swatch-light);
box-shadow: 0 0 0 2px var(--md-sys-color-surface-container-low);
}
[data-theme='dark'] [data-md-scheme-picker-swatch] {
background-color: var(--swatch-dark);
}
[data-md-scheme-picker-option]:has(:checked) [data-md-scheme-picker-swatch] {
box-shadow: 0 0 0 2px var(--md-sys-color-secondary-container);
}
[data-md-scheme-picker-label] {
inline-size: 100%;
padding-inline-end: var(--md-sys-measurement-space300);
}
[data-md-scheme-picker-check] {
position: absolute;
pointer-events: none;
inset-inline-end: var(--md-sys-measurement-space100);
top: var(--md-sys-measurement-space100);
display: none;
}
[data-md-scheme-picker-option]:has(:checked) [data-md-scheme-picker-check] {
display: block;
}
[data-md-scheme-picker-hint] {
margin-block-start: var(--md-sys-measurement-space50);
}
}
+205 -55
View File
@@ -4,93 +4,161 @@
*
* The bar is a 56px pill in surface-container-high: a leading search icon, the input in
* body-large, a clear button once something is typed, and whatever the caller trails it with (an
* avatar, an icon button). Focus opens the view. On a medium or wider window the view is docked:
* the bar grows down into an extra-large-cornered container at elevation 3 that holds the
* results. On a compact window it takes the whole screen, and the search icon turns into a back
* arrow. `docked` keeps it docked at every width.
* avatar, an icon button). Focus opens the view. On a medium or wider window the view is docked
* over a scrim: the bar grows down into an extra-large-cornered container at elevation 3 that
* holds the results. On a compact window it takes the whole screen, and the search icon turns into
* a back arrow. `docked` keeps it docked at every width.
*
* [data-search] the root; data-open, data-full-screen
* [data-search-bar] the pill, above the view
* [data-search-leading], [data-search-input], [data-search-clear], [data-search-trailing]
* [data-search-view] the container behind the bar
* [data-search-results]
* Widths are M3's: never wider than 720px, and the bar grows to that width while it is focused
* `--search-width` is its resting width (the room it is given, by default) and
* `--search-open-width` the focused one, so a caller that wants M3's 360px resting bar sets
* `--search-width: 360px` on a wrapper. The leading and trailing padding is 24px unfocused and
* 16px focused, as the search specs table gives it.
*
* Hovered and focused buttons in the bar take M3's state-layer opacities (tokens/state.css), and the
* docked scrim is M3's scrim role at 32%, which no system token names. The breakpoint that makes the
* view full screen is decided in resources/js/search.js (`upTo('medium')`, 600px).
*
* [data-md-search] the root; data-md-open, data-md-full-screen, data-md-trigger
* [data-md-search-scrim] over the page while the view is docked
* [data-md-search-trigger] data-md-trigger="icon" only: the icon button that expands the view
* [data-md-search-bar] the pill, above the view
* [data-md-search-leading], [data-md-search-field] (the combobox around
* [data-md-search-input]), [data-md-search-clear], [data-md-search-trailing]
* [data-md-search-view] the container behind the bar
* [data-md-search-suggestions] before the first keystroke
* [data-md-search-results] once something is typed; [data-md-search-empty] when nothing is
* [data-md-search-status] the polite live region that counts whichever list is on screen
*/
@layer components {
[data-search] {
--search-height: 3.5rem;
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@layer material.components {
[data-md-search] {
--search-height: 56px;
/* M3: min 360px, max 720px, and wider while focused. */
--search-width: 100%;
--search-open-width: 720px;
position: relative;
}
[data-search][data-open] {
/* Above the page while open, and while the view leaves: full screen through `data-md-full-screen`,
docked through the `md-transition` class Alpine's `x-transition` carries for the length of the
view's exit, so later positioned content on the page never covers the fading view. */
[data-md-search]:is([data-md-open], [data-md-full-screen], :has(> [data-md-search-view].md-transition)) {
z-index: 50;
}
[data-search-bar] {
/* Docked, the view opens over the page: M3 puts a scrim under it. Behind the bar and the view
inside the root's own stacking context, above everything the page draws outside it. */
[data-md-search-scrim] {
position: fixed;
inset: 0;
z-index: -1;
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
transition-property: opacity;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
}
@starting-style {
[data-md-search-scrim] {
opacity: 0;
}
}
/* Closing (or turning full screen), the scrim fades back out while the view's `x-transition`
holds its `display`: not `allow-discrete`, which Firefox does not honour for `display`. */
[data-md-search]:is(:not([data-md-open]), [data-md-full-screen]) > [data-md-search-scrim] {
opacity: 0;
}
[data-md-search-bar] {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 0.25rem;
gap: var(--md-sys-measurement-space50);
width: min(100%, var(--search-width));
height: var(--search-height);
padding-inline: 0.25rem;
padding-inline: 12px;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-surface-container-high);
color: var(--md-sys-color-on-surface);
cursor: text;
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
transition-property: background-color, width, padding-inline;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
}
/* Focused: wider, and the padding drops from 24px to the contained style's 16px. */
[data-md-search][data-md-open] [data-md-search-bar] {
width: min(100%, var(--search-open-width));
padding-inline: var(--md-sys-measurement-space50);
}
@media (hover: hover) {
[data-search]:not([data-open]) [data-search-bar]:hover {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, var(--md-sys-color-surface-container-high));
[data-md-search]:not([data-md-open]) [data-md-search-bar]:hover {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-hover-state-layer-opacity) * 100%), var(--md-sys-color-surface-container-high));
}
}
[data-search][data-open] [data-search-bar] {
[data-md-search][data-md-open] [data-md-search-bar] {
background-color: transparent;
}
[data-search-leading],
[data-search-clear] {
[data-md-search-leading],
[data-md-search-clear] {
display: grid;
flex: none;
place-items: center;
width: 3rem;
height: 3rem;
width: var(--md-sys-measurement-space600);
height: var(--md-sys-measurement-space600);
border-radius: var(--md-sys-shape-corner-full);
}
[data-search-leading] {
[data-md-search-leading] {
color: var(--md-sys-color-on-surface);
}
[data-search-clear] {
[data-md-search-clear] {
color: var(--md-sys-color-on-surface-variant);
cursor: pointer;
}
[data-search-bar] button {
[data-md-search-bar] button {
outline: none;
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
[data-search-bar] button:hover {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 8%, transparent);
[data-md-search-bar] button:hover {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-hover-state-layer-opacity) * 100%), transparent);
}
}
[data-search-bar] button:focus-visible {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, transparent);
[data-md-search-bar] button:focus-visible {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-focus-state-layer-opacity) * 100%), transparent);
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: -3px;
}
[data-search-input] {
/* The combobox wrapper is only a role holder; it has to lay out as the input used to. */
[data-md-search-field] {
display: flex;
flex: 1 1 0%;
min-width: 0;
height: 100%;
padding-inline: 0.25rem;
}
[data-md-search-input] {
flex: 1 1 0%;
min-width: 0;
height: 100%;
padding-inline: var(--md-sys-measurement-space50);
appearance: none;
background: transparent;
color: var(--md-sys-color-on-surface);
@@ -100,83 +168,165 @@
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
}
[data-search-input]::placeholder {
[data-md-search-input]::placeholder {
color: var(--md-sys-color-on-surface-variant);
opacity: 1;
}
[data-search-input]::-webkit-search-cancel-button,
[data-search-input]::-webkit-search-decoration {
[data-md-search-input]::-webkit-search-cancel-button,
[data-md-search-input]::-webkit-search-decoration {
appearance: none;
display: none;
}
[data-search-bar]:has([data-search-input]:placeholder-shown) [data-search-clear] {
[data-md-search-bar]:has([data-md-search-input]:placeholder-shown) [data-md-search-clear] {
display: none;
}
[data-search-trailing] {
[data-md-search-trailing] {
display: flex;
flex: none;
align-items: center;
gap: 0.25rem;
padding-inline-end: 0.25rem;
gap: var(--md-sys-measurement-space50);
padding-inline-end: var(--md-sys-measurement-space50);
}
[data-search][data-open] [data-search-trailing] {
[data-md-search][data-md-open] [data-md-search-trailing] {
display: none;
}
[data-search-view] {
[data-md-search-view] {
position: absolute;
inset-inline: 0;
top: 0;
display: flex;
flex-direction: column;
max-height: min(40rem, 70dvh);
max-width: var(--search-open-width);
max-height: min(640px, 70dvh);
padding-top: var(--search-height);
overflow: hidden;
border-radius: var(--md-sys-shape-corner-xl);
background-color: var(--md-sys-color-surface-container-high);
box-shadow: var(--md-sys-elevation-3);
transform-origin: top;
transition-property: opacity, scale, display;
transition-property: opacity, scale;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
transition-behavior: allow-discrete;
}
@starting-style {
[data-search-view] {
[data-md-search-view] {
opacity: 0;
scale: 1 0.9;
}
}
[data-search-results] {
/* Closing, the view goes back the way it came, into the bar, while its `x-transition` holds
`display` for the same duration. */
[data-md-search]:not([data-md-open]) > [data-md-search-view] {
opacity: 0;
scale: 1 0.9;
}
/* M3: the docked container is at least 240px tall once there is something in it to be tall
about; a search with nothing to show stays the height of its bar. */
[data-md-search-view]:has([data-md-search-results], [data-md-search-suggestions]) {
min-height: 240px;
}
/* No divider: that belongs to the divided style, which Expressive deprecates in favour of the
contained one this file draws. The suggestions stand in the same place, before the first
keystroke; only one of the two is ever on screen. */
[data-md-search-results],
[data-md-search-suggestions] {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
border-top: 1px solid var(--md-sys-color-outline);
padding-block: 0.5rem;
padding-block: var(--md-sys-measurement-space100);
}
/* M3's second entry point: search as a secondary action, one icon button that expands into the
full-screen view. The root keeps the button's 48px whether the view is open or not, so a
toolbar does not shift under it, and the bar is the view's header rather than a resting bar. */
[data-md-search][data-md-trigger='icon'] {
flex: none;
width: var(--md-sys-measurement-space600);
height: var(--md-sys-measurement-space600);
}
[data-md-search-trigger] {
display: grid;
place-items: center;
width: var(--md-sys-measurement-space600);
height: var(--md-sys-measurement-space600);
border-radius: var(--md-sys-shape-corner-full);
color: var(--md-sys-color-on-surface-variant);
cursor: pointer;
outline: none;
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
[data-md-search-trigger]:hover {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-hover-state-layer-opacity) * 100%), transparent);
}
}
[data-md-search-trigger]:focus-visible {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-focus-state-layer-opacity) * 100%), transparent);
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: -3px;
}
[data-md-search-trigger]:active {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-pressed-state-layer-opacity) * 100%), transparent);
}
[data-md-search][data-md-trigger='icon']:not([data-md-open], [data-md-full-screen]) [data-md-search-bar] {
display: none;
}
/* Full screen, on a compact window. */
[data-search][data-full-screen] [data-search-bar] {
[data-md-search][data-md-full-screen] [data-md-search-bar] {
position: fixed;
inset: 0 0 auto;
height: calc(4.5rem + env(safe-area-inset-top));
padding-top: env(safe-area-inset-top);
padding-inline: 0.25rem;
width: auto;
height: calc(var(--md-sys-measurement-space900) + var(--material-safe-top, env(safe-area-inset-top)));
padding-top: var(--material-safe-top, env(safe-area-inset-top));
padding-inline: var(--md-sys-measurement-space50);
border-radius: 0;
}
[data-search][data-full-screen] [data-search-view] {
/* Leaving full screen (`data-md-full-screen` outlives `data-md-open` by the view's exit,
resources/js/search.js): the header bar fades with the view, on the view's own spring, rather
than going back to its resting pill or to nothing, behind the icon on the first frame. */
[data-md-search][data-md-full-screen]:not([data-md-open]) [data-md-search-bar] {
opacity: 0;
background-color: transparent;
transition-property: opacity;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
[data-md-search][data-md-full-screen] [data-md-search-view] {
position: fixed;
inset: 0;
max-width: none;
max-height: none;
padding-top: calc(4.5rem + env(safe-area-inset-top));
padding-top: calc(var(--md-sys-measurement-space900) + var(--material-safe-top, env(safe-area-inset-top)));
/* The full-screen layout has its own container role, one step from the docked one. */
background-color: var(--md-sys-color-surface-container-low);
border-radius: 0;
box-shadow: none;
}
/* What the caller says when nothing matches: body-medium in on-surface-variant, padded like a
list row (16px across, 12px down). */
[data-md-search-empty] {
padding: 12px var(--md-sys-measurement-space200);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
font-variation-settings: normal;
}
}
+75
View File
@@ -0,0 +1,75 @@
/*
* <x-section-nav>: the navigation inside one area of an application M3's secondary tabs from
* `medium` (600px), five or more of them the scrollable tab bar M3's own accessibility page
* blesses rather than a grid that would break the tab bar's divider and strand the upper rows'
* indicators against nothing (docs/audits/m3-alignment/navigation.md N-16); below `medium`, where a
* row of them never fits, a button naming the current section opens the same list as a menu.
*
* [data-md-section-nav] min-inline-size: 0
* [data-md-section-nav-picker] hidden from medium
* [data-md-section-nav-trigger] 48px, an outlined field-like button
* [data-md-section-nav-trigger-label] the current section's title, truncated
* nav hidden below medium
* [data-md-tabs-bar] tabs.css, reused whole
*
* Reuses `<x-tabs>`'s own hooks and stylesheet rather than a tab bar of its own: a link marked
* `aria-current="page"` takes the same active indicator and colour tabs.css already draws for
* `aria-selected="true"` (N-21), so nothing here repeats a rule tabs.css owns; the only addition is
* this bar's own list items — flex rows, so tabs.css's `li > [data-md-tab]` share of the width
* applies, and `min-inline-size: 0`, so a long title in a fixed row wraps to its second line and
* truncates there (tabs.css, M3's "may wrap to a max second line if needed with truncation")
* instead of widening its item which `<x-tabs>`'s buttons, direct children of the bar, never
* need. Its tabs render `<x-tabs>`'s label anatomy (`data-md-tab-label` around
* `data-md-tab-text`), so that rule draws both. The picker's trigger is not one of M3's controls
* an outlined field-like button naming the current section, 48px tall to meet the target M3 asks
* for everywhere, in body-large type (a text class on the view, `md-type-body-lg
* md-ink md-text-start`, since it is prose on a plain button rather than a component's own type).
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './tabs.css';
@import './icon.css';
@import './menu.css';
@import './menu-item.css';
@import './badge.css';
@layer material.components {
[data-md-section-nav] {
min-inline-size: 0;
}
[data-md-section-nav-picker] {
@media (width >= 600px) {
display: none;
}
}
[data-md-section-nav] > nav {
@media (width < 600px) {
display: none;
}
}
[data-md-section-nav-trigger] {
display: flex;
align-items: center;
gap: 12px;
block-size: var(--md-sys-measurement-space600);
inline-size: min(320px, calc(100vw - 32px));
cursor: pointer;
border: 1px solid var(--md-sys-color-outline);
border-radius: var(--md-sys-shape-corner-xs);
padding-inline: var(--md-sys-measurement-space200);
}
[data-md-section-nav-trigger-label] {
min-inline-size: 0;
flex: 1 1 0%;
}
[data-md-section-nav] [data-md-tabs-bar] > li {
display: flex;
min-inline-size: 0;
}
}
+75
View File
@@ -0,0 +1,75 @@
/*
* <x-select>: the native <select> in the text field's chrome, drawn as M3's exposed dropdown menu
* (resources/views/components/select.blade.php).
*
* The chrome is the field's (components/field.css); this file is what a <select> changes in it. The
* select covers its whole field, so a press anywhere opens it and the list the browser anchors to
* it is the field's width. Where the browser has the customizable select (`appearance:
* base-select`), the closed select loses the button chrome that brings, the field reads as focused
* while the list is open, and the arrow turns over. The open list itself is M3's menu, drawn by
* components/menu.css.
*
* The field's root carries `data-md-select`.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './field.css';
@import './icon.css';
@import './menu.css';
@layer material.components {
/* A select covers its whole field out over the padding and the leading icon at
the start, under the arrow to the end and its own padding puts the value back
where it was. So a press anywhere on the field opens it, and the list, which
the browser anchors to the select, is the field's width (components/menu.css). */
select[data-md-field-control] {
--field-end: calc(var(--field-icon) + var(--field-gap) + var(--field-pad));
appearance: none;
cursor: pointer;
margin-inline: calc(-1 * var(--field-start)) calc(-1 * var(--field-end));
padding-inline: var(--field-start) var(--field-end);
}
select[data-md-field-control]:disabled {
cursor: default;
}
/* Where the browser has a customizable select, the closed select is its own
button: without the border, padding and arrow that button brings, it is the
same field as before. While its list is open the field reads as focused
said from `:open`, because focus inside the list is in the top layer and
the arrow turns over, as the searchable choices' does. */
@supports (appearance: base-select) {
select[data-md-field-control] {
appearance: base-select;
display: flex;
align-items: center;
border: 0;
padding-block: 0;
border-radius: 0;
background: transparent;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
select[data-md-field-control]::picker-icon {
display: none;
}
[data-md-field-box]:has(select[data-md-field-control]:open) {
--field-edge: var(--md-sys-color-primary);
--field-ink: var(--md-sys-color-primary);
[data-md-field-outline] {
border-width: 2px;
}
}
[data-md-field]:has(select[data-md-field-control]:open) [data-md-field-arrow] {
rotate: 180deg;
}
}
}
+69 -294
View File
@@ -1,66 +1,90 @@
/*
* M3's selection controls: the checkbox, the radio button and the switch (CheckboxTokens,
* RadioButtonTokens, SwitchTokens, androidx Compose Material 3, Apache-2.0).
* What M3's three selection controls share: the checkbox (components/checkbox.css), the radio button
* (components/radio.css) and the switch (components/toggle.css) CheckboxTokens, RadioButtonTokens,
* SwitchTokens, androidx Compose Material 3, Apache-2.0. Each control's stylesheet imports this one.
*
* Each is a real `<input>` with its appearance removed and drawn over, so focus, the keyboard
* (Space; the arrow keys between radios), the form value, `wire:model` and what a screen reader
* announces stay the browser's. The row around it is a `<label>`, so its text is pressable too.
* Hooks are data attributes, not class names: `checkbox`, `radio` and `toggle` are daisyUI's, which
* the design guard rejects.
* announces stay the browser's. The row around it is a `<label>`, so its text is pressable too:
*
* [data-selection] the <label> row; `data-invalid` when the server has an error for it
* [data-checkbox] the 18px box, its state layer a 40px circle around it
* input, [data-check], [data-mixed]
* [data-radio] the 20px ring and its 10px dot
* input, [data-dot]
* [data-switch] the 52×32 track; `data-icons` = "both" or "selected"
* input, [data-handle] (with [data-on] and [data-off] icons)
* [data-md-selection-row] the <label>: the control and its text, 16px apart; with a hint
* the control aligns to the first line; `data-md-right` puts the
* control at the end; `data-md-invalid` when the server has an error
* [data-md-checkbox-box] | [data-md-radio-button] | [data-md-switch] the control
* [data-md-selection-text] the label (body-large, on-surface) and the hint under it
* [data-md-selection-label], [data-md-selection-hint]
*
* Every state layer is on-surface when unselected and primary when selected, 8% on hover (only
* where a pointer can hover) and 10% on focus and press; the focus indicator is the package's
* secondary ring. A disabled control is on-surface at 38% and has no state layer.
* Every state layer is on-surface when unselected and primary when selected, at M3's hover opacity
* (only where a pointer can hover) and its focus and press opacities (tokens/state.css); the focus
* indicator is the package's secondary ring. A disabled control is on-surface at 38% and has no
* state layer.
*
* The state layer is a ::before with no pointer events, so it is not a target: a labelled control
* is pressed anywhere along its row, and a control with no label wears `md-touch-target`
* (foundation/interaction.css) so its box catches presses over M3's 48px minimum.
*/
@layer components {
[data-selection] {
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-selection-row] {
/* The state layer's tint. */
--selection-ink: var(--md-sys-color-on-surface);
display: flex;
align-items: center;
gap: var(--md-sys-measurement-space200);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
[data-selection]:has(input:disabled) {
[data-md-selection-row]:has([data-md-selection-hint]) {
align-items: flex-start;
}
[data-md-selection-row][data-md-right] {
flex-direction: row-reverse;
justify-content: space-between;
}
[data-md-selection-row]:has(input:disabled) {
cursor: default;
}
[data-selection]:has(input:disabled) [data-selection-label] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
[data-md-selection-text] {
min-width: 0;
}
[data-checkbox],
[data-radio] {
position: relative;
display: grid;
flex: none;
place-items: center;
[data-md-selection-label] {
display: block;
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
font-variation-settings: normal;
}
[data-checkbox] > *,
[data-radio] > * {
grid-area: 1 / 1;
[data-md-selection-hint] {
display: block;
margin-top: var(--md-sys-measurement-space25);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
font-variation-settings: normal;
}
[data-checkbox]::before,
[data-radio]::before,
[data-handle]::before {
content: "";
[data-md-selection-row]:has(input:disabled) [data-md-selection-label] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
}
:is([data-md-checkbox-box], [data-md-radio-button], [data-md-switch-handle])::before {
content: '';
position: absolute;
border-radius: var(--md-sys-shape-corner-full);
pointer-events: none;
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-checkbox] input,
[data-radio] input,
[data-switch] input {
:is([data-md-checkbox-box], [data-md-radio-button], [data-md-switch]) input {
margin: 0;
appearance: none;
cursor: inherit;
@@ -70,275 +94,26 @@
transition-timing-function: var(--md-sys-motion-effects-fast);
}
/* The state layer. `--selection-ink` is what it is tinted with. */
[data-selection] {
--selection-ink: var(--md-sys-color-on-surface);
}
[data-selection]:has(input:checked),
[data-selection]:has(input:indeterminate) {
[data-md-selection-row]:has(input:checked),
[data-md-selection-row]:has(input:indeterminate) {
--selection-ink: var(--md-sys-color-primary);
}
[data-selection][data-invalid] {
[data-md-selection-row][data-md-invalid] {
--selection-ink: var(--md-sys-color-error);
}
@media (hover: hover) {
[data-selection]:hover:not(:has(input:disabled)) :is([data-checkbox], [data-radio], [data-handle])::before {
background-color: color-mix(in srgb, var(--selection-ink) 8%, transparent);
[data-md-selection-row]:hover:not(:has(input:disabled)) :is([data-md-checkbox-box], [data-md-radio-button], [data-md-switch-handle])::before {
background-color: color-mix(in srgb, var(--selection-ink) calc(var(--md-sys-state-hover-state-layer-opacity) * 100%), transparent);
}
}
[data-selection]:has(input:focus-visible) :is([data-checkbox], [data-radio], [data-handle])::before,
[data-selection]:not(:has(input:disabled)):active :is([data-checkbox], [data-radio], [data-handle])::before {
background-color: color-mix(in srgb, var(--selection-ink) 10%, transparent);
[data-md-selection-row]:has(input:focus-visible) :is([data-md-checkbox-box], [data-md-radio-button], [data-md-switch-handle])::before {
background-color: color-mix(in srgb, var(--selection-ink) calc(var(--md-sys-state-focus-state-layer-opacity) * 100%), transparent);
}
[data-checkbox]:has(input:focus-visible)::before,
[data-radio]:has(input:focus-visible)::before {
outline: 3px solid var(--md-sys-color-secondary);
}
/* ---- Checkbox ---------------------------------------------------------------------------- */
[data-checkbox] {
width: 1.125rem;
height: 1.125rem;
}
[data-checkbox]::before {
inset: -0.6875rem;
}
[data-checkbox] input {
width: 1.125rem;
height: 1.125rem;
border: 2px solid var(--md-sys-color-on-surface-variant);
border-radius: 2px;
}
[data-checkbox] input:is(:checked, :indeterminate) {
border-color: var(--md-sys-color-primary);
background-color: var(--md-sys-color-primary);
}
[data-checkbox] :is([data-check], [data-mixed]) {
color: var(--md-sys-color-on-primary);
opacity: 0;
scale: 0.5;
pointer-events: none;
transition-property: opacity, scale;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
[data-checkbox] input:checked:not(:indeterminate) ~ [data-check],
[data-checkbox] input:indeterminate ~ [data-mixed] {
opacity: 1;
scale: 1;
}
[data-selection][data-invalid] [data-checkbox] input {
border-color: var(--md-sys-color-error);
}
[data-selection][data-invalid] [data-checkbox] input:is(:checked, :indeterminate) {
background-color: var(--md-sys-color-error);
}
[data-selection][data-invalid] [data-checkbox] :is([data-check], [data-mixed]) {
color: var(--md-sys-color-on-error);
}
[data-checkbox] input:disabled {
border-color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
}
[data-checkbox] input:disabled:is(:checked, :indeterminate) {
border-color: transparent;
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
}
[data-checkbox] input:disabled ~ :is([data-check], [data-mixed]) {
color: var(--md-sys-color-surface);
}
/* ---- Radio button ------------------------------------------------------------------------ */
[data-radio] {
width: 1.25rem;
height: 1.25rem;
}
[data-radio]::before {
inset: -0.625rem;
}
[data-radio] input {
width: 1.25rem;
height: 1.25rem;
border: 2px solid var(--md-sys-color-on-surface-variant);
border-radius: var(--md-sys-shape-corner-full);
}
[data-radio] [data-dot] {
width: 0.625rem;
height: 0.625rem;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-primary);
scale: 0;
pointer-events: none;
transition: scale var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast);
}
[data-radio] input:checked {
border-color: var(--md-sys-color-primary);
}
[data-radio] input:checked ~ [data-dot] {
scale: 1;
}
[data-selection][data-invalid] [data-radio] input {
border-color: var(--md-sys-color-error);
}
[data-selection][data-invalid] [data-radio] [data-dot] {
background-color: var(--md-sys-color-error);
}
[data-radio] input:disabled {
border-color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
}
[data-radio] input:disabled ~ [data-dot] {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
}
/* ---- Switch -------------------------------------------------------------------------------
*
* The handle's centre is 16px from the track's start unselected and 36px selected; it is 16px
* across unselected, 24px with an icon or selected, and 28px while pressed. The positions below
* are those centres less half the size, measured inside the track's 2px outline. */
[data-switch] {
position: relative;
display: grid;
flex: none;
width: 3.25rem;
height: 2rem;
}
[data-switch] input {
width: 100%;
height: 100%;
border: 2px solid var(--md-sys-color-outline);
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-surface-container-highest);
}
[data-switch] input:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
[data-handle] {
--handle: 1rem;
position: absolute;
top: 50%;
inset-inline-start: calc(1rem - var(--handle) / 2);
display: grid;
place-items: center;
width: var(--handle);
height: var(--handle);
translate: 0 -50%;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-outline);
color: var(--md-sys-color-surface-container-highest);
pointer-events: none;
transition-property: inset-inline-start, width, height, background-color;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
[data-handle]::before {
inset: 50% auto auto 50%;
width: 2.5rem;
height: 2.5rem;
translate: -50% -50%;
}
[data-handle] > svg {
grid-area: 1 / 1;
opacity: 0;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-switch][data-icons="both"] [data-handle] {
--handle: 1.5rem;
}
[data-switch][data-icons="both"] input:not(:checked) ~ [data-handle] [data-off],
[data-switch][data-icons] input:checked ~ [data-handle] [data-on] {
opacity: 1;
}
[data-switch] input:checked {
border-color: var(--md-sys-color-primary);
background-color: var(--md-sys-color-primary);
}
[data-switch] input:checked ~ [data-handle] {
--handle: 1.5rem;
inset-inline-start: calc(2.25rem - var(--handle) / 2);
background-color: var(--md-sys-color-on-primary);
color: var(--md-sys-color-on-primary-container);
}
[data-selection]:active [data-switch] input:not(:disabled) ~ [data-handle] {
--handle: 1.75rem;
}
@media (hover: hover) {
[data-selection]:hover [data-switch] input:not(:disabled) ~ [data-handle] {
background-color: var(--md-sys-color-on-surface-variant);
}
[data-selection]:hover [data-switch] input:checked:not(:disabled) ~ [data-handle] {
background-color: var(--md-sys-color-primary-container);
}
}
[data-switch] input:focus-visible ~ [data-handle],
[data-selection]:active [data-switch] input:not(:disabled) ~ [data-handle] {
background-color: var(--md-sys-color-on-surface-variant);
}
[data-switch] input:checked:focus-visible ~ [data-handle],
[data-selection]:active [data-switch] input:checked:not(:disabled) ~ [data-handle] {
background-color: var(--md-sys-color-primary-container);
}
[data-switch] input:disabled {
border-color: color-mix(in srgb, var(--md-sys-color-on-surface) 12%, transparent);
background-color: color-mix(in srgb, var(--md-sys-color-surface-container-highest) 12%, transparent);
}
[data-switch] input:disabled ~ [data-handle] {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
color: color-mix(in srgb, var(--md-sys-color-surface-container-highest) 38%, transparent);
}
[data-switch] input:disabled:checked {
border-color: transparent;
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 12%, transparent);
}
[data-switch] input:disabled:checked ~ [data-handle] {
background-color: var(--md-sys-color-surface);
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
[data-md-selection-row]:not(:has(input:disabled)):active :is([data-md-checkbox-box], [data-md-radio-button], [data-md-switch-handle])::before {
background-color: color-mix(in srgb, var(--selection-ink) calc(var(--md-sys-state-pressed-state-layer-opacity) * 100%), transparent);
}
}
+13
View File
@@ -0,0 +1,13 @@
/*
* <x-shape>: one of M3 Expressive's shapes, at `size` when one is given (`--md-shape-size`);
* otherwise the caller's own CSS sizes it.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-shape][style*='--md-shape-size'] {
inline-size: var(--md-shape-size);
block-size: var(--md-shape-size);
}
}
+403
View File
@@ -0,0 +1,403 @@
/*
* <x-slider>: M3 Expressive's slider (resources/views/components/slider.blade.php), drawn over native
* range inputs by resources/js/slider.js, which places every part with inline `left`, `width` and
* `border-radius`; this file gives the parts their size, colour and states.
*
* From androidx Compose Material 3 at commit 27cf9a7 (Slider.kt, SliderTokens.kt, Apache-2.0) and
* M3's sliders/specs table: a 4px handle, 2px while pressed or focused and 6px shorter while its
* focus ring shows (Material Components' m3_slider_focus_ring_thumb_height_decrease); 4px stop
* indicators and ticks; the active track and the handle in the colour, the inactive track in its
* container, and a stop, a tick or the inset icon in the "on" colour of the part of the track it
* sits on. Disabled, the active parts are on-surface at 38% and the inactive track, and the ticks
* over the active track, at 12% (tokens/state.css). The value label is M3's label container: 44px
* tall and at least 48px wide in inverse-surface with label-large, 4px above the handle, growing in
* on the fast effects spring while the handle is pressed or focused (`data-md-value-label="drag"`).
*
* Expressive's sizes, by `data-md-size`, as custom properties on the root:
*
* size track handle (and the slider across) inset icon
* xs 16px 44px (the slider 48px)
* sm 24px 44px (48px)
* md 40px 52px 24px
* lg 56px 68px 24px
* xl 96px 108px 32px
*
* `data-md-orientation="vertical"` turns the same drawing a quarter inside a size container: the
* slider is as wide as a horizontal one is tall and 192px long unless the root's height says
* otherwise; the value label turns back so it reads across, beside the handle. A right-to-left page
* mirrors a horizontal drawing and turns its label and icon back. Without script the inputs show as
* native ranges in the colour.
*
* [data-md-slider] the root; data-md-size, data-md-color, data-md-value-label,
* data-md-orientation, data-md-centered
* [data-md-slider-label]
* [data-md-slider-control] the element the pointer presses; holds the inputs
* [data-md-slider-drawing]
* [data-md-slider-track]
* [data-md-slider-segment="start|active|end"], [data-md-slider-tick] (data-md-active),
* [data-md-slider-stop="start|end"], [data-md-slider-icon] (data-md-active)
* [data-md-slider-handle="start|end"] (data-md-pressed, data-md-focused)
* [data-md-slider-thumb], [data-md-slider-value-anchor] > [data-md-slider-value]
* [data-md-slider-input="start|end"]
* [data-md-slider-hint] | [data-md-slider-errors]
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@layer material.components {
[data-md-slider] {
--slider-active: var(--md-sys-color-primary);
--slider-inactive: var(--md-sys-color-secondary-container);
--slider-on-active: var(--md-sys-color-on-primary);
--slider-on-inactive: var(--md-sys-color-on-secondary-container);
--slider-disabled-active: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
--slider-disabled-inactive: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-container-opacity) * 100%), transparent);
--slider-across: 48px;
--slider-track: 16px;
--slider-handle: 44px;
width: 100%;
min-width: 0;
}
[data-md-slider][data-md-size='sm'] {
--slider-track: 24px;
}
[data-md-slider][data-md-size='md'] {
--slider-across: 52px;
--slider-track: 40px;
--slider-handle: 52px;
}
[data-md-slider][data-md-size='lg'] {
--slider-across: 68px;
--slider-track: 56px;
--slider-handle: 68px;
}
[data-md-slider][data-md-size='xl'] {
--slider-across: 108px;
--slider-track: 96px;
--slider-handle: 108px;
}
[data-md-slider][data-md-color='secondary'] {
--slider-active: var(--md-sys-color-secondary);
--slider-on-active: var(--md-sys-color-on-secondary);
}
[data-md-slider][data-md-color='tertiary'] {
--slider-active: var(--md-sys-color-tertiary);
--slider-inactive: var(--md-sys-color-tertiary-container);
--slider-on-active: var(--md-sys-color-on-tertiary);
--slider-on-inactive: var(--md-sys-color-on-tertiary-container);
}
[data-md-slider][data-md-color='error'] {
--slider-active: var(--md-sys-color-error);
--slider-inactive: var(--md-sys-color-error-container);
--slider-on-active: var(--md-sys-color-on-error);
--slider-on-inactive: var(--md-sys-color-on-error-container);
}
[data-md-slider][data-md-color='success'] {
--slider-active: var(--md-sys-color-success);
--slider-inactive: var(--md-sys-color-success-container);
--slider-on-active: var(--md-sys-color-on-success);
--slider-on-inactive: var(--md-sys-color-on-success-container);
}
[data-md-slider][data-md-color='warning'] {
--slider-active: var(--md-sys-color-warning);
--slider-inactive: var(--md-sys-color-warning-container);
--slider-on-active: var(--md-sys-color-on-warning);
--slider-on-inactive: var(--md-sys-color-on-warning-container);
}
[data-md-slider][data-md-color='info'] {
--slider-active: var(--md-sys-color-info);
--slider-inactive: var(--md-sys-color-info-container);
--slider-on-active: var(--md-sys-color-on-info);
--slider-on-inactive: var(--md-sys-color-on-info-container);
}
/* Disabled: the colours give way to on-surface at M3's disabled opacities. */
[data-md-slider]:has([data-md-slider-input]:disabled) {
--slider-active: var(--slider-disabled-active);
--slider-inactive: var(--slider-disabled-inactive);
--slider-on-active: var(--slider-disabled-inactive);
--slider-on-inactive: var(--slider-disabled-active);
}
[data-md-slider][data-md-orientation='vertical'] {
display: flex;
flex-direction: column;
width: fit-content;
}
[data-md-slider-label] {
display: block;
margin-bottom: var(--md-sys-measurement-space100);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
}
[data-md-slider-control] {
position: relative;
height: var(--slider-across);
cursor: pointer;
touch-action: pan-y;
-webkit-user-select: none;
user-select: none;
}
[data-md-slider-control]:has(:disabled) {
cursor: not-allowed;
}
/* Standing up: as wide as a lying slider is tall, 192px long unless the root says otherwise, and
a size container, which lets the drawing be as long as this is tall. */
[data-md-slider][data-md-orientation='vertical'] [data-md-slider-control] {
flex: auto;
width: var(--slider-across);
height: 192px;
min-height: 0;
container-type: size;
touch-action: pan-x;
}
/* Room for a label that never hides: half the handle, 4px and the 44px pill above the track
lying down, beside it standing up. */
[data-md-slider][data-md-value-label='always'] [data-md-slider-control] {
margin-top: var(--md-sys-measurement-space600);
}
[data-md-slider][data-md-value-label='always'][data-md-orientation='vertical'] [data-md-slider-control] {
margin-top: 0;
margin-inline-start: var(--md-sys-measurement-space600);
}
[data-md-slider-drawing] {
position: absolute;
inset-block: 0;
inset-inline: var(--md-sys-measurement-space25);
pointer-events: none;
&:is([dir='rtl'], [dir='rtl'] *) {
scale: -1 1;
}
}
/* The same drawing, laid out as long as the slider is tall and turned a quarter anticlockwise so
the value grows upwards. Everything inside it is placed as if the slider were lying down. */
[data-md-slider][data-md-orientation='vertical'] [data-md-slider-drawing] {
inset: auto;
top: 50%;
left: 50%;
width: calc(100cqh - var(--md-sys-measurement-space50));
height: 100cqw;
translate: -50% -50%;
rotate: -90deg;
scale: none;
}
[data-md-slider-track] {
position: absolute;
inset-inline: 0;
top: 50%;
height: var(--slider-track);
translate: 0 -50%;
}
[data-md-slider-segment] {
position: absolute;
inset-block: 0;
background-color: var(--slider-inactive);
}
[data-md-slider-segment='active'] {
background-color: var(--slider-active);
}
:is([data-md-slider-tick], [data-md-slider-stop]) {
position: absolute;
top: 50%;
width: 4px;
height: 4px;
translate: -50% -50%;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--slider-on-inactive);
}
[data-md-slider-tick][data-md-active] {
background-color: var(--slider-on-active);
}
/* A disabled icon is on-surface at 38% wherever it sits. */
[data-md-slider]:has([data-md-slider-input]:disabled) [data-md-slider-icon] {
color: var(--slider-disabled-active);
}
[data-md-slider-icon] {
position: absolute;
top: 50%;
display: flex;
translate: 0 -50%;
color: var(--slider-on-inactive);
&[data-md-active] {
color: var(--slider-on-active);
}
&:is([dir='rtl'], [dir='rtl'] *) {
scale: -1 1;
}
}
/* Turned back the quarter the drawing turns, so the glyph stands up. */
[data-md-slider][data-md-orientation='vertical'] [data-md-slider-icon] {
rotate: 90deg;
scale: none;
}
[data-md-slider-handle] {
position: absolute;
inset-block: 0;
width: 0;
}
[data-md-slider-thumb] {
position: absolute;
top: 50%;
left: 0;
width: 4px;
height: var(--slider-handle);
translate: -50% -50%;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--slider-active);
outline-offset: 2px;
transition-property: width, height;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
:is([data-md-pressed], [data-md-focused]) > [data-md-slider-thumb] {
width: 2px;
}
[data-md-focused] > [data-md-slider-thumb] {
height: calc(var(--slider-handle) - 6px);
outline: 3px solid var(--md-sys-color-secondary);
}
[data-md-slider-value-anchor] {
position: absolute;
bottom: calc(50% + var(--slider-handle) / 2 + var(--md-sys-measurement-space50));
left: 0;
z-index: 10;
display: flex;
justify-content: center;
translate: -50% 0;
&:is([dir='rtl'], [dir='rtl'] *) {
scale: -1 1;
}
}
[data-md-slider][data-md-orientation='vertical'] [data-md-slider-value-anchor] {
scale: none;
}
[data-md-slider-value] {
display: flex;
align-items: center;
justify-content: center;
min-width: var(--md-sys-measurement-space600);
height: 44px;
padding-inline: var(--md-sys-measurement-space125);
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-inverse-surface);
color: var(--md-sys-color-inverse-on-surface);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
white-space: nowrap;
transform-origin: bottom;
transition-property: opacity, scale;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
}
/* Turned back the quarter the drawing turns: the pill lands beside the handle and reads across. */
[data-md-slider][data-md-orientation='vertical'] [data-md-slider-value] {
transform-origin: center;
rotate: 90deg;
}
[data-md-slider][data-md-value-label='drag'] [data-md-slider-value] {
opacity: 0;
scale: 0.75;
}
[data-md-slider][data-md-value-label='drag'] :is([data-md-pressed], [data-md-focused]) [data-md-slider-value] {
opacity: 1;
scale: 1;
}
[data-md-slider-input] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
appearance: none;
background: transparent;
opacity: 0;
pointer-events: none;
accent-color: var(--slider-active);
}
[data-md-slider-hint],
[data-md-slider-errors] > p {
margin-top: var(--md-sys-measurement-space50);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
font-variation-settings: normal;
}
[data-md-slider-errors] > p {
color: var(--md-sys-color-error);
}
/* Without script there is no drawing: the inputs show as native ranges, one under the other. */
@media (scripting: none) {
:is([data-md-slider], [data-md-slider][data-md-orientation]) [data-md-slider-control] {
height: auto;
cursor: auto;
}
[data-md-slider-control] > :not(:last-child) {
margin-block-end: var(--md-sys-measurement-space100);
}
[data-md-slider-drawing] {
display: none;
}
[data-md-slider-input] {
position: static;
display: block;
width: 100%;
height: 24px;
appearance: auto;
opacity: 1;
pointer-events: auto;
}
}
}
+58
View File
@@ -0,0 +1,58 @@
/*
* <x-sort-header>: a column header that sorts the table (resources/views/components/sort-header.blade.php),
* inside <x-table> (components/table.css), which draws the cell itself.
*
* The button is the header's own text, in the header cell's type and colour, with a 16px arrow 4px
* after it; the corner is extra-small, for the focus ring the button wears (`md-focus-ring`), and
* `md-touch-target` gives the one-line button M3's 48px target (foundation/interaction.css). The
* sorted column (`data-md-active`) reads in on-surface with its arrow showing the direction; another
* column turns on-surface only while hovered, and shows a faint upward arrow while hovered or
* focused, at 60% of the ink a hint of what pressing would do, not a state M3 names, fading on the
* fast effects spring.
*
* [data-md-sort-header] the <th>; data-md-active, aria-sort
* [data-md-sort-header-button]
* [data-md-sort-header-arrow]
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@layer material.components {
[data-md-sort-header-button] {
display: inline-flex;
align-items: center;
gap: var(--md-sys-measurement-space50);
border-radius: var(--md-sys-shape-corner-xs);
cursor: pointer;
}
[data-md-sort-header][data-md-active] [data-md-sort-header-button] {
color: var(--md-sys-color-on-surface);
}
@media (hover: hover) {
[data-md-sort-header-button]:hover {
color: var(--md-sys-color-on-surface);
}
}
[data-md-sort-header-arrow] {
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-md-sort-header]:not([data-md-active]) [data-md-sort-header-arrow] {
opacity: 0;
}
[data-md-sort-header]:not([data-md-active]) [data-md-sort-header-button]:focus-visible [data-md-sort-header-arrow] {
opacity: 0.6;
}
@media (hover: hover) {
[data-md-sort-header]:not([data-md-active]) [data-md-sort-header-button]:hover [data-md-sort-header-arrow] {
opacity: 0.6;
}
}
}
+116
View File
@@ -0,0 +1,116 @@
/*
* <x-split-button>: an action and a menu of its alternatives, as two `<x-button>`s 2px apart.
*
* The halves share one shape (SplitButtonXSmallXLargeTokens, androidx Compose Material 3,
* Apache-2.0): outer corners full half the height, 16/20/28/48/68px, never 9999px, which would
* scale the small inner corners away and inner corners of 4/4/4/8/12px that *grow* under the
* finger to 8/12/12/20/20px, the opposite of a connected button group. The trailing half turns
* fully round while its menu is open (`aria-expanded`). `<x-button>`'s own corner rules carry no
* specificity, so these win.
*
* At the two smallest sizes the leading half keeps less room on its inner side (10px at `xs`,
* 12px at `sm`) and the trailing half is 48px wide. The trailing chevron has its own size,
* 22/22/26/38/50px, which the icon-button table does not know about, and sits
* `--md-split-button-nudge` (1/1/2/3/6px) towards the leading half so the pair reads as one
* control: equal and opposite inline margins move it by exactly that much inside a centred flex
* row, in whichever direction the writing mode runs. It turns over while the menu is open, on M3's
* standard scheme rather than the expressive springs (the site: "the trailing button uses the
* standard motion scheme, not expressive, when rotating").
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './button.css';
@import './menu.css';
@layer material.components {
[data-md-split-button] {
--md-split-button-inner: var(--md-sys-shape-corner-xs);
--md-split-button-inner-pressed: var(--md-sys-shape-corner-md);
--md-split-button-full: 20px;
--md-split-button-icon: 22px;
--md-split-button-nudge: 1px;
display: inline-flex;
align-items: center;
gap: var(--md-sys-measurement-space25);
}
[data-md-split-button][data-md-size='xs'] {
--md-split-button-inner-pressed: var(--md-sys-shape-corner-sm);
--md-split-button-full: 16px;
}
[data-md-split-button][data-md-size='md'] {
--md-split-button-full: 28px;
--md-split-button-icon: 26px;
--md-split-button-nudge: 2px;
}
[data-md-split-button][data-md-size='lg'] {
--md-split-button-inner: var(--md-sys-shape-corner-sm);
--md-split-button-inner-pressed: var(--md-sys-shape-corner-lg-increased);
--md-split-button-full: 48px;
--md-split-button-icon: 38px;
--md-split-button-nudge: 3px;
}
[data-md-split-button][data-md-size='xl'] {
--md-split-button-inner: var(--md-sys-shape-corner-md);
--md-split-button-inner-pressed: var(--md-sys-shape-corner-lg-increased);
--md-split-button-full: 68px;
--md-split-button-icon: 50px;
--md-split-button-nudge: 6px;
}
[data-md-split-button] [data-md-split] {
--md-split-button-corner: var(--md-split-button-inner);
border-start-start-radius: var(--md-split-button-corner);
border-end-start-radius: var(--md-split-button-corner);
border-start-end-radius: var(--md-split-button-corner);
border-end-end-radius: var(--md-split-button-corner);
}
[data-md-split-button] [data-md-split]:active {
--md-split-button-corner: var(--md-split-button-inner-pressed);
}
[data-md-split-button] [data-md-split='trailing'][aria-expanded='true'] {
--md-split-button-corner: var(--md-split-button-full);
}
[data-md-split-button] [data-md-split='leading'] {
border-start-start-radius: var(--md-split-button-full);
border-end-start-radius: var(--md-split-button-full);
}
[data-md-split-button] [data-md-split='trailing'] {
border-start-end-radius: var(--md-split-button-full);
border-end-end-radius: var(--md-split-button-full);
}
[data-md-split-button][data-md-size='xs'] [data-md-split='leading'] {
padding-inline-end: var(--md-sys-measurement-space125);
}
[data-md-split-button][data-md-size='sm'] [data-md-split='leading'] {
padding-inline-end: 12px;
}
[data-md-split-button]:is([data-md-size='xs'], [data-md-size='sm']) [data-md-split='trailing'] {
inline-size: var(--md-sys-measurement-space600);
}
[data-md-split-button] [data-md-split='trailing'] [data-md-icon] {
inline-size: var(--md-split-button-icon);
block-size: var(--md-split-button-icon);
margin-inline-start: calc(-1 * var(--md-split-button-nudge));
margin-inline-end: var(--md-split-button-nudge);
transition: rotate var(--md-sys-motion-duration-short) var(--md-sys-motion-easing-standard);
}
[data-md-split-button] [data-md-split='trailing'][aria-expanded='true'] [data-md-icon] {
rotate: 180deg;
}
}
+50
View File
@@ -0,0 +1,50 @@
/*
* <x-stat>: a figure and what it counts not an M3 component; drawn in M3's terms, a panel that
* separates from the page in surface-container, a label-large title beside its icon, the value as
* an emphasized-headline-md hero with tabular figures (so the panel never reflows as it counts),
* and a body-small description. resources/js/figure.js counts the value up on first appearance
* and on every change after, and rests under reduced motion.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@layer material.components {
[data-md-stat] {
display: flex;
flex-direction: column;
gap: var(--md-sys-measurement-space50);
border-radius: var(--md-sys-shape-corner-lg);
background-color: var(--md-sys-color-surface-container);
padding: var(--md-sys-measurement-space200);
}
[data-md-stat-header] {
display: flex;
align-items: center;
gap: var(--md-sys-measurement-space100);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
}
[data-md-stat-value] {
font: var(--md-sys-typescale-emphasized-headline-md);
letter-spacing: var(--md-sys-typescale-emphasized-headline-md-tracking);
font-variation-settings: "ROND" 100;
font-variant-numeric: tabular-nums;
}
[data-md-stat-description] {
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
font-variation-settings: normal;
}
[data-md-stat-extra] {
padding-top: var(--md-sys-measurement-space100);
}
}
+47 -32
View File
@@ -2,21 +2,36 @@
* Data tables: `<x-table>` (resources/views/components/table.blade.php), whose callers write plain
* `<thead>`, `<tr>`, `<th>` and `<td>` inside it.
*
* So the styling is descendant selectors on the one attribute the component sets, all inside
* `:where()` and `@layer components`: a caller's `text-end` or `whitespace-nowrap` on a cell always
* wins. Header cells in title-small on-surface-variant over an outline-variant rule, body cells in
* body-medium between faint rules. One density step tighter on a fine pointer keyed on the
* pointer, not the width, so a touch tablet in landscape keeps rows a finger can hit. A row that
* opens something is `data-list-row` and answers a pointer as a list row does (components/list.css).
* So the styling is descendant selectors on the one attribute the component sets, `data-md-table`,
* with every cell and row selector inside `:where()`: a caller's alignment or wrapping on a cell
* always wins, whatever layer it comes from. Header cells in title-small on-surface-variant over an
* outline-variant rule, body cells in body-medium between outline-variant rules the role itself,
* never a fraction of it, since M3 reserves opacity for state layers and disabled. A selected row
* (`aria-selected="true"`) is secondary-container. A row that opens something is `data-md-list-row`
* and answers a pointer as a list row does (components/list-item.css, shared with `<x-card>` and
* `<x-list-item>`); a selected one names its fill `--md-list-row-fill`, so that state layer is laid
* over the secondary-container rather than in place of it.
*
* A row is 52px: 16px above and below a body-medium line, 12px beside each cell. Density is never
* applied by itself "don't apply density by default; offer an explicit density opt-in instead,
* keeping opt-out targets at >= 48x48 CSS px" (docs/reference/m3/foundations-supplement.md
* § Accessibility) so `dense` (`data-md-dense`) is the caller's decision, and it is the caller's
* to justify: a dense row is 36px (8px above and below). `data-md-size="xs"`, for a table inside a
* panel inside a panel, is body-small with label-medium headers, 8px around each cell (32px rows),
* and 4px above and below when dense (24px).
*/
@layer components {
/* `relative` makes the table the containing block for anything absolute inside it an `sr-only`
header label, a tooltip. Without it they escape the scroll box and widen a phone's layout
viewport. */
[data-table] {
--cell-x: 0.75rem;
--cell-y: 0.75rem;
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './list-item.css';
@layer material.components {
/* `position: relative` makes the table the containing block for anything absolute inside it
a visually hidden header label, a tooltip. Without it they escape the scroll box and widen a
phone's layout viewport. */
[data-md-table] {
--cell-x: 12px;
--cell-y: var(--md-sys-measurement-space200);
position: relative;
width: 100%;
@@ -26,31 +41,29 @@
letter-spacing: var(--md-sys-typescale-body-md-tracking);
}
[data-table][data-size="xs"] {
--cell-x: 0.5rem;
--cell-y: 0.5rem;
[data-md-table][data-md-size='xs'] {
--cell-x: var(--md-sys-measurement-space100);
--cell-y: var(--md-sys-measurement-space100);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
}
@media (pointer: fine) {
[data-table] {
--cell-y: 0.5rem;
}
[data-table][data-size="xs"] {
--cell-y: 0.25rem;
}
[data-md-table][data-md-dense] {
--cell-y: var(--md-sys-measurement-space100);
}
[data-table] :where(th, td) {
[data-md-table][data-md-size='xs'][data-md-dense] {
--cell-y: var(--md-sys-measurement-space50);
}
[data-md-table] :where(th, td) {
padding: var(--cell-y) var(--cell-x);
text-align: start;
vertical-align: middle;
}
[data-table] :where(thead th) {
[data-md-table] :where(thead th) {
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-title-sm);
letter-spacing: var(--md-sys-typescale-title-sm-tracking);
@@ -58,21 +71,23 @@
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
[data-table][data-size="xs"] :where(thead th) {
[data-md-table][data-md-size='xs'] :where(thead th) {
font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking);
}
[data-table] :where(tbody tr) {
border-bottom: 1px solid color-mix(in srgb, var(--md-sys-color-outline-variant) 60%, transparent);
[data-md-table] :where(tbody tr) {
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
[data-table] :where(tbody tr:last-child) {
[data-md-table] :where(tbody tr:last-child) {
border-bottom: 0;
}
[data-table] :where(tbody tr[aria-selected="true"]) {
background-color: var(--md-sys-color-secondary-container);
[data-md-table] :where(tbody tr[aria-selected='true']) {
--md-list-row-fill: var(--md-sys-color-secondary-container);
background-color: var(--md-list-row-fill);
color: var(--md-sys-color-on-secondary-container);
}
}
+137 -48
View File
@@ -1,125 +1,207 @@
/*
* M3's tabs (PrimaryNavigationTabTokens, SecondaryNavigationTabTokens, androidx Compose Material 3,
* Apache-2.0): a 48px tablist on the surface over an outline-variant divider (64px when a primary
* tab stacks its icon over its label), title-small labels in on-surface-variant, the chosen tab's
* in primary (primary tabs) or on-surface (secondary tabs).
* `<x-tabs>`, `<x-tab>`: M3's tabs (PrimaryNavigationTabTokens.kt, SecondaryNavigationTabTokens.kt,
* androidx Compose Material 3, Apache-2.0; docs/reference/m3/components-navigation-selection-inputs.md
* § Tabs) a tablist of tabs at least 48px tall on the surface over an outline-variant divider
* (64px when a primary tab stacks its icon over its label), title-small labels in on-surface-variant, the chosen tab's in
* primary (primary tabs) or on-surface (secondary tabs).
*
* [data-md-tabs] min-width: 0
* [data-md-tabs-bar] role="tablist"; data-md-variant, data-md-stacked,
* data-md-scrollable
* [data-md-tab] role="tab"
* [data-md-tab-content] [data-md-icon] (optional), [data-md-tab-label],
* [data-md-tab-indicator]
* [data-md-tab-label] [data-md-tab-text], [data-md-badge] (optional)
* [data-md-tab-panel] role="tabpanel", hidden server-side until chosen (N-05)
*
* "Labels: single row by default; may wrap to a max second line if needed with truncation, or use
* scrollable tabs to give longer titles more room" (§ Tabs, "Behaviour and guidelines"). A fixed
* tab shares the row equally, so its label keeps one row while it fits its share, then wraps to a
* second line and truncates that one with an ellipsis. 48px (64px stacked) is the tab's minimum,
* not its height: the tab grows to hold the second line, and the bar's flex row stretches every
* tab to the tallest, so the tabs keep one height, each content box fills its tab, and every
* indicator stays on the divider under its tab's content. A scrollable tab is as wide as its label
* and keeps it on one row, the room M3 gives longer titles instead.
*
* The active indicator is drawn in every tab and shown under the chosen one, so it is right before
* Alpine starts and after a morph: 3px with rounded top corners under the content of a primary tab
* (at least 24px wide), 2px across the whole of a secondary one. When the choice changes, a view
* transition moves it from the old tab to the new (resources/js/tabs.js), as M3's slides.
* (at least 24px wide, inset 2px at each side N-18), 2px across the whole of a secondary one.
* When the choice changes, a view transition moves it from the old tab to the new (resources/js/tabs.js),
* as M3's slides.
*
* The bar scrolls sideways, so it clips whatever leaves it including the focus ring, which the
* rest of the package draws 3px thick 2px outside the element (tokens/state.css). `--tabs-ring` is
* the room that takes: the bar carries it as padding on every side, and the state layer and the
* indicator reach back out into it, so the divider still sits against the tabs and the indicator
* still sits on the divider (N-20). That makes the bar 10px taller than M3's 48px tab. A scrollable
* set's first tab is offset 52dp from the leading edge, so it reads as scrollable (N-10). A link
* marked `aria-current="page"` (`<x-section-nav>`'s tabs) takes the active indicator and colour
* exactly as `aria-selected="true"` does (N-21).
*/
@layer components {
[data-tabs-bar] {
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@import './badge.css';
@layer material.components {
[data-md-tabs] {
min-width: 0;
}
[data-md-tabs-bar] {
--tabs-ring: 5px;
position: relative;
display: flex;
overflow-x: auto;
overscroll-behavior-x: contain;
padding: var(--tabs-ring);
scroll-padding-inline: var(--tabs-ring);
border-bottom: 1px solid var(--md-sys-color-outline-variant);
scrollbar-width: thin;
}
/* "Offset the first scrollable tab 52dp from the leading edge so it's clear that more content
is available" (N-10). Logical, so a right-to-left page mirrors it. */
[data-md-tabs-bar][data-md-scrollable] {
padding-inline-start: 52px;
scroll-padding-inline-start: 52px;
}
/* A section nav's links sit in list items that share the width. */
[data-tabs-bar] > li {
[data-md-tabs-bar] > li {
flex: 1 1 0%;
}
[data-tabs-bar] > li > [data-tab] {
[data-md-tabs-bar] > li > [data-md-tab] {
flex: 1 1 0%;
}
[data-tab] {
[data-md-tab] {
position: relative;
display: flex;
flex: 1 1 0%;
min-width: 5.625rem;
height: 3rem;
min-width: 90px;
min-height: 48px;
align-items: center;
justify-content: center;
padding-inline: 1rem;
padding-inline: var(--md-sys-measurement-space200);
color: var(--md-sys-color-on-surface-variant);
cursor: pointer;
outline: none;
font: var(--md-sys-typescale-title-sm);
letter-spacing: var(--md-sys-typescale-title-sm-tracking);
white-space: nowrap;
transition: color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
transition-property: color;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
}
[data-tabs-bar][data-scrollable] [data-tab] {
[data-md-tabs-bar][data-md-scrollable] > li,
[data-md-tabs-bar][data-md-scrollable] [data-md-tab] {
flex: none;
}
[data-tabs-bar][data-stacked] [data-tab] {
height: 4rem;
[data-md-tabs-bar][data-md-stacked] [data-md-tab] {
min-height: 64px;
}
[data-tab]::before {
content: "";
/* The state layer covers the tab and the ring's room around it, so the wash meets the divider. */
[data-md-tab]::before {
content: '';
position: absolute;
inset: 0;
inset-block: calc(-1 * var(--tabs-ring));
inset-inline: 0;
pointer-events: none;
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
transition-property: background-color;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
[data-tab]:hover {
[data-md-tab]:hover {
color: var(--md-sys-color-on-surface);
}
[data-tab]:hover::before {
[data-md-tab]:hover::before {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
}
}
[data-tab]:focus-visible::before,
[data-tab]:active::before {
[data-md-tab]:focus-visible::before,
[data-md-tab]:active::before {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
}
[data-tab]:focus-visible {
[data-md-tab]:focus-visible {
color: var(--md-sys-color-on-surface);
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: -3px;
outline-offset: 2px;
}
[data-tab][aria-selected="true"] {
/* A link-based bar marks the page with aria-current, not aria-selected; both are the chosen
tab, and both take the variant's active colour (N-21). */
[data-md-tab]:is([aria-selected='true'], [aria-current='page']) {
color: var(--md-sys-color-primary);
}
[data-tabs-bar][data-variant="secondary"] [data-tab]:is([aria-selected="true"], [aria-current="page"]) {
[data-md-tabs-bar][data-md-variant='secondary'] [data-md-tab]:is([aria-selected='true'], [aria-current='page']) {
color: var(--md-sys-color-on-surface);
}
[data-tab]:disabled {
[data-md-tab]:disabled {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
cursor: default;
}
[data-tab]:disabled::before {
[data-md-tab]:disabled::before {
background-color: transparent;
}
[data-tab-content] {
/* Stretched rather than 100% tall, so it fills a tab that grew past its minimum too, and the
primary indicator at its foot lands on the divider in every tab of the bar. */
[data-md-tab-content] {
position: relative;
display: inline-flex;
height: 100%;
min-width: 1.5rem;
align-self: stretch;
min-width: 24px;
align-items: center;
justify-content: center;
gap: 0.5rem;
gap: var(--md-sys-measurement-space100);
}
[data-tabs-bar][data-stacked] [data-tab-content] {
[data-md-tabs-bar][data-md-stacked] [data-md-tab-content] {
flex-direction: column;
gap: 0.125rem;
gap: var(--md-sys-measurement-space25);
}
[data-tab-indicator] {
[data-md-tab-label] {
display: inline-flex;
min-width: 0;
align-items: center;
gap: var(--md-sys-measurement-space75);
}
/* A fixed tab's label: one row while it fits, then a second, truncated (the M3 line above).
The tab's own nowrap still holds the badge and a scrollable tab's label to one row. */
[data-md-tabs-bar]:not([data-md-scrollable]) [data-md-tab-text] {
display: -webkit-box;
min-width: 0;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
text-align: center;
text-overflow: ellipsis;
white-space: normal;
}
/* Primary indicators are inset 2dp at each side (N-18); both variants sit on the divider, which
the ring's room has pushed below the tab. */
[data-md-tab-indicator] {
position: absolute;
inset-inline: 0;
bottom: 0;
inset-inline: 2px;
bottom: calc(-1 * var(--tabs-ring));
height: 3px;
border-radius: 3px 3px 0 0;
background-color: var(--md-sys-color-primary);
@@ -129,22 +211,29 @@
}
/* A secondary tab's indicator spans the tab, not its content. */
[data-tabs-bar][data-variant="secondary"] [data-tab-content] {
[data-md-tabs-bar][data-md-variant='secondary'] [data-md-tab-content] {
position: static;
}
[data-tabs-bar][data-variant="secondary"] [data-tab-indicator] {
[data-md-tabs-bar][data-md-variant='secondary'] [data-md-tab-indicator] {
inset-inline: 0;
height: 2px;
border-radius: 0;
}
[data-tab]:is([aria-selected="true"], [aria-current="page"]) [data-tab-indicator] {
[data-md-tab]:is([aria-selected='true'], [aria-current='page']) [data-md-tab-indicator] {
opacity: 1;
view-transition-name: var(--tabs-indicator);
}
}
::view-transition-group(*.material-tab-indicator) {
animation-duration: var(--md-sys-motion-spatial-default-duration);
animation-timing-function: var(--md-sys-motion-spatial-default);
/* pt-6, before the rewrite: the panel's content stands clear of the bar above it. */
[data-md-tab-panel] {
padding-top: var(--md-sys-measurement-space300);
outline: none;
}
::view-transition-group(*.material-tab-indicator) {
animation-duration: var(--md-sys-motion-spatial-default-duration);
animation-timing-function: var(--md-sys-motion-spatial-default);
}
}
+63
View File
@@ -0,0 +1,63 @@
/*
* <x-textarea>: the text field grown to several lines (resources/views/components/textarea.blade.php).
*
* The chrome is the field's (components/field.css); this file is what a <textarea> changes in it.
* The browser's resize grip belongs in the field's corner, so the textarea reaches to just inside
* the outline's end and bottom and its padding puts the text back. Its label rests on the first
* line rather than in the middle of the box. With `data-md-autogrow` it grows with its text from
* `--field-rows` lines to `--field-max-rows` (both set inline by the view) through
* `field-sizing: content`, and resources/js/field.js sets the height where the browser cannot.
* A line is body-large's 1.5rem, so the row arithmetic stays in rem with the text.
*
* The field's root carries `data-md-textarea`.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './field.css';
@layer material.components {
/* The browser draws the resize grip in the textarea's own corner, so the
textarea reaches to just inside the outline's end and bottom the grip sits
in the field's corner, not on the bottom outline a padding in from it and
its padding puts the text back where it was. */
textarea[data-md-field-control] {
--field-grip: var(--md-sys-measurement-space50);
/* The first half-line of the top offset is a margin, not padding: text scrolled up in a full
textarea disappears under the edge instead of running through the label. */
--textarea-clear: var(--md-sys-measurement-space100);
--textarea-pad-top: calc((var(--field-height) - 1.5rem) / 2 - var(--textarea-clear));
--textarea-pad-bottom: calc((var(--field-height) - 1.5rem) / 2 - var(--field-grip));
margin-block: var(--textarea-clear) var(--field-grip);
margin-inline-end: calc(var(--field-grip) - var(--field-pad));
padding-block: var(--textarea-pad-top) var(--textarea-pad-bottom);
padding-inline-end: calc(var(--field-pad) - var(--field-grip));
resize: vertical;
}
/* `<x-textarea>` grows with what is typed, from `rows` lines up to `max-rows`. Where the browser
cannot size a field to its content, resources/js/field.js sets the height instead. */
textarea[data-md-field-control][data-md-autogrow] {
field-sizing: content;
min-block-size: calc(var(--field-rows, 3) * 1.5rem + var(--textarea-pad-top) + var(--textarea-pad-bottom));
max-block-size: calc(var(--field-max-rows, 1000) * 1.5rem + var(--textarea-pad-top) + var(--textarea-pad-bottom));
resize: none;
}
/* A textarea's label rests on its first line, not in the middle of the box. */
[data-md-field]:not([data-md-floated]):has([data-md-field-label]):has(textarea[data-md-field-control]:placeholder-shown):not(:focus-within) [data-md-field-label] {
top: calc(var(--field-height) / 2);
}
/* Filled, the label floats inside the box: the text starts under it, 24px down, and the
bottom keeps the field's 8px. */
[data-md-field][data-md-variant='filled']:has([data-md-field-label]) textarea[data-md-field-control] {
--textarea-clear: var(--md-sys-measurement-space300);
--textarea-pad-top: 0px;
--textarea-pad-bottom: calc(var(--md-sys-measurement-space100) - var(--field-grip));
padding-block: var(--textarea-pad-top) var(--textarea-pad-bottom);
}
}
+45
View File
@@ -0,0 +1,45 @@
/*
* <x-theme-toggle>: `mode="toggle"|"cycle"` an icon button between light, dark (and system for
* cycle); `mode="picker"|"contrast"` a connected group over native radios (docs/audits/m3-alignment/
* navigation.md N-04, N-17 the Expressive-deprecated segmented button's successor,
* `<x-group>`, already fixes both there). This file only draws the icon button; the group's own
* shape, colour and 48px minimum below `medium` are group.css's.
*
* [data-md-theme-toggle="toggle"|"cycle"] the icon button: 40px reaching 48px through
* md-touch-target (N-01), morphing corner
* [data-md-theme-toggle="picker"|"contrast"] the group's own wrapper (`role="group"`)
*
* The icon button also carries `data-md-icon-button`, the hook `resources/css/components/
* toolbar.css` matches generically (not `[data-md-button][data-md-icon-button]`, as button.css
* requires): a theme toggle placed in a toolbar's `actions` takes the toolbar's own icon colour
* (N-13) the same way one of its `<x-button icon>` children would.
*
* The corner morphs from full to `sm` while pressed on the fast spatial spring M3 Expressive's
* pressed-state shape change, the same motion `<x-button>`'s own icon buttons use
* (button.css, `:active`).
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './group.css';
@import './icon.css';
@layer material.components {
[data-md-theme-toggle='toggle'],
[data-md-theme-toggle='cycle'] {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
inline-size: var(--md-sys-measurement-space500);
block-size: var(--md-sys-measurement-space500);
border-radius: var(--md-sys-shape-corner-full);
cursor: pointer;
color: var(--md-sys-color-on-surface-variant);
transition: border-radius var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast);
&:active {
border-radius: var(--md-sys-shape-corner-sm);
}
}
}
+206 -144
View File
@@ -5,71 +5,125 @@
* Sizes and roles are androidx Compose Material 3's (TimePickerTokens, TimeInputTokens, TimePicker.kt
* and TimePickerDialog.kt at commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326, Apache-2.0):
*
* [data-timepicker-surface] surface-container-high, extra-large corner, elevation 3; 24px in
* [data-timepicker-title] label-medium, on-surface-variant, 20px above the content
* [data-timepicker-picker] the dial variant
* [data-timepicker-display] the time selector: hour and minute boxes 96×80 (display-large,
* [data-md-timepicker-surface] surface-container-high, extra-large corner, elevation 3; 24px in
* [data-md-timepicker-title] label-medium, on-surface-variant, 20px above the content
* [data-md-timepicker-picker] the dial variant
* [data-md-timepicker-display] the time selector: hour and minute boxes 96×80 (display-large,
* small corner; primary-container when selected, otherwise
* surface-container-highest), a 24px separator, and the period
* selector 52×80 beside them, 4px away
* [data-timepicker-dial] 256px, surface-container-highest; numbers in body-large at radius
* selector 52×80 beside them, 4px away. On a 24-hour clock
* (data-md-cycle="24") there is no period selector and the boxes are
* 114px, TimeSelector24HVerticalContainerWidth.
* [data-md-timepicker-dial] 256px, surface-container-highest; numbers in body-large at radius
* 101 (the 24-hour inner ring at 69), a primary selector a 2px line,
* an 8px centre and a 48px handle with the number under the handle
* in on-primary; 36px below the display, 24px above the actions
* [data-timepicker-inputs] the input variant: fields 96×72 in display-medium, "Hour" and
* [data-md-timepicker-inputs] the input variant: fields 96×72 in display-medium, "Hour" and
* "Minute" (or the error) below in body-small, the period 52×72
* [data-timepicker-actions] a 48px row: the mode toggle, then Cancel and OK
* [data-md-timepicker-actions] a 48px row: the mode toggle, then Cancel and OK
*
* The period selector is Compose's current one (ComposeMaterial3Flags.isUpdatedTimepickerToggleEnabled,
* on by default): two toggle buttons 4px apart, round and surface-container-lowest when off, a 12px
* corner and primary-container with a bold label when on, not the outlined pair of the tokens.
* The period selector keeps Compose's current shape (ComposeMaterial3Flags.isUpdatedTimepickerToggleEnabled,
* on by default): two buttons 4px apart, round and surface-container-lowest when off, a 12px corner
* when on, not the outlined pair of the tokens. The selected colour is M3's own tertiary-container
* with a bold label (PeriodSelectorSelectedContainerColor), which keeps AM/PM apart from the
* primary-container hour and minute boxes. They are radios, so the state is `aria-checked`.
*
* In a landscape window the dial variant lies on its side, as Compose's HorizontalTimePicker does
* whenever the screen is wider than it is tall: the display with the period selector (216×38, 16px
* under it) on the start side, the dial 36px after it, the actions under both; and the dial shrinks
* to 238 or 200px when the window is short (ClockFaceSizeModifier). The input variant always stands.
* In a landscape window with no room for the upright dial the dial variant lies on its side, as
* Compose's HorizontalTimePicker does: the display with the period selector (216×38, 16px under it)
* on the start side, the dial 36px after it, the actions under both; and the dial shrinks to 238 or
* 200px when the window is shorter still (ClockFaceSizeModifier). The input variant always stands.
*
* The switch is orientation and viewport *height*, never a width breakpoint: M3 says the picker
* "swaps orientation/variant based on device orientation and viewport height"
* (docs/reference/m3/components-navigation-selection-inputs.md § Time pickers/Behaviour), and no
* window size class applies so the media queries below name a viewport height and never a width.
* 560px is what the upright dial needs 528px of surface (48 padding, 36 title, 116 display, 280
* dial, 48 actions) inside the dialog's `100dvh - 32px` so a landscape phone lies the dial down
* and a landscape tablet or desktop, which has the height, leaves it standing. The queries and the
* sizes they make room for are both px (M3 gives the sizes in dp), so a larger text size moves
* neither.
*
* The hour and minute boxes and the period buttons render the foundation's `md-state-layer` and
* `md-focus-ring` (foundation/interaction.css) each is its own interactive box with nothing drawn
* smaller inside it, so the classes need no refinement. The dial renders `md-focus-ring` alone (it
* draws no state layer of its own); nothing here copies its outline rule. Cancel, OK and the mode
* toggle are <x-button>, which draws from the classes already. `md-touch-target` is not rendered:
* the boxes, the dial and the inputs are well over 48px, and the period buttons are the two halves
* of M3's 52×80 (72 in the input variant, 216×38 lying down) selector, where a 48px target on each
* would reach over its neighbour.
*
* Disabled is on-surface at M3's 38% content and 12% container opacities (tokens/state.css).
*
* The selector's angle is a registered property, so a single transition turns the line, carries the
* handle round and moves the clip that inks the number under it, on the default spatial spring. The
* numbers cross-fade between hours and minutes on the default effects spring.
*/
@property --timepicker-angle {
syntax: '<angle>';
inherits: true;
initial-value: 0deg;
}
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer components {
[data-timepicker-field] .field-control {
@import './field.css';
@import './icon.css';
@import './button.css';
@layer material.components {
@property --timepicker-angle {
syntax: '<angle>';
inherits: true;
initial-value: 0deg;
}
[data-md-timepicker-field] [data-md-field-control] {
cursor: pointer;
caret-color: transparent;
}
[data-timepicker-dialog] {
max-width: calc(100vw - 2rem);
max-height: calc(100dvh - 2rem);
/* The dialog: M3's spatial-fast pop, a 32%-scrim backdrop, no default border or padding. */
[data-md-timepicker-dialog] {
max-width: calc(100vw - var(--md-sys-measurement-space400));
max-height: calc(100dvh - var(--md-sys-measurement-space400));
margin: auto;
padding: 0;
overflow: visible;
border: 0;
background: transparent;
color: var(--md-sys-color-on-surface);
opacity: 1;
scale: 1;
transition-property: opacity, scale;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
[data-timepicker-surface] {
@starting-style {
[data-md-timepicker-dialog] {
opacity: 0;
scale: 0.95;
}
}
[data-md-timepicker-dialog]::backdrop {
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
}
[data-md-timepicker-surface] {
display: grid;
grid-template-columns: max-content;
justify-content: center;
padding: 1.5rem;
padding: var(--md-sys-measurement-space300);
border-radius: var(--md-sys-shape-corner-xl);
background-color: var(--md-sys-color-surface-container-high);
box-shadow: var(--md-sys-elevation-3);
color: var(--md-sys-color-on-surface);
}
[data-timepicker-title] {
padding-bottom: 1.25rem;
[data-md-timepicker-title] {
padding-bottom: 20px;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking);
}
[data-timepicker-picker] {
[data-md-timepicker-picker] {
display: flex;
flex-direction: column;
align-items: center;
@@ -77,34 +131,34 @@
/* The mode is an attribute rather than x-show, which shows only on the next frame: focus moves
into the variant that was just switched to within the same tick. */
[data-timepicker-surface]:not([data-mode="input"]) :is([data-timepicker-typing], [data-timepicker-when="input"]),
[data-timepicker-surface][data-mode="input"] :is([data-timepicker-picker], [data-timepicker-when="dial"]) {
[data-md-timepicker-surface]:not([data-md-mode='input']) :is([data-md-timepicker-typing], [data-md-timepicker-when='input']),
[data-md-timepicker-surface][data-md-mode='input'] :is([data-md-timepicker-picker], [data-md-timepicker-when='dial']) {
display: none;
}
[data-timepicker-when] {
[data-md-timepicker-when] {
display: contents;
}
/* ---- The time selector and the period selector ------------------------------------------ */
[data-timepicker-display] {
[data-md-timepicker-display] {
display: flex;
margin-bottom: 2.25rem;
margin-bottom: 36px;
}
[data-timepicker-numbers],
[data-timepicker-inputs] {
[data-md-timepicker-numbers],
[data-md-timepicker-inputs] {
display: flex;
align-items: flex-start;
direction: ltr;
}
[data-timepicker-box] {
[data-md-timepicker-box] {
display: grid;
place-items: center;
width: 6rem;
height: 5rem;
width: 96px;
height: 80px;
border-radius: var(--md-sys-shape-corner-sm);
background-color: var(--md-sys-color-surface-container-highest);
color: var(--md-sys-color-on-surface);
@@ -117,32 +171,39 @@
transition-timing-function: var(--md-sys-motion-effects-fast);
}
[data-timepicker-box][aria-pressed="true"] {
[data-md-timepicker-box][aria-pressed='true'] {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}
[data-timepicker-separator] {
/* A 24-hour clock has no period selector beside the boxes, and M3 widens them into the room
(TimePickerTokens.TimeSelector24HVerticalContainerWidth = 114dp). Landscape keeps 96px: the
display is a 216px column there. */
[data-md-timepicker-display][data-md-cycle='24'] [data-md-timepicker-box] {
width: 114px;
}
[data-md-timepicker-separator] {
display: grid;
place-items: center;
width: 1.5rem;
height: 5rem;
width: 24px;
height: 80px;
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-display-lg);
translate: 0 -0.25rem;
translate: 0 -4px;
user-select: none;
}
[data-timepicker-period] {
[data-md-timepicker-period] {
display: flex;
flex-direction: column;
gap: 0.25rem;
width: 3.25rem;
height: 5rem;
margin-inline-start: 0.25rem;
gap: var(--md-sys-measurement-space50);
width: 52px;
height: 80px;
margin-inline-start: var(--md-sys-measurement-space50);
}
[data-timepicker-period] > button {
[data-md-timepicker-period] > button {
flex: 1 1 0%;
min-width: 0;
border-radius: var(--md-sys-shape-corner-full);
@@ -156,27 +217,29 @@
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
[data-timepicker-period] > button:active,
[data-timepicker-period] > button[aria-pressed="true"] {
border-radius: 0.75rem;
[data-md-timepicker-period] > button:active,
[data-md-timepicker-period] > button[aria-checked='true'] {
border-radius: var(--md-sys-shape-corner-md);
}
[data-timepicker-period] > button[aria-pressed="true"] {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
/* Tertiary, not the primary the hour and minute boxes take: M3 gives the two selectors different
emphases (PeriodSelectorSelectedContainerColor = TertiaryContainer). */
[data-md-timepicker-period] > button[aria-checked='true'] {
background-color: var(--md-sys-color-tertiary-container);
color: var(--md-sys-color-on-tertiary-container);
font-weight: var(--md-ref-typeface-weight-bold);
}
[data-timepicker-period] > button:disabled {
[data-md-timepicker-period] > button:disabled {
cursor: default;
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-container-opacity) * 100%), transparent);
}
/* ---- The dial ----------------------------------------------------------------------------- */
[data-timepicker-dial] {
--dial: 16rem;
[data-md-timepicker-dial] {
--dial: 256px;
--unit: calc(var(--dial) / 256);
--outer: calc(101 * var(--unit));
--inner: calc(69 * var(--unit));
@@ -187,7 +250,7 @@
flex: none;
width: var(--dial);
height: var(--dial);
margin-bottom: 1.5rem;
margin-bottom: var(--md-sys-measurement-space300);
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-surface-container-highest);
color: var(--md-sys-color-on-surface);
@@ -198,50 +261,44 @@
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
outline: none;
direction: ltr;
transition: --timepicker-angle var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default);
}
[data-timepicker-dial]:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
/* A drag keeps the handle under the pointer; the spring only settles it on release. */
[data-timepicker-dial][data-dragging] {
[data-md-timepicker-dial][data-md-dragging] {
transition: none;
}
[data-timepicker-dial][data-inner] {
[data-md-timepicker-dial][data-md-inner] {
--reach: var(--inner);
}
[data-timepicker-labels],
[data-timepicker-ink],
[data-timepicker-set] {
[data-md-timepicker-labels],
[data-md-timepicker-ink],
[data-md-timepicker-set] {
position: absolute;
inset: 0;
}
[data-timepicker-set] {
[data-md-timepicker-set] {
transition-property: opacity, visibility;
transition-duration: var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-effects-default);
}
[data-timepicker-dial]:not([data-cycle="24"]) [data-timepicker-set="hour24"],
[data-timepicker-dial][data-cycle="24"] [data-timepicker-set="hour12"] {
[data-md-timepicker-dial]:not([data-md-cycle='24']) [data-md-timepicker-set='hour24'],
[data-md-timepicker-dial][data-md-cycle='24'] [data-md-timepicker-set='hour12'] {
display: none;
}
[data-timepicker-dial][data-view="minute"] :is([data-timepicker-set="hour12"], [data-timepicker-set="hour24"]),
[data-timepicker-dial]:not([data-view="minute"]) [data-timepicker-set="minute"] {
[data-md-timepicker-dial][data-md-view='minute'] :is([data-md-timepicker-set='hour12'], [data-md-timepicker-set='hour24']),
[data-md-timepicker-dial]:not([data-md-view='minute']) [data-md-timepicker-set='minute'] {
opacity: 0;
visibility: hidden;
}
[data-timepicker-set] > span {
[data-md-timepicker-set] > span {
--ring: var(--outer);
position: absolute;
@@ -249,29 +306,29 @@
top: calc(50% + var(--y) * var(--ring));
display: grid;
place-items: center;
width: 3rem;
height: 3rem;
width: 48px;
height: 48px;
translate: -50% -50%;
border-radius: var(--md-sys-shape-corner-full);
font-variant-numeric: tabular-nums;
}
[data-timepicker-set] > span[data-inner] {
[data-md-timepicker-set] > span[data-md-inner] {
--ring: var(--inner);
}
[data-timepicker-set] > span[data-disabled] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
[data-md-timepicker-set] > span[data-md-disabled] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
}
/* The selector: the line from the centre to the handle's edge, the centre dot, the handle. */
[data-timepicker-selector] {
[data-md-timepicker-selector] {
position: absolute;
inset: 0;
pointer-events: none;
}
[data-timepicker-track] {
[data-md-timepicker-track] {
position: absolute;
left: calc(50% - 1px);
bottom: 50%;
@@ -282,22 +339,22 @@
rotate: var(--timepicker-angle);
}
[data-timepicker-centre],
[data-timepicker-handle] {
[data-md-timepicker-centre],
[data-md-timepicker-handle] {
position: absolute;
translate: -50% -50%;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-primary);
}
[data-timepicker-centre] {
[data-md-timepicker-centre] {
left: 50%;
top: 50%;
width: 0.5rem;
height: 0.5rem;
width: 8px;
height: 8px;
}
[data-timepicker-handle] {
[data-md-timepicker-handle] {
left: calc(50% + sin(var(--timepicker-angle)) * var(--reach));
top: calc(50% - cos(var(--timepicker-angle)) * var(--reach));
width: var(--handle);
@@ -305,34 +362,34 @@
}
/* The numbers again, in on-primary, cut to the handle: what Compose draws with BlendMode.DstOver. */
[data-timepicker-ink] {
[data-md-timepicker-ink] {
color: var(--md-sys-color-on-primary);
pointer-events: none;
clip-path: circle(calc(var(--handle) / 2) at calc(50% + sin(var(--timepicker-angle)) * var(--reach)) calc(50% - cos(var(--timepicker-angle)) * var(--reach)));
}
[data-timepicker-ink] [data-timepicker-set] > span[data-disabled] {
[data-md-timepicker-ink] [data-md-timepicker-set] > span[data-md-disabled] {
color: inherit;
}
/* ---- The input variant -------------------------------------------------------------------- */
[data-timepicker-inputs] [data-timepicker-separator] {
height: 4.5rem;
[data-md-timepicker-inputs] [data-md-timepicker-separator] {
height: 72px;
}
[data-timepicker-inputs] [data-timepicker-period] {
height: 4.5rem;
[data-md-timepicker-inputs] [data-md-timepicker-period] {
height: 72px;
}
[data-timepicker-column] {
width: 6rem;
[data-md-timepicker-column] {
width: 96px;
}
[data-timepicker-input] {
[data-md-timepicker-input] {
display: block;
width: 6rem;
height: 4.5rem;
width: 96px;
height: 72px;
padding: 0;
border: 0;
border-radius: var(--md-sys-shape-corner-sm);
@@ -349,112 +406,117 @@
transition-timing-function: var(--md-sys-motion-effects-fast);
}
[data-timepicker-input]:focus {
[data-md-timepicker-input]:focus {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
box-shadow: inset 0 0 0 2px var(--md-sys-color-primary);
}
[data-timepicker-input][aria-invalid="true"] {
[data-md-timepicker-input][aria-invalid='true'] {
background-color: var(--md-sys-color-error-container);
color: var(--md-sys-color-error);
caret-color: var(--md-sys-color-error);
box-shadow: inset 0 0 0 1px var(--md-sys-color-error);
}
[data-timepicker-input][aria-invalid="true"]:focus {
[data-md-timepicker-input][aria-invalid='true']:focus {
box-shadow: inset 0 0 0 2px var(--md-sys-color-error);
}
/* SupportingText: two lines' room, 7px under the field; the error in its place. */
[data-timepicker-support] {
/* SupportingText: two lines' room, 7px under the field; the error in its place. The room is two
body-small lines (1rem each), so it stays in rem and grows with the text. */
[data-md-timepicker-support] {
min-height: 2rem;
padding-top: 0.4375rem;
padding-top: 7px;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
}
[data-timepicker-support][data-error],
[data-timepicker-range-error] {
[data-md-timepicker-support][data-md-error],
[data-md-timepicker-range-error] {
color: var(--md-sys-color-error);
}
[data-timepicker-range-error] {
max-width: 17rem;
padding-bottom: 0.5rem;
[data-md-timepicker-range-error] {
max-width: 272px;
padding-bottom: var(--md-sys-measurement-space100);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
}
[data-timepicker-actions] {
[data-md-timepicker-actions] {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 3rem;
gap: var(--md-sys-measurement-space100);
min-height: 48px;
}
[data-timepicker-actions] > [data-timepicker-spacer] {
[data-md-timepicker-actions] > [data-md-timepicker-spacer] {
flex: 1 1 0%;
}
/* ---- Landscape ---------------------------------------------------------------------------- */
@media (orientation: landscape) and (min-width: 37rem) {
[data-timepicker-surface][data-mode="dial"] {
grid-template-columns: 13.5rem auto;
@media (orientation: landscape) and (height < 560px) {
[data-md-timepicker-surface][data-md-mode='dial'] {
grid-template-columns: 216px auto;
grid-template-areas:
"display dial"
"actions actions";
column-gap: 2.25rem;
padding: 1rem 1.5rem 0.5rem;
'display dial'
'actions actions';
column-gap: 36px;
padding: var(--md-sys-measurement-space200) var(--md-sys-measurement-space300) var(--md-sys-measurement-space100);
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-title] {
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-title] {
grid-area: display;
align-self: start;
margin-top: 0.5rem;
margin-top: var(--md-sys-measurement-space100);
padding: 0;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-picker] {
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-picker] {
display: contents;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-display] {
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-display] {
grid-area: display;
flex-direction: column;
align-self: center;
margin: 0;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-display] [data-timepicker-period] {
flex-direction: row;
width: 13.5rem;
height: 2.375rem;
margin: 1rem 0 0;
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-display][data-md-cycle='24'] [data-md-timepicker-box] {
width: 96px;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-dial] {
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-display] [data-md-timepicker-period] {
flex-direction: row;
width: 216px;
height: 38px;
margin: var(--md-sys-measurement-space200) 0 0;
}
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-dial] {
grid-area: dial;
margin: 0;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-actions] {
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-actions] {
grid-area: actions;
margin-top: 0.25rem;
margin-top: var(--md-sys-measurement-space50);
}
}
@media (orientation: landscape) and (min-width: 37rem) and (max-height: 22.75rem) {
[data-timepicker-surface][data-mode="dial"] [data-timepicker-dial] {
--dial: 14.875rem;
@media (orientation: landscape) and (height <= 364px) {
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-dial] {
--dial: 238px;
}
}
@media (orientation: landscape) and (min-width: 37rem) and (max-height: 21.625rem) {
[data-timepicker-surface][data-mode="dial"] [data-timepicker-dial] {
--dial: 12.5rem;
@media (orientation: landscape) and (height <= 346px) {
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-dial] {
--dial: 200px;
}
}
}
+138
View File
@@ -0,0 +1,138 @@
/*
* <x-toast>: the snackbar host shows every toast queued behind it, one at a time.
*
* SnackbarTokens (androidx Compose Material 3, Apache-2.0): inverse-surface container,
* inverse-on-surface text, an inverse-primary action, extra-small corners, elevation 3, 48px for
* one line and 68px for two (SnackbarTokens.TwoLinesContainerHeight the more precise of M3's
* two published figures; the site's prose says 64dp). `[data-md-two-line]`, bound from whether
* the current toast carries a description, pins the container to 68px instead of leaving it to
* grow into it; below `medium` (600px), a two-line snackbar with an action wraps the action under
* the text through `[data-md-toast-wrap]`, bound the same way M3's "two lines with longer
* action" configuration.
*
* It lifts above a bottom bar through `--material-bottom-bar`, and above a toolbar placed at the
* bottom through the `--material-bottom-toolbar` toolbar.css publishes whichever reaches higher,
* as M3 nudges a snackbar clear of a docked toolbar and never in front of navigation;
* `data-md-position="bottom-start"` moves it to the start edge, with M3's margin growing from `medium`. resources/js/snackbar.js
* publishes the snackbar's own height as `--material-snackbar-height`, so a FAB (fab.css) can
* lift clear of it M3 puts a snackbar above a FAB, never in front of or behind one (ACT-17).
* Below `medium` it is full width; from there it hugs its line length, which an application's own
* class can still bound.
*
* The action and the close button are both drawn at 40px and render the foundation's
* `md-state-layer`, `md-focus-ring` and `md-touch-target` (foundation/interaction.css) to reach
* M3's 48px target (ACT-18); nothing here copies their rules.
*
* It enters on a translate and a fade on the fast spatial spring, `@starting-style` giving both a
* start. No state icon: M3 says to avoid one in a snackbar (ACT-20). The live region is the host
* itself, in the page before any message is, never the snackbar that comes and goes (ACT-02);
* resources/js/snackbar.js queues, times and dismisses a toast, including on Escape while it
* holds the focus and on Alt+G, which moves the focus to one carrying an action (ACT-34).
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@layer material.components {
[data-md-toast] {
position: fixed;
z-index: 50;
inset-inline: var(--md-sys-measurement-space200);
bottom: calc(max(var(--material-bottom-bar, 0px), var(--material-bottom-toolbar, 0px)) + var(--md-sys-measurement-space200));
display: flex;
justify-content: center;
pointer-events: none;
}
[data-md-toast][data-md-position='bottom-start'] {
justify-content: flex-start;
}
@media (width >= 600px) {
[data-md-toast][data-md-position='bottom-start'] {
inset-inline-start: var(--md-sys-measurement-space300);
}
}
[data-md-toast-snackbar] {
pointer-events: auto;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
width: 100%;
max-width: min(100%, 576px);
min-height: var(--md-sys-measurement-space600);
border-radius: var(--md-sys-shape-corner-xs);
padding-block: 6px;
padding-inline: var(--md-sys-measurement-space200) var(--md-sys-measurement-space100);
background-color: var(--md-sys-color-inverse-surface);
color: var(--md-sys-color-inverse-on-surface);
box-shadow: var(--md-sys-elevation-3);
translate: 0;
opacity: 1;
transition-property: translate, opacity;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
@starting-style {
translate: 0 16px;
opacity: 0;
}
}
[data-md-toast-snackbar][data-md-two-line] {
min-height: 68px;
}
@media (width >= 600px) {
[data-md-toast-snackbar] {
width: auto;
min-width: 344px;
}
}
[data-md-toast-content] {
min-width: 0;
flex: 1;
padding-block: 6px;
}
@media (width < 600px) {
[data-md-toast-content][data-md-toast-wrap] {
flex-basis: 100%;
}
}
[data-md-toast-title],
[data-md-toast-description] {
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
font-variation-settings: normal;
}
:is([data-md-toast-action], [data-md-toast-dismiss]) {
flex-shrink: 0;
border-radius: var(--md-sys-shape-corner-full);
cursor: pointer;
}
[data-md-toast-action] {
margin-inline-start: auto;
height: var(--md-sys-measurement-space500);
padding-inline: 12px;
color: var(--md-sys-color-inverse-primary);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
}
[data-md-toast-dismiss] {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--md-sys-measurement-space500);
height: var(--md-sys-measurement-space500);
}
}
+151
View File
@@ -0,0 +1,151 @@
/*
* <x-toggle>: M3's switch (resources/views/components/toggle.blade.php), on the row the selection
* controls share (components/selection.css). The hooks say `switch` because `toggle` is daisyUI's
* class, which the design guard rejects.
*
* SwitchTokens (androidx Compose Material 3, Apache-2.0): a 52×32 track with a 2px outline, in
* surface-container-highest when off and primary when on. The handle's centre is 16px from the
* track's start off and 36px on; it is 16px across off, 24px with an icon or on, and 28px while
* pressed, moving and growing on the fast spatial spring. Off it is outline, on-surface-variant
* while hovered, focused or pressed; on it is on-primary, primary-container in those states. The
* state layer is a 40px circle around the handle. Its icons are 16px, on-primary-container when on.
* Disabled, the track is on-surface at 12% and the handle on-surface at 38% (surface when on).
*
* [data-md-toggle] the root; `class` and `style` land here
* [data-md-selection-row] data-md-right puts the switch at the end of the row
* [data-md-switch] the track; `data-md-icons` = "both" or "selected"
* input, [data-md-switch-handle] (with [data-md-switch-on] and [data-md-switch-off] icons)
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './selection.css';
@import './icon.css';
@layer material.components {
[data-md-toggle] {
min-width: 0;
}
/* The positions below are the handle's centres less half its size, measured inside the track's
2px outline. */
[data-md-switch] {
position: relative;
display: grid;
flex: none;
width: 52px;
height: 32px;
}
[data-md-switch] input {
width: 100%;
height: 100%;
border: 2px solid var(--md-sys-color-outline);
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-surface-container-highest);
}
[data-md-switch] input:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
[data-md-switch-handle] {
--handle: 16px;
position: absolute;
top: 50%;
inset-inline-start: calc(16px - var(--handle) / 2);
display: grid;
place-items: center;
width: var(--handle);
height: var(--handle);
translate: 0 -50%;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-outline);
color: var(--md-sys-color-surface-container-highest);
pointer-events: none;
transition-property: inset-inline-start, width, height, background-color;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
[data-md-switch-handle]::before {
inset: 50% auto auto 50%;
width: 40px;
height: 40px;
translate: -50% -50%;
}
[data-md-switch-handle] > svg {
grid-area: 1 / 1;
opacity: 0;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-md-switch][data-md-icons='both'] [data-md-switch-handle] {
--handle: 24px;
}
[data-md-switch][data-md-icons='both'] input:not(:checked) ~ [data-md-switch-handle] [data-md-switch-off],
[data-md-switch][data-md-icons] input:checked ~ [data-md-switch-handle] [data-md-switch-on] {
opacity: 1;
}
[data-md-switch] input:checked {
border-color: var(--md-sys-color-primary);
background-color: var(--md-sys-color-primary);
}
[data-md-switch] input:checked ~ [data-md-switch-handle] {
--handle: 24px;
inset-inline-start: calc(36px - var(--handle) / 2);
background-color: var(--md-sys-color-on-primary);
color: var(--md-sys-color-on-primary-container);
}
[data-md-selection-row]:active [data-md-switch] input:not(:disabled) ~ [data-md-switch-handle] {
--handle: 28px;
}
@media (hover: hover) {
[data-md-selection-row]:hover [data-md-switch] input:not(:disabled) ~ [data-md-switch-handle] {
background-color: var(--md-sys-color-on-surface-variant);
}
[data-md-selection-row]:hover [data-md-switch] input:checked:not(:disabled) ~ [data-md-switch-handle] {
background-color: var(--md-sys-color-primary-container);
}
}
[data-md-switch] input:focus-visible ~ [data-md-switch-handle],
[data-md-selection-row]:active [data-md-switch] input:not(:disabled) ~ [data-md-switch-handle] {
background-color: var(--md-sys-color-on-surface-variant);
}
[data-md-switch] input:checked:focus-visible ~ [data-md-switch-handle],
[data-md-selection-row]:active [data-md-switch] input:checked:not(:disabled) ~ [data-md-switch-handle] {
background-color: var(--md-sys-color-primary-container);
}
[data-md-switch] input:disabled {
border-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-container-opacity) * 100%), transparent);
background-color: color-mix(in srgb, var(--md-sys-color-surface-container-highest) calc(var(--md-sys-state-disabled-container-opacity) * 100%), transparent);
}
[data-md-switch] input:disabled ~ [data-md-switch-handle] {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
color: color-mix(in srgb, var(--md-sys-color-surface-container-highest) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
}
[data-md-switch] input:disabled:checked {
border-color: transparent;
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-container-opacity) * 100%), transparent);
}
[data-md-switch] input:disabled:checked ~ [data-md-switch-handle] {
background-color: var(--md-sys-color-surface);
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
}
}
+217 -76
View File
@@ -1,96 +1,237 @@
/*
* M3 Expressive's toolbars (DockedToolbarTokens, FloatingToolbarTokens, androidx Compose Material 3,
* Apache-2.0).
* `<x-toolbar>`: M3 Expressive's toolbars (DockedToolbarTokens.kt, FloatingToolbarTokens.kt,
* androidx Compose Material 3, Apache-2.0; docs/reference/m3/components-navigation-selection-inputs.md
* § Toolbars).
*
* [data-md-toolbar] role="toolbar"; data-md-variant, data-md-vertical, data-md-vibrant,
* data-md-rounded, data-md-toolbar-place
* [data-md-toolbar-fab] a FAB docked at the end, flat on the bar (elevation 0)
* [data-md-toolbar-group] a floating toolbar and its own FAB, side by side (or stacked)
*
* docked a 64px bar across the bottom of the screen in surface-container, square, its
* controls spread out 4 to 32px apart between 16px ends
* controls spread out 4 to 32px apart between 16px ends; with a FAB, the controls
* at the start and the FAB flat at the end
* floating a 64px pill in surface-container (or primary-container, `vibrant`) at elevation 3,
* 8px at its ends and 4px between controls; `vertical` stands it on end
*
* Unlayered on purpose: a vibrant toolbar recolours the icon buttons inside it, which draw their
* ink as utilities, and a rule in any layer loses to a utility.
* Placed over the page, a horizontal toolbar keeps 16dp from the window's edge and a vertical one
* 24dp, which is M3's minimum for each (N-12).
*
* On web and large screens a docked toolbar "can be rounded", and dividers can organise its
* controls into groups: `rounded` does the first from `expanded` (840px), and a divider in the
* slot the second at any width.
*
* `<x-button>` and `<x-fab>` draw their own ink from `data-md-color`/`data-md-variant`, not a
* utility, so a toolbar's recolouring rules — vibrant's icon buttons, a standard toolbar's primary
* ones (N-13), a docked FAB's flattened shadow — sit in this file's own `material.components`
* layer like everything else, each more specific than the button.css or fab.css rule it overrides,
* whichever file an application imports first. The recolouring leaves a disabled button alone, so
* button.css's disabled colours still apply inside a toolbar.
*/
[data-toolbar] {
display: flex;
align-items: center;
gap: 0.25rem;
}
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
[data-toolbar][data-variant="docked"] {
width: 100%;
min-height: calc(4rem + env(safe-area-inset-bottom));
justify-content: center;
column-gap: clamp(0.25rem, 4vw, 2rem);
padding-inline: 1rem;
padding-bottom: env(safe-area-inset-bottom);
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface-variant);
}
@layer material.components {
[data-md-toolbar] {
display: flex;
align-items: center;
gap: var(--md-sys-measurement-space50);
}
[data-toolbar][data-variant="floating"] {
display: inline-flex;
height: 4rem;
padding-inline: 0.5rem;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface-variant);
box-shadow: var(--md-sys-elevation-3);
}
[data-md-toolbar][data-md-variant='docked'] {
width: 100%;
min-height: calc(64px + var(--material-safe-bottom, env(safe-area-inset-bottom)));
justify-content: center;
column-gap: clamp(var(--md-sys-measurement-space50), 4vw, var(--md-sys-measurement-space400));
padding-inline: var(--md-sys-measurement-space200);
padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface-variant);
}
[data-toolbar][data-variant="floating"][data-vertical] {
width: 4rem;
height: auto;
flex-direction: column;
padding-block: 0.5rem;
padding-inline: 0;
}
[data-md-toolbar][data-md-variant='floating'] {
display: inline-flex;
height: 64px;
padding-inline: var(--md-sys-measurement-space100);
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface-variant);
box-shadow: var(--md-sys-elevation-3);
}
[data-toolbar][data-vibrant] {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}
[data-md-toolbar][data-md-variant='floating'][data-md-vertical] {
width: 64px;
height: auto;
flex-direction: column;
padding-block: var(--md-sys-measurement-space100);
padding-inline: 0;
}
[data-toolbar][data-vibrant] [data-icon-button]:not([aria-pressed="true"]) {
color: var(--md-sys-color-on-primary-container);
}
[data-md-toolbar][data-md-vibrant] {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}
[data-toolbar][data-vibrant] [data-icon-button][aria-pressed="true"] {
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface);
}
[data-md-toolbar][data-md-vibrant] [data-md-icon-button]:not([data-md-variant='filled'], [data-md-variant='tonal'], [data-md-selected='true'], [aria-pressed='true'], :disabled, [aria-disabled='true']) {
color: var(--md-sys-color-on-primary-container);
}
/* A floating toolbar and its FAB, side by side (or stacked, vertical). */
[data-toolbar-group] {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
[data-md-toolbar][data-md-vibrant] [data-md-icon-button][aria-pressed='true']:not(:disabled, [aria-disabled='true']) {
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface);
}
[data-toolbar-group][data-vertical] {
flex-direction: column;
}
/* M3's colour list for a standard toolbar ends "Standard button (Primary)", as the vibrant list
ends "Standard button (On primary container)" the row above (N-13). Both rows name the
*standard* button, so a button that fills its own container is left alone: it carries the
matching label colour (`filled` on-primary, `tonal` on-tone, either selected), and painting
that label primary put a primary icon on a primary fill the current page's button in a
navigation toolbar came out a blank circle. A disabled button keeps button.css's disabled
colours in either. */
[data-md-toolbar]:not([data-md-vibrant]) [data-md-icon-button]:not([data-md-variant='filled'], [data-md-variant='tonal'], [data-md-selected='true'], [aria-pressed='true'], :disabled, [aria-disabled='true']) {
color: var(--md-sys-color-primary);
}
/* Placed over the page: centred above the bottom edge, or centred against the end edge. */
[data-toolbar-place="bottom"] {
position: fixed;
bottom: calc(1rem + env(safe-area-inset-bottom));
left: 50%;
z-index: 30;
translate: -50% 0;
}
/* A docked toolbar with a FAB: the controls at the start, the FAB at the end, resting on the
bar at elevation 0 M3 puts a FAB nested in another component there, and Compose's
FloatingActionButtonDefaults.bottomAppBarFabElevation() is 0 in every state. */
[data-md-toolbar][data-md-variant='docked']:has(> [data-md-toolbar-fab]) {
justify-content: flex-start;
}
[data-toolbar-place="end"] {
position: fixed;
top: 50%;
inset-inline-end: calc(1rem + env(safe-area-inset-right));
z-index: 30;
translate: 0 -50%;
}
[data-md-toolbar-fab] {
display: flex;
margin-inline-start: auto;
}
[data-toolbar][data-variant="docked"][data-toolbar-place="bottom"] {
inset-inline: 0;
bottom: 0;
left: 0;
translate: none;
[data-md-toolbar-fab] [data-md-fab],
[data-md-toolbar-fab] [data-md-fab]:hover {
box-shadow: none;
}
/* Groups of controls, a divider between each two (`<x-divider vertical />`, or `<x-divider />`
in a vertical toolbar). The divider draws itself stretched across its row, which in a
toolbar would run it into the container's edges; here it stands as long as the 40px icon
buttons it separates, centred on them, as the large-screen docked toolbar in M3's
guidelines draws it (toolbars/guidelines § Adaptive design Resizing). */
[data-md-toolbar]:not([data-md-vertical]) > [role='separator'][aria-orientation='vertical'] {
align-self: center;
height: 40px;
}
[data-md-toolbar][data-md-vertical] > [role='separator'][aria-orientation='horizontal'] {
align-self: center;
width: 40px;
}
/* A floating toolbar and its FAB, side by side (or stacked, vertical). */
[data-md-toolbar-group] {
display: inline-flex;
align-items: center;
gap: var(--md-sys-measurement-space100);
}
[data-md-toolbar-group][data-md-vertical] {
flex-direction: column;
}
/* Placed over the page: centred above the bottom edge, or centred against the end edge.
*
* A toolbar at the bottom shares the screen region with a navigation bar, so it clears the one
* <x-scaffold> publishes as --material-bottom-bar, exactly as the FAB and the snackbar do
* `max()`, not a sum, because that height already swallows the bottom safe area; without a bar
* the safe area alone applies (N-02). A *docked* toolbar and a navigation bar must never be on
* screen together at all; the offset is the backstop, not a licence.
*/
[data-md-toolbar-place='bottom'] {
position: fixed;
bottom: calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + var(--md-sys-measurement-space200));
left: 50%;
z-index: 30;
translate: -50% 0;
}
[data-md-toolbar-place='end'] {
position: fixed;
top: 50%;
inset-inline-end: calc(var(--md-sys-measurement-space200) + var(--material-safe-right, env(safe-area-inset-right)));
z-index: 30;
translate: 0 -50%;
}
/* "Horizontal toolbars should have a minimum 16dp margin from the edge of the window. Vertical
toolbars should have a minimum 24dp margin" (N-12). */
[data-md-toolbar][data-md-vertical][data-md-toolbar-place='end'],
[data-md-toolbar-group][data-md-vertical][data-md-toolbar-place='end'] {
inset-inline-end: calc(var(--md-sys-measurement-space300) + var(--material-safe-right, env(safe-area-inset-right)));
}
[data-md-toolbar][data-md-variant='docked'][data-md-toolbar-place='bottom'] {
inset-inline: 0;
bottom: var(--material-bottom-bar, 0px);
left: 0;
translate: none;
}
/* The large-screen docked toolbar: from `expanded` (840px) fully rounded, like the one M3 shows
on the web, and spanning what it is written in. At the bottom of the window it lifts off the
edges 16dp from each side, M3's minimum outside padding for a toolbar, and 16px above the
bottom bar or the safe area, as a floating toolbar sits so the curve never meets the
window's frame, and the bottom safe area, which it no longer touches, stops padding it.
Below `expanded` it is the square, full-width docked toolbar M3 requires ("Avoid applying
rounded corners to the container"). */
@media (width >= 840px) {
[data-md-toolbar][data-md-variant='docked'][data-md-rounded] {
min-height: 64px;
padding-bottom: 0;
border-radius: var(--md-sys-shape-corner-full);
}
[data-md-toolbar][data-md-variant='docked'][data-md-rounded][data-md-toolbar-place='bottom'] {
right: calc(var(--md-sys-measurement-space200) + var(--material-safe-right, env(safe-area-inset-right)));
bottom: calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + var(--md-sys-measurement-space200));
left: calc(var(--md-sys-measurement-space200) + var(--material-safe-left, env(safe-area-inset-left)));
width: auto;
}
}
/* What a toolbar at the bottom covers, published as --material-bottom-toolbar: the distance from
* the window's bottom edge to the toolbar's top, each formula the placement above plus the
* height. The snackbar clears it (toast.css) M3 nudges a snackbar "upward to avoid overlapping
* FABs/docked toolbars" and never puts one "in front of navigation"
* (docs/reference/m3/components-actions-communication-containment.md § Snackbar) and a page
* pads its end with it, so its last line never hides behind the toolbar.
*
* Declared on the root and again on <x-scaffold>'s root: a custom property resolves its var()s
* where it is declared, and --material-bottom-bar is only set on the scaffold's root, so only
* there does the formula see the bar. A floating toolbar is 64px, beside an 80px (`md`) or
* 96px (`lg`) FAB as tall as the FAB; a docked one is 64px plus the bottom safe area it pads,
* until `rounded` lifts it off the edge from `expanded`. A vertical toolbar, which belongs at
* the end edge, publishes nothing. */
:root:has([data-md-toolbar-place='bottom']:is([data-md-toolbar], [data-md-toolbar-group]):not([data-md-vertical])),
[data-md-scaffold]:has([data-md-toolbar-place='bottom']:is([data-md-toolbar], [data-md-toolbar-group]):not([data-md-vertical])) {
--material-bottom-toolbar: calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + var(--md-sys-measurement-space200) + 64px);
}
:root:has([data-md-toolbar-group][data-md-toolbar-place='bottom']:not([data-md-vertical]) [data-md-fab][data-md-size='md']),
[data-md-scaffold]:has([data-md-toolbar-group][data-md-toolbar-place='bottom']:not([data-md-vertical]) [data-md-fab][data-md-size='md']) {
--material-bottom-toolbar: calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + var(--md-sys-measurement-space200) + 80px);
}
:root:has([data-md-toolbar-group][data-md-toolbar-place='bottom']:not([data-md-vertical]) [data-md-fab][data-md-size='lg']),
[data-md-scaffold]:has([data-md-toolbar-group][data-md-toolbar-place='bottom']:not([data-md-vertical]) [data-md-fab][data-md-size='lg']) {
--material-bottom-toolbar: calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + var(--md-sys-measurement-space200) + 96px);
}
:root:has([data-md-toolbar][data-md-variant='docked'][data-md-toolbar-place='bottom']),
[data-md-scaffold]:has([data-md-toolbar][data-md-variant='docked'][data-md-toolbar-place='bottom']) {
--material-bottom-toolbar: calc(var(--material-bottom-bar, 0px) + 64px + var(--material-safe-bottom, env(safe-area-inset-bottom)));
}
@media (width >= 840px) {
:root:has([data-md-toolbar][data-md-variant='docked'][data-md-rounded][data-md-toolbar-place='bottom']),
[data-md-scaffold]:has([data-md-toolbar][data-md-variant='docked'][data-md-rounded][data-md-toolbar-place='bottom']) {
--material-bottom-toolbar: calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + var(--md-sys-measurement-space200) + 64px);
}
}
}

Some files were not shown because too many files have changed in this diff Show More