Multi-browse, hero, uncontained and full-screen layouts on a native scroll-snap row, with Compose's keyline maths masking each item as it scrolls. Completes Phase 5. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
39 KiB
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(neverinline) mapping M3 roles to utilities;--color-*: initialso only tokens compile;:rootdark +[data-theme=light], each with its owncolor-scheme.- Material Symbols Rounded (400/0/24) as local SVGs registered as
blade-iconssets (ms/msf),config/blade-icons.phpturning off blade-icons' own<x-icon>. - Google Sans Flex, a 63 KB Latin subset (weight 400–700, ROND 0–100).
- Spring motion as CSS
linear()curves; press shape-morph; Expressive shapes generated by formula intoresources/svg/shapes. App\Livewire\Concerns\Toasts(protectedsuccess|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.selfon ashowModal()dialog; never passhiddenor a position to a component (wrap it); Blade directives do not compile inside a component tag's attributes;wire('model')->value()isfalse, notnull, withoutwire:model; a field's states are CSS selectors (:has()), marked withdata-invalid/data-floated/data-readonlywhere 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, becauseCssToInlineStylesstrips@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.chrequires 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
errorsview namespace at render time withconfig('view.paths')+/errorsand the framework's own (Illuminate/Foundation/Exceptions/RegisterErrorViewPaths.php), so error views a package adds withaddNamespace('errors', …)are wiped; only a path inview.pathssurvives. - 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-tryfrom Safari 18.4). - Boost loads a package's
resources/boost/guidelines/core.blade.phpand its skills onboost: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 avcsrepository entry. Material Symbols (Apache-2.0), Google Sans Flex (OFL) and material-web token values (Apache-2.0) are compatible; attributions ship inNOTICE. - Prerequisite: Gitea allows anonymous reads (
[service] REQUIRE_SIGNIN_VIEW = false, orexpensiveon 1.23+) — otherwise "public" is not installable. - Name
nonameweb/livewire-material, namespaceNoNameWeb\LivewireMaterial,config/livewire-material.php, viewslivewire-material::, commandsmaterial:*— 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 setsblade-icons.components.defaulttonull, 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.@sourcecovers 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-facein 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+colorprops —variant="filled|tonal|outlined|text|elevated",color="primary|secondary|tertiary|error|success|warning|info", defaulttextin 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 (tonestays an alias ofcolorwhere ReStride used it).php artisan material:scheme {seed} --variant=— runs Google'smaterial-color-utilitiesfrom a single prebundled Node script inside the package; writes the app'sresources/css/material-scheme.css(dark and light--md-sys-color-*, plussuccess/warning/infocustom colours, harmonisation off) andresources/css/material-scheme.json(the light hexes, for the mail theme). Output is committed.- Theme: a head script component,
light | dark | system— setsdata-themebefore paint; CSS keys only on the attribute and never asksprefers-color-scheme; only the script reads the OS (and follows its changes whilesystem).$store.themeis 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, FABsize="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 anenfile, 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 whenAPP_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'smaterial-scheme.json, so mail colours cannot drift from the app. - Boost guideline +
livewire-material-developmentskill 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 onmainuntagged (decided 2026-09-13; replaces "0.xper wave"). - Semantic ink and line utilities carried over from ReStride (
text-body,text-meta,text-quiet,border-structure,border-chrome,border-divider) — cheap@themenames 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
vcsrepository 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
- Gitea. Done 2026-09-13.
GITEA__service__REQUIRE_SIGNIN_VIEW=falsein the Gitea compose service (Gitea 1.26.4);/explore/reposand the API answer anonymously, and no other repo or org is public. The package lives in the public orgnoNameWEB:https://gitea.nonameweb.ch/noNameWEB/livewire-material.git(public, empty,main, Actions on). SealShare'svcsrepository entry uses that URL. - Split the plan. Done 2026-09-13. This file; SealShare keeps its adoption steps.
Phase 1 — Package skeleton
- Repository.
composer.json(nonameweb/livewire-material, PSR-4NoNameWeb\LivewireMaterial\, requiresphp ^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,.gitattributesexcludingworkbench/,tests/,bin/,docs/and the Node/Vite dev files from dist archives. - Service provider
LivewireMaterialServiceProvider: merge config; register the anonymous component path with the configured prefix;loadViewsFrom(livewire-material),loadTranslationsFrom; setblade-icons.components.defaulttonullinregister()(before blade-icons boots and registers its component); register the icon and shape sets; append the package's error-view root (a directory holding onlyerrors/) toview.pathsafter the app's own, so Laravel's namespace replacement keeps it and an app'sresources/views/errorsstill wins; publishable config, lang, error views; commands; the showcase routes when enabled. No request state anywhere in the container. - 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']). - Workbench (
orchestra/workbench): a Laravel app with Livewire, Vite and the package CSS/JS built, serving the showcase at/;composer servestarts it. - 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.
- Boost resources skeleton:
resources/boost/guidelines/core.blade.phpandresources/boost/skills/livewire-material-development/SKILL.md; the drift test (every component file underresources/views/componentsis 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 intoworkbench/public/build;composer servelinks that into the skeleton'spublic/buildthrough the Workbenchsyncoption, andtests/TestCase.phppointspublic_path()atworkbench/publicso browser tests find the manifest without the command. Verified:composer serveanswers/materialand its CSS. - The showcase is a plain view with
@extends, not a component:<x-livewire-material::…>resolves undercomponents/, which would have made the layout a public component. - blade-icons'
components.defaultis set in abootingcallback, not inregister():mergeConfigFromis shallow, so a nested key written before blade-icons merges its defaults wipes the rest of itscomponentsarray. The test fails with the call removed. - The showcase switch is tested by rebooting with
MATERIAL_SHOWCASEin 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.xmltrap: a comment containing a double hyphen (--browser) is invalid XML and PHPUnit refuses the file; Pest then reports an unrelatedPest\Plugins\Tiaerror 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'schromium|firefox|webkit. Locally only Chromium was run; Firefox and WebKit first run on CI. NOTICEmoves 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 atresources/node/scheme.mjs. - Found for Phase 2: blade-icons calls
Factory::registerComponents()whenever the view factory resolves, registering one Blade component per icon unlesscomponents.disabled— and withouticons:cacheit scans the folders first. With ~7,800 symbols that is per request under PHP-FPM. Decided before step 13.
Phase 2 — Foundation
- Scheme command.
resources/node/scheme.mjsbuilt 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 throughnodeand writesresources/css/material-scheme.cssandresources/css/material-scheme.json. Fails with a clear message whennodeis missing. A default scheme (M3 baseline#6750A4) ships inside the package so a fresh install renders before the command is run. - Tokens.
resources/css/material.cssas the single entry, importingtokens/shape.css,type.css(typescale utilities incl. emphasized),motion.css(six springlinear()curves — spatial/effects × fast/default/slow — and reduced-motion overrides),elevation.css,state.css(state layers, focus ring), and the@themeblock mapping every M3 role (primary/secondary/tertiary and their containers, surfaces, outlines, inverse, error, custom success/warning/info withinverse-*) plus the semantic ink and line utilities;--color-*: initialwith white and black re-added. Each theme block declarescolor-scheme. Values from material-web's token files, attributed. - Font.
resources/fonts/google-sans-flex/(woff2 + OFL),@font-faceand--font-sansin the entry CSS. - Theme.
<x-theme-script />(inline, before@vite): readsstorage_key, adoptslegacy_keys(their values may be JSON-encoded — maryUI's$persiststores"dark"with quotes), resolvessystemthroughmatchMedia, writesdata-theme;$store.themeinresources/js/material.jswithset()and amatchMedialistener whilesystem. - Icons.
bin/fetch-symbols(maintenance only) downloads Material Symbols Rounded 400/0/24 outlined and filled fromgoogle/material-design-iconsintoresources/svg/symbols/{outlined,filled}withfill="currentColor"and no width/height;<x-icon name="…" filled>resolvesms/msf; throws on an unknown name. - Shapes. The Expressive shape set generated by formula (ReStride's
components/shape.blade.phpmethod) intoresources/svg/shapes;<x-shape>. - JS entry
resources/js/material.js: theme store, snackbar listener,data-list-rowrows,x-figurecount-up directive, shared keyboard helpers. Imported by an app'sapp.js. ToastsconcernNoNameWeb\LivewireMaterial\Concerns\Toasts: protectedsuccess|error|warning|info(string $title, ?string $description = null, ?int $timeout = null, ?string $redirectTo = null), dispatching a browser event and flashing acrossredirectTo.- 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. - 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\SvgFilereads 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-symbolssparse-checks-out google/material-design-icons (5 GB, cloned without blobs) for the_24pxand_fill1_24pxfiles: 4,135 symbols, byte-identical to ReStride's hand-picked ones. The@material-symbols/svg-400npm package was rejected: its "rounded" SVGs are optical size 48. - All 35 M3 Expressive shapes, ported from androidx
MaterialShapes.ktand graphics-shapes intobin/shapes.mjs(commit in its header), each fitted to 2–98 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 plusinverse-{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, intokens/scheme.css. - Colour utilities are
@theme inline— contrary to ReStride's rule thatinlinebreaks theme switching. Verified in Chromium: withoutinline,--color-primaryresolves once on:root, so adata-theme="dark"section inside a light page keeps light colours; with it the utility readsvar(--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 ondata-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.defaultstill reaches them); only an adopted legacy value is stored.data-theme-choiceanddata-theme-keyon<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-rowrows (JS and CSS) and the list keyboard go to Phase 5 withlistandcard, which they style.Toastsdispatchestoastwith a 4 s default (M3's snackbar range is 4–10 s; ReStride used 3 s). DesignGuard:secondaryis 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 (iconandicon-righton the same tag).- Pest browser trap: a bare
htmlselector is taken as text to search for and times out; assert ondocument.documentElementthroughassertScript.@nametargetsdata-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 carriesAPP_URLand misses the dev server's port).
Phase 3 — Actions
- Primitives the actions need:
loading(M3 Expressive loading indicator, contained and not), plaintooltip(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). button— five variants × colours × sizesxs|sm|md|lg|xl,icon,icon-right,label,link(+wire:navigateunlessexternal/no-wire-navigate),spinner,responsive,tooltip*,disabledon links (aria-disabled), press shape-morph.icon-buttonbehaviour insidebutton(icon, no label): standard/filled/tonal/outlined,selectedtoggle (aria-pressed), widths.button-group(standard and connected;<x-group>alias withwire:modeloptions),split-button,fab(56px default — M3's deprecated small FAB is gone, sosmis the baseline FAB — 80pxmd, 96pxlg), extended FAB, and the responsivefabprop (extended FAB belowsm, 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 isprimary, noton-surface-variant, and the extra-small button's padding is 12px, not 16px. <x-button>is label button, icon button and toggle in one, withdata-icon-buttonon the icon-only form. A selected round button squares off; a selected square icon button rounds. Acornersprop 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@layerloses to a utility. Inner corners ride a--group-cornervariable 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 withmultiple), restyled as a connected button group —wire:model,x-modeland 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).
popoverelements must never get adisplayutility (flex), which beats the UA'sdisplay: nonefor a closed popover; useopen:flex. - Menu keyboard is WAI-ARIA's menu button;
aria-expandedand the first item's focus are set synchronously inopen(), because the popovertoggleevent 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 againstclosedAt = 0, so every click in the first 250ms after page load was swallowed; it starts at-Infinitynow, with a test. - Anonymous component trap: every prop is a local variable, so a helper variable in
@phpmust not reuse a prop's name — a local$cornersarray silently replaced thecornersprop. - The loading indicator is androidx's own geometry, in SVG + SMIL (
bin/loading-indicator.mjs,resources/svg/loading-indicator/):Morphis ported, so each of the seven morphs is a path whose control points SMIL can interpolate in every engine (CSSd: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.1–0.18 unit jump at three hand-overs. Reduced motion showsstatic.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-visibleonly 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 fetchessymbols.jsononx-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 isx-cloakso Playwright's click waits for it.
Phase 4 — Communication
badge(dot, count, label; variant/colour),progress(linear, circular, wavy, determinate and indeterminate),toast(M3 snackbar, action, timeout, stacked), richtooltip,alert(tinted container, icon, actions slot),stat(figure withx-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'sProgressIndicator.ktandWavyProgressIndicator.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 iswire:ignoreand a MutationObserver on the root'sdata-valueturns a morph or abindexpression into motion. Flat circular indeterminate has no track, as in Compose.<x-toast>listens from the momentsnackbar.jsloads, not on its Alpine component, and holds toasts until the host registers — a toast dispatched before Alpine starts is shown, not lost.@persistkeeps the host acrosswire:navigate.<x-badge>is M3's dot and count (floatingpins 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 orpersistent.- A commit went out broken and was fixed forward:
648ad8estagedmaterial.jsand 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 assertsdocument.readyState === 'complete'and that Alpine and Livewire exist; assertions retry.- Firefox runs Playwright's evaluate in a sandbox: an event built there has a
detailthe page cannot read, and assignments to Alpine's reactive proxies do not trigger. Go through the page's realm withwindow.eval("…")(orLivewire.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
card(elevated, filled, outlined;title,subtitle,actionsslot; clickable row contract),divider,list/list-item(one-, two-, three-line; leading/trailing; selectable),modal(native<dialog>,showModal(),wire:ignore.self, writes backfalse/null,fullscreenbelowsm, basic dialog with icon/headline/actions),bottom-sheet(modal and standard, drag handle),drawer(side sheet: modal and standard;panefor list-detail fromxl;widthprop),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, androidx7ac433e44e797de53af85226797862687f37735f; 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 aclip-pathinset; snap positions arescroll-margin-inline-start. Full-screen follows material-components-android, which Compose lacks; one item per swipe isscroll-snap-stop, not fling physics. Hooks aredata-material-carousel*: the design guard rejectscarousel*classes as daisyUI's.- The drawer is the side sheet, with
panefor list-detail fromxl, and the bottom sheet is its own component (modal orstandard) with drag-to-dismiss. <x-list-item>'s trailing slot isend: a slot named like thetrailingprop replaced it.- The design guard also rejects Blade directives inside component tags (
@classon<x-icon>), which reach the browser as text. x-trap.inerthides the page witharia-hidden, notinert; 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
form,field(the shared shell: outlined and filled, floating label via:has(), notch,hintreplaced by error,aria-invalid/aria-describedby,data-*state marks),input(prefix/suffix, icons,copyabletrailing 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).chip(assist, filter, input, suggestion),choices(filter chips, or a searchable combobox with a menu whensearchable),slider(standard, centered, range; native range inputs, value label),search(search bar and search view, results through a Livewire property).
Phase 7 — Pickers
datepicker(docked, modal, modal input;Intlmonth/day names and week start from the app locale;min/max; single and range;wire:modelstoresY-m-d),timepicker(dial and input; 12/24h from locale; storesH:i). APG grid keyboard for the calendar.
Phase 8 — Navigation
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 fromsm, menu picker below),account-menu(avatar trigger, slot for items, theme row),theme-toggle(cycles or picks light/dark/system through$store.theme).app-shell— a slot-based adaptive composition: app bar + navigation bar belowsm, railsm–lg, expanded collapsible rail fromlg(state in the store, applied before paint by the theme script), content region withwire:transition.navigate, snackbar host. Nothing app-specific inside; apps pass destinations and extra chrome as slots.
Phase 9 — Data, pages, mail
table(.data-table, descendant selectors, fine-pointer density,position: relative),sort-header(sortByarray shape,aria-sort), Livewire and Laravel pagination views (current page insecondary-container, "Page 2 of 7" on a phone).- Error pages: a layout and
403, 404, 419, 429, 500, 503in the package's error-view root (wired throughview.pathsin step 4); publishable for per-app wording. - Mail theme:
livewire-material::mail.themerenders CSS from the app'smaterial-scheme.json(falling back to the default scheme);html/headerandhtml/messageoverrides; typescale on bare tags; filled primary button.
Phase 10 — 1.0.0
- 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: eachvariant×colorrenders 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);linkaddswire:navigateunlessexternal;wire:modelnormalisation (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 declarescolor-scheme; noprefers-color-schemein 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@viteand resolvessystem;<x-icon>throws on an unknown name; every shape fills the 100-unit box withcurrentColoronly;Toastsdispatches and survivesredirectTo;DesignGuardcatches 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 ownerrors/404.blade.phpstill 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:modelvalue), 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:
Intlfor 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;.gitattributeskeeps dev files out;@sourcenever 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
noNameWEBorg, not only for ReStride — check before the first push in Phase 1.