Add the Material 3 Expressive foundation
tests / lint (push) Successful in 1m0s
tests / feature (8.4) (push) Successful in 1m0s
tests / feature (8.5) (push) Successful in 1m1s
tests / browser (safari, webkit) (push) Successful in 1m59s
tests / browser (chrome, chromium) (push) Successful in 1m49s
tests / browser (firefox, firefox) (push) Successful in 1m54s

Colour, shape, type, elevation and motion as tokens and Tailwind
utilities; `php artisan material:scheme`, which generates an app's colour
roles with Google's material-color-utilities (spec 2025); the theme head
script with light, dark and system and its Alpine store; Google Sans Flex;
every Material Symbol (4,135, outlined and filled) drawn by <x-icon>
without blade-icons; all 35 M3 Expressive shapes, ported from androidx, as
<x-shape>; the x-figure directive; the Toasts concern; DesignGuard for
applications' tests; and a showcase with every token, both themes side by
side and an icon search.

Colour utilities are `@theme inline`, so a section with its own
data-theme repaints; without it they resolve once on :root.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 05:24:11 +02:00
co-authored by Claude Opus 5
parent 9b53891a8e
commit b48e879254
8364 changed files with 12720 additions and 28 deletions
+34
View File
@@ -0,0 +1,34 @@
{{-- A Material Symbol (Rounded, weight 400, grade 0, optical size 24), inline.
`name` is Google's name for it, underscores and all (`arrow_back`), as listed on
fonts.google.com/icons; every symbol Google publishes is available, and an unknown name
throws. `filled` draws the filled state, which in M3 means active or selected.
Decorative by default and hidden from screen readers, because nearly every icon sits
beside words that already say what it means. Pass `label` when the icon alone carries
the meaning.
24px unless the caller sizes it. The default is added only when no size class is passed,
rather than merged under one: `size-6 size-4` compiles to whichever Tailwind emits last,
not to the one the caller wrote. --}}
@props([
'name',
'filled' => false,
'label' => null,
])
@php
$sized = preg_match('/(^|\s)(size|w|h)-/', (string) $attributes->get('class')) === 1;
$attributes = $attributes
->class(['shrink-0', 'size-6' => ! $sized])
->merge(array_filter([
'aria-hidden' => $label === null ? 'true' : null,
'aria-label' => $label,
'role' => $label === null ? null : 'img',
'focusable' => 'false',
]));
@endphp
{{ \NoNameWeb\LivewireMaterial\Support\SvgFile::symbol($name, (bool) $filled, $attributes) }}
@@ -0,0 +1,11 @@
{{-- One of M3 Expressive's shapes, filled in the colour of the text around it.
Decoration: a large tonal shape behind an illustration, a scalloped container behind an
icon. `name` is the shape's kebab-case name (`cookie-9`, `soft-burst`, `pill`). Every
shape fills a 100-unit box to within 2 units, so one sits behind another at the same size.
Hidden from assistive tech, and sized by its caller. --}}
@props(['name'])
{{ \NoNameWeb\LivewireMaterial\Support\SvgFile::shape($name, $attributes->merge(['aria-hidden' => 'true', 'focusable' => 'false'])) }}
@@ -0,0 +1,65 @@
{{-- The theme, decided before the first paint. Include it in <head>, ahead of @vite.
It reads the visitor's choice from localStorage (`livewire-material.theme.storage_key`):
`light`, `dark` or `system`, falling back to `theme.default`. `system` follows the
operating system, now and whenever it changes. The result is written to
<html data-theme>, which is the only thing the stylesheet keys on; the choice itself is
<html data-theme-choice>, where `$store.theme` picks it up.
A value under one of `theme.legacy_keys` — an earlier toggle's key, which may be
JSON-encoded (maryUI's `"dark"`) — is adopted once and removed. Nothing is written for a
visitor who never chose, so changing `theme.default` later reaches them too.
<html> survives wire:navigate, so this only has to run on a full load. --}}
@php
$theme = config('livewire-material.theme');
$settings = [
'default' => in_array($theme['default'] ?? null, ['light', 'dark', 'system'], true) ? $theme['default'] : 'system',
'key' => $theme['storage_key'] ?? 'material-theme',
'legacy' => array_values($theme['legacy_keys'] ?? []),
];
@endphp
<script>
(function (settings) {
var root = document.documentElement;
var media = window.matchMedia('(prefers-color-scheme: dark)');
var valid = function (value) { return value === 'light' || value === 'dark' || value === 'system'; };
var choice = settings.default;
try {
var stored = localStorage.getItem(settings.key);
if (valid(stored)) {
choice = stored;
} else {
settings.legacy.forEach(function (key) {
var legacy = (localStorage.getItem(key) || '').replace(/"/g, '');
if (valid(legacy)) {
choice = legacy;
localStorage.setItem(settings.key, legacy);
}
localStorage.removeItem(key);
});
}
} catch (e) {
// Private windows and blocked site data throw on access; the default stands.
}
var apply = function () {
var current = root.getAttribute('data-theme-choice');
root.setAttribute('data-theme', current === 'system' ? (media.matches ? 'dark' : 'light') : current);
};
root.setAttribute('data-theme-key', settings.key);
root.setAttribute('data-theme-choice', choice);
apply();
media.addEventListener('change', apply);
})(@json($settings));
</script>
+10 -6
View File
@@ -1,12 +1,16 @@
@extends('livewire-material::showcase.layout')
@section('content')
<main class="mx-auto max-w-5xl px-4 py-10">
<h1 class="text-3xl">Livewire Material</h1>
<p class="mt-2">
Every component, in every variant, colour, size and state, in this application's own scheme.
Sections appear here as the components land.
<main class="mx-auto max-w-6xl space-y-16 px-4 py-10">
<p class="max-w-3xl type-body-lg text-on-surface-variant">
Every token and component, in this application's own scheme. Components appear here as they land.
</p>
@include('livewire-material::showcase.sections.colour')
@include('livewire-material::showcase.sections.type')
@include('livewire-material::showcase.sections.shape')
@include('livewire-material::showcase.sections.elevation')
@include('livewire-material::showcase.sections.motion')
@include('livewire-material::showcase.sections.icons')
</main>
@endsection
+28 -1
View File
@@ -7,10 +7,37 @@
<title>@yield('title', 'Livewire Material')</title>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="min-h-screen antialiased">
<body class="min-h-screen bg-surface font-sans text-on-surface antialiased">
<header class="sticky top-0 z-10 border-b border-divider bg-surface-container">
<div class="mx-auto flex max-w-6xl flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3">
<a href="{{ route('livewire-material.showcase') }}" class="type-title-lg">Livewire Material</a>
<nav class="flex flex-wrap gap-x-4 gap-y-1 type-label-lg text-on-surface-variant" aria-label="Sections">
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons'] as $anchor => $section)
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a>
@endforeach
</nav>
<div class="ms-auto flex rounded-corner-full border border-outline" role="group" aria-label="Theme" x-data>
@foreach (['light' => 'Light', 'dark' => 'Dark', 'system' => 'System'] as $choice => $label)
<button
type="button"
data-test="theme-{{ $choice }}"
class="state-layer focus-ring px-4 py-1.5 type-label-lg first:rounded-s-corner-full last:rounded-e-corner-full"
x-on:click="$store.theme.set('{{ $choice }}')"
x-bind:class="$store.theme.choice === '{{ $choice }}' && 'bg-secondary-container text-on-secondary-container'"
x-bind:aria-pressed="($store.theme.choice === '{{ $choice }}').toString()"
>{{ $label }}</button>
@endforeach
</div>
</div>
</header>
@yield('content')
@livewireScripts
@@ -0,0 +1,45 @@
@php
$groups = [
'Primary' => ['bg-primary', 'bg-on-primary', 'bg-primary-container', 'bg-on-primary-container', 'bg-inverse-primary', 'bg-primary-fixed', 'bg-primary-fixed-dim', 'bg-on-primary-fixed', 'bg-on-primary-fixed-variant'],
'Secondary' => ['bg-secondary', 'bg-on-secondary', 'bg-secondary-container', 'bg-on-secondary-container', 'bg-secondary-fixed', 'bg-secondary-fixed-dim', 'bg-on-secondary-fixed', 'bg-on-secondary-fixed-variant'],
'Tertiary' => ['bg-tertiary', 'bg-on-tertiary', 'bg-tertiary-container', 'bg-on-tertiary-container', 'bg-tertiary-fixed', 'bg-tertiary-fixed-dim', 'bg-on-tertiary-fixed', 'bg-on-tertiary-fixed-variant'],
'Error' => ['bg-error', 'bg-on-error', 'bg-error-container', 'bg-on-error-container', 'bg-inverse-error'],
'Success' => ['bg-success', 'bg-on-success', 'bg-success-container', 'bg-on-success-container', 'bg-inverse-success'],
'Warning' => ['bg-warning', 'bg-on-warning', 'bg-warning-container', 'bg-on-warning-container', 'bg-inverse-warning'],
'Info' => ['bg-info', 'bg-on-info', 'bg-info-container', 'bg-on-info-container', 'bg-inverse-info'],
'Surface' => ['bg-surface', 'bg-surface-dim', 'bg-surface-bright', 'bg-surface-container-lowest', 'bg-surface-container-low', 'bg-surface-container', 'bg-surface-container-high', 'bg-surface-container-highest', 'bg-on-surface', 'bg-on-surface-variant', 'bg-inverse-surface', 'bg-inverse-on-surface', 'bg-outline', 'bg-outline-variant'],
'Ink and lines' => ['bg-body', 'bg-meta', 'bg-quiet', 'bg-structure', 'bg-chrome', 'bg-divider'],
];
@endphp
<section id="colour" class="scroll-mt-24 space-y-6">
<h2 class="type-headline-md">Colour</h2>
<p class="max-w-3xl type-body-md text-on-surface-variant">
Every role as <code>bg-*</code>, <code>text-*</code> and <code>border-*</code>, generated by
<code>php artisan material:scheme</code>. Both themes side by side, whatever the page is showing.
</p>
<div class="grid gap-4 lg:grid-cols-2">
@foreach (['light', 'dark'] as $theme)
<div data-theme="{{ $theme }}" class="space-y-6 rounded-corner-lg bg-surface p-4 text-on-surface">
<h3 class="type-title-md capitalize">{{ $theme }}</h3>
@foreach ($groups as $group => $roles)
<div class="space-y-2">
<h4 class="type-label-lg text-on-surface-variant">{{ $group }}</h4>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
@foreach ($roles as $role)
<div class="flex items-center gap-2">
<span @class(['size-8 shrink-0 rounded-corner-sm border border-outline-variant', $role])></span>
<span class="min-w-0 truncate type-body-sm">{{ \Illuminate\Support\Str::after($role, 'bg-') }}</span>
</div>
@endforeach
</div>
</div>
@endforeach
</div>
@endforeach
</div>
</section>
@@ -0,0 +1,15 @@
<section id="elevation" class="scroll-mt-24 space-y-6">
<h2 class="type-headline-md">Elevation</h2>
<p class="max-w-3xl type-body-md text-on-surface-variant">
Five shadow levels as <code>shadow-elevation-*</code>, for what floats over content.
</p>
<div class="grid grid-cols-2 gap-6 rounded-corner-lg bg-surface-container-low p-6 sm:grid-cols-5">
@foreach (['shadow-elevation-1', 'shadow-elevation-2', 'shadow-elevation-3', 'shadow-elevation-4', 'shadow-elevation-5'] as $shadow)
<div @class(['flex h-24 items-center justify-center rounded-corner-md bg-surface-container-high', $shadow])>
<code class="type-label-md">{{ $shadow }}</code>
</div>
@endforeach
</div>
</section>
@@ -0,0 +1,47 @@
<section
id="icons"
class="scroll-mt-24 space-y-6"
x-data="{
names: {{ json_encode(\NoNameWeb\LivewireMaterial\Support\SvgFile::symbolNames()) }},
base: '{{ rtrim(route('livewire-material.showcase', [], false), '/') }}/symbols',
query: '',
filled: false,
get matches() {
const query = this.query.trim().toLowerCase().replaceAll(' ', '_');
return (query === '' ? this.names : this.names.filter((name) => name.includes(query))).slice(0, 120);
},
}"
>
<h2 class="type-headline-md">Icons</h2>
<p class="max-w-3xl type-body-md text-on-surface-variant">
Every Material Symbol (Rounded, 400, grade 0, 24px) as <code>&lt;x-icon name="" /&gt;</code>, outlined or <code>filled</code>.
</p>
<div class="flex flex-wrap items-center gap-4">
<label class="flex min-w-72 flex-1 items-center gap-2 rounded-corner-xs border border-outline px-3 py-2 focus-within:outline-3 focus-within:outline-secondary">
<x-icon name="search" class="size-5 text-on-surface-variant" />
<input type="search" x-model.debounce.150ms="query" placeholder="Search {{ number_format(count(\NoNameWeb\LivewireMaterial\Support\SvgFile::symbolNames())) }} symbols" class="w-full bg-transparent type-body-lg outline-none" />
</label>
<label class="flex items-center gap-2 type-label-lg">
<input type="checkbox" x-model="filled" class="size-4 accent-primary" />
Filled
</label>
</div>
<ul class="grid grid-cols-[repeat(auto-fill,minmax(7.5rem,1fr))] gap-2">
<template x-for="name in matches" x-bind:key="name">
<li class="flex flex-col items-center gap-2 rounded-corner-md bg-surface-container p-3 text-center">
<span
class="size-6 bg-current"
x-bind:style="{
mask: `url('${base}/${filled ? 'filled' : 'outlined'}/${name}.svg') center / contain no-repeat`,
}"
></span>
<code class="w-full break-all type-label-sm text-on-surface-variant" x-text="name"></code>
</li>
</template>
</ul>
</section>
@@ -0,0 +1,35 @@
<section id="motion" class="scroll-mt-24 space-y-6" x-data="{ moved: false, figure: 1204 }">
<h2 class="type-headline-md">Motion</h2>
<p class="max-w-3xl type-body-md text-on-surface-variant">
M3 Expressive's springs as <code>ease-spatial-*</code> and <code>ease-effects-*</code>, each with its
<code>--md-sys-motion-*-duration</code>. Reduced motion makes all of them instant.
</p>
<button type="button" class="state-layer focus-ring rounded-corner-full bg-primary px-6 py-2.5 type-label-lg text-on-primary" x-on:click="moved = ! moved">
Move
</button>
<div class="space-y-3 rounded-corner-lg bg-surface-container p-4">
@foreach (['spatial-fast', 'spatial-default', 'spatial-slow', 'effects-fast', 'effects-default', 'effects-slow'] as $spring)
<div class="grid grid-cols-[9rem_1fr] items-center gap-4">
<code class="type-label-md text-on-surface-variant">ease-{{ $spring }}</code>
<div class="h-8">
<div
class="size-8 rounded-corner-full bg-tertiary"
style="transition: translate var(--md-sys-motion-{{ $spring }}-duration) var(--md-sys-motion-{{ $spring }})"
x-bind:style="moved && { translate: '16rem' }"
></div>
</div>
</div>
@endforeach
</div>
<div class="flex flex-wrap items-center gap-4 rounded-corner-lg bg-surface-container p-4">
<p class="type-emphasized-display-sm tabular-nums" x-figure x-text="figure.toLocaleString('en') + ' files'">1,204 files</p>
<button type="button" class="state-layer focus-ring rounded-corner-full px-4 py-2 type-label-lg text-primary" x-on:click="figure = Math.round(Math.random() * 5000)">
Change the figure
</button>
<code class="type-label-md text-on-surface-variant">x-figure</code>
</div>
</section>
@@ -0,0 +1,25 @@
<section id="shape" class="scroll-mt-24 space-y-6">
<h2 class="type-headline-md">Shape</h2>
<p class="max-w-3xl type-body-md text-on-surface-variant">
The corner scale as <code>rounded-corner-*</code>, and M3 Expressive's shapes as <code>&lt;x-shape name="…" /&gt;</code>.
</p>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-5">
@foreach (['rounded-corner-none', 'rounded-corner-xs', 'rounded-corner-sm', 'rounded-corner-md', 'rounded-corner-lg', 'rounded-corner-lg-increased', 'rounded-corner-xl', 'rounded-corner-xl-increased', 'rounded-corner-xxl', 'rounded-corner-full'] as $corner)
<div class="space-y-2">
<div @class(['h-20 bg-primary-container', $corner])></div>
<code class="type-label-md text-on-surface-variant">{{ $corner }}</code>
</div>
@endforeach
</div>
<div class="grid grid-cols-3 gap-4 sm:grid-cols-5 lg:grid-cols-7">
@foreach (\NoNameWeb\LivewireMaterial\Support\SvgFile::shapeNames() as $shape)
<div class="space-y-2 text-center">
<x-shape :name="$shape" class="mx-auto size-20 text-secondary-container" />
<code class="type-label-md text-on-surface-variant">{{ $shape }}</code>
</div>
@endforeach
</div>
</section>
@@ -0,0 +1,37 @@
@php
$styles = [
['type-display-lg', 'type-emphasized-display-lg'],
['type-display-md', 'type-emphasized-display-md'],
['type-display-sm', 'type-emphasized-display-sm'],
['type-headline-lg', 'type-emphasized-headline-lg'],
['type-headline-md', 'type-emphasized-headline-md'],
['type-headline-sm', 'type-emphasized-headline-sm'],
['type-title-lg', 'type-emphasized-title-lg'],
['type-title-md', 'type-emphasized-title-md'],
['type-title-sm', 'type-emphasized-title-sm'],
['type-body-lg', 'type-emphasized-body-lg'],
['type-body-md', 'type-emphasized-body-md'],
['type-body-sm', 'type-emphasized-body-sm'],
['type-label-lg', 'type-emphasized-label-lg'],
['type-label-md', 'type-emphasized-label-md'],
['type-label-sm', 'type-emphasized-label-sm'],
];
@endphp
<section id="type" class="scroll-mt-24 space-y-6">
<h2 class="type-headline-md">Type</h2>
<p class="max-w-3xl type-body-md text-on-surface-variant">
M3's typescale in Google Sans Flex, each style regular and emphasized: <code>type-title-lg</code>, <code>type-emphasized-title-lg</code>.
</p>
<div class="divide-y divide-divider rounded-corner-lg bg-surface-container">
@foreach ($styles as [$regular, $emphasized])
<div class="grid gap-2 p-4 md:grid-cols-[12rem_1fr_1fr] md:items-baseline">
<code class="type-label-md text-on-surface-variant">{{ $regular }}</code>
<p @class([$regular, 'truncate'])>Share files, safely</p>
<p @class([$emphasized, 'truncate'])>Share files, safely</p>
</div>
@endforeach
</div>
</section>