Files
livewire-material/docs/plans/livewire-material.md
T
Andreas Reinhold / reiniandClaude Opus 5 51bf034b74
tests / feature (8.5) (push) Successful in 1m14s
tests / lint (push) Successful in 1m1s
tests / feature (8.4) (push) Successful in 1m9s
tests / browser (chrome, chromium) (push) Successful in 3m22s
tests / browser (firefox, firefox) (push) Successful in 4m37s
tests / browser (safari, webkit) (push) Successful in 5m4s
Add a search to the showcase
A search app bar looks through every section, example and component,
opens the section, or the example at its anchor on its page, and is
reached from anywhere with / or Ctrl+K. Section pages now carry their
own heading.

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

48 KiB
Raw Permalink Blame History

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 propsvariant="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

  1. 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.
  2. 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.
  3. 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']).
  4. Workbench (orchestra/workbench): a Laravel app with Livewire, Vite and the package CSS/JS built, serving the showcase at /; composer serve starts it.
  5. 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.
  6. 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

  1. 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.
  2. 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.
  3. Font. resources/fonts/google-sans-flex/ (woff2 + OFL), @font-face and --font-sans in the entry CSS.
  4. 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.
  5. 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.
  6. Shapes. The Expressive shape set generated by formula (ReStride's components/shape.blade.php method) into resources/svg/shapes; <x-shape>.
  7. 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.
  8. 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.
  9. 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.
  10. 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

  1. 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).
  2. 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.
  3. icon-button behaviour inside button (icon, no label): standard/filled/tonal/outlined, selected toggle (aria-pressed), widths.
  4. 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

  1. 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

  1. 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

  1. 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).
  2. 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

  1. 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 (dedd.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

  1. 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).
  2. app-shell — a slot-based adaptive composition: app bar + navigation bar below sm, rail smlg, 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

  1. 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).
  2. 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.
  3. 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

  1. 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 10 is done (2026-09-13). What changed from the step above:

  • The package's own views write <x-livewire-material::name> for the components they use. Blade spells a configured prefix <x-m::button> (not <x-m-button>, as the config first claimed), and an unqualified <x-button> inside a package view would have broken under a prefix and been shadowed by an application's own components/button.blade.php. A test scans every package view (outside comments and the showcase's example heredocs) for unqualified tags, and the showcase rewrites its examples to the configured prefix. The component path string stays __DIR__.'/../resources/views/components': Blade names the view namespace after its hash, and compiled views keep that name.
  • A test checks that every component appears in the showcase.
  • The showcase is the package's own app shell: an overview at /material and a page per section (/material/{section}, Showcase\Sections), grouped in the navigation rail, moved between with wire:navigate. It turned up a WebKit bug in <x-menu>: inside a focusable region (the shell's <main tabindex="-1">), WebKit hands focus back to that region as the popover closes, so the menu now reads whether focus was inside on beforetoggle before returning it to the trigger.
  • The showcase has a search in a search app bar (<x-search>, / or Ctrl+K): an index of every section, every example (by its anchor) and every component, read from the section views and fetched as /material/search.json on first focus. Names in a slot's Alpine scope must keep clear of the component's own: results and open in <x-search>'s scope hid the page's. wire:navigate keeps a URL's hash but scrolls to the top, so the layout lands on the hash after the swap settles.
  • Verified in a fresh Laravel 13.31 application (composer create-project, the package from a path repository, the README's CSS, JS and layout, material:scheme "#4f46e5", npm run build): a Livewire page with an app bar, tabs, a card, a form with validation, a date picker, a dialog and a toast works without console errors; /material and the 404 page render in its scheme.
  • <x-datepicker clearable> empties a date, or both ends of a range, as <x-timepicker clearable> does.

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 (falsenull); 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.