Andreas Reinhold / reiniandClaude Opus 5 e7885ce1eb
tests / feature (8.4) (push) Successful in 1m49s
tests / feature (8.5) (push) Successful in 1m51s
tests / browser (chrome, chromium) (push) Successful in 7m34s
tests / browser (firefox, firefox) (push) Failing after 14m19s
tests / browser (safari, webkit) (push) Failing after 13m16s
Keep the search view closed when its focus comes back late
On a slow machine Escape could close the full-screen search and have it
open again for good. A close hands focus back to the input — Escape does,
and so does the view's focus trap as it lets go — and `focused()` told
that return from someone coming to search by a 250ms wall-clock window.
Both returns run on frames, and a runner painting a few frames a second
took longer than that, so the returning focus opened the view again. A
`returning` flag now covers the close until the hand-back has actually
run, on the same frame, and the constant is gone.

`hold()` times the full-screen layout from the exit's own duration token,
the one Alpine's x-transition holds `display` for, instead of waiting a
frame or two for the exit's transitions to appear: `getAnimations()` is
empty both before an engine creates them and after they end, and a loaded
engine can leave a second between frames.

The browser tests were racing the same slow machine, reproduced in a
Linux container like the runner, with its Playwright Firefox and
WebKitGTK and two cores kept busy:

- The browser plugin retries every script and action with a one-second
  attempt until the budget runs out. An in-page sleep longer than that
  only ever passed on the last attempt, which is where the 47-50s tests
  came from, and a script that clicks was run again against a page that
  had moved on. `onceInPage()` runs such a script once however often it
  is retried; the long sleeps are plain retried conditions now.
- Under load WebKitGTK paints no frame while a tight setTimeout loop
  runs, so a sample loop saw the start value and then nothing.
  `caughtMidExit()` samples on animation frames, for 2.5s.
- "No animations running" is also true before an opening transition
  exists, so a close could be sampled from a scrim at 4% opacity.
  `settled()` waits two frames before it asks.

The workflow no longer uploads failure screenshots: Gitea's artifact
service timed out on every attempt, two minutes per red run, and the job
logs are readable without it.

Feature 1159 passed. Browser 299 passed on Chrome, Firefox and WebKit on
macOS, and on Firefox and WebKitGTK in the Linux container under load,
twice each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 09:49:17 +02:00
2026-09-13 04:59:21 +02:00

Livewire Material

Material 3 Expressive components for Laravel and Livewire, in plain CSS.

  • Anonymous Blade components for the current M3 Expressive catalogue: buttons and FABs, menus, chips, text fields, selection controls, sliders, pickers, dialogs and sheets, lists, cards, carousels, progress and loading indicators, snackbars, tabs, app bars, toolbars, navigation bars and rails, an adaptive scaffold, the layout components (panes, list-detail, supporting pane, feed), data tables and pagination.
  • A colour scheme generated from one seed colour with Google's colour science (php artisan material:scheme), light and dark, and a theme that is chosen before the first paint.
  • The full Material Symbols Rounded set and the M3 Expressive shapes, drawn inline without an icon package.
  • Error pages and a Markdown mail theme in the same scheme.
  • A showcase of every component in the application's own scheme, a design guard for tests, and for AI agents two Laravel Boost guidelines and two skills: the library's own, and Material 3's rules and tables beside its utilities.

No JavaScript libraries beyond the Alpine that ships with Livewire. Browsers: Chrome 125+, Firefox 151+, Safari 18.4+.

Requirements

PHP 8.4+, Laravel 13, Livewire 4, Vite, and Node (for material:scheme).

Installation

The package is served from Gitea. Add the repository and require it:

composer config repositories.livewire-material vcs https://gitea.nonameweb.ch/noNameWEB/livewire-material.git
composer require nonameweb/livewire-material

Stylesheet and script

The application's build imports from vendor/, so Composer packages must be installed before npm run build — in a Dockerfile, copy composer.json, run composer install, then build the assets.

The package's stylesheets are plain CSS, no build step of its own. all.css brings the foundation and every component; an application can instead import foundation.css first and then the stylesheet of each component its views render (resources/css/components/button.css, resources/css/layout/scaffold.css, …), since each imports the stylesheets of the components it draws and Vite keeps a file several of them import once. The error pages need no import: they take the foundation, scheme and font from the application's build and inline their own layout's rules beside it.

/* resources/css/app.css */
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import '../../vendor/nonameweb/livewire-material/resources/css/all.css';
@import './material-scheme.css';
// resources/js/app.js
import '../../vendor/nonameweb/livewire-material/resources/js/material.js'

Do not install Alpine separately; Livewire provides it.

An application writes no utility layer of its own: layout components (<x-scaffold>, <x-pane>, <x-stack>, <x-row>, <x-grid>, the canonical layouts) take M3's spacing tokens and breakpoints as props, a small set of md-type-* and md-ink-* classes covers text on plain elements, and --md-sys-* custom properties serve the rest of an application's own stylesheet. Breakpoints are M3's window size classes and only those — compact below medium (600px), then expanded (840px), large (1200px) and extra-large (1600px) — written as literal range media queries (@media (width >= 840px)) in the package's CSS; a script asks resources/js/breakpoints.js (from() / upTo()) for the same numbers rather than writing its own query.

Layout

The theme script goes in <head>, before @vite, so the page paints in the visitor's theme:

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <x-theme-script />
        @vite(['resources/css/app.css', 'resources/js/app.js'])
    </head>
    <body>
        {{ $slot }}
        <x-toast />
    </body>
</html>

Colour scheme

Generate the scheme from a seed colour. It writes resources/css/material-scheme.css (imported above) and material-scheme.json (read by the mail theme):

php artisan material:scheme "#4f46e5" --variant=tonal-spot

Variants: tonal-spot, vibrant, expressive, neutral, fidelity, content, monochrome, rainbow, fruit-salad. --spec is the colour spec: 2025 (M3 Expressive, the default) or 2021 (M3 as it first shipped, for a palette generated before Expressive). --success, --warning and --info seed the state colours, which are built exactly as M3 builds error; --harmonize pulls them towards the seed. The stylesheet's header records the command that regenerates it; regenerate instead of editing the file.

Every scheme is written at M3's three contrast levels — standard, medium (3:1) and high (7:1), for light and dark — keyed on <html data-contrast>, which the head script sets before the first paint from the visitor's choice or the operating system's. --contrast sets the standard level alone (below 0.5); <x-theme-toggle mode="contrast" /> lets someone choose.

Colour profiles

To let an installation switch between several schemes, list them as profiles in the config (name ⇒ label, seed, variant, and optionally contrast, harmonize, spec, success, warning, info, which otherwise come from the command's options) and run php artisan material:scheme without a seed: every profile lands in the same stylesheet under <html data-scheme>. Tell the package which one is active — Scheme::resolveProfileUsing(fn () => Setting::get('color_profile')) in a service provider — and the head script, mails and error pages follow it. <x-scheme-picker wire:model="colorProfile" /> lets someone choose, previewing each profile on the page.

Configuration

php artisan vendor:publish --tag=livewire-material-config
  • prefix — components are <x-button>, <x-card>… Set 'm' when a name clashes with the application's own components, and they become <x-m::button>. <x-livewire-material::button> always works.
  • theme.default (light, dark or system), theme.storage_key, theme.legacy_keys (an earlier toggle's localStorage keys, adopted once).
  • theme.contrast.default (system, standard, medium or high) and theme.contrast.storage_key — M3's contrast level, resolved before the first paint into <html data-contrast> and followed on the operating system while system.
  • theme.meta — keep <meta name="theme-color"> (an installed web app's or a mobile browser's bar) on the resolved theme's surface and the active colour profile, before the first paint and after every change, wire:navigate included; one is added when the page has none (default false).
  • motion.scheme — M3's motion scheme: expressive (default) or standard, the restrained springs, written to <html data-motion>.
  • profiles, profile — colour profiles and the default one (see Colour profiles).
  • fields.variant — text fields outlined (default) or filled.
  • pagination — draw Laravel's and Livewire's paginators in M3 (default true); the provider also sets livewire.pagination_theme to material whenever it still reads as Livewire's own default (tailwind), unless an application's own theme, or a component's $paginationTheme property or paginationView() method, already chose one.
  • showcase.enabled, showcase.path, showcase.middleware, showcase.vite.
  • node — the Node binary for material:scheme.

Usage

<x-card title="holiday-photos.zip" subtitle="248 MB · expires in 3 days" variant="outlined">
    <x-slot:actions>
        <x-button label="Copy link" icon="content_copy" wire:click="copy" />
        <x-button label="Delete" danger wire:click="$set('confirming', true)" />
    </x-slot:actions>
</x-card>

<x-modal wire:model="confirming" title="Delete this share?" icon="delete">
    Recipients lose access at once.
    <x-slot:actions>
        <x-button label="Cancel" x-on:click="close()" />
        <x-button label="Delete" danger wire:click="delete" />
    </x-slot:actions>
</x-modal>
use NoNameWeb\LivewireMaterial\Concerns\Toasts;

class Shares extends Component
{
    use Toasts;

    public function copy(): void
    {
        $this->success('Link copied');
    }
}

Every component, prop and slot is documented in the Boost skill (resources/boost/skills/livewire-material-development/SKILL.md) and shown in the showcase.

Showcase

While the application runs locally (or with MATERIAL_SHOWCASE=true), /material shows every token and component, in every variant, in the application's own scheme and theme: an overview, and a page per section behind a navigation rail (the package's own scaffold), with a search over every section, example and component (press /).

Testing the design

use NoNameWeb\LivewireMaterial\Testing\DesignGuard;

it('uses only what compiles', function () {
    expect(DesignGuard::scan([resource_path('views'), resource_path('js'), resource_path('css'), app_path()])
        ->missingStylesheets(resource_path('css/app.css'))
        ->forbidColours(['tertiary'])
        ->violations())->toBe([]);
});

The guard reads every path it is given and fails, with path:line, on a class written in a view or PHP file that no stylesheet declares — not the application's own CSS, not the package's md-* — whatever it came from: a utility of a framework that isn't in the stack, a typo, or a class whose rule was deleted. Without a .css file among the scanned paths or a missingStylesheets() entry to supply the application's import graph, it has no declared class to compare against and reports every one of them. In the .css files it is given (material-scheme.css skipped) it fails on a literal colour, radius, shadow, font, easing or duration, and on a media query off M3's 600/840/1200/1600px; a var(), or a calc()/min()/max()/clamp() built on one, is fine. It also fails on unknown Material Symbol names and Blade directives written inside component tags. Markdown mail components (under mail.markdown.paths, resources/views/vendor/mail by default) are the mail theme's to style: their classes are not read by the class check, and a mail theme stylesheet there is neither checked for literals nor a source of declared classes.

missingStylesheets($cssEntry) checks the entry's relative @import graph, followed through every package file's own imports, against the package tags the views render (unprefixed, under the configured prefix, or <x-livewire-material::…>), ->links() and the rows they write by hand (data-md-list-row on anything but <x-card> needs components/list-item.css), and names each missing @import line once; a tag the application shadows with its own component is reported instead. It reads imports only, so leave resource_path('css') out of scan() while the stylesheets still hold literals — as long as missingStylesheets() is given an entry, its import graph still supplies the declared classes family (i) needs. forbidColours([...]) names roles the application leaves out, and fails wherever one (with its on- and container roles) is still written: var(--md-sys-color-…) in CSS or an inline style, its md-ink-* class, or a component's color/tone prop. forbid($pattern, $reason) adds any pattern of your own, line by line.

AI agents

With Laravel Boost, php artisan boost:install (or boost:update --discover) picks up the package's two guidelines — the library's own, and material-3, a page of M3's rules an agent reads in every session — and two skills: livewire-material-development (every component, prop, slot and trap) and material-3-design (M3's colour roles, surfaces, elevation, shape, type, motion, states, window size classes and accessibility, with the library's utility beside each M3 name and Google's source page for each chapter).

Developing the package

composer install && npm install
npm run build           # the Workbench's assets (or `npm run dev` while working)
composer serve          # the showcase at http://127.0.0.1:8000/material
vendor/bin/pest --testsuite=Feature
npx playwright install
vendor/bin/pest --testsuite=Browser --browser chrome   # also firefox, safari

Credits

Material Symbols, the M3 Expressive shapes, Google Sans Flex, material-color-utilities and Jetpack Compose Material 3's tokens and algorithms are Google's and the Android Open Source Project's; see NOTICE.

License

MIT. See LICENSE.

S
Description
No description provided
Readme MIT
9.6 MiB
Languages
PHP 40.8%
CSS 21.8%
Blade 20.5%
JavaScript 16.8%
Shell 0.1%