Files
livewire-material/resources/boost/skills/livewire-material-development/SKILL.md
T
Andreas Reinhold / reiniandClaude Opus 5 cd64f4f371
tests / browser (firefox, firefox) (push) Successful in 1m54s
tests / browser (safari, webkit) (push) Successful in 2m17s
tests / lint (push) Successful in 59s
tests / feature (8.4) (push) Successful in 1m7s
tests / feature (8.5) (push) Successful in 1m0s
tests / browser (chrome, chromium) (push) Successful in 1m49s
Add buttons, menus and the rest of M3 Expressive's actions
<x-button> (label buttons, icon buttons and toggles in five sizes, with
filled, tonal, outlined, elevated and text variants in any colour role),
<x-tooltip>, <x-menu> with items, groups and separators, <x-button-group>,
<x-group> as a connected button group, <x-split-button>, <x-fab>,
<x-fab-menu> and <x-loading>. Sizes, colours and shapes come from
androidx Compose Material 3's tokens; the loading indicator ports its
Morph into SVG + SMIL.

Menus follow WAI-ARIA's menu button pattern on popovers placed by CSS
anchor positioning. Browser tests run in Chromium, Firefox and WebKit.
The showcase fetches the icon names on demand: inlined, they tripped
Pest's test server into HTTP 431s under Firefox.

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

14 KiB

name, description
name description
livewire-material-development Build Laravel and Livewire views with Livewire Material's Material 3 Expressive Blade components — props, slots, colour roles, type, shape, motion, theming, toasts, the design guard, and the Livewire traps each component handles.

Livewire Material Development

When to use this skill

Use this skill when writing or changing any Blade view, Livewire component view or layout in an application that requires nonameweb/livewire-material, and when styling, theming or testing such views.

Setup

Composer packages must be installed before the Vite build (in Dockerfiles and CI alike), because the application's build imports from vendor/:

/* resources/css/app.css */
@import 'tailwindcss';
@import '../../vendor/nonameweb/livewire-material/resources/css/material.css';
@import './material-scheme.css';
@source '../../vendor/nonameweb/livewire-material/resources/views';
@source '../../vendor/nonameweb/livewire-material/src';
// resources/js/app.js
import '../../vendor/nonameweb/livewire-material/resources/js/material.js'

Every layout puts the theme script in <head>, before @vite:

<head>
    <x-theme-script />
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>

Colour scheme

The scheme is generated, never hand-edited. Regenerate it with the seed and variant recorded at the top of resources/css/material-scheme.css:

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

Variants: tonal-spot (M3's default), vibrant, expressive, neutral, fidelity, content, monochrome, rainbow, fruit-salad. --success, --warning and --info set the source of the state colours; --contrast goes from -1 to 1. The command also writes material-scheme.json beside the stylesheet.

Tokens

Tailwind's default palette is cleared: every colour class names an M3 role. text-red-600, bg-base-200 or text-gray-500 compile to nothing.

  • Colour roles (bg-*, text-*, border-*, …): primary, on-primary, primary-container, on-primary-container, inverse-primary, primary-fixed, primary-fixed-dim, on-primary-fixed, on-primary-fixed-variant; the same for secondary and tertiary; error, on-error, error-container, on-error-container; success, warning and info with their on-, -container and on-…-container; inverse-error|success|warning|info; surface, surface-dim, surface-bright, surface-container-lowest|low||high|highest, on-surface, on-surface-variant, inverse-surface, inverse-on-surface, outline, outline-variant, scrim, shadow; plus white and black.
  • Ink and lines by meaning: text-body (body copy), text-meta (metadata), text-quiet (decoration only), border-structure, border-chrome, border-divider / divide-divider.
  • Type: type-{display|headline|title|body|label}-{lg|md|sm} and type-emphasized-…. Never assemble text-*, leading-* and tracking-* by hand. The font is Google Sans Flex (font-sans).
  • Shape: rounded-corner-{none|xs|sm|md|lg|lg-increased|xl|xl-increased|xxl|full}.
  • Elevation: shadow-elevation-{1…5} — for what floats over content, not for panels (a panel separates by its container tone).
  • Motion: ease-spatial-{fast|default|slow} (position, size, shape; springs that overshoot) and ease-effects-{fast|default|slow} (colour, opacity). Always pair an easing with its duration: duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast. Reduced motion zeroes the durations.
  • States: state-layer (M3's hover/focus/press overlay; makes the element relative and isolate), focus-ring (keyboard focus indicator), link (a link in running text).
  • dark: follows the page's theme (data-theme), not the operating system.
  • x-figure on an element holding one number counts it up on first appearance and on change.

Theme

config/livewire-material.phptheme.default (light, dark or system), theme.storage_key, theme.legacy_keys. In Alpine, $store.theme holds choice (what the visitor picked), resolved (light or dark, what shows), set('light'|'dark'|'system') and toggle(); x-model="$store.theme.value" binds a control.

Toasts

use NoNameWeb\LivewireMaterial\Concerns\Toasts;

class Settings extends Component
{
    use Toasts;

    public function save(): void
    {
        // …
        $this->success('Settings saved');                         // also warning(), error(), info()
        $this->info('Link copied', timeout: 6000);
        $this->success('Share created', redirectTo: route('shares.show', $share));
    }
}

The methods are protected. They dispatch a toast browser event (assertDispatched('toast', type: 'success', title: 'Settings saved') in tests).

Components

<x-icon>

A Material Symbol (Rounded, weight 400, grade 0, 24px), inline. Every symbol on fonts.google.com/icons exists, by Google's name with underscores. An unknown name throws.

Prop Default
name required calendar_month, cloud_upload, content_copy
filled false the filled symbol — M3 uses it for active or selected
label null names the icon for screen readers when it carries the meaning alone; otherwise it is aria-hidden

24px (size-6) unless a size-*, w-* or h-* class is passed. Colour follows the text: <x-icon name="lock" class="size-5 text-on-surface-variant" />.

<x-shape>

One of M3 Expressive's 35 shapes, filled in the text colour, aria-hidden, sized by its caller: <x-shape name="cookie-9" class="size-40 text-secondary-container" />. Names: circle, square, slanted, arch, fan, arrow, semi-circle, oval, pill, triangle, diamond, clam-shell, pentagon, gem, very-sunny, sunny, cookie-4, cookie-6, cookie-7, cookie-9, cookie-12, ghostish, clover-4, clover-8, burst, soft-burst, boom, soft-boom, flower, puffy, puffy-diamond, pixel-circle, pixel-triangle, bun, heart.

<x-theme-script>

The theme decided before the first paint. Exactly once per layout, in <head>, before @vite. No props; configured in config/livewire-material.php.

<x-button>

Label button, icon button, toggle and responsive FAB in one component.

Prop Default
label / slot the words; without them and with an icon it is an icon button
variant text filled, tonal, outlined, elevated, text
color (alias tone) primary primary, secondary, tertiary, error, success, warning, info
primary, danger, caution shorthands: filled primary, filled error, filled warning
size sm xs 32px, sm 40px, md 56px, lg 96px, xl 136px
shape round or square; both square off further while pressed
icon, icon-right Material Symbol names
width default icon buttons only: narrow, default, wide
selected null true/false makes it a toggle (aria-pressed, selected colours and shape)
link, external, no-wire-navigate renders <a>, with wire:navigate unless external
spinner true shows the loading indicator while its wire:click runs; a string names the action
tooltip, tooltip-left, tooltip-right, tooltip-bottom plain tooltip; also the icon button's accessible name
disabled, type, responsive, fab responsive hides the label below lg; fab is an extended FAB below sm, a filled button above
<x-button label="Create link" icon="link" variant="filled" size="md" wire:click="create" spinner />
<x-button icon="delete" tooltip="Delete share" wire:click="delete({{ $share->id }})" />
<x-button icon="favorite" aria-label="Keep" variant="tonal" :selected="$kept" wire:click="toggleKeep" />

<x-tooltip>

M3's plain tooltip, standalone around any trigger: <x-tooltip text="Copy link" side="bottom"><button>…</button></x-tooltip>. side: top (default), bottom, left, right. Shows on hover (fine pointers) and keyboard focus; aria-hidden, so the trigger still needs its own accessible name. Buttons and FABs take a tooltip prop instead.

<x-menu>, <x-menu-item>, <x-menu-group>, <x-menu-separator>

<x-menu label="Share actions" position="bottom-end">
    <x-slot:trigger>
        <x-button icon="more_vert" tooltip="More" />
    </x-slot:trigger>

    <x-menu-group label="Sort by">
        <x-menu-item label="Newest" :selected="$sort === 'newest'" wire:click="$set('sort', 'newest')" keep-open />
    </x-menu-group>
    <x-menu-separator />
    <x-menu-item label="Settings" icon="settings" link="{{ route('settings') }}" />
    <x-menu-item label="Delete" icon="delete" wire:click="delete" description="Recipients lose access" shortcut="⌘⌫" />
</x-menu>

<x-menu>: trigger slot (its first button or link becomes the menu button), label, position (bottom-start default, bottom-end, top-start, top-end), vibrant. <x-menu-item>: label, icon, icon-right, description, shortcut, link, external, selected (makes it a menuitemcheckbox), disabled, keep-open. Choosing an item closes the menu unless keep-open. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab.

<x-button-group>

A row of <x-button>s: <x-button-group label="View" size="md">…</x-button-group>. connected sets them 2px apart with small inner corners (a selected toggle rounds fully). Pass the size of the buttons inside.

<x-group>

A choice between a few options as a connected button group of native radios (checkboxes with multiple):

<x-group label="Expires after" wire:model.live="expiry" :options="[
    ['id' => '1h', 'name' => '1 hour'],
    ['id' => '1d', 'name' => '1 day', 'icon' => 'today'],
    ['id' => '7d', 'name' => '7 days', 'disabled' => true],
]" hint="Recipients lose access after that" />

Props: label, hint, name (required with x-model), options, option-value (id), option-label (name), option-icon (icon), size, variant (tonal, filled, outlined), multiple, inline (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint.

<x-split-button>

<x-split-button label="Download all" icon="download" wire:click="downloadZip" menu-label="Download options">
    <x-menu-item label="Download files one by one" wire:click="downloadEach" />
</x-split-button>

Attributes go to the leading button; the slot is the menu. variant (filled default, tonal, outlined, elevated), color, size, disabled, spinner, menu-label, position.

<x-fab>

<x-fab icon="add" tooltip="New share" />size sm 56px (default), md 80px, lg 96px; with label it is an extended FAB. color primary/secondary/tertiary, drawn in the container, or variant="filled". It does not position itself; wrap it (<div class="fixed end-4 bottom-4">). link, external, disabled, type.

<x-fab-menu>, <x-fab-menu-item>

<div class="fixed end-4 bottom-4">
    <x-fab-menu label="New">
        <x-fab-menu-item label="Upload files" icon="upload_file" wire:click="uploadFiles" />
        <x-fab-menu-item label="Paste text" icon="content_paste" link="{{ route('paste') }}" />
    </x-fab-menu>
</div>

Two to six items open above the FAB, which turns into a close button. <x-fab-menu>: icon (add), label, color, position (top-end default). Give items the same color. Keyboard as <x-menu>.

<x-loading>

M3 Expressive's loading indicator — a shape morphing through seven Expressive shapes as it turns — for a wait of unknown length. 48px and primary unless sized or coloured by class; contained sets it on a primary-container circle. A progressbar named by label ("Loading"); :label="false" makes it decorative. It rests under reduced motion.

<x-loading />
<x-loading contained class="size-8" label="Uploading" />
<div wire:loading.flex wire:target="upload"><x-loading /></div>

<x-button spinner> shows a decorative one in place of its icon.

Testing the design

use NoNameWeb\LivewireMaterial\Testing\DesignGuard;

it('uses only what compiles', function () {
    expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])
        ->forbidColours(['tertiary'])          // roles this application's rules leave out
        ->violations())->toBe([]);
});

It fails on maryUI tags, daisyUI classes, colours the theme does not declare and unknown symbol names, with path:line for each.

Conventions

  • Components are anonymous Blade components: <x-name> without a prefix, or <x-{prefix}name> when config('livewire-material.prefix') is set.
  • Write class names out whole. Tailwind cannot compile 'text-'.$tone or type-{{ $size }}, and the design guard cannot read them.
  • The showcase at /material (local only, MATERIAL_SHOWCASE=true to force it) renders every token and component.

Livewire traps

  • Blade directives do not compile inside a component tag's attributes: <x-foo x-show="ok(@js($value))"> reaches the browser as literal text. On a component tag use {{ }} and :prop bindings, or put the Alpine on a plain element inside the slot.
  • Never pass hidden, a display utility or a position (absolute, relative) to a component: it is merged beside the component's own and whichever Tailwind emits last wins. Wrap the component in an element that carries it. A variant that only hides (max-sm:hidden) is safe.
  • $attributes->wire('model')->value() is false, not null, when there is no wire:model, and filled(false) is true. Normalise with ?: null.
  • End every statement in a multi-line Alpine attribute with ;: an inline @if … @endif inside it swallows the newline after it.