Files
livewire-material/docs/plans/livewire-material.md
T
Andreas Reinhold / reiniandClaude Opus 5 8f174520eb
tests / feature (8.5) (push) Successful in 1m8s
tests / browser (safari, webkit) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / lint (push) Successful in 1m5s
tests / feature (8.4) (push) Successful in 1m11s
tests / browser (chrome, chromium) (push) Failing after 6m9s
Add M3 error pages and the Markdown mail theme
Error pages for 401 to 503 in an error-view root appended to view.paths,
with an inline fallback when the Vite build is missing, and a mail theme
rendered from the application's scheme JSON, with opt-in header and
message components. Completes Phase 9.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 08:55:58 +02:00

631 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Livewire Material: a shared Material 3 Expressive component library
> Split on 2026-09-13 from SealShare's `docs/plans/livewire-material.md`, where the plan was
> made. SealShare's adoption (Phase 11) stays in SealShare's copy.
## Goal
ReStride has just left maryUI and daisyUI for **Material 3 Expressive on its own Blade
components** (`ReStride/docs/plans/material-expressive.md`, done 2026-09-10). Doing that
again, by hand, in every Laravel + Livewire app is what this package avoids.
**`nonameweb/livewire-material`** carries the whole current M3 Expressive component catalogue
as Blade components for Livewire, together with everything around them — colour scheme
generation, tokens, the theme script, Material Symbols, Google Sans Flex, motion, error pages,
a mail theme, a showcase, test helpers and AI guidelines. SealShare converts to it once it is
complete (`1.0.0`); ReStride adopts it later, in a plan of its own.
## Context
**Stacks.** SealShare: Laravel 13.31, Livewire 4.4, maryUI 2.9.10 (no prefix), daisyUI 5.7,
Tailwind 4.3, Pest 5.1, Octane on FrankenPHP, PHP 8.5; public on GitHub under MIT, image
published to `ghcr.io/surtic86/sealshare`. ReStride: same Laravel / Livewire / Tailwind / Pest,
private on `gitea.nonameweb.ch`, CI through Gitea act_runner.
**What ReStride already solved** (and the package generalises):
- 45 anonymous components in `resources/views/components/`, maryUI's names and props, M3
styling; class components only where PHP earns it.
- `resources/css/material/{color,shape,type,motion,elevation,field,menu,table,list}.css`;
`@theme` (never `inline`) mapping M3 roles to utilities; `--color-*: initial` so only tokens
compile; `:root` dark + `[data-theme=light]`, each with its own `color-scheme`.
- Material Symbols Rounded (400/0/24) as local SVGs registered as `blade-icons` sets
(`ms`/`msf`), `config/blade-icons.php` turning off blade-icons' own `<x-icon>`.
- Google Sans Flex, a 63 KB Latin subset (weight 400700, ROND 0100).
- Spring motion as CSS `linear()` curves; press shape-morph; Expressive shapes generated by
formula into `resources/svg/shapes`.
- `App\Livewire\Concerns\Toasts` (protected `success|error|warning|info`, dispatching a window
event) and a snackbar `<x-toast>`.
- ~30 component tests (`$this->blade()`), browser tests (Pest browser plugin), and guard tests
(`DesignLanguageTest`, `MaterialTokensTest`, `MaterialSymbolsTest`).
- The Livewire traps, recorded in `../ReStride/.ai/rules/ui.md`: `wire:ignore.self` on a
`showModal()` dialog; never pass `hidden` or a position to a component (wrap it); Blade
directives do not compile inside a component tag's attributes; `wire('model')->value()` is
`false`, not `null`, without `wire:model`; a field's states are CSS selectors (`:has()`),
marked with `data-invalid` / `data-floated` / `data-readonly` where the control cannot carry
them; the customizable select's traps (`../ReStride/.ai/rules/components-views-components.md`).
- Mail on M3 (`../ReStride/docs/plans/material-mail.md`): one theme CSS inlined onto bare
tags, light only, because `CssToInlineStyles` strips `@media`.
**What in ReStride is app-specific** and does not move: the doctrine (two button weights, no
tertiary / primary-container — enforced by its build), `restride-theme`, `$store.install` /
`$store.connection` in the shell, `--bottom-bar` set by its layout, sport / zone / route
tokens, `training-row`, `map-shell`, `map-chip`, `product-shot`, `star-rating`,
`share-button`, the unDraw repaint, the Ace code editor.
**Constraints found.**
- `gitea.nonameweb.ch` requires sign-in to view anything (`/explore/repos` → login, API 403).
A "public" repo there cannot be installed anonymously until that changes.
*(Resolved in step 1.)*
- Laravel's Markdown mail accepts a namespaced view as theme
(`Illuminate/Mail/Markdown.php:114`), so a package can render the theme CSS from data.
- Laravel **replaces** the `errors` view namespace at render time with
`config('view.paths')` + `/errors` and the framework's own
(`Illuminate/Foundation/Exceptions/RegisterErrorViewPaths.php`), so error views a package
adds with `addNamespace('errors', …)` are wiped; only a path in `view.paths` survives.
- M3 Expressive deprecates bottom app bar, navigation drawer, the original navigation bar,
segmented button, small FAB and medium/large top app bar (material-components-android docs).
- CSS anchor positioning is in Chrome 125+, Firefox 147+, Safari 18.4+ (flip via
`@position-try` from Safari 18.4).
- Boost loads a package's `resources/boost/guidelines/core.blade.php` and its skills on
`boost:install` / `boost:update --discover`.
## Decisions
- **A shared Composer package, SealShare its first consumer; ReStride adopts later in its own
plan** — SealShare's small surface proves the API; ReStride's three-day-old migration is not
put at risk.
- **Public repo on `gitea.nonameweb.ch`, MIT** — SealShare is public; its CI, Docker build,
manual install and fork PRs must install the package without credentials. SealShare
requires it through a `vcs` repository entry. Material Symbols (Apache-2.0), Google Sans
Flex (OFL) and material-web token values (Apache-2.0) are compatible; attributions ship in
`NOTICE`.
- **Prerequisite: Gitea allows anonymous reads** (`[service] REQUIRE_SIGNIN_VIEW = false`, or
`expensive` on 1.23+) — otherwise "public" is not installable.
- **Name `nonameweb/livewire-material`, namespace `NoNameWeb\LivewireMaterial`,
`config/livewire-material.php`, views `livewire-material::`, commands `material:*`** — says
what it is and what it is for.
- **Components unprefixed by default, prefix configurable** (maryUI's model, via
`Blade::anonymousComponentPath($path, $prefix)`) — ReStride's call sites keep their names;
SealShare's maryUI tags keep theirs. The provider sets `blade-icons.components.default` to
`null`, or blade-icons' class `<x-icon>` beats ours.
- **CSS and JS imported from `vendor/`** (`@import`, `@source`, `import`) — one dependency, one
version; consuming Dockerfiles install Composer packages before the Vite build.
`@source` covers the package's views and PHP, never its SVG folders.
- **The full Material Symbols Rounded set** (400 / 0 / 24, outlined and filled, 4,135 symbols,
5.3 MB), fetched by a maintenance script in the package repo, never at runtime — any name works
in any app. **`<x-icon>` reads the SVG files itself; blade-icons is not used for them** (decided
after Phase 1): blade-icons registers one Blade component per icon whenever the view factory
resolves, ~8,000 registrations per request here. blade-icons stays a dev dependency, only to
test that the provider still keeps its `<x-icon>` from shadowing ours in apps that have it.
- **Google Sans Flex bundled** (ReStride's subset) via `@font-face` in the package CSS; an app
overrides `--font-sans`.
- **Full M3, not ReStride's doctrine** — every variant, colour role (tertiary and
primary-container included) and container is available; each app enforces its own rules
through a configurable guard helper the package ships.
- **`variant` + `color` props** — `variant="filled|tonal|outlined|text|elevated"`,
`color="primary|secondary|tertiary|error|success|warning|info"`, default `text` in primary;
both validated against fixed lists, unknown values fall back to the default. Shorthands
kept: `primary` = filled primary, `danger` = filled error, `caution` = filled warning. The
same pattern for badge, alert, chip, icon button and FAB (`tone` stays an alias of `color`
where ReStride used it).
- **`php artisan material:scheme {seed} --variant=`** — runs Google's
`material-color-utilities` from a single prebundled Node script inside the package; writes
the app's `resources/css/material-scheme.css` (dark and light `--md-sys-color-*`, plus
`success`/`warning`/`info` custom colours, harmonisation off) and
`resources/css/material-scheme.json` (the light hexes, for the mail theme). Output is
committed.
- **Theme: a head script component, `light | dark | system`** — sets `data-theme` before paint;
CSS keys only on the attribute and never asks `prefers-color-scheme`; only the script reads
the OS (and follows its changes while `system`). `$store.theme` is the one state every
toggle shares. Config: `theme.default`, `theme.storage_key`, `theme.legacy_keys` (adopted
once, then removed).
- **The whole M3 Expressive catalogue, current components only** — the six deprecated ones are
skipped; their names alias where free (`<x-group>` renders a connected button group, FAB
`size="sm"` renders medium).
- **Plus the non-M3 pieces apps need** — data table, sort header, pagination views, file input,
password, stat, alert, empty state, collapse, section nav, account menu, theme toggle, an
adaptive app-shell composition, error pages, mail theme.
- **Modern browser floor, no third-party JS** — Chrome 125+, Firefox 147+, Safari 18.4+:
native `<dialog>`, Popover API, CSS anchor positioning, customizable `<select>` as a
progressive enhancement; Alpine (bundled with Livewire) for behaviour. Date and time
pickers, carousel and search are our own.
- **PHP ^8.4, Laravel ^13, Livewire ^4.** No request state in singletons (Octane).
- **Strings through `__()` with an `en` file**, publishable and overridable.
- **Accessibility: WAI-ARIA Authoring Practices patterns, WCAG 2.2 AA contrast, full keyboard.**
- **Showcase in the package, mounted twice** — by the Workbench for development and browser
tests, and opt-in inside an app (`showcase.enabled`, default only when `APP_ENV=local`) so
an app sees every component in its own scheme. Every variant, colour, size and state, a
light/dark/system switch, and the Blade snippet beside each example.
- **Tests: render tests for every component, browser tests against the Workbench showcase in
Chromium, Firefox and WebKit; no screenshot diffs.**
- **Error pages and a mail theme in the package** — the mail theme is the namespaced view
`livewire-material::mail.theme`, rendering CSS from the app's `material-scheme.json`, so
mail colours cannot drift from the app.
- **Boost guideline + `livewire-material-development` skill** in the package, and a test that
fails when a component is missing from the skill.
- **No version tags until the whole catalogue is done; then `1.0.0`** — the waves land on `main` untagged (decided 2026-09-13; replaces "`0.x` per wave").
- **Semantic ink and line utilities carried over from ReStride** (`text-body`, `text-meta`,
`text-quiet`, `border-structure`, `border-chrome`, `border-divider`) — cheap `@theme` names
ReStride's templates already use, derived from M3 roles.
## Out of scope
- ReStride adopting the package — its own plan, after `1.0.0`.
- A brand colour chosen at runtime (e.g. in SealShare's admin), dynamic colour, a PHP port of
the colour maths.
- M3 Expressive's deprecated components (bottom app bar, navigation drawer, original
navigation bar, segmented button, small FAB, medium/large top app bar).
- Screenshot / visual regression tests.
- Blaze optimisation of the components — a follow-up if rendering gets slow.
- ReStride-specific components and tokens (training row, maps, product shot, star rating, share
button, sport/zone/route tokens, unDraw repaint, Ace code editor).
- Publishing on Packagist — the `vcs` repository is enough; revisit if others adopt it.
- Translations beyond `en`.
- Browsers below the floor.
- Non-Livewire stacks (Inertia, React, Vue).
## Implementation steps
### Phase 0 — Prerequisites
1. **Gitea.** *Done 2026-09-13.* `GITEA__service__REQUIRE_SIGNIN_VIEW=false` in the Gitea
compose service (Gitea 1.26.4); `/explore/repos` and the API answer anonymously, and no
other repo or org is public. The package lives in the public org **`noNameWEB`**:
`https://gitea.nonameweb.ch/noNameWEB/livewire-material.git` (public, empty, `main`,
Actions on). SealShare's `vcs` repository entry uses that URL.
2. **Split the plan.** *Done 2026-09-13.* This file; SealShare keeps its adoption steps.
### Phase 1 — Package skeleton
3. **Repository.** `composer.json` (`nonameweb/livewire-material`, PSR-4
`NoNameWeb\LivewireMaterial\`, requires `php ^8.4`, `laravel/framework ^13`,
`livewire/livewire ^4`, `blade-ui-kit/blade-icons ^1.10`; dev: `orchestra/testbench`,
`pestphp/pest`, `pestphp/pest-plugin-laravel`, `pestphp/pest-plugin-browser`,
`laravel/pint`), `LICENSE` (MIT), `NOTICE`, `pint.json`, `phpunit.xml`, `.gitattributes`
excluding `workbench/`, `tests/`, `bin/`, `docs/` and the Node/Vite dev files from dist
archives.
4. **Service provider** `LivewireMaterialServiceProvider`: merge config; register the
anonymous component path with the configured prefix; `loadViewsFrom` (`livewire-material`),
`loadTranslationsFrom`; set `blade-icons.components.default` to `null` in `register()`
(before blade-icons boots and registers its component); register the icon and shape sets;
append the package's error-view root (a directory holding only `errors/`) to
`view.paths` after the app's own, so Laravel's namespace replacement keeps it and an app's
`resources/views/errors` still wins; publishable config, lang, error views; commands; the
showcase routes when enabled. No request state anywhere in the container.
5. **Config** `config/livewire-material.php`: `prefix`, `theme.default`, `theme.storage_key`,
`theme.legacy_keys`, `showcase.enabled` (`env('MATERIAL_SHOWCASE', app()->isLocal())`),
`showcase.path` (`material`), `showcase.middleware` (`['web']`).
6. **Workbench** (`orchestra/workbench`): a Laravel app with Livewire, Vite and the package
CSS/JS built, serving the showcase at `/`; `composer serve` starts it.
7. **CI on Gitea act_runner:** Pint, Pest render tests, browser tests in Chromium, Firefox and
WebKit (Playwright installed with deps), on pushes and pull requests.
8. **Boost resources skeleton:** `resources/boost/guidelines/core.blade.php` and
`resources/boost/skills/livewire-material-development/SKILL.md`; the drift test (every
component file under `resources/views/components` is named in the skill).
**Phase 1 is done (2026-09-13).** What changed from the steps above:
- **Testbench 11.2** runs the Workbench (`testbench.yaml`: providers listed, `start: /material`,
`MATERIAL_SHOWCASE=true`). Vite builds into `workbench/public/build`; `composer serve` links
that into the skeleton's `public/build` through the Workbench `sync` option, and
`tests/TestCase.php` points `public_path()` at `workbench/public` so browser tests find the
manifest without the command. Verified: `composer serve` answers `/material` and its CSS.
- **The showcase is a plain view with `@extends`**, not a component: `<x-livewire-material::…>`
resolves under `components/`, which would have made the layout a public component.
- **blade-icons' `components.default` is set in a `booting` callback**, not in `register()`:
`mergeConfigFrom` is shallow, so a nested key written before blade-icons merges its defaults
wipes the rest of its `components` array. The test fails with the call removed.
- **The showcase switch is tested by rebooting** with `MATERIAL_SHOWCASE` in the environment:
Testbench's per-test attributes do not reach Pest closures.
- **The Boost guideline is wrapped in `@verbatim`**: it is rendered as Blade, and a
`<x-mary-*>` in its prose would compile as a component tag.
- **`phpunit.xml` trap:** a comment containing a double hyphen (`--browser`) is invalid XML and
PHPUnit refuses the file; Pest then reports an unrelated `Pest\Plugins\Tia` error on shutdown.
- **CI** (`.github/workflows/tests.yml`): Pint, feature tests on PHP 8.4 and 8.5, browser tests
per engine — Pest's `--browser chrome|firefox|safari`, Playwright's `chromium|firefox|webkit`.
Locally only Chromium was run; Firefox and WebKit first run on CI.
- **`NOTICE` moves to Phase 2**, when the first third-party assets (Symbols, font, tokens,
colour utilities) arrive — a notice with nothing to attribute would be wrong.
- **The scheme script is not in `bin/`:** `bin/` is export-ignored (maintenance scripts only),
but applications run the scheme script, so it lives at `resources/node/scheme.mjs`.
- **Found for Phase 2:** blade-icons calls `Factory::registerComponents()` whenever the view
factory resolves, registering one Blade component per icon unless `components.disabled`
and without `icons:cache` it scans the folders first. With ~7,800 symbols that is per
request under PHP-FPM. Decided before step 13.
### Phase 2 — Foundation
9. **Scheme command.** `resources/node/scheme.mjs` built once in the package repo (esbuild bundle of
`@material/material-color-utilities`, committed, Apache-2.0 header); `material:scheme {seed}
{--variant=tonal-spot|vibrant|expressive|fidelity|content|neutral|monochrome}
{--success=} {--warning=} {--info=} {--output=}` runs it through `node` and writes
`resources/css/material-scheme.css` and `resources/css/material-scheme.json`. Fails with a
clear message when `node` is missing. A default scheme (M3 baseline `#6750A4`) ships inside
the package so a fresh install renders before the command is run.
10. **Tokens.** `resources/css/material.css` as the single entry, importing `tokens/shape.css`,
`type.css` (typescale utilities incl. emphasized), `motion.css` (six spring `linear()`
curves — spatial/effects × fast/default/slow — and reduced-motion overrides),
`elevation.css`, `state.css` (state layers, focus ring), and the `@theme` block mapping
**every** M3 role (primary/secondary/tertiary and their containers, surfaces, outlines,
inverse, error, custom success/warning/info with `inverse-*`) plus the semantic ink and
line utilities; `--color-*: initial` with white and black re-added. Each theme block
declares `color-scheme`. Values from material-web's token files, attributed.
11. **Font.** `resources/fonts/google-sans-flex/` (woff2 + OFL), `@font-face` and `--font-sans`
in the entry CSS.
12. **Theme.** `<x-theme-script />` (inline, before `@vite`): reads `storage_key`, adopts
`legacy_keys` (their values may be JSON-encoded — maryUI's `$persist` stores `"dark"`
with quotes), resolves `system` through `matchMedia`, writes `data-theme`; `$store.theme`
in `resources/js/material.js` with `set()` and a `matchMedia` listener while `system`.
13. **Icons.** `bin/fetch-symbols` (maintenance only) downloads Material Symbols Rounded
400/0/24 outlined and filled from `google/material-design-icons` into
`resources/svg/symbols/{outlined,filled}` with `fill="currentColor"` and no width/height;
`<x-icon name="…" filled>` resolves `ms`/`msf`; throws on an unknown name.
14. **Shapes.** The Expressive shape set generated by formula (ReStride's
`components/shape.blade.php` method) into `resources/svg/shapes`; `<x-shape>`.
15. **JS entry** `resources/js/material.js`: theme store, snackbar listener, `data-list-row`
rows, `x-figure` count-up directive, shared keyboard helpers. Imported by an app's `app.js`.
16. **`Toasts` concern** `NoNameWeb\LivewireMaterial\Concerns\Toasts`: protected
`success|error|warning|info(string $title, ?string $description = null, ?int $timeout = null, ?string $redirectTo = null)`,
dispatching a browser event and flashing across `redirectTo`.
17. **Guard helpers** (`NoNameWeb\LivewireMaterial\Testing\DesignGuard`, Pest-friendly):
scan given paths for maryUI tags, daisyUI component classes, colour utilities not declared
by the compiled tokens, unknown icon names, and app-configured banned roles/variants.
18. **Showcase shell**: layout, section navigation, theme switch, snippet renderer; a section
per foundation piece (colour roles in both themes, type scale, shapes, motion, icons
search).
**Phase 2 is done (2026-09-13).** What changed from the steps above:
- **Icons without blade-icons** (see Decisions): `NoNameWeb\LivewireMaterial\Support\SvgFile`
reads a symbol or shape file on first use and keeps it for the worker. An unknown name throws,
and a Heroicon-style name (`o-home`) throws with a pointer to the catalogue. `bin/fetch-symbols`
sparse-checks-out google/material-design-icons (5 GB, cloned without blobs) for the
`_24px` and `_fill1_24px` files: 4,135 symbols, byte-identical to ReStride's hand-picked ones.
The `@material-symbols/svg-400` npm package was rejected: its "rounded" SVGs are optical size 48.
- **All 35 M3 Expressive shapes**, ported from androidx `MaterialShapes.kt` and graphics-shapes
into `bin/shapes.mjs` (commit in its header), each fitted to 298 of a 100-unit box; eight had
control points outside the box and are split into more segments along the same outline.
- **The scheme script** lives at `resources/node/scheme.mjs` (93 KB, esbuild bundle of
material-color-utilities 0.4.0, spec 2025 — M3 Expressive's colour; variants the 2025 spec
does not define fall back to 2021). The library's extensionless imports mean it cannot run
unbundled. 65 roles per theme plus `inverse-{error,success,warning,info}`. Default state
sources: success `#22a06b`, warning `#e2a400`, info `#1d7afc`. `config('livewire-material.node')`
names the binary. The package's own default is `#6750A4`, tonal-spot, in `tokens/scheme.css`.
- **Colour utilities are `@theme inline`** — contrary to ReStride's rule that `inline` breaks
theme switching. Verified in Chromium: without `inline`, `--color-primary` resolves once on
`:root`, so a `data-theme="dark"` section inside a light page keeps light colours; with it the
utility reads `var(--md-sys-color-primary)` on the element and both the page toggle and nested
themes work. Only blocks whose values are variables are inline. A browser test fails without it.
- **Tailwind emits only the theme variables that are used**, and cannot see a class assembled in
Blade (`type-{{ $style }}`): the showcase lists every class literally.
- **`dark:`** is keyed on `data-theme`, so it follows the page's theme rather than the OS.
- **Theme script** writes nothing for a visitor who never chose (a later change of
`theme.default` still reaches them); only an adopted legacy value is stored. `data-theme-choice`
and `data-theme-key` on `<html>` hand the state to `$store.theme` (`choice`, `resolved`, `set`,
`toggle`, `value`).
- **Moved out of Phase 2:** the snackbar listener goes to Phase 4 with `<x-toast>`; `data-list-row`
rows (JS and CSS) and the list keyboard go to Phase 5 with `list` and `card`, which they style.
`Toasts` dispatches `toast` with a 4 s default (M3's snackbar range is 410 s; ReStride used 3 s).
- **`DesignGuard`**: `secondary` is a valid role now that the package exposes full M3, so it is no
longer flagged as a daisyUI colour; icon attributes are checked per component tag (`icon` and
`icon-right` on the same tag).
- **Pest browser trap:** a bare `html` selector is taken as text to search for and times out;
assert on `document.documentElement` through `assertScript`. `@name` targets `data-test`.
- **Showcase**: colour roles in both themes side by side, type, corners and shapes, elevation,
springs and `x-figure`, and an icon search drawing matches as CSS masks from a showcase-only
symbol route (relative URLs — an absolute one carries `APP_URL` and misses the dev server's port).
### Phase 3 — Actions
19. Primitives the actions need: `loading` (M3 Expressive loading indicator, contained and
not), plain `tooltip` (from a fine pointer only, not laid out while hidden), `menu` /
`menu-item` / `menu-separator` (Popover API + anchor positioning, Expressive vertical menu,
APG menu keyboard).
20. `button` — five variants × colours × sizes `xs|sm|md|lg|xl`, `icon`, `icon-right`,
`label`, `link` (+ `wire:navigate` unless `external` / `no-wire-navigate`), `spinner`,
`responsive`, `tooltip*`, `disabled` on links (`aria-disabled`), press shape-morph.
21. `icon-button` behaviour inside `button` (icon, no label): standard/filled/tonal/outlined,
`selected` toggle (`aria-pressed`), widths.
22. `button-group` (standard and connected; `<x-group>` alias with `wire:model` options),
`split-button`, `fab` (56px default — M3's deprecated small FAB is gone, so `sm` is the baseline FAB — 80px `md`, 96px `lg`), extended FAB, and the
responsive `fab` prop (extended FAB below `sm`, filled header button above — one element),
`fab-menu`.
**Phase 3 is done (2026-09-13).** What changed from the steps above:
- **Values from androidx Compose Material 3's generated tokens** (`Button*Tokens`,
`*IconButtonTokens`, `Fab*Tokens`, `ExtendedFab*Tokens`, `FabMenuBaselineTokens`,
`ButtonGroupSmallTokens`, `ConnectedButtonGroupSmallTokens`, `SplitButton*Tokens`,
`StandardMenuTokens`, `VibrantMenuTokens`, `SegmentedMenuTokens`, `PlainTooltipTokens`,
`LoadingIndicatorTokens`), cited in each component's header. Two tokens are wrong and Compose
overrides them in code, so the package does too: the text button's label is `primary`, not
`on-surface-variant`, and the extra-small button's padding is 12px, not 16px.
- **`<x-button>` is label button, icon button and toggle in one**, with `data-icon-button` on the
icon-only form. A selected round button squares off; a selected square icon button rounds. A
`corners` prop lets composite components (the split button) draw the corners themselves.
- **Group and split corners are unlayered CSS** (`resources/css/components/groups.css`): a
child button's corners are utilities, and anything in a `@layer` loses to a utility. Inner
corners ride a `--group-corner` variable so pressing and selecting change one value while the
rounded outer corners stay put. The standard group's press expansion is padding moved from the
neighbours to the pressed button (a fixed step per size); icon buttons keep their width.
- **`<x-group>` stays ReStride's native-radio design** (checkboxes with `multiple`), restyled as a
connected button group — `wire:model`, `x-model` and the arrow keys need no script.
- **Tooltips and menus are popovers placed by CSS anchor positioning**, with per-render anchor
names generated in Blade (a morph updates the trigger and the popover together). `popover`
elements must never get a `display` utility (`flex`), which beats the UA's `display: none` for
a closed popover; use `open:flex`.
- **Menu keyboard is WAI-ARIA's menu button**; `aria-expanded` and the first item's focus are set
synchronously in `open()`, because the popover `toggle` event is queued and a test (or a screen
reader) reading in between saw a shut menu. **Bug found by the browser tests:** the guard
against a light-dismiss press reopening the menu compared against `closedAt = 0`, so every click
in the first 250ms after page load was swallowed; it starts at `-Infinity` now, with a test.
- **Anonymous component trap:** every prop is a local variable, so a helper variable in `@php`
must not reuse a prop's name — a local `$corners` array silently replaced the `corners` prop.
- **The loading indicator is androidx's own geometry, in SVG + SMIL** (`bin/loading-indicator.mjs`,
`resources/svg/loading-indicator/`): `Morph` is ported, so each of the seven morphs is a path
whose control points SMIL can interpolate in every engine (CSS `d:` has no WebKit support).
The spring is two keySplines (<1% error), each morph's path shrinks under its successor at the
hand-over, and the per-morph quarter turn runs on its own 2.6s cycle so the 630° per shape
cycle never needs a reset. 21.7 KB. Two deliberate differences from Compose: the spring settles
inside the 650ms instead of snapping back from 9% past, and frames centre on exact curve bounds,
so there is no 0.10.18 unit jump at three hand-overs. Reduced motion shows `static.svg`.
- **Showcase examples are Blade strings rendered with `Blade::render()` beside their source**
(`<x-showcase::example>`, an anonymous component path registered only when the showcase is
enabled), so the snippet can never disagree with what is drawn.
- **Browser tests in three engines, locally too** (Playwright's Firefox and WebKit are installed):
- WebKit, like Safari on macOS, leaves buttons out of the Tab order; focus them directly.
- Firefox counts a scripted focus as `:focus-visible` only after a key press.
- Firefox flaked ~3 runs in 4 with **HTTP 431 from Pest's in-process Amp server** on
`livewire.js`, so Alpine never started. It appeared once the showcase inlined a 60 KB list of
symbol names; the icon search now fetches `symbols.json` on `x-intersect.once`, and the suite
passed 4 of 4. Keep showcase pages lean.
- A click that lands before Alpine starts does nothing; tests wait for `networkidle`, and the
showcase's theme switch is `x-cloak` so Playwright's click waits for it.
### Phase 4 — Communication
23. `badge` (dot, count, label; variant/colour), `progress` (linear, circular, **wavy**,
determinate and indeterminate), `toast` (M3 snackbar, action, timeout, stacked), rich
`tooltip`, `alert` (tinted container, icon, actions slot), `stat` (figure with
`x-figure`), `empty-state`.
**Phase 4 is done (2026-09-13).** What changed from the steps above:
- **`<x-progress>` was built by a separate agent** from Compose's `ProgressIndicator.kt` and
`WavyProgressIndicator.kt`: server-rendered SVG first frame, then an Alpine component that ports
the drawing and keyframes and animates only while something moves and it is on screen; the SVG
is `wire:ignore` and a MutationObserver on the root's `data-value` turns a morph or a `bind`
expression into motion. Flat circular indeterminate has no track, as in Compose.
- **`<x-toast>` listens from the moment `snackbar.js` loads**, not on its Alpine component, and holds
toasts until the host registers — a toast dispatched before Alpine starts is shown, not lost.
`@persist` keeps the host across `wire:navigate`.
- **`<x-badge>` is M3's dot and count** (`floating` pins it to an icon) **plus a status label**
(`tonal`, `outline`), which M3 lacks and every app needs. `<x-alert>`, `<x-stat>` and
`<x-empty-state>` are built from M3's parts; `<x-rich-tooltip>` is transient or `persistent`.
- **A commit went out broken and was fixed forward:** `648ad8e` staged `material.js` and the
showcase index while they held the progress agent's temporary lines, without its files.
**With agents in the same tree, stage by explicit path and read the diff of shared files first.**
- **Browser test lessons (all three engines, locally):**
- `waitForEvent('networkidle')` can return before a repeated visit has loaded in Firefox (an
empty document, then a page still streaming in). Every helper now also asserts
`document.readyState === 'complete'` and that Alpine and Livewire exist; assertions retry.
- Firefox runs Playwright's evaluate in a sandbox: an event built there has a `detail` the page
cannot read, and assignments to Alpine's reactive proxies do not trigger. Go through the page's
realm with `window.eval("…")` (or `Livewire.dispatch`).
- Pest retries a failing `assertScript`; a script that changes state must not be written so
that a retry starts from the changed state.
### Phase 5 — Containment
24. `card` (elevated, filled, outlined; `title`, `subtitle`, `actions` slot; clickable row
contract), `divider`, `list` / `list-item` (one-, two-, three-line; leading/trailing;
selectable), `modal` (native `<dialog>`, `showModal()`, `wire:ignore.self`, writes back
`false`/`null`, `fullscreen` below `sm`, basic dialog with icon/headline/actions),
`bottom-sheet` (modal and standard, drag handle), `drawer` (side sheet: modal and
standard; `pane` for list-detail from `xl`; `width` prop), `carousel` (multi-browse, uncontained, hero,
full-screen on CSS scroll-snap), `collapse`.
**Phase 5 is done (2026-09-13).** What changed from the steps above:
- **`<x-carousel>` was built by a separate agent in its own worktree**, porting Compose's keyline
maths (`Arrangement`, `Keylines`, `Strategy`, `KeylineSnapPosition`, androidx
`7ac433e44e797de53af85226797862687f37735f`; checked against 43 values from androidx's unit tests).
Items are laid out at the large size on a native scroll-snap row and masked each frame with a
`clip-path` inset; snap positions are `scroll-margin-inline-start`. Full-screen follows
material-components-android, which Compose lacks; one item per swipe is `scroll-snap-stop`, not
fling physics. Hooks are `data-material-carousel*`: the design guard rejects `carousel*` classes
as daisyUI's.
- **The drawer is the side sheet**, with `pane` for list-detail from `xl`, and the bottom sheet is its
own component (modal or `standard`) with drag-to-dismiss.
- **`<x-list-item>`'s trailing slot is `end`**: a slot named like the `trailing` prop replaced it.
- **The design guard also rejects Blade directives inside component tags** (`@class` on
`<x-icon>`), which reach the browser as text.
- **`x-trap.inert` hides the page with `aria-hidden`**, not `inert`; tests assert what it sets.
- **WebKit returns focus from a dialog only to an element that had focus**, and a click does not
focus a button there: tests open dialogs from the keyboard.
### Phase 6 — Text inputs and selection
25. `form`, `field` (the shared shell: **outlined and filled**, floating label via `:has()`,
notch, `hint` replaced by error, `aria-invalid` / `aria-describedby`, `data-*` state
marks), `input` (prefix/suffix, icons, `copyable` trailing button with a snackbar),
`password` (reveal toggle), `textarea` (auto-grow), `select` (native, customizable-select
enhancement with ReStride's traps), `checkbox` (incl. indeterminate), `radio`, `toggle`
(M3 switch, icons), `file` (native input inside the field, batch and per-file errors).
26. `chip` (assist, filter, input, suggestion), `choices` (filter chips, or a searchable
combobox with a menu when `searchable`), `slider` (standard, centered, range; native range
inputs, value label), `search` (search bar and search view, results through a Livewire
property).
**Phase 6 is done (2026-09-13).** What changed from the steps above:
- **The field family is ReStride's, extended with M3's filled text field** (`variant`, default from
`config('livewire-material.fields.variant')`): a surface-container-highest box, extra-small top
corners, the outline turned into an indicator line and the label floating inside. A textarea's
top offset is half margin, so text scrolled up in a full textarea disappears under the edge
instead of running through the label; it grows with `field-sizing: content` from `rows` to
`max-rows`, with a script fallback (`field.js`) that no current engine needs.
- **Checkbox, radio and switch are CSS on native inputs** (`components/selection.css`), hooked by
`data-checkbox`, `data-radio`, `data-switch`: `checkbox`, `radio` and `toggle` are daisyUI class
names the design guard rejects. The indeterminate checkbox is `data-indeterminate`, kept in step
with the property by a MutationObserver, since HTML has no attribute for it.
- **`<x-chip>` and `<x-slider>` were built by separate agents in their own worktrees.** Chips: one
component for four types; a filter chip is a native checkbox when bound and a toggle button
otherwise; removable input chips hand focus on by `wire:key` after a morph. Slider: native range
inputs under a drawing ported from Compose (`wire:ignore`); `.number` is added to the binding,
because a string sent and an integer returned made Livewire move the handle back mid-drag.
- **`<x-choices>` keeps typed values in Alpine** (filter chips as toggle buttons, or a searchable
combobox whose list is a `popover="manual"` placed by CSS anchor positioning, so a card's
`overflow-hidden` never clips it). Selecting the text on focus has to happen on the `click` that
follows the press: Chromium collapses a selection made in the focus handler.
- **`<x-search>` is the search bar and view**: docked from `sm`, full screen and trapped below it.
It closes on `pointerdown` outside, not `click` (the bar moves under the pointer as it goes full
screen, and the click then lands outside), and focus returned to the input within 250ms of a close
does not reopen it (Escape, and the trap letting go).
- **A slot rendered by a `@foreach` with no items is not empty** to `isNotEmpty()`; use
`hasActualContent()`.
### Phase 7 — Pickers
27. `datepicker` (docked, modal, modal input; `Intl` month/day names and week start from the
app locale; `min`/`max`; single and range; `wire:model` stores `Y-m-d`), `timepicker`
(dial and input; 12/24h from locale; stores `H:i`). APG grid keyboard for the calendar.
**Phase 7 is done (2026-09-13).** Both pickers were built by separate agents in their own
worktrees. What changed from the step above:
- **`<x-datepicker>` is one `<dialog wire:ignore>` for every mode**: docked opens it as a
`popover="manual"` under the field (CSS anchor positioning, flipping above), modal and input
with `showModal()`, and docked becomes modal below `sm`. Dates are `Y-m-d` strings computed in
UTC; only "today" reads the browser's zone. The typed format is androidx's
`datePatternAsInputFormat` from `Intl` (`de``dd.MM.yyyy`). **A range binds one array**,
`['start' => …, 'end' => …]`, not two models: one update, so `after_or_equal:trip.start`
validates against the matching end, and all its errors land on one field. Docked values come from
material-web's docked tokens, which Compose lacks.
- **`<x-timepicker>` follows current Compose, not older Android**: a 24-hour dial puts 0011 outside
and 1223 inside, and the period selector is two separate toggles. The selector angle is a
registered custom property, so one transition turns the line and moves the handle.
- **Alpine's `x-show` reveals an element on the next animation frame**: focusing into it from
`$nextTick` failed in Firefox and Safari. Wait a frame, or switch views with an attribute and CSS.
- **A Livewire public property named `$slot` renders empty** in the component's view.
### Phase 8 — Navigation
28. `app-bar` (small, center-aligned, medium flexible, large flexible, search app bar; sticky,
scroll-elevation), `navigation-bar` (flexible), `navigation-rail` (collapsed, expanded,
modal; badges), `tabs` / `tab` (primary and secondary; server-rendered tablist, roving
tabindex), `toolbar` (docked and floating), `section-nav` (secondary tabs from `sm`, menu
picker below), `account-menu` (avatar trigger, slot for items, theme row), `theme-toggle`
(cycles or picks light/dark/system through `$store.theme`).
29. `app-shell` — a slot-based adaptive composition: app bar + navigation bar below `sm`, rail
`sm``lg`, expanded collapsible rail from `lg` (state in the store, applied before paint by
the theme script), content region with `wire:transition.navigate`, snackbar host. Nothing
app-specific inside; apps pass destinations and extra chrome as slots.
**Phase 8 is done (2026-09-13).** What changed from the steps above:
- **App bars, toolbars, tabs, section nav, account menu and theme toggle were built in main; the
navigation bar, rail and app shell by an agent in its own worktree.**
- **A medium or large app bar collapses without script moving anything**: the bar is sticky at a
negative top (its measured height less the 64px row, so a wrapped title still fits) and its row
is sticky at 0 inside it; script only reports `scrolled` and `collapsed`.
- **The tab indicator moves in a view transition** (`view-transition-name` from a per-tablist
custom property, `view-transition-class` for the timing); every tab draws its own indicator, so it
is right before Alpine starts.
- **Livewire 4.4's `wire:navigate` gives `<html>` the next page's attributes and removes the rest**,
which dropped `data-theme` on every in-app navigation. The head script saves the theme and rail
attributes on `livewire:navigating` and puts them back in `onSwap`, before anything paints.
- **The rail's collapsed state is applied before first paint** by the head script
(`<html data-rail>`, `rail.default`, `rail.storage_key`) and read by a `rail-collapsed:` variant;
`$store.rail` changes it. The shell draws no phone menu button: the app bar in `top` calls
`$store.rail.show()`.
- **Playwright's locators are strict**: `assertAttribute` on a selector matching two elements fails.
- **Tailwind only compiles classes it can see**: a class written only in a test probe (`h-[200vh]`)
does not exist in the Workbench build; use inline styles there.
- **A Tailwind `@variant` nested under a pseudo-element or a `* +` selector compiles to broken CSS.**
### Phase 9 — Data, pages, mail
30. `table` (`.data-table`, descendant selectors, fine-pointer density, `position: relative`),
`sort-header` (`sortBy` array shape, `aria-sort`), Livewire and Laravel pagination views
(current page in `secondary-container`, "Page 2 of 7" on a phone).
31. Error pages: a layout and `403, 404, 419, 429, 500, 503` in the package's error-view root
(wired through `view.paths` in step 4); publishable for per-app wording.
32. Mail theme: `livewire-material::mail.theme` renders CSS from the app's
`material-scheme.json` (falling back to the default scheme); `html/header` and
`html/message` overrides; typescale on bare tags; filled primary button.
**Steps 3032 are done (2026-09-13).** Tables, sort headers and pagination were built in main; error
pages and the mail theme by an agent in its own worktree. What changed from the steps above:
- **The paginators are prepended to the `pagination` and `livewire` view namespaces** rather than
set as the default view, because Livewire sets its own default on every render; published
`vendor/pagination` and `vendor/livewire` views still win (`livewire-material.pagination`).
- **The error-view root is `resources/views/error-pages`** (inside `views`, so an application's
`@source` line already covers its classes), appended to `view.paths` in `register()`, before the
view finder is built. The layout is `errors::minimal`, so the framework's 401 and 402 use it too.
Without a Vite build the pages fall back to an inline stylesheet from the scheme JSON. Testbench's
skeleton ships its own `errors/503`, which wins in the Workbench.
- **Mail is light only**: the CSS inliner strips `@media`. The header and message components are
opt-in (`livewire-material.mail.components`) or published, because they change every Markdown mail.
### Phase 10 — `1.0.0`
33. Showcase complete (every component, variant, colour, size and state, both themes); the
in-app mount verified inside a fresh Laravel app; Boost guideline and skill complete
(drift test green); README (install, CSS/JS wiring, scheme, theme, prefix, showcase,
guard, Docker ordering note); tag `1.0.0`.
### Phase 11 — SealShare 2.0.0
Tracked in SealShare's `docs/plans/livewire-material.md`, after `1.0.0`.
## Testing
- **Render tests** (`tests/Feature/Components/*Test.php`, Testbench, `$this->blade()`), per
component: each `variant` × `color` renders its classes and falls back on an unknown value;
shorthands (`primary`, `danger`, `caution`, `tone`) map correctly; sizes; ARIA
(`aria-pressed`, `aria-expanded`, `aria-current`, `aria-invalid` + `aria-describedby`,
labelled dialogs, `aria-sort`); `link` adds `wire:navigate` unless `external`; `wire:model`
normalisation (`false``null`); prefix config renames the tags.
- **Foundation tests:** every `--md-sys-color-*` role defined in both theme blocks and
different between them; each block declares `color-scheme`; no `prefers-color-scheme` in
the CSS; colour blocks are `@theme inline`; the scheme command writes both files for a known seed
(snapshot of a few roles) and fails cleanly without Node; the theme script lands before
`@vite` and resolves `system`; `<x-icon>` throws on an unknown name; every shape fills the
100-unit box with `currentColor` only; `Toasts` dispatches and survives `redirectTo`;
`DesignGuard` catches each forbidden pattern on fixtures; the skill drift test; the mail
theme renders the JSON's hexes; a 404 renders the package's error view and an app's own
`errors/404.blade.php` still wins; the showcase route is 404 when disabled and 200 when
enabled; blade-icons' own `<x-icon>` is not registered.
- **Browser tests** against the Workbench showcase in Chromium, Firefox and WebKit: dialog
(open, Esc, morph survival, write-back), bottom and side sheets and the pane from `xl`,
menu and select (keyboard, anchoring, flip), tabs (arrows, Home/End), date and time pickers
(keyboard grid, locale, `wire:model` value), carousel (snap, keyboard), sliders, chips and
choices, search, snackbar timing and action, theme (light/dark/system, legacy adoption, OS
change followed), app shell at 393 / 768 / 1024 / 1512px, focus rings on keyboard focus,
and reduced motion leaving no running animations.
## Risks and open questions
- **Scope and time.** The whole catalogue (~45 components plus extras) comes before any app
uses it. Mitigation: each wave is reviewed in the showcase and green on CI before the next.
- **Accessibility is entirely ours** — menus, pickers, carousel, sheets. Mitigation: APG
patterns, native elements first, ARIA in render tests, keyboard in browser tests across
three engines.
- **Date and time pickers without a library** are the largest single components. Mitigation:
`Intl` for all locale data; the modal-input variant as the accessible baseline.
- **Full M3 invites inconsistency per app.** Mitigation: the configurable `DesignGuard`; each
app records its own rules.
- **Livewire morphing against Alpine state** inside components. Mitigation: ReStride's
recorded traps become package rules in the skill, with tests pinning each.
- **~8,300 SVGs** make dist archives and `vendor/` larger (5.3 MB). Mitigation: each file is read
only when drawn and kept per worker; `.gitattributes` keeps dev files out; `@source`
never scans the SVG folders.
- **Maintenance of a public package** is one person's job; breaking changes need semver
discipline once ReStride also depends on it.
- **Runner scope.** The package's CI needs an act_runner registered for the instance or the
`noNameWEB` org, not only for ReStride — check before the first push in Phase 1.