457 Commits
Author SHA1 Message Date
Andreas Reinhold / reiniandClaude Opus 5 94d6e31432 Catch an exit half-way on a slow machine, not only on a fast one
tests / browser (safari, webkit) (push) Waiting to run
tests / feature (8.4) (push) Successful in 1m45s
tests / feature (8.5) (push) Successful in 2m0s
tests / browser (chrome, chromium) (push) Successful in 8m6s
tests / browser (firefox, firefox) (push) In progress
The browser suite passes on every engine here and fails on the runner,
which takes three times as long: each failing test triggers a close and
then samples for an in-between state — a rail part-way out, a scrim
part-way faded, a menu's exit copy part-way sunk — and a starved runner
takes its one sample after the 150-650ms exit has already finished.

So the tests that assert *that* something animates now stretch every
motion duration token to three seconds first (`slowMotion()`, beside
`ready()` in tests/Pest.php; ActionsTest's own copy of it goes). The two
polling helpers grew their budget to match: the rail panel and the sheet
slide on emphasized-accelerate, which is under 1% of its travel at a
quarter of the way through, so a 300ms window no longer reached the
threshold once the exit itself was three seconds long.

The two full-screen date picker tests waited for a resize through
click()'s own retry, which ate the whole 15s budget on Firefox; they now
wait for the new width and a settled document first. The bottom sheet's
preset test waits for its entry to finish before pressing the grip.

`hold()` in search.js reads the view's animations a frame after the
closed state, as the rail's settle() does, but one frame is not always
enough: an engine that starts them on its next tick shows none, and the
full-screen layout would end at once, mid-exit. An empty list is now
asked again on the following frame.

Feature 1159 passed. Browser 299 passed on Chrome, Firefox and WebKit,
and again on WebKit under ten spinning cores.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:57:43 +02:00
Andreas Reinhold / reiniandClaude Opus 5 145f60aa11 Measure the time picker's dial after its dialog has finished opening
tests / feature (8.4) (push) Successful in 1m51s
tests / feature (8.5) (push) Successful in 1m57s
tests / browser (chrome, chromium) (push) Successful in 7m45s
tests / browser (firefox, firefox) (push) Failing after 12m42s
tests / browser (safari, webkit) (push) Failing after 13m15s
The test that holds the dial to its pixel size at a 20px root font size
read the dial's width straight after the click, so on a loaded machine it
caught the dialog mid-entry: scale 0.95 of 256px rounds to 243, a number
the stylesheet never sets. The assertion then compared the settled dial
against that, and failed for the whole retry window.

It now waits for the dialog's own animations to finish before taking the
resting width, the way the sheet tests already wait.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:42:54 +02:00
Andreas Reinhold / reiniandClaude Opus 5 fb7007c976 Take Tailwind out of the package, and its detection out of the guard
tests / feature (8.4) (push) Successful in 2m0s
tests / feature (8.5) (push) Successful in 2m0s
tests / browser (chrome, chromium) (push) Failing after 8m3s
tests / browser (firefox, firefox) (push) Failing after 12m58s
tests / browser (safari, webkit) (push) Failing after 13m8s
Tailwind left the stack in 2.0.0, but the package still carried about 330
mentions of it. What the guard's Tailwind detection protected — a class
that compiles to nothing — is now protected by a check that does not care
where a dead class came from.

DesignGuard: about 500 lines of Tailwind tables, scales, palettes and
"2.0.0 replacement" hints give way to one check — a class a view or PHP
file writes that neither the application's stylesheets nor the package's
own declare. It catches a utility of any framework, a typo and a class
whose rules were deleted alike, so it also found two classes ReStride
draws nothing with. A stylesheet has to be in reach for it: the `.css`
files among the scanned paths, or what the `missingStylesheets()` entry
imports. The class reader no longer mistakes an array index for a class
list (`$block['base']`), and it reads the array a class helper is given,
where it read nothing before.

The package's own three Tailwind self-guards go with it. Only their one
unique check stays, as a test of its own: every `matchMedia` width in
resources/js is an M3 breakpoint.

The pagination views are `material.blade.php` and
`simple-material.blade.php`; only Laravel's and Livewire's default theme
names ever made them `tailwind`. The provider sets `Paginator`'s default
views and switches `livewire.pagination_theme` to `material` when it is
still Livewire's own default, so no application can forget the config; a
theme an application chose, and a component's own `$paginationTheme` or
`paginationView()`, still win.

The rest is prose: the layer-order guidance for an application that still
builds Tailwind, the Tailwind wording in the README, the Boost guidelines
and the development skill, and about 25 "this used to be a Tailwind
utility" comments, along with every "plan step NN" pointer into a
gitignored folder. The reset keeps its credit, and NOTICE now carries it
too.

Feature suite 1159 passed, Chrome browser suite 299 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:07:39 +02:00
Andreas Reinhold / reiniandClaude Opus 5 247c596c3a Cut duplicated and speculative code across the package
An over-engineering audit of the whole tree, applied in five reviewed
batches. Behaviour stays the same except where UPGRADE.md says otherwise.

PHP: the showcase and error-page stylesheets are prebuilt into
resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import
(first occurrence kept, the order an application's build gives), instead
of Stylesheets::bundle() inlining imports on every request; only the
import walk DesignGuard needs stays. SchemeStylesheet::withProfiles()
replaces three copies of the scheme-plus-profiles loop, material:scheme
leaves spec and contrast checks to the node script that already made
them, and the error page's scheme cache, the hashed view namespace, the
translations path with no lang/ folder and DesignGuard's 1.x-name hints
are gone.

JS: the androidx shape port progress.js and both bin scripts each carried
lives once in resources/js/shapes.js (the generated SVGs are unchanged);
util.js holds ringIndex(), ms(), reopenGuard() and remember(), which
were written out several times; listeners are released through
AbortController; tooltip.js's hoverPopover() serves the rich tooltip too.

CSS: every rule for an element inside the navigation rail queries
`--md-navigation-rail-value` instead of repeating the seven collapsed
conditions under five media branches; badge, alert, progress, slider and
button read one non-inheriting colour-role table (components/color.css);
the dialog chrome, the submenu's popover chrome, the chip's state layer
and touch target, and the visually-hidden inputs use the shared rules
they copied; foundation/tokens.css is folded into foundation.css.

Views: Support\Field and Support\Link replace the error-key, bound-value
and link-attribute blocks copied into the fields and link components;
the timepicker period group, the menu filter and the showcase head are
partials; the datepicker's steppers and entry fields are loops; component
docblocks no longer restate SKILL.md.

Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces
four per-group files, DesignGuardTest and the layout-component tests use
datasets, browser tests share one ready() helper, CSS parsing lives in
ComponentStylesheet alone. docs/audits and the finding IDs citing it are
removed, as are pestphp/pest-plugin-laravel, the unused composer scripts
and check:font; the lint job runs in the feature job, which now installs
node packages so the prebuilt-stylesheet staleness test runs in CI.

Feature suite 1177 passed, Chrome browser suite 299 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:29:21 +02:00
Andreas Reinhold / reiniandClaude Opus 5 471d927e64 Count a row written by hand in the design guard's stylesheet checks
`data-md-list-row` on an application's own `<li>`, `<div>`, `<tr>` or
`<x-row>` draws its hover, focus and press state layer, its cursor and
its `data-md-selected` fill from list-item.css, but missingStylesheets()
and unusedStylesheets() read only component tags and `->links()`.
ReStride had the stylesheet only because table.css imports it: had its
last `<x-table>` gone, every hand-made row would have lost its states
and its fill without a finding, and importing list-item.css for the
rows directly was reported unused.

A Blade view outside the package's own `resources/views` that writes a
hook in the new `HOOK_STYLESHEETS` now needs that hook's stylesheet in
both checks, named at the earliest line that needs it. The one entry is
`data-md-list-row` → `components/list-item.css`, except on `<x-card>`
(any spelling), whose row card.css draws; the card's opening tag is
blanked out before the hook is read, and a selector such as
`[data-md-list-row]` or a name in backticks writes no row. Nothing else
list-rows.js, the skill or the README has an application write needs a
stylesheet of its own: `data-md-list-open` and a row's
`data-md-selected` are drawn only through the row, a selected row in
`<x-table>` by table.css, `data-md-dragged` by foundation.css's
`md-state-layer` or card.css, `data-md-field-control` by field.css
inside `<x-field>`. The constant's docblock says so, and the guard's
docblocks, the README, the skill and UPGRADE.md describe the check.

A fixture test reads a hand-made `<li>` and `<tr>` row and a card-only
view (plain and namespaced `<x-card data-md-list-row>`, and a selector
in a script) against both checks; it fails without the change. A
second test scans the package's list-item and card views and expects
no row finding; it fails when package views are read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 08:05:33 +02:00
Andreas Reinhold / reiniandClaude Opus 5 b62c83f244 Show no tooltip on the control a sheet or dialog focuses as it opens
A modal side sheet, bottom sheet and the modal rail move the focus to
their first control as they open (x-trap), and `<x-modal>` does in
`showModal()`. On a page loaded with the sheet already open (a
`wire:model` set from `?workout=` in the URL) the browsers count that
focus as `:focus-visible`, and so they do for a sheet or dialog opened
from the keyboard, so tooltip.js showed the close button's "Close"
tooltip the moment the layer appeared, over its title, with no keyboard
use. Confirmed in Chrome, Firefox and Safari for the deep-linked sheet
and a full-screen dialog opened with Enter; a deep-linked `<x-modal>`'s
own focus was not counted as keyboard focus. M3 lets a tooltip appear
on hover or focus and says nothing that asks for one on a focus the
component moves itself.

layers.js gains `openingFocus(event)`: a focus is a modal layer's own
when the nearest open dialog, `aria-modal` panel or modal rail around
its target is entered from outside it, or from nowhere, and no Tab is
moving it (a Tab moves the focus while its keydown is handled, by the
browser or by focus-trap wrapping round the panel). tooltip.js and
rich-tooltip.js leave such a focus out; focus moved within the layer,
a Tab onto the control and a focus returned to it from a menu inside
still show the tooltip. The docblocks, the skill and UPGRADE.md say so.

A browser test loads a page with a modal sheet open at 393px, opens a
full-screen dialog and a dialog whose first control is a rich tooltip's
trigger from the keyboard, and checks that no tooltip is up on the
focused control, then that Tab wrapping round to the close button shows
its tooltip; it fails without the change in Chrome, Firefox and Safari,
each part on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 07:16:32 +02:00
Andreas Reinhold / reiniandClaude Opus 5 16c52d46e9 Hang a searchable choice's list as wide as its field
The searchable choice put its `anchor-name` on a `<div>` around its
field, and menu.css sizes the popover list with
`anchor-size(width)`. The wrapper fills its container, but from
`medium` the field inside it stops at 40rem, so in a wider pane the
list ran past the field's end across the whole container: 1168px under
a 640px field. Found while giving the choice `full`, which only hid it.

The field itself carries the anchor now and the wrapper is gone, so the
list is as wide as the field, bounded or `full`, and still hangs under
the field's supporting text as before.

A browser test opens a bounded and a `full` searchable choice in a
1200px column and compares each list's width and start with its field;
it fails without the change in Chrome, Firefox and Safari.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 07:06:25 +02:00
Andreas Reinhold / reiniandClaude Opus 5 7aa6dc7d33 Take full on the searchable choice and the pickers, as on every field
`full` takes the 40rem bound off `[data-md-field]` from `medium`, and
`<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>` and `<x-file>`
pass it to the field they draw. `<x-choices searchable>`,
`<x-datepicker>` and `<x-timepicker>` draw the same field but did not
declare the prop, and their roots only forward `class` and `style`, so
`full` reached no element and the field stopped at 40rem: ReStride's
time zone choice stood narrower than the rows filling its card.

The three now declare `full` and pass it to their field (the date
picker's own field, not the dialog's entry fields). `<x-choices>`' chips
are not a field and have no bound, so they take `full` and render
nothing for it, and a call site can switch `searchable` on and off
without it. The docblocks and the skill list every component that takes
it.

The browser test that bounds a field at 600px now measures a bounded
and a `full` searchable choice, date picker and time picker too; it
fails without the change in Chrome, Firefox and Safari. A feature test
renders `full` on every field-shaped component and none on the chips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 07:04:12 +02:00
Andreas Reinhold / reiniandClaude Opus 5 6d4487feb3 Hide a disabled fab button on a compact window, as M3 removes a FAB
`<x-button fab>` is an extended FAB below `medium` and a filled button
from there, but a disabled one was still drawn as the FAB below
`medium`: the compact FAB's rule set no state of its own, so the button
stood greyed out and fixed over the content. M3 never disables a FAB:
"if its action is unavailable, remove the FAB entirely".

A `fab` given `disabled` (a `<button disabled>`, or a link's
`aria-disabled="true"`) now renders `data-md-unavailable`, and
button.css draws it `display: none` below `medium`, which also takes it
out of the accessibility tree and the Tab order; from `medium` it is
the disabled filled button it was. The mark comes from the prop, not
from `:disabled`, because a `spinner` puts `disabled` on its button
while the action runs, and that FAB is busy rather than unavailable:
hidden, it would vanish instead of showing its loading indicator. The
docblocks and the skill's button table say so.

A browser test renders an enabled fab with a slow spinner action, a
disabled one and a disabled link on a 393px window: the disabled two
are not rendered, the busy one stays on screen with its indicator, and
at 600px both disabled ones are the disabled button again; it fails
without the change in Chrome, Firefox and Safari (and in Chrome with
`:disabled` alone as the condition, on the busy FAB). A feature test
reads the mark and the rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 07:00:40 +02:00
Andreas Reinhold / reiniandClaude Opus 5 cf63f11bfb Keep a switch's track whole beside long text in a row
`[data-md-toggle] { min-width: 0 }` let the switch's root shrink below
its 52px track as a flex item. In `<x-row justify="between">` beside a
paragraph, flex shrinking weighs each item by its width, so the root of
a bare switch gave up a share of its 52px, and a switch with a long
label of its own gave its label a column one word wide and less; the
track, which does not shrink inside the root, ran out of it and past a
card's edge on a phone. `<x-checkbox>`'s root did the same with its box.

Both roots now keep their automatic minimum, the control and, beside a
label, its longest word, so the text beside the control, or the
control's own label, wraps instead. The toggle's docblock and the skill
say so.

A browser test lays a bare switch, a labelled switch and a bare
checkbox beside long text in rows on a 393px window and checks each
control's size and that it stays inside its row, and that the switch's
own label wraps inside its root; it fails without the change in Chrome,
Firefox and Safari.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 06:55:36 +02:00
Andreas Reinhold / reiniandClaude Opus 5 1885c3e54e End a select's long value before its arrow, with an ellipsis
Where the browser has the customizable select, select.css made the
select `display: flex` with `text-overflow: ellipsis`, but the closed
select draws its value in a block of its user-agent shadow tree: a flex
item at its automatic minimum width that takes no `text-overflow` from
the select, and no author style reaches it (`display: block` on the
select does not help either). So a long option in a narrow field, or
at `size="sm"` and `xs`, ran on past the end padding under the arrow,
which `overflow: hidden` only clips at the select's edge. The native
select in Firefox cut the value at the padding with no ellipsis.

The view now gives the select a first child `<button type="button"
wire:ignore>` holding `<selectedcontent>`, the customizable select's own
copy of the chosen option, and select.css makes that the flex item that
shrinks and ends in an ellipsis; the native select takes `nowrap`,
`overflow: hidden` and the ellipsis on itself. `wire:ignore`, because a
Livewire render parses the server's empty element into the first
option's label and morphed it back over the chosen one. A browser
without the customizable select never renders the button; `type` keeps
it from being a form's default button.

A browser test measures the value against the arrow in md, sm, xs,
filled and icon selects 240px wide, and again after choosing another
option re-renders the component; it fails without the change in Chrome
and Firefox.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 06:50:53 +02:00
Andreas Reinhold / reiniandClaude Opus 5 c5a3870b7f Wait for a standard side sheet to settle before timing its exit in the browser test
The test closed the showcase's standard sheet straight after resizing
the window and sampled its exit, but the sheet has no transitions until
the view settles, two animation frames after Alpine starts, and WebKit
had often not run those frames yet: the sheet closed at once, as it is
meant to while settling, and the test read that as a missing exit. It
failed in Safari when run on its own on 2.1.0's code as well. The
sampling script now waits for `data-md-drawer-settled` first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 06:22:02 +02:00
Andreas Reinhold / reiniandClaude Opus 5 a6dc213e67 Keep a closed standard side sheet out of its row from the first paint
A closed standard sheet is `inline-size: 0` with a negative margin as
wide as its row's gap, but the view writes that gap (`--md-drawer-gap`)
and `data-md-drawer-collapsed` only once Alpine runs. Until then each
closed sheet was a zero-wide flex item that still cost the row one gap,
and a sheet bound to an open Livewire property had no width at all, so
the content beside them changed width when the script started - two
closed sheets beside a column made it 48px narrower, then it jumped.
Until the view has settled, drawer.css now takes a standard sheet that
is not `data-md-open` out of the layout, and the view renders
`data-md-open` on a standard sheet whose `wire:model` property is open
(truthy as Alpine reads it), so it stands at its width from the first
paint. Transitions still wait for `data-md-drawer-settled`. A browser
test measures a column beside two closed sheets (one bound to Livewire,
one to Alpine) and an open one before Alpine starts and once settled;
it fails without the change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 06:09:29 +02:00
Andreas Reinhold / reiniandClaude Opus 5 207270a23d Stick a standard side sheet under the top safe area and the scaffold's sticky app bar
The standard sheet was sticky at `top: 0` and `100dvh` tall, so in an
installed app with a status bar its head row and close button slid under
the status bar once the page scrolled, where the modal sheet already
starts at `--material-safe-top`. Inside `<x-scaffold>` a sticky
`<x-app-bar>` in the `top` slot spans the content region, sheet and all,
and covered it the same way, safe area or not. The sheet now sticks at
`--drawer-top`, the top safe area, or under a scaffold's sticky bar that
safe area plus the bar's 64px row (just the row, if taller than the safe
area, for a medium or large bar, which collapses to it), and is as tall
as the window below that. Every side sheet keeps the bottom safe area
inside its bottom padding. A browser test scrolls a standard sheet in a
scaffold with no bar, a small bar and a large bar at a 47px top and 34px
bottom inset and measures where it sticks, its foot, its close button
and its padding; it fails without the change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 06:06:23 +02:00
Andreas Reinhold / reiniandClaude Opus 5 94a376a4fd Close only the topmost layer on Escape, and keep a layer opened over a sheet readable
A modal side sheet, bottom sheet or the modal rail closed on any Escape
the window heard, so a dialog opened from a sheet, a menu, select list
or searchable choice inside one, a sheet opened from a sheet, and a
sheet inside a dialog each closed two layers on one press. And a dialog
or a second sheet rendered elsewhere on the page sat inside the
`aria-hidden` the first sheet's `x-trap.inert` put on its siblings, so a
screen reader could not read it, while the sheet's focus trap took every
Tab inside the dialog back to the inert sheet.

resources/js/layers.js adds `x-layer`, on each of those panels beside
its `x-trap`. An Escape is the panel's only when nothing has handled it
and the nearest open layer around its target is the panel itself - not
an open dialog, popover or customizable select, nor a panel inside it;
the panel claims it with preventDefault(), which also keeps a dialog
around it from cancelling, and dispatches `material-escape`, which the
views close on. A panel that opens lifts `aria-hidden` from its own
ancestors and puts it back on close only where a panel still open hides
them; materialShowModal() does the same for `<x-modal>`, whose new
`x-trap.noautofocus.noreturn` pauses the sheet's focus trap while it is
open and moves no focus of its own. The searchable choice, the search
view and the supporting pane's sheet now preventDefault() the Escape
they act on, so the dialog or sheet around them stays.

Four browser tests stack the layers every way above and fail without
the change in Chrome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 05:59:32 +02:00
Andreas Reinhold / reiniandClaude Opus 5 029c78d301 Keep a focused field's focus edge under the pointer
The hover rule's `:not(:has([data-md-field-control]:disabled))` weighs
as much as its argument, so hover weighed (0,5,0) against focus's
(0,2,0), and the error's hover (0,6,0) against the error's focus
(0,4,0): a focused field with the pointer on it drew a 2px on-surface
edge instead of primary, a field in error on-error-container instead of
error, in both variants. A customizable select with its menu open lost
the same way, since focus is then on an option in the top layer and
only select.css's `:open` rule (0,3,1) draws the focus edge. M3 layers
focus over hover. The disabled condition is now weightless inside
`:where()`, still keeping hover off a disabled field, and the focus
rule gains the field ancestor: focus (0,3,0) outweighs hover (0,2,0),
the error's focus (0,4,0) the error's hover (0,3,0), and the open
select both. An open select in error drew primary, not error, and now
draws error. A browser test hovers and clicks an outlined, a filled and
an invalid field and opens a select and a select in error, comparing
each edge with its role; it fails without the change (the error select
without select.css's rule).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 05:29:29 +02:00
Andreas Reinhold / reiniandClaude Opus 5 0641071a06 Leave a Markdown mail component's classes to the mail theme in the design guard
A mail component is drawn by the mail theme, not by the application's
CSS entry, and the theme's classes share Tailwind's names: ReStride's
`x-mail::sessions` wraps its table in `<div class="table">`, the class
the package's own mail theme styles, and the guard reported it as
Tailwind's `display: table`. A view under a path `mail.markdown.paths`
names (Laravel's `resources/views/vendor/mail` by default) now skips
family (i) and the breakpoint, scale, palette and colour-value checks;
its icon names, the directives in its component tags, forbidColours()
and forbid() are still read. A mail theme stylesheet under that path,
which has to write literal values, is left out of check (iii) and of
the exempt classes, so its `.table` no longer hides `table` in the
application's other views either. The guard's docblock, the README and
the skill's "Testing the design" say so; a fixture test reads the same
mail view and theme with and without the path configured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 04:18:17 +02:00
Andreas Reinhold / reiniandClaude Opus 5 7a95449c82 Draw the 67 Material Symbols Google ships without a viewBox at every size
Google publishes 67 symbols per cut (auto_awesome, insights,
tips_and_updates, battery_50 ...) with width and height but no viewBox,
their paths in 24 units, or 20 in the 20 cut, where every other symbol
draws in `0 -960 960 960`. bin/fetch-symbols dropped the size so CSS
decides it, which left these with no coordinate system: `<x-icon>`
drew them at 1:1, whole only at exactly 24px and cut off or misplaced
at any other size, the 20 cut every small button uses included.
normalise() now turns a file's width and height into its viewBox when
it has none, before dropping them. The 268 files are regenerated from
Google's originals at the recorded SOURCE commit with the fixed step
(each shipped file matched the old step's output first; the other
16,272 do not change), and every path lies inside its new viewBox.
IconTest checks that each shipped symbol has a viewBox and that
`<x-icon name="auto_awesome">` renders in 20 units at size 16 and in 24
at size 32; both fail without the change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 04:18:10 +02:00
Andreas Reinhold / reiniandClaude Opus 5 b7e16476cd Fill a selected row written by hand in secondary-container again
1.x drew `data-selected` on any `data-list-row` that was not a card in
secondary-container with on-secondary-container ink, whatever element
the row was. 2.0.0 kept that only for `<x-list-item selected>` and a
table row's `aria-selected="true"`, so an application's own `<li>`,
`<div>` or `<tr>` with `data-md-list-row` and `data-md-selected` - the
contract list-rows.js still describes - lost its selected state without
a word. list-item.css now fills such a row, cards and list items
excluded, and names the fill `--md-list-row-fill`, so hover, focus and
press tint it as they tint a selected list item rather than replacing
it (1.x's selected fill hid them). In `<x-table>`, `data-md-selected`
selects a plain row too, beside `aria-selected="true"`, which ARIA
allows on a row only in a grid. A browser test compares a hand-made
selected `<li>` and a plain selected `<tr>` with the role pair in the
light and dark themes, checks that a selected card keeps its own fill
and that hover tints the row's fill; it fails without the change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 04:18:10 +02:00
Andreas Reinhold / reiniandClaude Opus 5 613ac20013 Report a colour utility on the application's own theme colour in the design guard
The guard knew M3's roles and Tailwind's palette, so `bg-off-plan`,
`text-sport-run`, `from-brand` or `border-l-zone-4` - colours an
application's own Tailwind theme named before 2.0.0 - were the leftovers
its count never showed, though none has compiled since. After every
other family, a class-list token shaped like a colour utility on a name
that starts with a letter is now reported with the application's own
`var(--...)` as its replacement (`color-mix()` with an opacity), unless
the application's CSS declares the class.

So that none of Tailwind's own utilities on those prefixes reads as a
theme colour, each one Tailwind 4.2 lists is now named by its family:
`border-collapse`, `border-spacing-*`, the block borders `border-bs`/
`border-be`, `bg-blend-*` and the other background positions and
angles, gradient stops, `fill-none`/`stroke-*`, `text-shadow-*`,
`shadow-inner`, `accent-auto`, `inherit`/`initial` as colour keywords,
and the `mauve`, `olive`, `mist` and `taupe` palettes; only
`inset-ring-*` is still not read. BoostVocabularyTest no longer reads
a span holding a single property, prop or role name (`border-color`,
`placeholder-value`, `outline-variant`) as a class list. A fixture
test covers the theme colours and the Tailwind names beside them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 04:18:10 +02:00
Andreas Reinhold / reiniandClaude Opus 5 394c9326ac Flip what the rail on the page draws from $store.rail.toggle()
toggle() was set(!collapsed), and while nothing is stored `collapsed`
is only `rail.default`: an adaptive rail between 840 and 1199px is
drawn collapsed whatever that says, so an application's shortcut
collapsed a collapsed rail and the first press changed nothing. Each
interactive rail now registers its root, and toggle() asks the first
one on the page, the way its menu button asks itself: an open rail is
hidden, a rail with no room to expand (modal, or adaptive below
expanded) opens over its scrim without storing anything, and any
other rail stores the opposite of what it draws, so a rail that hid
itself docks back into the layout as before. With no rail on the
page it still flips the choice. collapse() and expand() stay the
choice itself. The browser tests toggle the scaffold's rail at 960
and 1512px (in place) and at 768 and 393px (the modal), and a hiding
rail back into the layout; the first two fail without the change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 03:50:00 +02:00
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
8837 changed files with 70666 additions and 10271 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
+10 -25
View File
@@ -11,28 +11,6 @@ permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with:
php-version: '8.5'
tools: composer:v2
coverage: none
- name: Install dependencies
run: composer install --no-interaction --no-progress --prefer-dist
- name: Check formatting
run: vendor/bin/pint --test
feature:
runs-on: ubuntu-latest
@@ -54,19 +32,26 @@ jobs:
tools: composer:v2
coverage: none
# material:scheme's tests run the bundled colour script through Node.
# material:scheme's tests run the bundled colour script through Node, and the prebuilt
# stylesheets' staleness test rebuilds them with the installed Vite.
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: '22'
- name: Install dependencies
run: composer install --no-interaction --no-progress --prefer-dist
run: |
composer install --no-interaction --no-progress --prefer-dist
npm ci
- name: Check formatting
if: matrix.php == '8.5'
run: vendor/bin/pint --test
- 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
+16
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,18 @@ 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.
CSS reset (resources/css/foundation/reset.css)
Derived rule for rule from Tailwind CSS 4's preflight, after modern-normalize.
Copyright Tailwind Labs. MIT License.
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.
A copy of the MIT License is available at https://opensource.org/licenses/MIT.
+27 -18
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. `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,9 +89,12 @@ 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`).
- `pagination` — draw Laravel's and Livewire's paginators in M3 (default `true`); the provider also sets `livewire.pagination_theme` to `material` whenever it still reads as Livewire's own default (`tailwind`), unless an application's own theme, or a component's `$paginationTheme` property or `paginationView()` method, already chose one.
- `showcase.enabled`, `showcase.path`, `showcase.middleware`, `showcase.vite`.
- `node` — the Node binary for `material:scheme`.
@@ -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`, on a class written in a view or PHP file that no stylesheet declares — not the application's own CSS, not the package's `md-*` — whatever it came from: a utility of a framework that isn't in the stack, a typo, or a class whose rule was deleted. Without a `.css` file among the scanned paths or a `missingStylesheets()` entry to supply the application's import graph, it has no declared class to compare against and reports every one of them. 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. Markdown mail components (under `mail.markdown.paths`, `resources/views/vendor/mail` by default) are the mail theme's to style: their classes are not read by the class check, and a mail theme stylesheet there is neither checked for literals nor a source of declared classes.
`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::…>`), `->links()` and the rows they write by hand (`data-md-list-row` on anything but `<x-card>` needs `components/list-item.css`), 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 — as long as `missingStylesheets()` is given an entry, its import graph still supplies the declared classes family (i) needs. `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
+363
View File
@@ -0,0 +1,363 @@
# Upgrading
## From 2.1.0 to 2.2.0
- **Run `php artisan view:clear` after upgrading.** The components are no longer registered under a
view namespace named after a hash of their folder, which only views compiled before 1.0.1 still
named; such a view shows the tag as text until it is compiled again.
- **`DesignGuard`** no longer reports the 1.x package's own names (`text-meta`, `focus-ring`,
`rounded-corner-lg`, `shadow-elevation-2`, `type-body-md`, `ease-spatial-fast`) with their 2.0.0
replacement. A colour utility on a 1.x ink is still reported, as a colour of the application's own
theme; the rest compile to nothing and pass unnoticed.
- **`<x-slider>`** redraws when a script sets its input's `value`, as before, but no longer when it
sets `valueAsNumber`; write `value` instead.
- **The showcase's stylesheet** is served from `assets/css/showcase.{hash}.css`, prebuilt in
`resources/dist/`, and the error page inlines prebuilt bundles too. `Stylesheets::bundle()` and
`Stylesheets::resetCache()` are gone; nothing outside the package called them.
- **`$store.rail.toggle()`** flips what the first rail on the page draws, as its menu button does.
With nothing stored, `<x-scaffold>`'s rail is drawn collapsed from 840 to 1199px whatever
`rail.default` says, and `toggle()` collapsed it again, so the first press of an application's
shortcut changed nothing; it now expands the rail. Below 840px, where there is no room to expand
it in the layout, `toggle()` opens and closes the modal rail (`show()`, `hide()`) instead of
changing a choice nothing draws there. A shortcut that pressed the rail's menu button to get this
can call `$store.rail.toggle()` again.
- **`DesignGuard`** no longer reads a Markdown mail component's classes as Tailwind: a view under
`mail.markdown.paths` (`resources/views/vendor/mail` by default) is drawn by the mail theme, whose
`table`, `button` and `panel` share Tailwind's names, so only its icon names, the directives in
its component tags and the application's own bans are checked there. A mail theme stylesheet
under that path is no longer checked for literal values, and its classes no longer exempt the
same names in the application's other views. A filter an application wrote around such a
finding can go.
- **`DesignGuard`** counts a row written by hand. `data-md-list-row` on an `<li>`, a `<div>`, a
`<tr>` or `<x-row>` takes its hover, focus and press state layer and its selected fill from
`components/list-item.css`, but `missingStylesheets()` and `unusedStylesheets()` read only tags
and `->links()`: an application had the stylesheet through `table.css` or `<x-list-item>`, lost
it without a finding when the last of those left its views, and an `@import` of `list-item.css`
for its own rows was reported unused. A view of the application that writes `data-md-list-row`
on anything but `<x-card>`, whose row card.css draws, now needs `list-item.css`; a selector such
as `[data-md-list-row]` and the package's own views do not count. A test that expects no findings
may name the `@import` to add, and a filter written around the unused report can go.
- **A selected row written by hand** — `data-md-list-row` with `data-md-selected` on an `<li>`, a
`<div>` or a `<tr>`, not `<x-list-item>` or `<x-card>` — is drawn in `secondary-container` with
`on-secondary-container` ink again, as 1.x's `data-list-row` with `data-selected` was; 2.0.0
dropped the fill without saying so. Hover, focus and press now tint the fill instead of hiding
it. In `<x-table>` a plain row takes `data-md-selected` too, beside `aria-selected="true"`. A
card still takes no selected fill.
- **67 Material Symbols** in each cut (`auto_awesome`, `insights`, `tips_and_updates`,
`battery_50` …) are drawn whole at every size. Google publishes them in 24 units (20 in the 20
cut) with a width and height but no viewBox, and the package dropped the size, so they fitted
only at exactly 24px and were cut off or misplaced at any other — the 20 cut every small button
draws from included. Nothing to change.
- **A focused text field keeps its focus edge under the pointer.** The hover rule outweighed focus,
so a focused field the pointer rested on drew a 2px `on-surface` edge instead of `primary`, and
a field in error `on-error-container` instead of `error` — in the filled variant too, and on a
customizable `<x-select>` while its menu is open. An open select in error now draws `error`
rather than `primary`. A test that read the hover colour off a focused field reads `primary`
now.
- **Escape closes one layer.** A dialog, menu, customizable select or searchable choice opened
over or inside a modal `<x-drawer>`, `<x-bottom-sheet>` or modal navigation rail — or a sheet
opened from a sheet, or inside `<x-modal>` — closed the layer under it on the same Escape, since
each sheet closed on any Escape the window heard. Now only the topmost layer closes; a sheet
with `close-on-escape` off still keeps the Escape from closing what is under it. A dialog or
sheet opened from a modal sheet but rendered elsewhere on the page was also hidden from screen
readers by the sheet's `aria-hidden`, and Tab could not move inside the dialog; both work now.
An application's own guard around a sheet's Escape can go.
- **A standard `<x-drawer>` sticks under the top safe area** (`--material-safe-top`, else the
device's inset) instead of at the window's edge, so its head and close button no longer slide
under an installed app's status bar as the page scrolls; inside `<x-scaffold>` it also sticks
under a sticky `<x-app-bar>` in the `top` slot, which covered it. It is as tall as the window
below that, and every side sheet keeps the bottom safe area inside its bottom padding.
- **A standard `<x-drawer>` takes its place from the first paint.** Until Alpine started, a closed
standard sheet was a zero-wide item that still cost its row one gap, and a sheet bound to an open
Livewire property had no width yet, so the content beside them changed width when the script
ran. A standard sheet is now out of the row until then, and one whose `wire:model` property is
open is rendered open and stands at its width. One opened from an Alpine scope alone still
appears when Alpine starts, which only the script can know.
- **A long value in `<x-select>` ends before the arrow, with an ellipsis.** Where the browser has
the customizable select (Chrome, Safari), the closed select drew its value in a box of its own
that took neither `text-overflow` nor a width from the select, so a long option in a narrow
field, or at `size="sm"` and `xs`, ran on under the arrow; the native select (Firefox) cut it at
the arrow with no ellipsis. The select's first child is now a `<button type="button"
wire:ignore>` holding `<selectedcontent>`, the browser's copy of the chosen option, which
select.css cuts; a browser without the customizable select never renders it. A test that
matched a select's markup up to its first `<option>` finds the button there.
- **A switch beside long text in a row keeps its track.** `<x-toggle>`'s root could shrink to
nothing as a flex item, so beside a paragraph in `<x-row justify="between">`, or with a long
label of its own, the 52px track ran out of the row and past a card's edge on a phone.
`<x-checkbox>` did the same with its box. Both roots now keep their automatic minimum — the
control, and beside a label its longest word — so the text wraps instead. A `flex: none` an
application put on the switch to hold it can go.
- **A disabled `<x-button fab>` is not drawn on a compact window.** Below `medium` it was still
the extended FAB, greyed out and fixed over the content; M3 never shows a disabled FAB and
removes one whose action is unavailable, so a `fab` given `disabled` (on a link too) now renders
`data-md-unavailable` and is `display: none` there, out of the accessibility tree with it. From
`medium` it is the disabled filled button, as before. A `spinner` that disables the button while
its action runs leaves the FAB on screen. An application's own `hide-below` or condition around
a disabled fab button can go.
- **`<x-choices searchable>`, `<x-datepicker>` and `<x-timepicker>` take `full`**, as `<x-input>`,
`<x-textarea>`, `<x-select>` and `<x-file>` do, to take the 40rem bound off a field from
`medium`; the prop did nothing there, so the field stopped at 40rem beside rows that filled their
card. `<x-choices>`' chips have no bound and take `full` without a change, so a call site can
switch `searchable` on and off. A width rule an application wrote around one of them for this
can go.
- **A searchable `<x-choices>`' list is as wide as its field.** It was anchored to a wrapper
around the field, which fills its container, so from `medium`, where the field stops at 40rem,
the list ran on past the field's end across the whole container. The field itself is the anchor
now, and the wrapper `<div>` is gone; the list keeps hanging under the field's supporting text.
- **A sheet or dialog that opens shows no tooltip on the control it focuses.** A modal
`<x-drawer>`, `<x-bottom-sheet>`, the modal rail and `<x-modal>` move the focus to their first
control as they open, and on a page loaded with the sheet open (`wire:model` already set, say
from `?workout=` in the URL) or a layer opened from the keyboard, browsers count that focus as
keyboard focus: the close button's plain tooltip stood over the sheet the moment it appeared, in
Chrome, Firefox and Safari. Plain and rich tooltips now leave out focus a modal layer moves into
itself from outside; a Tab onto the control, including the one that wraps round to it, and focus
moved within the layer still show them. An `autofocus` an application put on another control to
keep the focus off the close button can go.
- **`DesignGuard`** reports any class a view or PHP file writes that no stylesheet declares —
not the package's `md-*`, not the application's own CSS — instead of naming Tailwind's
utilities, variants and theme colours with their 2.0.0 replacement. It now catches a dead class
from any source: a typo, a utility of a framework that isn't in the stack, a class whose rule
was deleted — not only Tailwind's — but a class assembled in a PHP string (`'text-'.$tone`) is
still not seen. A test that asserted on the old Tailwind-shaped message needs the new one
instead.
- **The pagination views are `material.blade.php` and `simple-material.blade.php`**, no longer named
after Tailwind. The provider sets `Paginator::defaultView('pagination::material')` and
`defaultSimpleView('pagination::simple-material')`, and switches `livewire.pagination_theme` to
`material` whenever it still reads as Livewire's own default (`tailwind`, including when the key
is missing), so an application can no longer forget the config and silently render Livewire's own
Tailwind view. An application that already chose another theme, or a component with its own
`$paginationTheme` property or `paginationView()` method, is untouched — either still wins.
- **`foundation.css` no longer explains how to order layers beside Tailwind.** No consumer builds
Tailwind any more, so the paragraph about opening both entries with
`@layer properties, theme, base, material, components, utilities;` is gone; the layer statement
and the order it declares are otherwise unchanged.
## 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). 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 of Tailwind's own utilities with its replacement; the 1.x names are no longer reported.
### 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.
+32
View File
@@ -0,0 +1,32 @@
/**
* Prints the variable axes of the packaged Google Sans Flex subset.
*
* Run from the repository root with `node bin/check-font.mjs [woff2]`.
* tests/Feature/FontTest.php runs it through the configured `node` binary and asserts the ranges
* the CSS needs (wght, ROND — see that test for why), because PHP cannot open a woff2 without the
* Brotli extension.
*
* Output is one JSON object on stdout — {file, postscriptName, numGlyphs, axes: {tag: {name, min,
* default, max}}}. The exit status is 1, with the reason on stderr, only when the file itself
* can't be opened.
*/
import { openSync } from 'fontkit'
import { fileURLToPath } from 'node:url'
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)
}
process.stdout.write(`${JSON.stringify({
file,
postscriptName: font.postscriptName,
numGlyphs: font.numGlyphs,
axes: font.variationAxes ?? {},
})}\n`)
+44 -18
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,33 @@ 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.
# it, and paint in currentColor so an icon takes the colour of the text around it. Nearly
# every file draws in a viewBox="0 -960 960 960"; a few carry none and draw in the units of
# their width and height (24, or 20 in the 20 cut), so the size becomes their viewBox before
# it goes, or the glyph would only fit at exactly that many pixels.
normalise() {
sed -E \
-e 's/ (width|height)="[0-9]+"//g' \
-e '/viewBox=/!s/<svg ([^>]*)height="([0-9]+)(px)?"([^>]*)width="([0-9]+)(px)?"/<svg \1height="\2"\4width="\5" viewBox="0 0 \5 \2"/' \
-e '/viewBox=/!s/<svg ([^>]*)width="([0-9]+)(px)?"([^>]*)height="([0-9]+)(px)?"/<svg \1width="\2"\4height="\5" viewBox="0 0 \2 \5"/' \
-e 's/ (width|height)="[0-9]+(px)?"//g' \
-e 's/<svg /<svg fill="currentColor" /' \
"$1"
}
@@ -47,19 +70,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"
+5 -341
View File
@@ -42,12 +42,11 @@
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/LoadingIndicator.kt
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/LoadingIndicatorTokens.kt
* compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/SpringSimulation.kt
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/FeatureMapping.kt
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/FloatMapping.kt
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Morph.kt
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/PolygonMeasure.kt
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/RoundedPolygon.kt (calculateMaxBounds)
*
* The feature matching and Morph itself (FeatureMapping.kt, FloatMapping.kt, Morph.kt,
* PolygonMeasure.kt) are shared with progress.js: see resources/js/shapes.js.
*
* Copyright 2022-2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
@@ -65,13 +64,11 @@
*/
import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { cubicBounds, point, pointOnCurve, split, SHAPES } from './shapes.mjs'
import { SHAPES } from './shapes.mjs'
import { asCubics, cubicBounds, match, pointOnCurve } from '../resources/js/shapes.js'
const OUTPUT = 'resources/svg/loading-indicator'
const DISTANCE_EPSILON = 1e-4
const ANGLE_EPSILON = 1e-6
// LoadingIndicator.kt / LoadingIndicatorTokens.kt --------------------------------------
const SEQUENCE = ['soft-burst', 'cookie-9', 'pentagon', 'pill', 'sunny', 'cookie-4', 'oval']
@@ -94,339 +91,6 @@ const HANDOVER_LEAD = 0.001
/** The fastest collapse a keySpline can give: half the size within a frame, then a long tail. */
const COLLAPSE = [0, 1, 0, 1]
// Utils.kt / FloatMapping.kt ------------------------------------------------------------
const positiveModulo = (num, mod) => ((num % mod) + mod) % mod
const progressInRange = (progress, from, to) =>
to >= from ? progress >= from && progress <= to : progress >= from || progress <= to
function progressDistance(a, b) {
const d = Math.abs(a - b)
return Math.min(d, 1 - d)
}
function linearMap(xValues, yValues, x) {
const n = xValues.length
const start = xValues.findIndex((_, i) => progressInRange(x, xValues[i], xValues[(i + 1) % n]))
const end = (start + 1) % n
const sizeX = positiveModulo(xValues[end] - xValues[start], 1)
const sizeY = positiveModulo(yValues[end] - yValues[start], 1)
const position = sizeX < 0.001 ? 0.5 : positiveModulo(x - xValues[start], 1) / sizeX
return positiveModulo(yValues[start] + sizeY * position, 1)
}
/** DoubleMapper: maps outline progress on one shape to the other and back, from [source, target] pairs. */
function doubleMapper(mappings) {
const sources = mappings.map((m) => m[0])
const targets = mappings.map((m) => m[1])
return { map: (x) => linearMap(sources, targets, x), mapBack: (x) => linearMap(targets, sources, x) }
}
// PolygonMeasure.kt ---------------------------------------------------------------------
const MEASURE_SEGMENTS = 3
/** LengthMeasurer.closestProgressTo: [the parameter at which `threshold` length is reached, the length]. */
function closestProgressTo(c, threshold) {
let total = 0
let remainder = threshold
let previous = point(c[0], c[1])
for (let i = 1; i <= MEASURE_SEGMENTS; i++) {
const progress = i / MEASURE_SEGMENTS
const p = pointOnCurve(c, progress)
const segment = Math.hypot(p.x - previous.x, p.y - previous.y)
if (segment >= remainder) {
return [progress - (1 - remainder / segment) / MEASURE_SEGMENTS, threshold]
}
remainder -= segment
total += segment
previous = p
}
return [1, total]
}
const measureCubic = (c) => closestProgressTo(c, Infinity)[1]
const findCubicCutPoint = (c, measure) => closestProgressTo(c, measure)[0]
class MeasuredCubic {
constructor(cubic, startOutlineProgress, endOutlineProgress) {
if (endOutlineProgress < startOutlineProgress) {
throw new Error('endOutlineProgress is expected to be equal or greater than startOutlineProgress')
}
this.cubic = cubic
this.startOutlineProgress = startOutlineProgress
this.endOutlineProgress = endOutlineProgress
this.measuredSize = measureCubic(cubic)
}
cutAtProgress(cutOutlineProgress) {
const bounded = Math.min(Math.max(cutOutlineProgress, this.startOutlineProgress), this.endOutlineProgress)
const relativeProgress =
(bounded - this.startOutlineProgress) / (this.endOutlineProgress - this.startOutlineProgress)
const t = findCubicCutPoint(this.cubic, relativeProgress * this.measuredSize)
const [c1, c2] = split(this.cubic, t)
return [
new MeasuredCubic(c1, this.startOutlineProgress, bounded),
new MeasuredCubic(c2, bounded, this.endOutlineProgress),
]
}
}
class MeasuredPolygon {
constructor(features, cubics, outlineProgress) {
this.features = features
this.cubics = []
let startOutlineProgress = 0
for (let i = 0; i < cubics.length; i++) {
if (outlineProgress[i + 1] - outlineProgress[i] > DISTANCE_EPSILON) {
this.cubics.push(new MeasuredCubic(cubics[i], startOutlineProgress, outlineProgress[i + 1]))
startOutlineProgress = outlineProgress[i + 1]
}
}
this.cubics.at(-1).endOutlineProgress = 1
}
static measure(polygon) {
const cubics = []
const featureToCubic = []
for (const feature of polygon.features) {
feature.cubics.forEach((cubic, i) => {
if (feature.type === 'corner' && i === Math.floor(feature.cubics.length / 2)) {
featureToCubic.push([feature, cubics.length])
}
cubics.push(cubic)
})
}
const measures = [0]
for (const cubic of cubics) {
measures.push(measures.at(-1) + measureCubic(cubic))
}
const outlineProgress = measures.map((measure) => measure / measures.at(-1))
const features = featureToCubic.map(([feature, ix]) => ({
progress: positiveModulo((outlineProgress[ix] + outlineProgress[ix + 1]) / 2, 1),
feature,
}))
return new MeasuredPolygon(features, cubics, outlineProgress)
}
cutAndShift(cuttingPoint) {
if (cuttingPoint < DISTANCE_EPSILON) {
return this
}
const n = this.cubics.length
const targetIndex = this.cubics.findIndex(
(c) => cuttingPoint >= c.startOutlineProgress && cuttingPoint <= c.endOutlineProgress,
)
const [b1, b2] = this.cubics[targetIndex].cutAtProgress(cuttingPoint)
const cubics = [b2.cubic]
for (let i = 1; i < n; i++) {
cubics.push(this.cubics[(i + targetIndex) % n].cubic)
}
cubics.push(b1.cubic)
const outlineProgress = Array.from({ length: n + 2 }, (_, index) => {
if (index === 0) {
return 0
}
if (index === n + 1) {
return 1
}
return positiveModulo(this.cubics[(targetIndex + index - 1) % n].endOutlineProgress - cuttingPoint, 1)
})
const features = this.features.map(({ progress, feature }) => ({
progress: positiveModulo(progress - cuttingPoint, 1),
feature,
}))
return new MeasuredPolygon(features, cubics, outlineProgress)
}
}
// FeatureMapping.kt ---------------------------------------------------------------------
function featureRepresentativePoint(feature) {
const first = feature.cubics[0]
const last = feature.cubics.at(-1)
return point((first[0] + last[6]) / 2, (first[1] + last[7]) / 2)
}
function featureDistSquared(f1, f2) {
if (f1.type === 'corner' && f2.type === 'corner' && f1.convex !== f2.convex) {
return Infinity
}
const p1 = featureRepresentativePoint(f1)
const p2 = featureRepresentativePoint(f2)
return (p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2
}
function doMapping(features1, features2) {
const distanceVertexList = []
for (const f1 of features1) {
for (const f2 of features2) {
const distance = featureDistSquared(f1.feature, f2.feature)
if (distance !== Infinity) {
distanceVertexList.push({ distance, f1, f2 })
}
}
}
// Array.prototype.sort is stable, like Kotlin's sortedBy.
distanceVertexList.sort((a, b) => a.distance - b.distance)
if (distanceVertexList.length === 0) {
return [
[0, 0],
[0.5, 0.5],
]
}
if (distanceVertexList.length === 1) {
const { f1, f2 } = distanceVertexList[0]
return [
[f1.progress, f2.progress],
[(f1.progress + 0.5) % 1, (f2.progress + 0.5) % 1],
]
}
const mapping = []
const usedF1 = new Set()
const usedF2 = new Set()
for (const { f1, f2 } of distanceVertexList) {
if (usedF1.has(f1) || usedF2.has(f2)) {
continue
}
const insertionIndex = mapping.findIndex((m) => m[0] >= f1.progress)
const index = insertionIndex === -1 ? mapping.length : insertionIndex
if (index < mapping.length && mapping[index][0] === f1.progress) {
throw new Error("There can't be two features with the same progress")
}
const n = mapping.length
if (n >= 1) {
const [before1, before2] = mapping[(index + n - 1) % n]
const [after1, after2] = mapping[index % n]
if (
progressDistance(f1.progress, before1) < DISTANCE_EPSILON ||
progressDistance(f1.progress, after1) < DISTANCE_EPSILON ||
progressDistance(f2.progress, before2) < DISTANCE_EPSILON ||
progressDistance(f2.progress, after2) < DISTANCE_EPSILON
) {
continue
}
if (n > 1 && !progressInRange(f2.progress, before2, after2)) {
continue
}
}
mapping.splice(index, 0, [f1.progress, f2.progress])
usedF1.add(f1)
usedF2.add(f2)
}
return mapping
}
function featureMapper(features1, features2) {
const corners = (features) => features.filter(({ feature }) => feature.type === 'corner')
return doubleMapper(doMapping(corners(features1), corners(features2)))
}
// Morph.kt ------------------------------------------------------------------------------
/** Morph.match: the start and end shapes cut into pairs of matching cubics. */
function match(p1, p2) {
const measuredPolygon1 = MeasuredPolygon.measure(p1)
const measuredPolygon2 = MeasuredPolygon.measure(p2)
const mapper = featureMapper(measuredPolygon1.features, measuredPolygon2.features)
const polygon2CutPoint = mapper.map(0)
const bs1 = measuredPolygon1.cubics
const bs2 = measuredPolygon2.cutAndShift(polygon2CutPoint).cubics
const pairs = []
let i1 = 0
let i2 = 0
let b1 = bs1[i1++]
let b2 = bs2[i2++]
while (b1 !== undefined && b2 !== undefined) {
const b1a = i1 === bs1.length ? 1 : b1.endOutlineProgress
const b2a =
i2 === bs2.length ? 1 : mapper.mapBack(positiveModulo(b2.endOutlineProgress + polygon2CutPoint, 1))
const minb = Math.min(b1a, b2a)
let seg1
let seg2
if (b1a > minb + ANGLE_EPSILON) {
;[seg1, b1] = b1.cutAtProgress(minb)
} else {
seg1 = b1
b1 = bs1[i1++]
}
if (b2a > minb + ANGLE_EPSILON) {
;[seg2, b2] = b2.cutAtProgress(positiveModulo(mapper.map(minb) - polygon2CutPoint, 1))
} else {
seg2 = b2
b2 = bs2[i2++]
}
pairs.push([seg1.cubic, seg2.cubic])
}
if (b1 !== undefined || b2 !== undefined) {
throw new Error("Expected both Polygon's Cubic to be fully matched")
}
return pairs
}
/** Morph.asCubics: every matched pair interpolated at `progress`, closed exactly on its first anchor. */
function asCubics(pairs, progress) {
const cubics = pairs.map(([start, end]) => start.map((value, i) => value + (end[i] - value) * progress))
cubics.at(-1)[6] = cubics[0][0]
cubics.at(-1)[7] = cubics[0][1]
return cubics
}
// LoadingIndicator.kt: calculateScaleFactor, processPath --------------------------------
/** RoundedPolygon.calculateMaxBounds: a square holding the shape in any rotation. */
+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 contrast level ${input.contrast} is the standard block's, from -1 to below ${LEVELS.medium}. Medium (${LEVELS.medium}) and high (${LEVELS.high}) are always generated beside it, under [data-contrast]; the head script picks one.`)
}
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,
}))
+6 -486
View File
@@ -20,12 +20,9 @@
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/MaterialShapes.kt
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/internal/ShapeUtil.kt
* compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Matrix.kt (rotateZ, scale)
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/CornerRounding.kt
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Cubic.kt
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Point.kt
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/RoundedPolygon.kt
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Shapes.kt
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Utils.kt
*
* The RoundedPolygon construction itself (CornerRounding, Cubic, Point, RoundedPolygon, Shapes,
* Utils) is shared with bin/loading-indicator.mjs and progress.js: see resources/js/shapes.js.
*
* Copyright 2022-2024 The Android Open Source Project
*
@@ -45,480 +42,14 @@
import { mkdirSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { circlePolygon, length, minus, plus, point, polygonFromVertices, regularPolygon, rounding, split, star, times, turningPoints, UNROUNDED } from '../resources/js/shapes.js'
const OUTPUT = 'resources/svg/shapes'
const VIEWBOX = 100
const FILL = 96
const DISTANCE_EPSILON = 1e-4
// Point.kt / Utils.kt ------------------------------------------------------------------
const point = (x, y) => ({ x, y })
const plus = (a, b) => point(a.x + b.x, a.y + b.y)
const minus = (a, b) => point(a.x - b.x, a.y - b.y)
const times = (a, k) => point(a.x * k, a.y * k)
const div = (a, k) => point(a.x / k, a.y / k)
const dot = (a, b) => a.x * b.x + a.y * b.y
const length = (a) => Math.sqrt(a.x * a.x + a.y * a.y)
const rotate90 = (a) => point(-a.y, a.x)
const clockwise = (a, b) => a.x * b.y - a.y * b.x > 0
const lerp = (a, b, f) => (1 - f) * a + f * b
const lerpPoint = (a, b, f) => point(lerp(a.x, b.x, f), lerp(a.y, b.y, f))
function direction(a) {
const d = length(a)
if (!(d > 0)) {
throw new Error("Can't get the direction of a 0-length vector")
}
return div(a, d)
}
const radialToCartesian = (radius, angle) => point(Math.cos(angle) * radius, Math.sin(angle) * radius)
const convex = (previous, current, next) => clockwise(minus(current, previous), minus(next, current))
// Cubic.kt ------------------------------------------------------------------------------
/** A cubic is [anchor0X, anchor0Y, control0X, control0Y, control1X, control1Y, anchor1X, anchor1Y]. */
const cubic = (a0, c0, c1, a1) => [a0.x, a0.y, c0.x, c0.y, c1.x, c1.y, a1.x, a1.y]
function straightLine(x0, y0, x1, y1) {
return [x0, y0, lerp(x0, x1, 1 / 3), lerp(y0, y1, 1 / 3), lerp(x0, x1, 2 / 3), lerp(y0, y1, 2 / 3), x1, y1]
}
function circularArc(centerX, centerY, x0, y0, x1, y1) {
const p0d = direction(point(x0 - centerX, y0 - centerY))
const p1d = direction(point(x1 - centerX, y1 - centerY))
const rotatedP0 = rotate90(p0d)
const rotatedP1 = rotate90(p1d)
const isClockwise = dot(rotatedP0, point(x1 - centerX, y1 - centerY)) >= 0
const cosa = dot(p0d, p1d)
if (cosa > 0.999) {
return straightLine(x0, y0, x1, y1)
}
const k =
(((length(point(x0 - centerX, y0 - centerY)) * 4) / 3) *
(Math.sqrt(2 * (1 - cosa)) - Math.sqrt(1 - cosa * cosa))) /
(1 - cosa) *
(isClockwise ? 1 : -1)
return [x0, y0, x0 + rotatedP0.x * k, y0 + rotatedP0.y * k, x1 - rotatedP1.x * k, y1 - rotatedP1.y * k, x1, y1]
}
function pointOnCurve(c, t) {
const u = 1 - t
return point(
c[0] * (u * u * u) + c[2] * (3 * t * u * u) + c[4] * (3 * t * t * u) + c[6] * (t * t * t),
c[1] * (u * u * u) + c[3] * (3 * t * u * u) + c[5] * (3 * t * t * u) + c[7] * (t * t * t),
)
}
function split(c, t) {
const u = 1 - t
const p = pointOnCurve(c, t)
return [
[
c[0],
c[1],
c[0] * u + c[2] * t,
c[1] * u + c[3] * t,
c[0] * (u * u) + c[2] * (2 * u * t) + c[4] * (t * t),
c[1] * (u * u) + c[3] * (2 * u * t) + c[5] * (t * t),
p.x,
p.y,
],
[
p.x,
p.y,
c[2] * (u * u) + c[4] * (2 * u * t) + c[6] * (t * t),
c[3] * (u * u) + c[5] * (2 * u * t) + c[7] * (t * t),
c[4] * u + c[6] * t,
c[5] * u + c[7] * t,
c[6],
c[7],
],
]
}
const reverse = (c) => [c[6], c[7], c[4], c[5], c[2], c[3], c[0], c[1]]
const zeroLength = (c) => Math.abs(c[0] - c[6]) < DISTANCE_EPSILON && Math.abs(c[1] - c[7]) < DISTANCE_EPSILON
/** The parameters in (0, 1) where one axis of a cubic turns: the roots of its derivative. */
function turningPoints(c, axis) {
const [p0, p1, p2, p3] = [c[axis], c[axis + 2], c[axis + 4], c[axis + 6]]
const a = -p0 + 3 * p1 - 3 * p2 + p3
const b = 2 * (p0 - 2 * p1 + p2)
const k = p1 - p0
const roots = []
if (Math.abs(a) < 1e-9) {
if (Math.abs(b) > 1e-9) {
roots.push(-k / b)
}
} else if (b * b - 4 * a * k >= 0) {
const root = Math.sqrt(b * b - 4 * a * k)
roots.push((-b + root) / (2 * a), (-b - root) / (2 * a))
}
return roots.filter((t) => t > 1e-6 && t < 1 - 1e-6)
}
/** Axis-aligned bounds of one cubic: of all four points when approximate, else of the curve itself. */
function cubicBounds(c, approximate) {
const xs = [c[0], c[6]]
const ys = [c[1], c[7]]
if (approximate) {
xs.push(c[2], c[4])
ys.push(c[3], c[5])
} else {
turningPoints(c, 0).forEach((t) => xs.push(pointOnCurve(c, t).x))
turningPoints(c, 1).forEach((t) => ys.push(pointOnCurve(c, t).y))
}
return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)]
}
// CornerRounding.kt ---------------------------------------------------------------------
const rounding = (radius = 0, smoothing = 0) => ({ radius, smoothing })
const UNROUNDED = rounding()
// RoundedPolygon.kt ---------------------------------------------------------------------
class RoundedCorner {
constructor(p0, p1, p2, cornerRounding) {
this.p0 = p0
this.p1 = p1
this.p2 = p2
const v01 = minus(p0, p1)
const v21 = minus(p2, p1)
const d01 = length(v01)
const d21 = length(v21)
if (d01 > 0 && d21 > 0) {
this.d1 = div(v01, d01)
this.d2 = div(v21, d21)
this.cornerRadius = cornerRounding?.radius ?? 0
this.smoothing = cornerRounding?.smoothing ?? 0
this.cosAngle = dot(this.d1, this.d2)
this.sinAngle = Math.sqrt(1 - this.cosAngle * this.cosAngle)
this.expectedRoundCut = this.sinAngle > 1e-3 ? (this.cornerRadius * (this.cosAngle + 1)) / this.sinAngle : 0
} else {
this.d1 = point(0, 0)
this.d2 = point(0, 0)
this.cornerRadius = 0
this.smoothing = 0
this.cosAngle = 0
this.sinAngle = 0
this.expectedRoundCut = 0
}
}
get expectedCut() {
return (1 + this.smoothing) * this.expectedRoundCut
}
getCubics(allowedCut0, allowedCut1 = allowedCut0) {
const allowedCut = Math.min(allowedCut0, allowedCut1)
if (
this.expectedRoundCut < DISTANCE_EPSILON ||
allowedCut < DISTANCE_EPSILON ||
this.cornerRadius < DISTANCE_EPSILON
) {
return [straightLine(this.p1.x, this.p1.y, this.p1.x, this.p1.y)]
}
const actualRoundCut = Math.min(allowedCut, this.expectedRoundCut)
const actualSmoothing0 = this.actualSmoothing(allowedCut0)
const actualSmoothing1 = this.actualSmoothing(allowedCut1)
const actualR = (this.cornerRadius * actualRoundCut) / this.expectedRoundCut
const centerDistance = Math.sqrt(actualR * actualR + actualRoundCut * actualRoundCut)
const center = plus(this.p1, times(direction(div(plus(this.d1, this.d2), 2)), centerDistance))
const circleIntersection0 = plus(this.p1, times(this.d1, actualRoundCut))
const circleIntersection2 = plus(this.p1, times(this.d2, actualRoundCut))
const flanking0 = this.flankingCurve(
actualRoundCut,
actualSmoothing0,
this.p1,
this.p0,
circleIntersection0,
circleIntersection2,
center,
actualR,
)
const flanking2 = reverse(
this.flankingCurve(
actualRoundCut,
actualSmoothing1,
this.p1,
this.p2,
circleIntersection2,
circleIntersection0,
center,
actualR,
),
)
return [
flanking0,
circularArc(center.x, center.y, flanking0[6], flanking0[7], flanking2[0], flanking2[1]),
flanking2,
]
}
actualSmoothing(allowedCut) {
if (allowedCut > this.expectedCut) {
return this.smoothing
}
if (allowedCut > this.expectedRoundCut) {
return (this.smoothing * (allowedCut - this.expectedRoundCut)) / (this.expectedCut - this.expectedRoundCut)
}
return 0
}
flankingCurve(actualRoundCut, smoothing, corner, sideStart, intersection, otherIntersection, circleCenter, actualR) {
const sideDirection = direction(minus(sideStart, corner))
const curveStart = plus(corner, times(sideDirection, actualRoundCut * (1 + smoothing)))
const p = lerpPoint(intersection, div(plus(intersection, otherIntersection), 2), smoothing)
const curveEnd = plus(circleCenter, times(direction(minus(p, circleCenter)), actualR))
const circleTangent = rotate90(minus(curveEnd, circleCenter))
const anchorEnd = lineIntersection(sideStart, sideDirection, curveEnd, circleTangent) ?? intersection
const anchorStart = div(plus(curveStart, times(anchorEnd, 2)), 3)
return cubic(curveStart, anchorStart, anchorEnd, curveEnd)
}
}
function lineIntersection(p0, d0, p1, d1) {
const rotatedD1 = rotate90(d1)
const den = dot(d0, rotatedD1)
if (Math.abs(den) < DISTANCE_EPSILON) {
return null
}
const num = dot(minus(p1, p0), rotatedD1)
if (Math.abs(den) < DISTANCE_EPSILON * Math.abs(num)) {
return null
}
return plus(p0, times(d0, num / den))
}
/**
* A polygon as androidx keeps it: its features (corners and the edges between them, each a
* list of cubics) and a centre. `cubics` flattens the features exactly as RoundedPolygon does.
*/
class RoundedPolygon {
constructor(features, center) {
this.features = features
this.center = center
this.cubics = flatten(features, center)
}
transformed(f) {
const move = (c) => {
const out = []
for (let i = 0; i < 8; i += 2) {
const p = f(c[i], c[i + 1])
out.push(p.x, p.y)
}
return out
}
return new RoundedPolygon(
this.features.map((feature) => ({ ...feature, cubics: feature.cubics.map(move) })),
f(this.center.x, this.center.y),
)
}
bounds(approximate) {
const all = this.cubics.map((c) => (zeroLength(c) ? [c[0], c[1], c[0], c[1]] : cubicBounds(c, approximate)))
return [
Math.min(...all.map((b) => b[0])),
Math.min(...all.map((b) => b[1])),
Math.max(...all.map((b) => b[2])),
Math.max(...all.map((b) => b[3])),
]
}
normalized() {
const [left, top, right, bottom] = this.bounds(true)
const width = right - left
const height = bottom - top
const side = Math.max(width, height)
const offsetX = (side - width) / 2 - left
const offsetY = (side - height) / 2 - top
return this.transformed((x, y) => point((x + offsetX) / side, (y + offsetY) / side))
}
}
function flatten(features, center) {
const out = []
let firstCubic = null
let lastCubic = null
let firstFeatureSplitStart = null
let firstFeatureSplitEnd = null
if (features.length > 0 && features[0].cubics.length === 3) {
const [start, end] = split(features[0].cubics[1], 0.5)
firstFeatureSplitStart = [features[0].cubics[0], start]
firstFeatureSplitEnd = [end, features[0].cubics[2]]
}
for (let i = 0; i <= features.length; i++) {
let featureCubics
if (i === 0 && firstFeatureSplitEnd !== null) {
featureCubics = firstFeatureSplitEnd
} else if (i === features.length) {
if (firstFeatureSplitStart === null) {
break
}
featureCubics = firstFeatureSplitStart
} else {
featureCubics = features[i].cubics
}
for (const c of featureCubics) {
if (!zeroLength(c)) {
if (lastCubic !== null) {
out.push(lastCubic)
}
lastCubic = c
if (firstCubic === null) {
firstCubic = c
}
} else if (lastCubic !== null) {
lastCubic = [...lastCubic]
lastCubic[6] = c[0]
lastCubic[7] = c[1]
}
}
}
if (lastCubic !== null && firstCubic !== null) {
out.push([...lastCubic.slice(0, 6), firstCubic[0], firstCubic[1]])
} else {
out.push([center.x, center.y, center.x, center.y, center.x, center.y, center.x, center.y])
}
for (let i = 0; i < out.length; i++) {
const previous = out[(i + out.length - 1) % out.length]
if (
Math.abs(out[i][0] - previous[6]) > DISTANCE_EPSILON ||
Math.abs(out[i][1] - previous[7]) > DISTANCE_EPSILON
) {
throw new Error('RoundedPolygon must be contiguous')
}
}
return out
}
/** RoundedPolygon(vertices, rounding, perVertexRounding, centerX, centerY) */
function polygonFromVertices(vertices, { cornerRounding = UNROUNDED, perVertexRounding = null, center = null } = {}) {
const n = vertices.length
if (n < 3) {
throw new Error('Polygons must have at least 3 vertices')
}
if (perVertexRounding !== null && perVertexRounding.length !== n) {
throw new Error('perVertexRounding list should be either null or the same size as the number of vertices')
}
const roundedCorners = vertices.map(
(vertex, i) =>
new RoundedCorner(vertices[(i + n - 1) % n], vertex, vertices[(i + 1) % n], perVertexRounding?.[i] ?? cornerRounding),
)
const cutAdjusts = vertices.map((vertex, i) => {
const next = (i + 1) % n
const expectedRoundCut = roundedCorners[i].expectedRoundCut + roundedCorners[next].expectedRoundCut
const expectedCut = roundedCorners[i].expectedCut + roundedCorners[next].expectedCut
const sideSize = length(minus(vertex, vertices[next]))
if (expectedRoundCut > sideSize) {
return [sideSize / expectedRoundCut, 0]
}
if (expectedCut > sideSize) {
return [1, (sideSize - expectedRoundCut) / (expectedCut - expectedRoundCut)]
}
return [1, 1]
})
const corners = roundedCorners.map((corner, i) => {
const allowedCuts = [0, 1].map((delta) => {
const [roundCutRatio, cutRatio] = cutAdjusts[(i + n - 1 + delta) % n]
return corner.expectedRoundCut * roundCutRatio + (corner.expectedCut - corner.expectedRoundCut) * cutRatio
})
return corner.getCubics(allowedCuts[0], allowedCuts[1])
})
const features = []
for (let i = 0; i < n; i++) {
const previous = vertices[(i + n - 1) % n]
const next = vertices[(i + 1) % n]
const end = corners[i].at(-1)
const start = corners[(i + 1) % n][0]
features.push({ type: 'corner', convex: convex(previous, vertices[i], next), cubics: corners[i] })
features.push({ type: 'edge', cubics: [straightLine(end[6], end[7], start[0], start[1])] })
}
if (center === null) {
center = point(
vertices.reduce((sum, v) => sum + v.x, 0) / n,
vertices.reduce((sum, v) => sum + v.y, 0) / n,
)
}
return new RoundedPolygon(features, center)
}
// Shapes.kt -----------------------------------------------------------------------------
/** RoundedPolygon(numVertices, radius, centerX, centerY, rounding, perVertexRounding) */
function regularPolygon(numVertices, { radius = 1, cornerRounding = UNROUNDED, perVertexRounding = null } = {}) {
const vertices = Array.from({ length: numVertices }, (_, i) =>
radialToCartesian(radius, (Math.PI / numVertices) * 2 * i),
)
return polygonFromVertices(vertices, { cornerRounding, perVertexRounding, center: point(0, 0) })
}
function circlePolygon(numVertices = 8, radius = 1) {
const polygonRadius = radius / Math.cos(Math.PI / numVertices)
return regularPolygon(numVertices, { radius: polygonRadius, cornerRounding: rounding(radius) })
}
function rectangle({ width = 2, height = 2, cornerRounding = UNROUNDED, perVertexRounding = null } = {}) {
const [left, top, right, bottom] = [-width / 2, -height / 2, width / 2, height / 2]
@@ -529,17 +60,6 @@ function rectangle({ width = 2, height = 2, cornerRounding = UNROUNDED, perVerte
})
}
function star(numVerticesPerRadius, { radius = 1, innerRadius = 0.5, cornerRounding = UNROUNDED } = {}) {
const vertices = []
for (let i = 0; i < numVerticesPerRadius; i++) {
vertices.push(radialToCartesian(radius, (Math.PI / numVerticesPerRadius) * 2 * i))
vertices.push(radialToCartesian(innerRadius, (Math.PI / numVerticesPerRadius) * (2 * i + 1)))
}
return polygonFromVertices(vertices, { cornerRounding, center: point(0, 0) })
}
// Matrix.kt (rotateZ, scale) and ShapeUtil.kt (RoundedPolygon.transformed(Matrix)) --------
function rotateZ(degrees) {
@@ -865,8 +385,8 @@ function pathData(polygon) {
return `${d}Z`
}
/** The geometry, for other build scripts (bin/loading-indicator.mjs); importing this module writes nothing. */
export { point, pointOnCurve, split, cubicBounds, RoundedPolygon, SHAPES }
/** The shape catalog, for other build scripts (bin/loading-indicator.mjs); importing this module writes nothing. */
export { SHAPES }
if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
mkdirSync(OUTPUT, { recursive: true })
+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`)
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Builds resources/dist/, the stylesheets the package serves on its own, outside an application's
* Vite build: the showcase's (`ShowcaseAssetController`) and the error page's
* (`ErrorPage::layoutStyles()`, `ErrorPage::fallbackStyles()`).
*
* Rebuild with `npm run build:stylesheets` after changing anything under resources/css; the
* output is committed so applications need no Node, and tests/Feature/StylesheetsBundleTest.php
* fails while it is stale. `node bin/stylesheets.mjs <dir>` writes the same files elsewhere.
*
* The `@import`s are inlined by Vite's own `preprocessCSS()` — postcss-import, the same inlining an
* application's build does, each file once, first occurrence kept — so these bundles cannot
* order a rule differently from an application's. A relative `url()` is rebased onto
* resources/dist/, the folder the showcase serves beside the fonts and SVGs. The fallback drops
* every `@font-face`: without a build nothing serves the font, and the brand typeface's stack
* (tokens/type.css) falls back to the system fonts on its own.
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, relative, resolve } from 'node:path'
import { preprocessCSS, resolveConfig } from 'vite'
const css = resolve(import.meta.dirname, '../resources/css')
const dist = resolve(import.meta.dirname, '../resources/dist')
const out = resolve(process.argv[2] ?? dist)
const BUNDLES = {
'showcase.css': ['all.css', 'showcase.css'],
'error-page.css': ['components/error-page.css'],
'error-page-fallback.css': ['foundation.css', 'components/error-page.css'],
}
const rebase = {
postcssPlugin: 'livewire-material',
OnceExit(root, { result }) {
// The prose lives in resources/css; the bundle only has to be served.
root.walkComments((comment) => comment.remove())
root.walkDecls((decl) => {
decl.value = decl.value.replace(/url\((['"]?)(?![a-z][a-z0-9+.-]*:|\/|#)([^'")]+)\1\)/gi, (_, quote, url) =>
`url(${quote}${relative(dist, resolve(dirname(decl.source.input.file), url))}${quote})`)
})
if (result.opts.from.endsWith('error-page-fallback.css')) {
root.walkAtRules('font-face', (rule) => rule.remove())
}
},
}
const config = await resolveConfig({ configFile: false, logLevel: 'error', css: { postcss: { plugins: [rebase] } } }, 'build')
await mkdir(out, { recursive: true })
for (const [name, files] of Object.entries(BUNDLES)) {
const entry = files.map((file) => `@import './${file}';`).join('\n')
// The entry is named after the bundle, beside the files it imports, but is never read from disk.
const { code } = await preprocessCSS(entry, `${css}/bundle-${name}`, config)
await writeFile(`${out}/${name}`, `${code.trim()}\n`)
}
+3 -7
View File
@@ -11,8 +11,7 @@
"components",
"material",
"material-design",
"material-3",
"tailwindcss"
"material-3"
],
"homepage": "https://gitea.nonameweb.ch/noNameWEB/livewire-material",
"authors": [
@@ -30,8 +29,7 @@
"laravel/pint": "^1.32",
"orchestra/testbench": "^11.2",
"pestphp/pest": "^5.1",
"pestphp/pest-plugin-browser": "^5.0",
"pestphp/pest-plugin-laravel": "^5.0"
"pestphp/pest-plugin-browser": "^5.0"
},
"autoload": {
"psr-4": {
@@ -63,9 +61,7 @@
"Composer\\Config::disableProcessTimeout",
"@build",
"@php vendor/bin/testbench serve --ansi"
],
"lint": "pint",
"test": "pest"
]
},
"config": {
"sort-packages": true,
+53 -8
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',
],
/*
@@ -72,10 +107,11 @@ return [
| Pagination
|--------------------------------------------------------------------------
|
| Draw Laravel's and Livewire's paginators in M3: the package's views are
| put in front of `pagination::tailwind` and `livewire::tailwind` (and
| their simple versions). An application's own published pagination views
| still win.
| Draw Laravel's and Livewire's paginators in M3: the package's views
| become `Paginator::defaultView()`/`defaultSimpleView()`, and
| `livewire.pagination_theme` is taken over while it is still Livewire's
| own default. An application's own published pagination views still
| win, and so does a theme it chose deliberately.
|
*/
@@ -113,7 +149,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 +192,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.
|
*/
+35
View File
@@ -0,0 +1,35 @@
# 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
```
## 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:stylesheets": "node bin/stylesheets.mjs",
"build:loading": "node bin/loading-indicator.mjs",
"build:springs": "node bin/springs.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"
}
}
+2 -2
View File
@@ -1,10 +1,10 @@
@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. No utility classes Tailwind, daisyUI or any other belong here: a class your CSS does not declare does nothing, and `DesignGuard` fails it.
- 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`.
- 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. No utility classes Tailwind, daisyUI or any other belong here: a class your CSS does not declare does nothing, and `DesignGuard` fails it. 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,293 @@
---
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
The guideline's own Accessibility line has the rule; beyond it: every repeated landmark —
`search`, `complementary`, `form`, `region`, not just `nav` — is labelled the same way; an
ambiguous button ("Save", "Learn more") needs a name that says what it does, not just what kind
of control it is; DOM order is reading order, a dialog returns focus to its opener, and a group of
related controls is one Tab stop with the arrows moving inside it; an invalid field also carries
`aria-invalid`, and a loading state has a name too.
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
The guideline's Don'ts, Type and Motion bullets name them; where they name no replacement — a vertical
group or chips for radios in a row, `<x-divider>` for the outline case, the `md-type-*`/`md-ink-*`
classes and `--md-sys-*` tokens for the utility-class case, wrap/grow/a tooltip instead of a bare
ellipsis, the paired motion tokens instead of a literal duration — the components and layout
sections above have it.
## 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/`.
+114
View File
@@ -0,0 +1,114 @@
/*
* 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 one
* block per group, 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';
@import './components/color.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 not one of M3's own catalogued components, so its states are judged against the general
* foundations instead: a 40px trigger reaching M3's 48px target through `md-touch-target`, with
* `md-state-layer` for the hover and pressed feedback every other trigger in the library already
* has 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;
}
}
+77
View File
@@ -0,0 +1,77 @@
/*
* <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; nothing here copies their rules.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@import './color.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-container, var(--md-sys-color-info-container));
color: var(--md-on-container, var(--md-sys-color-on-info-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: 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
*
* 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. */
[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. 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);
}
+103
View File
@@ -0,0 +1,103 @@
/*
* <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;
@import './color.css';
@layer material.components {
[data-md-badge] {
--md-badge-color: var(--md-color, var(--md-sys-color-error));
--md-badge-on-color: var(--md-on-color, var(--md-sys-color-on-error));
--md-badge-container: var(--md-container, var(--md-sys-color-error-container));
--md-badge-on-container: var(--md-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='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
* [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. 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);
}
}
+166
View File
@@ -0,0 +1,166 @@
/*
* <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-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);
}
}
+376
View File
@@ -0,0 +1,376 @@
/*
* <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.) 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 (a deliberate deviation from the token).
*
* 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-unavailable`, a `fab` given `disabled`, is not drawn below `medium`: M3 never shows a
* disabled FAB, but removes one whose action is unavailable. From `medium` it is the disabled
* button. `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';
@import './color.css';
@layer material.components {
[data-md-button] {
--md-button-color: var(--md-color, var(--md-sys-color-primary));
--md-button-on-color: var(--md-on-color, var(--md-sys-color-on-primary));
--md-button-tone: var(--md-container, var(--md-sys-color-secondary-container));
--md-button-on-tone: var(--md-on-container, 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: every hue but primary comes straight off the shared table (color.css). Its
`primary` entry is plain primary-container, so the one hue this file draws differently keeps
a small override of its own. */
[data-md-button][data-md-color='primary'] {
--md-button-tone: var(--md-sys-color-secondary-container);
--md-button-on-tone: var(--md-sys-color-on-secondary-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;
}
/* M3 never disables a FAB: "if its action is unavailable, remove the FAB entirely"
(docs/reference/m3/components-actions-communication-containment.md § FAB). Not drawn,
it is out of the accessibility tree and the Tab order too. */
[data-md-button][data-md-compact-fab][data-md-unavailable] {
display: none;
}
}
}
+183
View File
@@ -0,0 +1,183 @@
/*
* <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).
*
* 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);
}
}
+111
View File
@@ -0,0 +1,111 @@
/*
* <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).
*
* [data-md-carousel-item] the tab stop, 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
* (deliberately kept).
*/
@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;
}
}
+109
View File
@@ -0,0 +1,109 @@
/*
* <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).
*
* [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); 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; reduced motion needs no rule here at all
* the script writes zero inset, shift and full opacity itself for every layout.
*
* 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;
}
[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);
}
}
+116
View File
@@ -0,0 +1,116 @@
/*
* <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 {
/* In a flex row the root keeps its automatic minimum, the box and beside a label its longest
word, so the text beside it wraps before the box is pushed out of the row (toggle.css). */
[data-md-checkbox] {
min-width: auto;
}
[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;
}
}
}
+417
View File
@@ -0,0 +1,417 @@
/*
* <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 ------------------------------ */
/* The state layer and the 48px target are the foundation's `md-state-layer md-touch-target`
classes now (chip.blade.php, as group and scheme-picker's own controls render them;
foundation/interaction.css), at the same opacities and off the same `currentColor` this
chip always painted them from. Three extras stay chip's own: a filter chip's label is never
itself `:focus-visible` the checkbox inside it is, caught with `:has()`, the same way the
ring below already had to; `data-md-disabled` marks a chip with no native `disabled` to
catch (a link, or a toggle button with no press of its own). */
[data-md-chip]:not([data-md-chip='input']) {
padding-inline: 15px;
outline: none;
cursor: pointer;
&:has(:focus-visible)::before {
opacity: var(--md-sys-state-focus-state-layer-opacity);
}
&:is(:focus-visible, :has(:focus-visible)) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
&[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, since M3 has no standalone disclosure.
*
* 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);
}
}
+94
View File
@@ -0,0 +1,94 @@
/*
* The colour-role table M3's semantic colours share, so a component that offers the same set of
* roles does not repeat it: `data-md-color` picks primary (most components' default, drawn
* without the attribute), secondary, tertiary, error, success, warning or info, and this sets the
* four generic variables a component's own stylesheet reads `--md-color`/`--md-on-color` the
* role itself, `--md-container`/`--md-on-container` its tonal container.
*
* A component gives its own prefixed variables (`--md-badge-color`, ) a fallback of its default
* role, `var(--md-color, var(--md-sys-color-<default>))`, so the generic ones only take over once
* `data-md-color` is actually written on that element. The four are registered as not inheriting,
* so a component inside a coloured one (a badge on an error button) still falls back to its own
* default rather than taking its ancestor's role. A component whose primary
* deliberately reads secondary-container instead of primary-container (button.css, slider.css,
* progress.css) keeps a small override of its own for an explicit `data-md-color="primary"` too:
* this file's `primary` entry is the plain M3 mapping every other role, and every other component,
* wants.
*
* A component that supports only part of this set (fab.css's primary/secondary/tertiary) does not
* import this file: wiring it in would make every role it leaves out suddenly take effect through
* the bare `[data-md-color]` selector below, a behaviour change rather than a rewrite.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
@property --md-color {
syntax: '*';
inherits: false;
}
@property --md-on-color {
syntax: '*';
inherits: false;
}
@property --md-container {
syntax: '*';
inherits: false;
}
@property --md-on-container {
syntax: '*';
inherits: false;
}
[data-md-color='primary'] {
--md-color: var(--md-sys-color-primary);
--md-on-color: var(--md-sys-color-on-primary);
--md-container: var(--md-sys-color-primary-container);
--md-on-container: var(--md-sys-color-on-primary-container);
}
[data-md-color='secondary'] {
--md-color: var(--md-sys-color-secondary);
--md-on-color: var(--md-sys-color-on-secondary);
--md-container: var(--md-sys-color-secondary-container);
--md-on-container: var(--md-sys-color-on-secondary-container);
}
[data-md-color='tertiary'] {
--md-color: var(--md-sys-color-tertiary);
--md-on-color: var(--md-sys-color-on-tertiary);
--md-container: var(--md-sys-color-tertiary-container);
--md-on-container: var(--md-sys-color-on-tertiary-container);
}
[data-md-color='error'] {
--md-color: var(--md-sys-color-error);
--md-on-color: var(--md-sys-color-on-error);
--md-container: var(--md-sys-color-error-container);
--md-on-container: var(--md-sys-color-on-error-container);
}
[data-md-color='success'] {
--md-color: var(--md-sys-color-success);
--md-on-color: var(--md-sys-color-on-success);
--md-container: var(--md-sys-color-success-container);
--md-on-container: var(--md-sys-color-on-success-container);
}
[data-md-color='warning'] {
--md-color: var(--md-sys-color-warning);
--md-on-color: var(--md-sys-color-on-warning);
--md-container: var(--md-sys-color-warning-container);
--md-on-container: var(--md-sys-color-on-warning-container);
}
[data-md-color='info'] {
--md-color: var(--md-sys-color-info);
--md-on-color: var(--md-sys-color-on-info);
--md-container: var(--md-sys-color-info-container);
--md-on-container: var(--md-sys-color-on-info-container);
}
}
+279 -214
View File
@@ -1,71 +1,93 @@
/*
* 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';
@import './modal.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 +96,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,33 +108,18 @@
}
@starting-style {
[data-datepicker-picker]:popover-open {
[data-md-datepicker-picker]:popover-open {
opacity: 0;
}
}
[data-datepicker-picker]:modal {
/* The entry pop and the scrim are modal.css's shared dialog chrome, imported above. */
[data-md-datepicker-picker]:modal {
inset: 0;
margin: auto;
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-datepicker-picker]:modal {
opacity: 0;
scale: 0.95;
}
}
[data-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 +131,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 +255,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 +333,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 +349,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 +503,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);
}
}
+327
View File
@@ -0,0 +1,327 @@
/*
* `<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 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);
padding-block-end: calc(var(--md-sys-measurement-space300) + var(--material-safe-bottom, env(safe-area-inset-bottom)));
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. It sticks under the top safe area
(`--drawer-top`), as tall as the window below it, and its padding keeps its foot clear of
the bottom one, so a status bar never covers its head once the page scrolls. */
@media (width >= 840px) {
[data-md-drawer][data-md-standard] {
--drawer-top: var(--material-safe-top, env(safe-area-inset-top));
position: sticky;
top: var(--drawer-top);
display: flex;
justify-content: flex-end;
height: calc(100dvh - var(--drawer-top));
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);
}
/* In <x-scaffold>, a sticky `<x-app-bar>` in the `top` slot spans the content region, sheet
and all, so the sheet sticks under it: a small, centre-aligned or search bar is its 64px
row (AppBarSmallTokens.ContainerHeight) under the safe area it pads itself with; a medium
or large bar collapses to that 64px row alone, which the safe area may still be taller
than. */
[data-md-scaffold-content]:has(> [data-md-app-bar][data-md-sticky]) [data-md-drawer][data-md-standard] {
--drawer-top: calc(var(--material-safe-top, env(safe-area-inset-top)) + 64px);
}
[data-md-scaffold-content]:has(> [data-md-app-bar][data-md-sticky]:is([data-md-variant='medium'], [data-md-variant='large'])) [data-md-drawer][data-md-standard] {
--drawer-top: max(var(--material-safe-top, env(safe-area-inset-top)), 64px);
}
/* 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;
}
/* Before the view has settled, a sheet not rendered open is out of the layout already. The
view writes `data-md-drawer-collapsed` and measures the row's gap only once Alpine runs,
so until then a closed sheet would be a zero-wide flex item that still costs its row one
gap, and the content beside it would widen when the script started. A sheet whose
`wire:model` property is open is rendered `data-md-open` and stands at its width. */
[data-md-drawer][data-md-standard]:not([data-md-open], [data-md-drawer-settled]) {
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()`) so an application imports nothing for its error pages. 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 a
* correct on-primary-container/primary-container contrast pairing; 24px page gutters and the
* 600px growth of the art and headline follow the same spacing rules as every other M3 surface in
* the library. `--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 behind
* `prefers-reduced-motion: no-preference` like every other ambient animation in the library.
*/
@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.
*/
@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 (split between 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.
*
* 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 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);
}
}
+195 -252
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,115 @@
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);
}
.field-control::placeholder {
color: var(--md-sys-color-on-surface-variant);
opacity: 1;
[data-md-field][data-md-mono] [data-md-field-control] {
font-family: var(--md-ref-typeface-mono);
}
/* 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 +233,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 +247,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 +272,157 @@
/* `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. M3 layers focus over hover, so a focused
field keeps its focus edge under the pointer: the condition is weightless (`:where()`, where
`:not(:has())` would weigh as much as its argument), and each state drawn over hover
outweighs it focus (0,3,0) over hover (0,2,0), the error's focus (0,4,0) over the error's
hover (0,3,0), and select.css's open select (0,3,1) over both. */
@media (hover: hover) {
:where([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] [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) {
:where([data-md-field]:not(:has([data-md-field-control]:disabled)))[data-md-invalid] [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 +430,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);
}
}
+214
View File
@@ -0,0 +1,214 @@
/*
* <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
`md-visually-hidden` on the element itself (text.css), which outranks this layer
whatever wins the specificity, `material.text` sitting above `material.components`. */
}
[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';
+270
View File
@@ -0,0 +1,270 @@
/*
* <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).
*
* `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.
* `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. The same attribute on any other row that is
* not a card an application's own `<li>`, `<div>` or `<tr>` draws the same fill and ink, as
* 1.x's `data-selected` did; a card keeps its own container. A selected option draws a second cue, a
* trailing check, so selection is never colour alone. `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.
*
* `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),
* 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. 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.
*
* `data-md-dividers` on the list insets its rule 16px from both ends (`DividerLeadingSpace`/
* `DividerTrailingSpace`) 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);
}
/* A row written by hand list-rows.js's contract, not an `<x-list-item>` selected the same
way. The state layers below are mixed over this fill, as they are over an item's. */
[data-md-list-row][data-md-selected]:not([data-md-card], [data-md-list-item]) {
--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) 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);
}
}
+158
View File
@@ -0,0 +1,158 @@
/*
* <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`), 16px either side
* (`ItemLeadingSpace`/`ItemTrailingSpace`), 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) 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 a colour must never
* overshoot 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';
@import './menu.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. Its size, radius, padding, shadow and pop-and-fade entry/exit are menu.css's shared
popover chrome (imported above); only its axis and its position are its own inline,
beside the item, rather than the standard menu's block axis under its trigger. */
[data-md-submenu] {
margin: 0 var(--md-sys-measurement-space50);
transform-origin: top;
position-area: inline-end span-block-end;
position-try-fallbacks: flip-inline;
}
}
@@ -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);
}
}
+389 -143
View File
@@ -1,189 +1,435 @@
/*
* 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, and transitions in by growing from its trigger's corner while it
* fades, on the spatial and effects springs respectively. `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;
}
/* The popover chrome a submenu shares (menu-item.css, which imports this file for it): size
limits, the surface's own colour (a submenu with no ancestor popover falls back to the
standard menu's own), radius, padding, shadow, and the pop-and-fade entry/exit — M3's
effects-fast fade behind the spatial-fast scale. Each writes its own whole `margin`, so no
rule here depends on which file a bundler puts first. */
[data-md-menu-popover],
[data-md-submenu] {
inset: auto;
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);
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] {
--material-menu-surface: var(--md-sys-color-surface-container-low);
--material-menu-ink: var(--md-sys-color-on-surface);
margin: var(--md-sys-measurement-space50) 0;
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
}
[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;
}
}
}
+294
View File
@@ -0,0 +1,294 @@
/*
* `<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
* (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): 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);
}
/* Dialog chrome shared with the time picker's dialog and a modal date picker (timepicker.css,
* datepicker.css, both import this file for it): M3's spatial-fast pop and a 32%-scrim
* backdrop, entering only a native `<dialog>` makes an exit transition awkward, since
* `@starting-style` only ever supplies an entry. A plain selector list, not `:is()`: `:is()`
* would flatten every branch to the specificity of `[data-md-datepicker-picker]:modal`, the
* one with a pseudo-class, and outrank whatever else the modal or the time picker's dialog is
* layered against.
*/
[data-md-modal],
[data-md-timepicker-dialog],
[data-md-datepicker-picker]:modal {
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],
[data-md-timepicker-dialog],
[data-md-datepicker-picker]:modal {
opacity: 0;
scale: 0.95;
}
}
[data-md-modal]::backdrop,
[data-md-timepicker-dialog]::backdrop,
[data-md-datepicker-picker]: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. */
[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). 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,90 @@
/*
* `<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. `<x-scaffold>` publishes
* `--material-bottom-bar` 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. Keyed on `data-md-scaffold`, the hook the scaffold renders on its root.
*/
[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. 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,186 @@
/*
* `<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] {
@container style(--md-navigation-rail-value: collapsed) {
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;
--navigation-item-layer: 0;
--navigation-indicator-layer: var(--navigation-layer);
}
}
[data-md-navigation-rail-item] [data-md-navigation-label] {
@container style(--md-navigation-rail-value: collapsed) {
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] {
@container style(--md-navigation-rail-value: collapsed) {
background-image: none;
color: var(--md-sys-color-secondary);
}
}
[data-md-navigation-rail-item] [data-md-navigation-indicator] {
@container style(--md-navigation-rail-value: collapsed) {
width: 56px;
height: 32px;
}
}
[data-md-navigation-rail-item][data-md-active] [data-md-navigation-indicator] {
@container style(--md-navigation-rail-value: 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);
}
}
[data-md-navigation-rail-item]:focus-visible {
@container style(--md-navigation-rail-value: collapsed) {
outline: none;
}
}
[data-md-navigation-rail-item]:focus-visible [data-md-navigation-indicator] {
@container style(--md-navigation-rail-value: collapsed) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
}
[data-md-navigation-badge-end],
[data-md-navigation-badge-icon] {
@container style(--md-navigation-rail-value: collapsed) {
display: none;
}
}
[data-md-navigation-badge-icon] {
@container style(--md-navigation-rail-value: collapsed) {
display: contents;
}
}
}
@@ -0,0 +1,38 @@
/*
* `<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) {
@container style(--md-navigation-rail-value: collapsed) {
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;
@container style(--md-navigation-rail-value: collapsed) {
display: none;
}
}
}
@@ -0,0 +1,612 @@
/*
* `<x-navigation-rail>`: M3 Expressive's navigation rail, collapsed (96px, icon over label) or
* expanded (a full-width pill, icon beside label) down the start edge of a `medium` or wider
* window (NavigationRailCollapsedTokens.kt, NavigationRailExpandedTokens.kt, androidx Compose
* Material 3, Apache-2.0, and the layout of WideNavigationRail.kt;
* docs/reference/m3/components-navigation-selection-inputs.md § Navigation Rail).
*
* [data-md-navigation-rail="collapsed|expanded|collapsible|modal|adaptive"] data-md-open
* data-md-closing="sheet|scrim", while it exits
* [data-md-navigation-rail-scrim] modal and adaptive rails, open or closing only
* [data-md-navigation-rail-panel] the <nav>
* [data-md-navigation-rail-header] never scrolls
* [data-md-navigation-rail-menu-row] the menu button and the brand
* [data-md-navigation-rail-menu] md-state-layer md-focus-ring md-touch-target
* [data-md-navigation-rail-brand]
* [data-md-navigation-rail-fab-row] one <x-fab>, morphed (see the footer note)
* [data-md-navigation-rail-destinations] scrolls when the window is too short
* [data-md-navigation-rail-section] navigation-rail-section.css
* [data-md-navigation-rail-item] navigation-rail-item.css
* [data-md-navigation-rail-footer] never scrolls
*
* **Collapsed**: a rail (and anything in it) is drawn collapsed while any of these hold, which the
* rail's own two rules below publish as `--md-navigation-rail-value` for everything inside it a fixed `collapsed` mode; a `collapsible` rail the visitor collapsed
* (`<html data-rail="collapsed">`, set before the first paint by `<x-theme-script>`) and not
* currently open over the page; a `modal` rail not open; below `medium` (600px) a `collapsible`
* rail regardless of choice, M3's floor ("compact → use a navigation bar, not a standard rail");
* below `expanded` (840px) an `adaptive` rail not open there is no standard rail below it; at
* `expanded` (8401199) an `adaptive` rail not open, when the visitor chose collapsed *or* has not
* chosen (`data-rail-auto`, which `data-rail` alone cannot say, since it also carries
* `rail.default` for a visitor who never chose); from `large` (1200) an `adaptive` rail not open,
* only when the visitor chose collapsed M3 starts it expanded there by default. Every branch but
* the first reads `:not([data-md-open])`: a rail open over a scrim is drawn expanded whatever made
* it collapsed, which only `hide-when-collapsed` can bring about outside the modal modes.
* `resources/js/navigation.js` reads the same numbers, so the menu button and the drawing agree at
* every width.
*
* **Closing**: nothing here transitions `display`, which Firefox cannot do even with
* `allow-discrete`, so a panel sliding off the window and a fading scrim were cut to nothing there.
* `resources/js/navigation.js` sets `data-md-closing` on a rail that was open over the page until
* its exit transitions have run `sheet` when the panel leaves the window (a compact adaptive
* rail, or one that hides when collapsed), `scrim` when the panel stands in the layout again and
* the rules near the end of the file keep what is leaving displayed meanwhile, in every engine.
* The collapsed branches do not read it: a closing rail is already collapsed, which is what
* `--md-navigation-rail-value` says the moment it starts to close.
*
* The first set of branches also publishes the answer: `--md-navigation-rail-value` is `expanded`
* on every rail and `collapsed` wherever those branches hold M3's two rail values, Compose's
* WideNavigationRailValue so what an application puts in a rail reads it with
* `@container style(--md-navigation-rail-value: collapsed)` rather than copying the conditions. It
* is an unregistered custom property, so it inherits into every descendant and is unset outside a
* rail, where neither value matches.
*
* `data-md-width="narrow"` is M3's other collapsed width, `NarrowContainerWidth` 80px against the
* default 96 (`CollapsedContainerWidth`) a variable, so every rule below that has its own reason
* to name a collapsed width does not have to know which one applies.
*
* `data-md-divider` and `data-md-fill="false"` are both Missing until this rewrite: an optional
* vertical divider on the page's edge, M3's own answer to a page scrolling under a fixed rail, and
* a transparent container as long as the items keep 3:1 contrast. `data-md-align="center"`
* is M3's other alignment, preferred on a tablet for reach only the destinations move; the
* header, footer and menu button stay at their ends, and `safe` gives the top back once there are
* more destinations than fit.
*
* The rail-header FAB rules at the end draw M3's nested-FAB elevation (level 0, not a standalone
* FAB's 3) and morph it into an extended FAB as the rail opens, rather than swapping two by
* `display`. Both sit in this file's own `material.components` layer and win by specificity
* alone, whichever copy of fab.css a build places last: the flat shadow outranks fab.css's resting
* and hover shadows, and the collapsed FAB's `gap` and minimum width outrank fab.css's extended-size
* and collapse-on-scroll rules, which would otherwise tie with them.
*
* The destination item, its indicator and its state layer are navigation-rail-item.css and
* navigation-item.css (shared with the bar's item); a section's heading is
* navigation-rail-section.css.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@import './fab.css';
@layer material.components {
[data-md-navigation-rail] {
--navigation-rail-expanded-width: clamp(220px, var(--navigation-rail-width, 256px), 360px);
--navigation-rail-collapsed-width: 96px;
--md-navigation-rail-value: expanded;
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);
&: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: var(--navigation-rail-collapsed-width);
--md-navigation-rail-value: collapsed;
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
width: var(--navigation-rail-collapsed-width);
--md-navigation-rail-value: collapsed;
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
width: var(--navigation-rail-collapsed-width);
--md-navigation-rail-value: collapsed;
}
}
@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: var(--navigation-rail-collapsed-width);
--md-navigation-rail-value: collapsed;
}
}
@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: var(--navigation-rail-collapsed-width);
--md-navigation-rail-value: collapsed;
}
}
}
[data-md-navigation-rail][data-md-width='narrow'] {
--navigation-rail-collapsed-width: 80px;
}
[data-md-navigation-rail][data-md-hide-when-collapsed] {
&: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: 0;
}
@media (width < 600px) {
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
width: 0;
}
}
@media (width < 840px) {
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
width: 0;
}
}
@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: 0;
}
}
@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: 0;
}
}
}
/* There is no room for a label under the 56px indicator at 80px, so a narrow rail is the icons
alone; the label is taken out of the drawing, not out of the page (navigation-rail-item.css
renders it always it is what names the destination). */
[data-md-navigation-rail][data-md-width='narrow'] [data-md-navigation-rail-item] [data-md-navigation-label] {
@container style(--md-navigation-rail-value: collapsed) {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
}
/* 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 `expanded`, and takes no room at
all below `medium`. */
[data-md-navigation-rail='modal'] {
width: var(--navigation-rail-collapsed-width);
}
@media (width < 840px) {
[data-md-navigation-rail='adaptive'] {
width: var(--navigation-rail-collapsed-width);
}
}
@media (width < 600px) {
[data-md-navigation-rail='adaptive'] {
width: 0;
}
/* "Compact → don't use a standard rail, use a navigation bar." A `collapsible` rail takes
the width the visitor chose, which on a 360px screen would be two-thirds of it, so it is
floored at its collapsed width. `collapsed` and `expanded` mean what they say and
are left alone: wrap one in an application's own `medium:` element if it must not show
on a phone. */
[data-md-navigation-rail='collapsible'] {
width: var(--navigation-rail-collapsed-width);
}
}
/* `--material-banner` is the height of a bar pinned across the top of the window what
<x-scaffold>'s `banner` slot holds, if the application made it sticky. The rail sticks under
it rather than behind it; 0 without one. */
[data-md-navigation-rail-panel] {
position: sticky;
top: var(--material-banner, 0px);
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
max-height: calc(100dvh - var(--material-banner, 0px));
overflow-x: clip;
padding-bottom: var(--material-safe-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);
}
/* M3's two optional container treatments: "optional vertical divider separates rail from
content, placed on the content-adjacent edge" also its answer to a page scrolling under a
fixed rail and "container fill can be turned off (transparent) as long as items keep 3:1
contrast". Neither applies to a rail open over a scrim, a surface over the page. */
[data-md-navigation-rail][data-md-divider]:not([data-md-open], [data-md-closing='sheet']) > [data-md-navigation-rail-panel] {
border-inline-end: 1px solid var(--md-sys-color-outline-variant);
}
[data-md-navigation-rail][data-md-fill='false']:not([data-md-open], [data-md-closing='sheet']) > [data-md-navigation-rail-panel] {
background-color: transparent;
}
/* Open: expanded over a scrim, in surface-container with a large corner at its inner edge and
still, while a panel that is leaving the window slides off it (`data-md-closing="sheet"`). */
[data-md-navigation-rail]:is([data-md-open], [data-md-closing='sheet']) > [data-md-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 - 56px);
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);
}
/* On a compact window 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 < 600px) {
[data-md-navigation-rail='adaptive'] > [data-md-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 - 56px);
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);
&:is([dir='rtl'], [dir='rtl'] *) {
translate: 100% 0;
}
}
[data-md-navigation-rail='adaptive'][data-md-open] > [data-md-navigation-rail-panel] {
display: flex;
translate: 0 0;
transition:
translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-easing-emphasized-decelerate);
@starting-style {
translate: -100% 0;
}
&:is([dir='rtl'], [dir='rtl'] *) {
translate: 0 0;
@starting-style {
translate: 100% 0;
}
}
}
}
/* A rail that hides when collapsed is in the same position: nothing is left in the layout for
it to grow out of, so it takes the compact window's slide-in. The whole treatment is under
the collapsed branches, which stop the moment the rail is open that is what makes it the
expanded rail that comes back, on the unconditional rule below. */
[data-md-navigation-rail][data-md-hide-when-collapsed] > [data-md-navigation-rail-panel] {
@container style(--md-navigation-rail-value: collapsed) {
display: none;
translate: -100% 0;
transition:
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate);
&:is([dir='rtl'], [dir='rtl'] *) {
translate: 100% 0;
}
}
}
[data-md-navigation-rail][data-md-hide-when-collapsed][data-md-open] > [data-md-navigation-rail-panel] {
display: flex;
translate: 0 0;
transition:
translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-easing-emphasized-decelerate);
@starting-style {
translate: -100% 0;
}
&:is([dir='rtl'], [dir='rtl'] *) {
translate: 0 0;
@starting-style {
translate: 100% 0;
}
}
}
/* Closing: what leaves stays drawn while it animates out. The exits above transition `translate`
and the scrim's `opacity` only not `display`, which Firefox cannot transition even with
`allow-discrete`, so the slide and the fade were cut to nothing there and
resources/js/navigation.js sets `data-md-closing` on the rail until those transitions have
run: `sheet` when the panel leaves the window, `scrim` when only the scrim was over the page.
One attribute more than every collapsed branch, so a hold outranks the `display: none` those
branches draw. */
[data-md-navigation-rail][data-md-hide-when-collapsed][data-md-closing='sheet'] > [data-md-navigation-rail-panel] {
display: flex;
}
@media (width < 600px) {
[data-md-navigation-rail='adaptive'][data-md-closing='sheet'] > [data-md-navigation-rail-panel] {
display: flex;
}
}
/* The two bands the configuration above does not reach, because there it is the window and not
the visitor that collapses the rail: below `medium` for a collapsible rail already held at
its collapsed width above and `medium` itself for the adaptive one, which M3 gives a
collapsed rail (below `medium` the adaptive rail is already away, over the page). This file's
header and resources/js/navigation.js read the same two numbers. */
@media (width < 600px) {
[data-md-navigation-rail='collapsible'][data-md-hide-when-collapsed] {
width: var(--navigation-rail-collapsed-width);
}
[data-md-navigation-rail='collapsible'][data-md-hide-when-collapsed] > [data-md-navigation-rail-panel] {
display: flex;
translate: none;
}
}
@media (600px <= width < 840px) {
[data-md-navigation-rail='adaptive'][data-md-hide-when-collapsed] {
width: var(--navigation-rail-collapsed-width);
}
[data-md-navigation-rail='adaptive'][data-md-hide-when-collapsed] > [data-md-navigation-rail-panel] {
display: flex;
translate: none;
}
}
[data-md-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);
}
[data-md-navigation-rail][data-md-closing] > [data-md-navigation-rail-scrim] {
display: block;
}
[data-md-navigation-rail][data-md-open] > [data-md-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-md-navigation-rail-header] {
display: flex;
flex-shrink: 0;
flex-direction: column;
align-items: flex-start;
gap: 8px;
padding-top: calc(var(--material-safe-top, env(safe-area-inset-top)) + 44px);
padding-bottom: 32px;
}
[data-md-navigation-rail-panel] > [data-md-navigation-rail-destinations]:first-child {
padding-top: calc(var(--material-safe-top, env(safe-area-inset-top)) + 44px);
}
/* The menu button and the brand: 20px at the row's end always; 28px at its start with the
button, so it centres a 40px button in the default 96px collapsed rail (28 either side); 20px
without one. A narrow (80px) rail's button re-centres to 20px either side while collapsed
expanded, the row always keeps 28, since a rail's *expanded* width never depends on `narrow`. */
[data-md-navigation-rail-menu-row] {
display: flex;
width: 100%;
min-width: 0;
align-items: center;
gap: 12px;
padding-inline-end: 20px;
padding-inline-start: 20px;
}
[data-md-navigation-rail-header]:has([data-md-navigation-rail-menu]) [data-md-navigation-rail-menu-row] {
padding-inline-start: 28px;
}
[data-md-navigation-rail][data-md-width='narrow'] [data-md-navigation-rail-header]:has([data-md-navigation-rail-menu]) [data-md-navigation-rail-menu-row] {
@container style(--md-navigation-rail-value: collapsed) {
padding-inline-start: 20px;
}
}
[data-md-navigation-rail-menu] {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--md-sys-shape-corner-full);
color: var(--md-sys-color-on-surface-variant);
cursor: pointer;
}
/* The two glyphs swap by `display: contents`/`none`, so the button's own size never depends on
which is shown, and the hidden one never announces to a screen reader (the button's own
aria-label already says what the click will do). */
[data-md-navigation-rail-menu-collapse-icon] {
display: contents;
}
[data-md-navigation-rail-menu-expand-icon] {
display: none;
}
[data-md-navigation-rail-menu-collapse-icon],
[data-md-navigation-rail-menu-expand-icon] {
@container style(--md-navigation-rail-value: collapsed) {
display: none;
}
}
[data-md-navigation-rail-menu-expand-icon] {
@container style(--md-navigation-rail-value: collapsed) {
display: contents;
}
}
[data-md-navigation-rail-brand] {
min-width: 0;
flex: 1 1 0%;
@container style(--md-navigation-rail-value: collapsed) {
display: none;
}
}
/* The FAB row: 20px either side in the default 96px rail, 12px in a narrow 80 while collapsed
(the FAB itself is centred by the row's own width, not by this padding). */
[data-md-navigation-rail-fab-row] {
display: flex;
width: 100%;
flex-direction: column;
align-items: flex-start;
gap: 8px;
padding-inline: 20px;
}
[data-md-navigation-rail][data-md-width='narrow'] [data-md-navigation-rail-fab-row] {
@container style(--md-navigation-rail-value: collapsed) {
padding-inline: 12px;
}
}
[data-md-navigation-rail-destinations] {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding-block: 8px;
scrollbar-width: thin;
}
[data-md-navigation-rail-destinations],
[data-md-navigation-rail-footer] {
display: flex;
flex-direction: column;
@container style(--md-navigation-rail-value: collapsed) {
gap: 4px;
}
}
[data-md-navigation-rail-footer] {
flex-shrink: 0;
padding-block: 8px 16px;
}
/* "Alignment: top or center; on tablets prefer center alignment for reach. Menu icon and FAB
are always top-aligned" (§ Navigation Rail/Behaviour). Only the destinations move the
header and the footer stay at their ends and `safe` puts them back at the top the moment
there are more of them than fit, where centring would otherwise push the first one out of
reach above the scroller. */
[data-md-navigation-rail][data-md-align='center'] [data-md-navigation-rail-destinations] {
justify-content: safe center;
}
/*
* A FAB in the rail's header rests at elevation 0, not the 3 a standalone FAB has
* (§ Navigation Rail/Behaviour: "when nested within another component, such as the navigation
* rail, the FAB's resting elevation should be level 0"). The doubled `[data-md-fab]`
* attribute only adds specificity, over fab.css's `[data-md-fab]:hover` too, as
* `[data-md-navigation-rail-item][data-md-navigation-rail-item]::before`
* (navigation-rail-item.css) does for the same reason.
*/
[data-md-navigation-rail-header] [data-md-fab][data-md-fab],
[data-md-navigation-rail-header] [data-md-fab][data-md-fab]:hover {
box-shadow: none;
}
/*
* "Rail transitions from collapsed to expanded should animate the FAB into and out of an
* extended FAB" so the header takes one `<x-fab label icon>` and morphs it, rather
* than swapping two by `display`, which showed the collapsed one at once while the rail's
* width was still springing. The label's width springs on the rail's own spatial spring; with
* the gap and the extended FAB's own minimum width both zeroed, `aspect-ratio` squares what is
* left against the height its size already sets 56, 80 or 96px, the three FAB sizes so no
* number is repeated here. The label stays in the accessibility tree at both widths, so the FAB
* keeps its name. The cap is there because a transition needs a length to travel to; a rail
* FAB's label is a word or two.
*/
[data-md-navigation-rail-header] [data-md-fab] > span {
max-width: 256px;
overflow: hidden;
white-space: nowrap;
transition:
max-width var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default),
opacity var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
@container style(--md-navigation-rail-value: collapsed) {
max-width: 0;
opacity: 0;
}
}
/* `[data-md-extended]` doubled for the same reason as the shadow's `[data-md-fab]` above: it
outranks fab.css's `[data-md-fab][data-md-extended][data-md-size]` gap and its collapsed
collapse-on-scroll FAB's minimum width, three attributes each. */
[data-md-navigation-rail-header] [data-md-fab][data-md-extended][data-md-extended] {
@container style(--md-navigation-rail-value: collapsed) {
min-inline-size: 0;
aspect-ratio: 1;
gap: 0;
}
}
}
-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;
}
}
}
+88
View File
@@ -0,0 +1,88 @@
/*
* The paginators, Laravel's and Livewire's, drawn in M3 (resources/views/pagination/**): named
* `material`/`simple-material`, put in front of the frameworks' own namespaces and set as
* Laravel's default view (`Paginator::defaultView()`/`defaultSimpleView()`) and, where Livewire
* still reads its own default, as its `pagination_theme`.
*
* 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';
+90
View File
@@ -0,0 +1,90 @@
/*
* <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;
@import './color.css';
@layer material.components {
[data-md-progress] {
color: var(--md-color, var(--md-sys-color-primary));
stroke: var(--md-container, 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;
}
/* Every other hue comes from the shared colour-role table (color.css): its own `primary` entry
is plain primary-container, so the one hue this file draws differently keeps its override. */
[data-md-progress][data-md-color='primary'] {
stroke: var(--md-sys-color-secondary-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` 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;
}
}
+137
View File
@@ -0,0 +1,137 @@
/*
* <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);
}
}
/* The radio stays in the page, for the form, the keyboard and a screen reader
`md-visually-hidden` on the element itself (text.css), which outranks this layer
whatever wins the specificity, `material.text` sitting above `material.components`. */
}
[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);
}
}
+204 -59
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,160 @@
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
}
[data-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; 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"`, 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;
}
}
+101
View File
@@ -0,0 +1,101 @@
/*
* <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.
*
* A value too long for the field ends before the arrow with an ellipsis. The native select cuts its
* own text at its end padding; the customizable one draws the value in a box of its shadow tree
* that no author style reaches, so the view gives it a `<button>` holding `<selectedcontent>`, the
* browser's copy of the chosen option, and the ellipsis is drawn there.
*
* 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).
A value longer than the field is cut at that padding, with an ellipsis, rather
than running on under the arrow. */
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);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
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 in
the error colour when the field is in error, as a focused one is and the
arrow turns over, as the searchable choices' does. Both outweigh field.css's
hover, so the pointer left on the field does not take the focus edge away. */
@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;
}
/* The customizable select's value is `<selectedcontent>`, the only part of the
closed select an author style reaches: the box the browser draws otherwise
takes neither `text-overflow` nor a width from the select, and ran on under
the arrow. As the select's flex item it may shrink to the room left. */
select[data-md-field-control] selectedcontent {
flex: 1 1 0%;
min-width: 0;
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][data-md-invalid] [data-md-field-box]:has(select[data-md-field-control]:open) {
--field-edge: var(--md-sys-color-error);
--field-ink: var(--md-sys-color-error);
}
[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);
}
}
+371
View File
@@ -0,0 +1,371 @@
/*
* <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';
@import './color.css';
@layer material.components {
[data-md-slider] {
--slider-active: var(--md-color, var(--md-sys-color-primary));
--slider-inactive: var(--md-container, var(--md-sys-color-secondary-container));
--slider-on-active: var(--md-on-color, var(--md-sys-color-on-primary));
--slider-on-inactive: var(--md-on-container, 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;
}
/* Every other hue comes from the shared colour-role table (color.css): its own `primary` entry
is plain primary-container, so the one hue this file draws differently keeps its override. */
[data-md-slider][data-md-color='primary'] {
--slider-inactive: var(--md-sys-color-secondary-container);
--slider-on-inactive: var(--md-sys-color-on-secondary-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;
}
}
}
+113
View File
@@ -0,0 +1,113 @@
/*
* <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-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);
}
}
+48 -32
View File
@@ -2,21 +2,37 @@
* 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
* (`data-md-selected`, or `aria-selected="true"` where the table is a grid, the only place ARIA
* allows it on a row) 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 +42,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 +72,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:is([aria-selected='true'], [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);
}
}
+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
*
* "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), 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. 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. A link
* marked `aria-current="page"` (`<x-section-nav>`'s tabs) takes the active indicator and colour
* exactly as `aria-selected="true"` does.
*/
@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". 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. */
[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; 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 the Expressive-
* deprecated segmented button's successor, `<x-group>`, already gives it the keyboard handling and
* shape a hand-rolled control would have to reimplement. 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, 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
* 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);
}
}
}
+192 -144
View File
@@ -5,71 +5,111 @@
* 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';
@import './modal.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's size, no default border or padding; the entry pop and the scrim are
modal.css's shared dialog chrome, imported above. */
[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);
}
[data-timepicker-surface] {
[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 +117,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 +157,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 +203,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 +236,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 +247,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 +292,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 +325,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 +348,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 +392,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.
* 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; 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. The live region is the host
* itself, in the page before any message is, never the snackbar that comes and goes;
* 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.
*/
@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);
}
}
+156
View File
@@ -0,0 +1,156 @@
/*
* <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 {
/* In a flex row, a setting's text beside its switch, the root keeps its automatic minimum:
the track, and beside a label the track, the gap and the label's longest word. So the text
beside a bare switch, or the switch's own label, wraps before the track gives way; a root
free to shrink below 52px left the track running out of it, and off a card's edge on a
phone. */
[data-md-toggle] {
min-width: auto;
}
/* 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.
*
* 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, 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. 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. 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". */
[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);
}
}
}
+80
View File
@@ -0,0 +1,80 @@
/*
* <x-tooltip>: M3's plain tooltip, a short label for a control in the inverse surface.
*
* The bubble is a `popover="manual"` in the top layer, so no `overflow: hidden` parent clips it,
* placed by CSS anchor positioning on its `data-md-side` and flipping to the other side where the
* window has no room. PlainTooltipTokens and TooltipDefaults (androidx Compose Material 3,
* Apache-2.0): `inverse-surface` container with the extra-small corner, `body-small` supporting
* text in `inverse-on-surface`, 8px either side and 4px above and below, 200px wide at most, 4px
* from its anchor. It never takes the pointer, so it can stand over the control it names.
*
* 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/tooltip.js shows and
* hides it.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-tooltip-anchor] {
display: inline-flex;
}
[data-md-tooltip] {
inset: auto;
margin: 0;
max-inline-size: 200px;
overflow: visible;
border-width: 0;
border-radius: var(--md-sys-shape-corner-xs);
padding-block: var(--md-sys-measurement-space50);
padding-inline: var(--md-sys-measurement-space100);
background-color: var(--md-sys-color-inverse-surface);
color: var(--md-sys-color-inverse-on-surface);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
font-variation-settings: normal;
text-align: center;
white-space: normal;
pointer-events: none;
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-tooltip]:is([data-md-side='top'], [data-md-side='bottom']) {
margin-block: var(--md-sys-measurement-space50);
position-try-fallbacks: flip-block;
}
[data-md-tooltip]:is([data-md-side='left'], [data-md-side='right']) {
margin-inline: var(--md-sys-measurement-space50);
position-try-fallbacks: flip-inline;
}
[data-md-tooltip][data-md-side='top'] {
position-area: top;
}
[data-md-tooltip][data-md-side='bottom'] {
position-area: bottom;
}
[data-md-tooltip][data-md-side='left'] {
position-area: left;
}
[data-md-tooltip][data-md-side='right'] {
position-area: right;
}
}

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